Architecture overview, code conventions, and patterns for contributors and AI agents working on this codebase.
Agent Orchestrator is a monorepo with four main packages:
packages/
├── core/ # Types, services, config — the engine
├── cli/ # `ao` command (depends on core + all plugins)
├── web/ # Next.js dashboard (depends on core)
└── plugins/ # 21 plugin packages across 8 slots
Build order matters: core must be built before cli, web, or plugins.
Every abstraction is a swappable plugin. All interfaces are defined in packages/core/src/types.ts.
| Slot | Interface | Default | Alternatives |
|---|---|---|---|
| Runtime | Runtime |
tmux (Unix) / process (Windows; ConPTY via node-pty) |
process, docker, k8s, ssh, e2b |
| Agent | Agent |
claude-code |
codex, aider, cursor, kimicode, opencode |
| Workspace | Workspace |
worktree |
clone |
| Tracker | Tracker |
github |
linear |
| SCM | SCM |
github |
— |
| Notifier | Notifier |
desktop |
slack, webhook, composio |
| Terminal | Terminal |
iterm2 |
web |
| Lifecycle | — | (core) | Non-pluggable |
All runtime data paths are derived from a SHA-256 hash of the config file directory:
const hash = sha256(path.dirname(configPath)).slice(0, 12); // e.g. "a3b4c5d6e7f8"
const instanceId = `${hash}-${projectId}`; // e.g. "a3b4c5d6e7f8-myapp"
const dataDir = `~/.agent-orchestrator/${instanceId}`;This means:
- Multiple orchestrator checkouts on the same machine never collide
- Runtime handles are globally unique:
{hash}-{prefix}-{num}(tmux session name on Unix; suffix of the named pipe\\.\pipe\ao-pty-{sessionId}on Windows) - User-facing names stay clean:
ao-1,myapp-2
spawning → working → pr_open → ci_failed
→ review_pending → changes_requested
→ approved → mergeable → merged
↓
cleanup → done (or killed/terminated)
Activity states (orthogonal to lifecycle): active, ready, idle, waiting_input, blocked, exited.
| File | Purpose |
|---|---|
packages/core/src/session-manager.ts |
Session CRUD: spawn, list, kill, send, restore |
packages/core/src/lifecycle-manager.ts |
State machine, polling loop, reactions engine |
packages/core/src/prompt-builder.ts |
Layered worker prompt assembly (system + task) |
packages/core/src/config.ts |
Config loading and Zod validation |
packages/core/src/plugin-registry.ts |
Plugin discovery, loading, resolution |
packages/core/src/agent-selection.ts |
Resolves worker vs orchestrator agent roles |
packages/core/src/observability.ts |
Correlation IDs, structured logging, metrics |
packages/core/src/paths.ts |
Hash-based path and session name generation |
These apply to both human contributors and AI agents:
- Think before coding. If a task is ambiguous, ask for clarification. If multiple approaches exist, present the tradeoff.
- Minimum code. No speculative features. No abstractions for code used once. Plugin slots exist for extensibility - use them instead of config proliferation.
- Surgical diffs. Don't touch files outside your change scope. Don't reformat adjacent code. Match existing patterns even if you prefer differently. Every changed line should trace to a specific requirement.
- Verifiable goals. Before implementing, state what "done" looks like and how to verify it. For bug fixes: write a test that reproduces the bug first.
For AI agent-specific guidance (including high-risk files like types.ts, lifecycle-manager.ts, globals.css), see CLAUDE.md -> Working Principles.
Prerequisites: Node.js 20.18.3+, pnpm 9.15+, Git 2.25+
git clone https://github.com/ComposioHQ/agent-orchestrator.git
cd agent-orchestrator
pnpm install
pnpm build
cp agent-orchestrator.yaml.example agent-orchestrator.yaml
$EDITOR agent-orchestrator.yamlAlways build before starting the web dev server — it depends on built packages:
pnpm build
cd packages/web && pnpm dev
# Open http://localhost:3000agent-orchestrator/
├── packages/
│ ├── core/ # Core types, services, config
│ ├── cli/ # CLI tool (ao command)
│ ├── web/ # Next.js dashboard
│ ├── plugins/ # All plugin packages
│ │ ├── runtime-*/ # Runtime plugins (tmux, docker, k8s)
│ │ ├── agent-*/ # Agent adapters (claude-code, codex, aider)
│ │ ├── workspace-*/ # Workspace providers (worktree, clone)
│ │ ├── tracker-*/ # Issue trackers (github, linear)
│ │ ├── scm-github/ # SCM adapter
│ │ ├── notifier-*/ # Notification channels
│ │ └── terminal-*/ # Terminal UIs
│ └── integration-tests/ # Integration tests
├── agent-orchestrator.yaml.example
└── docs/ # Documentation
-
Create a feature branch
git checkout -b feat/your-feature
-
Make your changes — follow conventions below, add tests, update docs
-
Build and test
pnpm build && pnpm test && pnpm lint && pnpm typecheck
-
Commit using Conventional Commits
git commit -m "feat: add your feature"Pre-commit hook scans for secrets automatically.
-
Push and open a PR
When you are developing Agent Orchestrator from a long-lived local checkout, refresh the local ao install before debugging launcher or packaging issues:
git switch main
git status --short --branch # `ao update` expects a clean working tree on main
ao updateao update is intentionally conservative: it fast-forwards the local install checkout from origin/main, runs pnpm install, clean-rebuilds @aoagents/ao-core, @aoagents/ao-cli, and @aoagents/ao-web, refreshes the global launcher with npm link, and ends with CLI smoke tests. Use ao update --skip-smoke to stop after the rebuild, or ao update --smoke-only to rerun the smoke checks without fetching or rebuilding.
If your branch has drift from main, update the install checkout first and then return to your feature worktree. That keeps CLI behavior and generated docs aligned with the version contributors are expected to run.
// ESM modules only — all packages use "type": "module"
// .js extension required on local imports
import { foo } from "./bar.js";
import type { Session } from "./types.js";
// node: prefix for builtins
import { execFile } from "node:child_process";
import { readFile } from "node:fs/promises";
// No `any` — use `unknown` + type guards
function processInput(value: unknown): string {
if (typeof value !== "string") throw new Error("Expected string");
return value.trim();
}
// Type-only imports for type-only usage
import type { PluginModule, Runtime } from "@aoagents/ao-core";Formatting: semicolons, double quotes, 2-space indent, strict mode.
These rules prevent command injection. Follow them exactly.
// Always execFile (never exec — exec runs a shell, enabling injection)
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
// Always pass arguments as an array (never interpolate into strings)
await execFileAsync("git", ["checkout", "-b", branchName]);
// Always add timeouts
await execFileAsync("gh", ["pr", "create", "--title", title], {
timeout: 30_000,
});
// Never use JSON.stringify for shell escaping — use the array form
// ❌ Bad
await execFileAsync("sh", ["-c", `git commit -m "${message}"`]);
// ✅ Good
await execFileAsync("git", ["commit", "-m", message]);A plugin exports a manifest, a create() factory, and a default PluginModule export.
// packages/plugins/runtime-myplugin/src/index.ts
import type { PluginModule, Runtime } from "@aoagents/ao-core";
export const manifest = {
name: "myplugin",
slot: "runtime" as const,
description: "My custom runtime",
version: "0.1.0",
};
export function create(): Runtime {
return {
name: "myplugin",
async create(config) {
/* start session */
},
async destroy(sessionName) {
/* tear down */
},
async send(sessionName, text) {
/* send input */
},
async isRunning(sessionName) {
return false;
},
};
}
export default { manifest, create } satisfies PluginModule<Runtime>;Plugin package setup — package.json:
{
"name": "@aoagents/ao-runtime-myplugin",
"version": "0.1.0",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc",
"typecheck": "tsc --noEmit",
"test": "vitest"
},
"dependencies": {
"@aoagents/ao-core": "workspace:*"
}
}After creating the package, add it to packages/cli/package.json and register it in packages/core/src/plugin-registry.ts inside loadBuiltins().
session-manager.ts:spawn() is the core path most features touch:
spawn(config)
├─ Validate issue (Tracker.getIssue) — fails fast, no resources created yet
├─ Reserve session ID
├─ Determine branch name
├─ Create workspace (Workspace.create)
├─ Generate issue prompt (Tracker.generatePrompt)
├─ Assemble layered prompt (prompt-builder.ts) → {systemPrompt, taskPrompt}
├─ Persist worker system prompt file
├─ For OpenCode workers: write OPENCODE_CONFIG pointing at that file
├─ Build agent launch command (Agent.getLaunchCommand)
├─ Create runtime session (Runtime.create)
├─ Post-launch setup (Agent.postLaunchSetup, optional)
└─ Write metadata file → return Session
If issue validation fails, nothing is created — fail before allocating resources.
Worker prompts are built in three persistent layers (packages/core/src/prompt-builder.ts):
- Base agent guidance — standard instructions for all sessions (git workflow, PR conventions, lifecycle hooks)
- Config context — project-specific info (repo, branch, tracker, issue details, automated reactions)
- Project rules — content from
agentRules/agentRulesFile
The explicit user request is returned separately as taskPrompt. This lets session manager persist stable system instructions to disk while still sending only task-specific text to agents that need post-launch prompt delivery.
Orchestrator sessions use a separate prompt from packages/core/src/orchestrator-prompt.ts.
# Run all tests
pnpm test
# Run tests for a specific package
pnpm --filter @aoagents/ao-core test
# Watch mode
pnpm --filter @aoagents/ao-core test -- --watch
# Integration tests
pnpm test:integrationKey test files in core (src/__tests__/):
session-manager.test.ts— session CRUD and spawn flowlifecycle-manager.test.ts— state machine and reactionsplugin-registry.test.ts— plugin loading and resolutionprompt-builder.test.ts— prompt generation
Use mock plugins in tests — don't call real tmux or external services in unit tests.
- Edit
Sessioninterface inpackages/core/src/types.ts - Initialize the field in
spawn()insession-manager.ts - Rebuild:
pnpm --filter @aoagents/ao-core build
- Add handler in
packages/core/src/lifecycle-manager.ts - Wire it up in the polling loop
- Add config schema in
packages/core/src/config.tsif needed
- Extend
EventTypeunion inpackages/core/src/types.ts - Emit it via
eventEmitter.emit()in the relevant service - Handle it in
lifecycle-manager.tsif it should trigger a reaction
- Add the command in
packages/cli/src/index.tsusingcommander - Import from core services as needed
- Update the CLI reference in
README.md
# Inspect raw metadata
cat ~/.agent-orchestrator/{hash}-{project}/sessions/{session-id}
# Check API state
curl http://localhost:3000/api/sessions/{session-id}
# Attach to the runtime session directly
# Unix:
tmux attach -t {hash}-{prefix}-{num}
# Windows: there's no tmux. Use the AO command, which connects to \\.\pipe\ao-pty-<sessionId>:
ao session attach <sessionId>
# Enable verbose logging
AO_LOG_LEVEL=debug ao startThis project uses itself to develop itself — agents work in git worktrees:
# Create a worktree for a feature branch
git worktree add ../ao-feature-x feat/feature-x
cd ../ao-feature-x
# Install and build in the worktree
pnpm install
pnpm build
# Copy config
cp ../agent-orchestrator/agent-orchestrator.yaml .
# Start dev server
cd packages/web && pnpm devPre-commit hooks scan for secrets automatically on every commit. If triggered:
- Remove the secret from the file
- Use environment variables:
${SECRET_NAME} - Store real values in
.env.local(gitignored)
To manually scan:
gitleaks detect --no-git # scan current files
gitleaks protect --staged # scan staged files (same as pre-commit)To allow a false positive, add it to .gitleaks.toml:
[allowlist]
regexes = ['''your-pattern-here''']# Mux WebSocket server port (web dashboard terminal + session updates)
DIRECT_TERMINAL_PORT=14801
# User integrations
GITHUB_TOKEN=ghp_...
LINEAR_API_KEY=lin_api_...
SLACK_WEBHOOK_URL=https://hooks.slack.com/services/...
ANTHROPIC_API_KEY=sk-ant-api03-...Store in .env.local (gitignored). Never commit real values.
Why flat metadata files instead of a database?
Debuggability: cat ~/.agent-orchestrator/a3b4-myapp/sessions/ao-1 shows full state. No database to spin up, no schema to migrate, survives crashes.
Why polling instead of webhooks? Simpler local setup (no ngrok), survives orchestrator restarts, works offline. CI/review state is fetched, not pushed.
Why plugin slots?
Swappability: use process (ConPTY) on Windows, tmux on Linux/macOS, Docker in CI, Kubernetes in prod — without changing application code. The Runtime interface is the layer that lets the same agent/workspace/tracker stack run across all of them. Testability: mock any plugin in unit tests. Extensibility: users add company-specific plugins without forking.
Why hash-based namespacing? Multiple orchestrator checkouts on the same machine don't collide at the runtime layer (tmux session names on Unix, named-pipe paths on Windows) or on disk. Different checkouts get different hashes; projects within the same config share a hash.
Why ESM with .js extensions?
Node.js ESM requires explicit extensions on local imports. All packages use "type": "module". Missing extensions cause runtime errors.
packages/core/README.md— Core service referenceARCHITECTURE.md— Hash-based namespace designSETUP.md— Installation and configuration referenceSECURITY.md— Security practicesagent-orchestrator.yaml.example— Full config reference