Agent Orchestrator
Architecture Design Doc

A platform for spawning and supervising many parallel AI coding agents — each isolated in its own git worktree with its own PR — behind a single dashboard. Agents autonomously fix CI failures, address review comments, and drive their PRs to merge.

~30 pnpm packages 8 plugin slots 0 databases — flat files TypeScript · Node 20+ Next.js 15 · React 19 macOS · Linux · Windows

Every major claim below cites real source as file:line. Verified against the tree on the main branch.

1System overview

AO runs multiple coding agents (Claude Code, Codex, Aider, OpenCode, Cursor, Grok, Kimi) simultaneously. One call — ao spawn <project> <issue> — creates an isolated workspace, launches an agent inside a managed runtime, and wires up the feedback loops so that PR reviews and CI failures route back to the right agent automatically.

The defining design choice is that every external dependency is a swappable plugin. Where the agent runs, which AI tool it is, how code is isolated, where issues live, how PRs and CI are read, how you get notified, and how a human attaches to a terminal — all eight are pluggable interfaces resolved at startup. The orchestration engine in packages/core knows nothing concrete about tmux, GitHub, or Claude; it only knows the interfaces.

Why plugins? The problem space is a matrix of "your runtime × your agent × your tracker × your SCM." Hard-coding any one combination would fork the project for every team. Plugin slots make the matrix a configuration concern (agent-orchestrator.yaml) rather than a code change. New capability = new plugin, not new branches in the core.

The big picture

The core engine sits in the middle. Plugins plug into its eight slots. A polling lifecycle manager drives state forward; a session manager owns CRUD and disk persistence; the Next.js dashboard observes everything over a multiplexed WebSocket.

Figure 1 — Plugin-slot model around the core engine
flowchart TB
  subgraph CORE["packages/core — Orchestration Engine"]
    direction TB
    SM["Session Manager
CRUD · spawn · restore · reconcile"] LM["Lifecycle Manager
30s poll · state machine · reactions"] REG["Plugin Registry
discover · resolve · instantiate"] SM --- LM REG --- SM REG --- LM end RT["RUNTIME
tmux · process"]:::runtime AG["AGENT
claude-code · codex · aider
opencode · cursor · grok · kimi
"]:::agent WS["WORKSPACE
worktree · clone"]:::workspace TR["TRACKER
github · linear · gitlab"]:::tracker SCM["SCM
github · gitlab"]:::scm NO["NOTIFIER
desktop · slack · discord
webhook · composio · …
"]:::notifier TE["TERMINAL
iterm2 · web"]:::terminal RT --- REG AG --- REG WS --- REG TR --- REG SCM --- REG NO --- LM TE --- SM CFG["agent-orchestrator.yaml"]:::cfg --> REG LM --> WEB["Next.js Dashboard
mux WebSocket → React + xterm.js"]:::web classDef runtime fill:#2a1c14,stroke:#f2845c,color:#ffd9c7; classDef agent fill:#16223a,stroke:#6ea8ff,color:#cfe2ff; classDef workspace fill:#11281f,stroke:#4fd1a1,color:#c4f0df; classDef tracker fill:#2a2410,stroke:#f5c451,color:#f6e6b0; classDef scm fill:#1f1830,stroke:#9d7bff,color:#ddd0ff; classDef notifier fill:#2a1320,stroke:#f06595,color:#ffc9de; classDef terminal fill:#102528,stroke:#38c7d4,color:#bdf0f5; classDef cfg fill:#1b2330,stroke:#6b7689,color:#e6ebf2; classDef web fill:#151b27,stroke:#6ea8ff,color:#e6ebf2;
Figure 2 — Data flow at a glance
flowchart LR
  A["agent-orchestrator.yaml"] --> B["Config Loader
Zod validate"] B --> C["Plugin Registry"] C --> D["Session Manager"] D --> E["Lifecycle Manager
poll loop"] E --> F["Events"] F --> G["Notifiers"] D -. flat files .-> H[("~/.agent-orchestrator")] E --> I["Next.js API routes"] I --> J["mux WebSocket"] J --> K["React Dashboard
+ xterm.js"] classDef d fill:#151b27,stroke:#2f3a4f,color:#e6ebf2; class A,B,C,D,E,F,G,H,I,J,K d;

2Monorepo & build order

AO is a pnpm workspace (pnpm 9.15.4, package.json) with roughly 30 packages. Cross-package dependencies use the workspace:* protocol so the whole graph builds from source.

# root build — pnpm -r build, dependency-ordered
packages/
  core/            # engine: types, config, registry, session + lifecycle managers
  cli/             # the `ao` command — depends on every plugin
  web/             # Next.js 15 dashboard (App Router, React 19, xterm.js)
  ao/              # global CLI wrapper (thin shim around cli)
  notifier-macos/  # native macOS notifier helper
  integration-tests/
  plugins/         # 25 plugin packages, one per implementation
    agent-claude-code  agent-codex  agent-aider  agent-opencode
    agent-cursor  agent-grok  agent-kimicode
    runtime-tmux  runtime-process
    workspace-worktree  workspace-clone
    tracker-github  tracker-linear  tracker-gitlab
    scm-github  scm-gitlab
    notifier-desktop  notifier-slack  notifier-discord  notifier-webhook
    notifier-composio  notifier-dashboard  notifier-openclaw
    terminal-iterm2  terminal-web
Figure 3 — Monorepo dependency & build order
flowchart TD
  CORE["@aoagents/ao-core
types · config · registry
session + lifecycle managers
"]:::core PLUGINS["25 plugin packages
@aoagents/ao-plugin-*"]:::plug CLI["@aoagents/ao-cli
the `ao` command"]:::leaf WEB["@aoagents/ao-web
Next.js dashboard"]:::leaf AO["ao
global shim"]:::leaf CORE --> PLUGINS CORE --> CLI CORE --> WEB PLUGINS --> CLI CLI --> AO classDef core fill:#16223a,stroke:#6ea8ff,color:#cfe2ff; classDef plug fill:#11281f,stroke:#4fd1a1,color:#c4f0df; classDef leaf fill:#151b27,stroke:#2f3a4f,color:#e6ebf2;

Build order is therefore core → plugins → cli / web (cli and web build in parallel once their deps are ready). Core is the only barrel-exported package (packages/core/src/index.ts) and forms the stable public API every plugin imports as @aoagents/ao-core.

3The 8 plugin slots

All slot interfaces live in packages/core/src/types.ts. Each is async-first (every I/O method returns a Promise), and plugins never call each other directly — they communicate only through the Session object and the lifecycle manager.

Runtime Agent Workspace Tracker SCM Notifier Terminal Lifecycle (non-pluggable)

Runtime

types.ts:394

Where agents execute. Owns the process/PTY: create, destroy, sendMessage, getOutput, isAlive.

tmuxprocess

Agent

types.ts:477

Which AI tool. Owns introspection: getLaunchCommand, getActivityState, isProcessRunning, getSessionInfo, setupWorkspaceHooks.

claude-codecodexaideropencodecursorgrokkimicode

Workspace

types.ts:656

Code isolation. create, destroy, list, plus optional exists/restore for session revival.

worktreeclone

Tracker

types.ts:713

Issue tracking. getIssue, isCompleted, branchName, generatePrompt, optional listIssues/createIssue.

githublineargitlab

SCM

types.ts:794

PRs, CI, reviews. Largest interface: detectPR, getCIChecks, getReviews, getMergeability, mergePR, batch enrichSessionsPRBatch.

githubgitlab

Notifier

types.ts:1170

Delivery. notify(event), optional notifyWithActions and post. The only slot that can register multiple instances.

desktopslackdiscordwebhookcomposiodashboardopenclaw

Terminal

types.ts:1204

Human attach UI. openSession, openAll, optional isSessionOpen.

iterm2web

Lifecycle

core (built-in)

The state machine + polling loop. Not pluggable — it is the engine itself, the single authority on terminal decisions.

lifecycle-manager.ts

The registry & the PluginModule contract

The plugin registry (packages/core/src/plugin-registry.ts) discovers, resolves, and instantiates plugins. Built-ins are a hard-coded table keyed by slot:name (plugin-registry.ts:39); external plugins come from the config's plugins[] via npm package or local path (plugin-registry.ts:533). Every import is normalized to a PluginModule shape (plugin-registry.ts:303), accepting either a direct module or a default export.

Every plugin default-exports a PluginModule<T> (types.ts:1747): a manifest, a create() factory, and an optional detect() for binary availability. Config is validated once in create() and captured in a closure — never re-validated per call.

import type { PluginModule, Runtime } from "@aoagents/ao-core";

export const manifest = {
  name: "tmux",            // must match the {name} package suffix
  slot: "runtime" as const, // `as const` preserves the literal type
  description: "tmux session runtime",
  version: "0.1.0",
};

export function create(config?: Record<string, unknown>): Runtime {
  // validate config here, capture in closure
  return { /* …Runtime methods… */ };
}

export function detect(): boolean { /* is the binary installed? */ }

export default { manifest, create, detect } satisfies PluginModule<Runtime>;

The Session object (types.ts:280) is the shared currency that flows through every slot. Key fields: id, projectId, status (legacy), lifecycle (canonical truth, types.ts:297), activity, branch, pr, workspacePath, runtimeHandle, and a free-form metadata map.

4Session lifecycle

A session's truth is stored as a canonical lifecycle with separate state and reason fields (plus parallel pr and runtime sub-states). The dashboard's familiar status labels are derived from this canonical truth, never stored as the source.

Why split canonical vs legacy status? A flat status like ci_failed conflates three independent facts: is the agent process alive, what is the PR's review state, and what is the orchestrator doing about it. The canonical model keeps these orthogonal (session.state / pr.state / runtime.state), which is what makes the reconciliation invariant possible — the session manager can record a runtime fact without accidentally deciding the session is dead. deriveLegacyStatus() then flattens the orthogonal truth back into one label only for display and backward compatibility.

Canonical states & terminal reasons

Defined as Zod enums in packages/core/src/lifecycle-state.ts:50. There are 8 session states and a rich reason vocabulary; the terminal set is just {done, terminated} (lifecycle-state.ts:131).

Session states (lifecycle-state.ts:50)

StateMeaning
not_startedSpawned, agent not yet running
workingAgent actively processing
idleQuiet, awaiting external event
needs_inputBlocked on a user decision
stuckIdle past threshold / unrecoverable
detectingRuntime unknown — probing
doneterminal — completed successfully
terminatedterminal — killed / errored / cleaned

Terminal reasons (lifecycle-state.ts:60)

ReasonMaps to
research_completedone
manually_killedterminated → killed
runtime_lostterminated → killed
agent_process_exitedterminated
probe_failureterminated → errored
error_in_processterminated → errored
auto_cleanupterminated → cleanup
pr_mergedterminated → cleanup

The lifecycle also tracks a parallel PR sub-state (none/open/merged/closed with reasons like ci_failing, changes_requested, approved, merge_ready) and a runtime sub-state (unknown/alive/exited/missing/probe_failed) — see lifecycle-state.ts:87 and :108.

Legacy-status derivation

deriveLegacyStatus(lifecycle) (lifecycle-state.ts:432) flattens the canonical triple into the single status the dashboard kanban understands. Terminal and operational states map directly; for an idle/working session with an open PR, the PR sub-state decides the label.

Figure 4 — Legacy status flow (derived for display)
flowchart LR
  spawning --> working --> pr_open
  pr_open --> ci_failed
  pr_open --> review_pending
  ci_failed --> changes_requested
  review_pending --> approved
  changes_requested --> approved
  approved --> mergeable --> merged --> cleanup --> done
  classDef s fill:#16223a,stroke:#6ea8ff,color:#cfe2ff;
  classDef bad fill:#2a1320,stroke:#f06595,color:#ffc9de;
  classDef good fill:#11281f,stroke:#4fd1a1,color:#c4f0df;
  class spawning,working,pr_open,review_pending s;
  class ci_failed,changes_requested bad;
  class approved,mergeable,merged,cleanup,done good;
    

The canonical state machine

Figure 5 — Canonical session state machine (states · transitions · terminal reasons)
stateDiagram-v2
  [*] --> not_started: spawn_requested
  not_started --> working: agent_acknowledged
  working --> idle: task quiet
  idle --> working: new activity
  working --> needs_input: awaiting_user_input
  idle --> needs_input: awaiting_user_input
  needs_input --> working: input received
  working --> detecting: runtime probe ambiguous
  idle --> detecting: runtime_lost
  detecting --> working: runtime alive
  detecting --> stuck: probe_failure
  detecting --> terminated: runtime_lost / agent_process_exited
  working --> stuck: error_in_process
  stuck --> working: recovered
  idle --> done: research_complete
  working --> done: research_complete
  idle --> terminated: pr_merged / auto_cleanup
  working --> terminated: manually_killed
  stuck --> terminated: probe_failure / error_in_process
  done --> [*]
  terminated --> [*]
    
Invariant (#1735): only the lifecycle manager's resolveProbeDecision pipeline (lifecycle-manager.ts:1202) may write a terminal state. The session manager records runtime facts and parks a session in detecting — it never writes terminated directly.

Polling loop & reactions

The lifecycle manager (packages/core/src/lifecycle-manager.ts) runs a polling loop, default 30 s (lifecycle-manager.ts:3016). Each tick, pollAll() (:2866) lists sessions, batch-enriches PR data over GraphQL, then runs checkSession() concurrently. determineStatus() (:901) probes runtime liveness (runtime.isAlive), agent activity (agent.getActivityState), and PR state (scm.enrichSessionsPRBatch).

When a transition fires, a reaction may execute (executeReaction, :1400). Reactions track per-session attempt counts and escalate to a human after a retry budget or time window.

Transition / eventReactionAction
→ ci_failedci-failedsend-to-agent, enriched with failed job/step/log
→ changes_requestedchanges-requestedsend-to-agent with review comments
review.pendingbacklog dispatchmaybeDispatchReviewBacklog() — human/bot comments routed separately, throttled 2 min
→ merge.readyapproved-and-greenauto-merge or notify
PR branch behindmerge-conflictsrebase message, one-shot per conflict
session.stuckagent-stucksend-to-agent or notify human
session.needs_inputagent-needs-inputnotify human

ci-failed is a persistent reaction key (:116) so its escalation budget accumulates across CI oscillation rather than resetting each cycle.

5Session manager

The session manager (packages/core/src/session-manager.ts, factory createSessionManager at :377) owns session CRUD and all disk persistence. It is the only component that mutates session metadata files.

MethodRefWhat it does
spawn(config):1188Validate issue → reserve ID atomically → create workspace → create runtime → launch agent. Uses a LIFO CleanupStack to roll back on any failure.
list(projectId?):2235Load metadata from disk, repair legacy→canonical lifecycle on read, enrich each with live runtime state. Results cached ~35 s.
get(id):2386Find + enrich a single session.
kill(id, opts?):2445Idempotent (early-return if already terminal) → destroy runtime → tear down managed workspace → record events.
restore(id)Re-attach to a live runtime and restore metadata; pairs with the agent's getRestoreCommand.

Stale-runtime reconciliation

During list() enrichment, if a session's tmux/process runtime is found dead, the manager persists the fact to disk and parks the session in detecting with reason runtime_lost (session-manager.ts:2303) — crucially before emitting any event, so a mid-event crash can't lose the update. It does not decide the session is terminated; the lifecycle manager's probe pipeline makes that call on the next poll. This is the concrete enforcement of invariant #1735.

6Storage model — flat files, no database

Why no database? AO is a single-operator local tool, not a multi-tenant service. Flat files under ~/.agent-orchestrator mean zero setup, trivial inspection (cat a session's JSON), git-style hashing for collision-free multi-checkout, and crash safety via atomic writes + file locks. The cost — no queries, no transactions — simply doesn't bite at this scale.

Paths are computed in packages/core/src/paths.ts using a project-based v2 layout:

~/.agent-orchestrator/
├─ config.yaml                              # global config — all registered projects
├─ running.json                             # current `ao start` PID, port, projects
├─ last-stop.json                           # sessions killed by stop/Ctrl+C (for restore)
├─ portfolio/                               # preferences.json, registered.json
└─ projects/{projectId}/
   ├─ orchestrator.json                     # getOrchestratorPath()  paths.ts:139
   ├─ sessions/{sessionId}.json          # getSessionPath()       paths.ts:144
   ├─ worktrees/{sessionId}/             # isolated git workspace paths.ts:124
   ├─ code-reviews/  feedback-reports/

Session metadata (packages/core/src/metadata.ts) is JSON written via atomic read-modify-write under a .lock file (mutateMetadata, metadata.ts:376; 5 s lock timeout, corrupt files preserved as .corrupt-{ts}). The lifecycle key is the single source of truth: when present, the stored status is ignored and recomputed via deriveLegacyStatus() on read (metadata.ts:226).

Config (packages/core/src/config.ts) is YAML validated by a Zod schema (OrchestratorConfigSchema, config.ts:357). findConfigFile() (:756) resolves in priority order: AO_CONFIG_PATH env → search up the directory tree from cwd (git-style) → global ~/.agent-orchestrator/config.yaml → legacy home locations.

7End-to-end data flow: ao start → dashboard

ao start (packages/cli/src/commands/start.ts) spawns the dashboard as a managed daemon child (start.ts:939), which in turn launches the Next.js server plus a dedicated direct-terminal WebSocket server (packages/web/server/start-all.ts:108), listening on DIRECT_TERMINAL_PORT (default 14801, direct-terminal-ws.ts:103).

The browser opens one multiplexed WebSocket (packages/web/server/mux-websocket.ts) carrying three logical channels:

  • sessionsSessionBroadcaster polls /api/sessions/patches every 3 s (mux-websocket.ts:138) and broadcasts lightweight SessionPatch[].
  • terminalTerminalManager attaches a node-pty to tmux attach-session, streaming live output with a 50 KB ring buffer for late subscribers (:466).
  • notificationsNotificationBroadcaster tails the JSONL store every 1 s (:319).
Note on transport: the dashboard's primary real-time path is this mux WebSocket; the React hook useSessionEvents (packages/web/src/hooks/useSessionEvents.ts) applies the broadcast patches and falls back to an HTTP refresh of /api/sessions when session membership changes or data goes stale (~15 s). Terminals render through xterm.js in DirectTerminal.tsx driven by useXtermTerminal + the global MuxProvider WebSocket client.
Figure 6 — Sequence: from ao start to a live dashboard render
sequenceDiagram
  autonumber
  actor U as User
  participant CLI as ao start (CLI)
  participant DASH as Dashboard daemon
  participant NEXT as Next.js :3000
  participant WS as mux WS :14801
  participant API as /api/sessions/patches
  participant SM as Session Manager
  participant LM as Lifecycle Manager
  participant BR as Browser (React + xterm)

  U->>CLI: ao start
  CLI->>DASH: spawnManagedDaemonChild("dashboard")
  DASH->>NEXT: start Next.js (PORT)
  DASH->>WS: start direct-terminal WS (14801)
  CLI->>LM: start poll loop (30s)
  loop every 30s
    LM->>SM: list() + enrich PRs
    SM-->>LM: sessions (canonical lifecycle)
    LM->>LM: determineStatus → reactions
  end
  U->>BR: open http://localhost:3000
  BR->>NEXT: SSR initial page
  BR->>WS: connect mux, subscribe(sessions,notifications)
  loop every 3s
    WS->>API: GET /api/sessions/patches
    API->>SM: listCached(project)
    SM-->>API: SessionPatch[]
    API-->>WS: patches
    WS-->>BR: {ch:sessions, snapshot}
  end
  BR->>WS: {ch:terminal, type:open, tmuxName}
  WS->>WS: node-pty ⇄ tmux attach-session
  WS-->>BR: {ch:terminal, type:data} (stream)
  BR->>BR: xterm renders + kanban updates
    

8Prompt assembly (3 layers)

buildPrompt() (packages/core/src/prompt-builder.ts:191) composes a worker agent's system prompt from three layers, returning { systemPrompt, taskPrompt? }:

Figure 7 — 3-layer prompt assembly
flowchart TB
  L1["Layer 1 — Base prompt
BASE_AGENT_PROMPT (repo) or
BASE_AGENT_PROMPT_NO_REPO · prompt-builder.ts:21
"]:::l L2["Layer 2 — Config context
project · repo · branch · issue
buildConfigLayer() :111
"]:::l L3["Layer 3 — User rules
agentRules + agentRulesFile
readUserRules() :162
"]:::l L1 --> OUT["systemPrompt"]:::o L2 --> OUT L3 --> OUT classDef l fill:#16223a,stroke:#6ea8ff,color:#cfe2ff; classDef o fill:#11281f,stroke:#4fd1a1,color:#c4f0df;
  1. Base prompt — system instructions (session lifecycle, ao report commands, git/PR workflow). Two variants: full, or _NO_REPO when the project has no repo configured (prompt-builder.ts:60).
  2. Config context — project name/repo/branch/tracker and the task (issue id + pre-fetched issue details), built by buildConfigLayer() (:111). An optional orchestrator back-channel block injects an ao send {sessionId} "…" command (:205).
  3. User rules — inline agentRules and/or an external agentRulesFile resolved relative to the project (:162).

The orchestrator session gets a separate prompt rendered from a markdown template with conditional section blocks (generateOrchestratorPrompt(), packages/core/src/orchestrator-prompt.ts:189) — this is what ao start injects via --append-system-prompt.

9Cross-platform abstractions

AO ships first-class on macOS, Linux, and Windows. All OS branching is centralized in packages/core/src/platform.ts (full reference in docs/CROSS_PLATFORM.md).

The Golden Rule: never write process.platform === "win32" in new code. Use the helpers from @aoagents/ao-core. Inline checks bypass the centralized tests (which mock process.platform) and become silent regressions.
NeedHelperRef
OS checkisWindows() / isMac() / isLinux()platform.ts:15
Default runtime (tmux vs process)getDefaultRuntime():27
Resolve shell (PowerShell vs /bin/sh)getShell():130
Kill process + descendantskillProcessTree(pid, sig?):154
PID listening on a portfindPidByPort(port):189
HOME / SHELL / TMPDIR / PATH / USERgetEnvDefaults():227

On Windows the default runtime is process (ConPTY, no tmux); killProcessTree always uses taskkill /F (WM_CLOSE fails for headless processes), while Unix kills the negative-PID process group first. getShell() prefers pwshpowershell.execmd.exe and honors the AO_SHELL escape hatch.

10Code map — where to look

FileResponsibility
core/src/types.tsAll 8 plugin interfaces + Session, PluginModule, manifest
core/src/plugin-registry.tsDiscovery, resolution, instantiation of plugins
core/src/session-manager.tsSession CRUD, spawn, restore, stale-runtime reconciliation
core/src/lifecycle-manager.tsPolling loop, state machine, reactions, probe pipeline
core/src/lifecycle-state.tsCanonical schema + deriveLegacyStatus()
core/src/config.tsYAML config loading + Zod validation
core/src/paths.ts & metadata.tsOn-disk layout + atomic metadata I/O
core/src/prompt-builder.ts3-layer worker prompt assembly
core/src/platform.tsCross-platform helpers (the Golden Rule)
cli/src/commands/start.tsao start/ao stop, daemon spawning, Ctrl+C shutdown
web/server/mux-websocket.tsSessions/terminal/notifications mux WebSocket server
web/src/hooks/useSessionEvents.tsDashboard session-state consumer
web/src/components/Dashboard.tsxKanban board, attention zones