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
Explicit path to config file. If omitted, searches standard locations.
Returns:
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 }
Explicit path to config file. If omitted, searches standard locations.
Returns:
Validated configuration object.
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
Directory to start searching from. Defaults to CWD.
Returns:
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 config object (from YAML parse, JSON, etc.).
Returns:
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:
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:
-
AO_CONFIG_PATH environment variable (if set)
export AO_CONFIG_PATH=~/custom/config.yaml
-
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
...
-
Explicit
startDir parameter (if provided)
findConfig('/path/to/start');
-
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