Skip to main content

Overview

The Config Loader reads agent-orchestrator.yaml, validates it with Zod schemas, applies defaults, and expands paths. It provides a type-safe configuration object for the entire orchestrator. Key features:
  • Auto-discovery of config files (searches up directory tree like git)
  • Zod validation with helpful error messages
  • Automatic defaults for all optional fields
  • Path expansion (~ to home directory)
  • Project uniqueness validation
  • Default reaction configuration
The config loader follows a search order similar to git: check AO_CONFIG_PATH env var, search up from CWD, check home directory locations.

Usage

import { loadConfig } from '@composio/ao-core';

// Auto-discover config file
const config = loadConfig();

// Explicit path
const config2 = loadConfig('./agent-orchestrator.yaml');

// Get config with resolved path
const { config: cfg, path } = loadConfigWithPath();
console.log('Loaded from:', path);

Functions

loadConfig

Load and validate configuration from a YAML file.
loadConfig(configPath?: string): OrchestratorConfig
configPath
string
Explicit path to config file. If omitted, searches standard locations.
Returns:
OrchestratorConfig
object
Validated and normalized configuration object.
Throws:
  • Error("No agent-orchestrator.yaml found") - No config file found in search locations
  • ZodError - Config validation failed
Example:
try {
  const config = loadConfig();
  console.log('Projects:', Object.keys(config.projects));
  console.log('Defaults:', config.defaults);
} catch (err) {
  if (err instanceof ZodError) {
    console.error('Config validation failed:');
    for (const issue of err.issues) {
      console.error(`  ${issue.path.join('.')}: ${issue.message}`);
    }
  } else {
    console.error('Config not found:', err.message);
  }
}

loadConfigWithPath

Load config and return both the config object and resolved file path.
loadConfigWithPath(configPath?: string): { config: OrchestratorConfig; path: string }
configPath
string
Explicit path to config file. If omitted, searches standard locations.
Returns:
config
OrchestratorConfig
Validated configuration object.
path
string
Absolute path to the loaded config file.
Example:
const { config, path } = loadConfigWithPath();
console.log(`Loaded from: ${path}`);
console.log(`Config hash: ${generateConfigHash(path)}`);

findConfig

Find the config file path without loading it.
findConfig(startDir?: string): string | null
startDir
string
Directory to start searching from. Defaults to CWD.
Returns:
string | null
string
Absolute path to config file, or null if not found.
Example:
const configPath = findConfig();
if (configPath) {
  console.log('Config found at:', configPath);
} else {
  console.log('No config file found. Run `ao init`.');
}

validateConfig

Validate a raw config object without loading from file.
validateConfig(raw: unknown): OrchestratorConfig
raw
unknown
required
Raw config object (from YAML parse, JSON, etc.).
Returns:
OrchestratorConfig
object
Validated and normalized configuration.
Example:
import { parse as parseYaml } from 'yaml';
import { validateConfig } from '@composio/ao-core';

const yaml = `
projects:
  my-app:
    repo: org/repo
    path: ~/my-app
`;

const raw = parseYaml(yaml);
const config = validateConfig(raw);

getDefaultConfig

Get a default config object (useful for ao init).
getDefaultConfig(): OrchestratorConfig
Returns:
OrchestratorConfig
object
Config with all defaults and empty projects.
Example:
import { stringify } from 'yaml';
import { getDefaultConfig } from '@composio/ao-core';

const defaultConfig = getDefaultConfig();
const yaml = stringify(defaultConfig);
writeFileSync('agent-orchestrator.yaml', yaml);

Config Search Order

The config loader searches these locations in order:
  1. AO_CONFIG_PATH environment variable (if set)
    export AO_CONFIG_PATH=~/custom/config.yaml
    
  2. Search up directory tree from CWD (like git)
    /Users/foo/projects/my-app/agent-orchestrator.yaml
    /Users/foo/projects/agent-orchestrator.yaml
    /Users/foo/agent-orchestrator.yaml
    ...
    
  3. Explicit startDir parameter (if provided)
    findConfig('/path/to/start');
    
  4. Home directory locations
    • ~/.agent-orchestrator.yaml
    • ~/.agent-orchestrator.yml
    • ~/.config/agent-orchestrator/config.yaml
Both .yaml and .yml extensions are supported. The loader tries .yaml first, then .yml.

Configuration Schema

Top-Level Config

interface OrchestratorConfig {
  configPath: string;  // Set automatically during load
  port?: number;       // Web dashboard port (default: 3000)
  terminalPort?: number;  // Terminal WebSocket port (default: 3001)
  directTerminalPort?: number;  // Direct terminal WS port (default: 3003)
  readyThresholdMs: number;  // "ready" → "idle" threshold (default: 300000)
  defaults: DefaultPlugins;
  projects: Record<string, ProjectConfig>;
  notifiers: Record<string, NotifierConfig>;
  notificationRouting: Record<EventPriority, string[]>;
  reactions: Record<string, ReactionConfig>;
}
Example:
port: 3000
terminalPort: 3001
readyThresholdMs: 300000  # 5 minutes

defaults:
  runtime: tmux
  agent: claude-code
  workspace: worktree
  notifiers: [composio, desktop]

projects:
  my-app:
    repo: org/repo
    path: ~/my-app

notificationRouting:
  urgent: [desktop, composio]
  action: [desktop, composio]
  warning: [composio]
  info: [composio]

reactions:
  ci-failed:
    auto: true
    action: send-to-agent
    message: "CI is failing. Run `gh pr checks` and fix."

DefaultPlugins

interface DefaultPlugins {
  runtime: string;      // Default: "tmux"
  agent: string;        // Default: "claude-code"
  workspace: string;    // Default: "worktree"
  notifiers: string[];  // Default: ["composio", "desktop"]
}

ProjectConfig

interface ProjectConfig {
  name: string;           // Display name (defaults to config key)
  repo: string;           // Required: "owner/repo"
  path: string;           // Required: ~/path/to/project
  defaultBranch: string;  // Default: "main"
  sessionPrefix: string;  // Default: auto-generated from path
  runtime?: string;       // Override default runtime
  agent?: string;         // Override default agent
  workspace?: string;     // Override default workspace
  tracker?: TrackerConfig;
  scm?: SCMConfig;
  symlinks?: string[];    // Files to symlink into workspaces
  postCreate?: string[];  // Commands to run after workspace creation
  agentConfig?: AgentSpecificConfig;
  reactions?: Record<string, Partial<ReactionConfig>>;
  agentRules?: string;    // Inline rules for agents
  agentRulesFile?: string;  // Path to rules file
  orchestratorRules?: string;  // Rules for orchestrator (reserved)
}
Example:
projects:
  my-app:
    name: "My Application"
    repo: org/my-app
    path: ~/projects/my-app
    defaultBranch: main
    sessionPrefix: app  # Sessions: app-1, app-2, ...
    
    # Plugin overrides
    runtime: tmux
    agent: claude-code
    workspace: worktree
    
    # Tracker config
    tracker:
      plugin: linear
      teamId: "abc123"
    
    # SCM config
    scm:
      plugin: github
    
    # Workspace setup
    symlinks:
      - .env
      - node_modules
    postCreate:
      - pnpm install
    
    # Agent config
    agentConfig:
      permissions: skip
      model: claude-4.5-sonnet
    
    # Reaction overrides
    reactions:
      ci-failed:
        retries: 3  # Override default
    
    # Agent rules
    agentRulesFile: .claude/rules.md

ReactionConfig

interface ReactionConfig {
  auto: boolean;              // Default: true
  action: 'send-to-agent' | 'notify' | 'auto-merge';
  message?: string;           // For send-to-agent
  priority?: EventPriority;   // For notify
  retries?: number;           // Max send-to-agent retries
  escalateAfter?: number | string;  // Attempts or duration
  threshold?: string;         // Duration trigger (e.g., "10m")
  includeSummary?: boolean;   // Include session summary in notification
}
Example:
reactions:
  ci-failed:
    auto: true
    action: send-to-agent
    message: "CI is failing. Run `gh pr checks`, fix issues, and push."
    retries: 2
    escalateAfter: 2
  
  changes-requested:
    auto: true
    action: send-to-agent
    message: "Review comments found. Check with `gh pr view --comments`."
    escalateAfter: "30m"
  
  approved-and-green:
    auto: false
    action: notify
    priority: action

TrackerConfig

interface TrackerConfig {
  plugin: string;  // "github" | "linear" | custom
  [key: string]: unknown;  // Plugin-specific config
}
Example:
tracker:
  plugin: linear
  teamId: "abc123"
  apiKey: "${LINEAR_API_KEY}"  # From environment

NotifierConfig

interface NotifierConfig {
  plugin: string;  // "desktop" | "slack" | "webhook" | custom
  [key: string]: unknown;  // Plugin-specific config
}
Example:
notifiers:
  slack:
    plugin: slack
    webhookUrl: "${SLACK_WEBHOOK_URL}"
    channel: "#eng-notifications"
  
  webhook:
    plugin: webhook
    url: "https://example.com/webhook"
    headers:
      Authorization: "Bearer ${API_TOKEN}"

Validation

The config loader performs extensive validation:

1. Schema Validation (Zod)

const OrchestratorConfigSchema = z.object({
  port: z.number().default(3000),
  readyThresholdMs: z.number().nonnegative().default(300_000),
  defaults: DefaultPluginsSchema.default({}),
  projects: z.record(ProjectConfigSchema),
  // ...
});
Example error:
Validation error at "projects.my-app.repo": Required
Validation error at "projects.my-app.path": Required

2. Project Uniqueness

Prevents duplicate project IDs (directory basenames):
if (projectIds.has(projectId)) {
  throw new Error(
    `Duplicate project ID detected: "${projectId}"\n` +
    `Multiple projects have the same directory basename.`
  );
}
Example:
# ERROR: Both projects have basename "my-app"
projects:
  app1:
    path: ~/projects/my-app
  app2:
    path: ~/work/my-app

3. Session Prefix Collisions

Prevents duplicate session prefixes:
if (prefixes.has(prefix)) {
  throw new Error(
    `Duplicate session prefix detected: "${prefix}"\n` +
    `Add explicit sessionPrefix to one of these projects.`
  );
}
Fix:
projects:
  app1:
    path: ~/projects/my-app
    sessionPrefix: app1  # Explicit prefix
  app2:
    path: ~/work/my-app
    sessionPrefix: app2  # Explicit prefix

Defaults

The config loader applies intelligent defaults:

1. Plugin Defaults

defaults: {
  runtime: "tmux",
  agent: "claude-code",
  workspace: "worktree",
  notifiers: ["composio", "desktop"],
}

2. Project Defaults

// Name from config key
if (!project.name) {
  project.name = configKey;
}

// Session prefix from path basename
if (!project.sessionPrefix) {
  const projectId = basename(project.path);
  project.sessionPrefix = generateSessionPrefix(projectId);
}

// Infer SCM from repo
if (!project.scm && project.repo.includes('/')) {
  project.scm = { plugin: 'github' };
}

// Default tracker to GitHub issues
if (!project.tracker) {
  project.tracker = { plugin: 'github' };
}

3. Notification Routing Defaults

notificationRouting: {
  urgent: ['desktop', 'composio'],
  action: ['desktop', 'composio'],
  warning: ['composio'],
  info: ['composio'],
}

4. Reaction Defaults

See Reaction System for default reactions.

Path Expansion

All path fields are expanded:
function expandHome(filepath: string): string {
  if (filepath.startsWith('~/')) {
    return join(homedir(), filepath.slice(2));
  }
  return filepath;
}

for (const project of Object.values(config.projects)) {
  project.path = expandHome(project.path);
}
Example:
# Input
projects:
  my-app:
    path: ~/projects/my-app

# After expansion (on macOS/Linux)
projects:
  my-app:
    path: /Users/foo/projects/my-app

Minimal Config

You only need to specify projects - everything else has defaults:
projects:
  my-app:
    repo: org/repo
    path: ~/my-app
This expands to:
port: 3000
readyThresholdMs: 300000

defaults:
  runtime: tmux
  agent: claude-code
  workspace: worktree
  notifiers: [composio, desktop]

projects:
  my-app:
    name: my-app
    repo: org/repo
    path: /Users/foo/my-app
    defaultBranch: main
    sessionPrefix: my-app
    tracker:
      plugin: github
    scm:
      plugin: github
    agentConfig:
      permissions: skip

notifiers: {}

notificationRouting:
  urgent: [desktop, composio]
  action: [desktop, composio]
  warning: [composio]
  info: [composio]

reactions:
  ci-failed:
    auto: true
    action: send-to-agent
    message: "CI is failing. Run `gh pr checks`, fix issues, and push."
    retries: 2
    escalateAfter: 2
  # ... (all default reactions)

Complete Example

import { loadConfig, findConfig } from '@composio/ao-core';
import { ZodError } from 'zod';

// Find config file
const configPath = findConfig();
if (!configPath) {
  console.error('No config file found. Run `ao init`.');
  process.exit(1);
}

console.log(`Using config: ${configPath}`);

// Load and validate
try {
  const config = loadConfig(configPath);
  
  // Display projects
  console.log('\nProjects:');
  for (const [id, project] of Object.entries(config.projects)) {
    console.log(`  ${id}:`);
    console.log(`    Name: ${project.name}`);
    console.log(`    Repo: ${project.repo}`);
    console.log(`    Path: ${project.path}`);
    console.log(`    Prefix: ${project.sessionPrefix}`);
    console.log(`    Agent: ${project.agent ?? config.defaults.agent}`);
  }
  
  // Display defaults
  console.log('\nDefaults:');
  console.log(`  Runtime: ${config.defaults.runtime}`);
  console.log(`  Agent: ${config.defaults.agent}`);
  console.log(`  Workspace: ${config.defaults.workspace}`);
  console.log(`  Notifiers: ${config.defaults.notifiers.join(', ')}`);
  
  // Display reactions
  console.log('\nReactions:');
  for (const [key, reaction] of Object.entries(config.reactions)) {
    console.log(`  ${key}: ${reaction.action} (auto: ${reaction.auto})`);
  }
  
} catch (err) {
  if (err instanceof ZodError) {
    console.error('\nConfig validation failed:');
    for (const issue of err.issues) {
      console.error(`  ${issue.path.join('.')}: ${issue.message}`);
    }
  } else {
    console.error('Failed to load config:', err);
  }
  process.exit(1);
}

See Also

Build docs developers (and LLMs) love