---
title: Architecture
description: How Agent Orchestrator fits together — plugin slots, session lifecycle, event bus, prompt assembly, and activity detection.
---

Agent Orchestrator (AO) is a Node.js orchestrator that spawns and manages parallel AI coding agents across isolated git worktrees. Every moving part is a plugin; the core provides the state machine, event bus, and prompt assembly that ties them together.

## The 8 Plugin Slots

Each abstraction in AO is a named interface defined in `packages/core/src/types.ts`. Seven of the eight slots are pluggable at runtime; the eighth (Lifecycle) is built into core and cannot be replaced.

| Slot | Default | Purpose | Interface |
|------|---------|---------|-----------|
| Runtime | [tmux](/docs/plugins/runtimes/tmux) | Where agent sessions execute (tmux, process, docker, k8s) | `Runtime` |
| Agent | [claude-code](/docs/plugins/agents/claude-code) | Which AI coding tool is launched | `Agent` |
| Workspace | [worktree](/docs/plugins/workspaces/worktree) | Code isolation — each session gets its own git worktree or clone | `Workspace` |
| Tracker | [github](/docs/plugins/trackers/github) | Issue tracking (GitHub Issues, Linear, GitLab) | `Tracker` |
| SCM | [github](/docs/plugins/scm/github) | PR lifecycle, CI checks, and code reviews | `SCM` |
| Notifier | [desktop](/docs/plugins/notifiers/desktop) | Push notifications to the human (desktop, Slack, webhook) | `Notifier` |
| Terminal | [iterm2](/docs/plugins/terminals/iterm2) | How humans view and interact with running sessions | `Terminal` |
| Lifecycle | core (non-pluggable) | State machine, poll loop, and reaction engine | `LifecycleManager` |

<Callout type="info">
The Lifecycle slot is not pluggable. It is instantiated by core and wired to all other plugins automatically. You configure its behaviour (poll interval, reactions, thresholds) through `agent-orchestrator.yaml` rather than by replacing the implementation.
</Callout>

---

## Session Status Lifecycle

Every session moves through a well-defined set of statuses. The values are defined by the `SESSION_STATUS` constant in `packages/core/src/types.ts`.

```
spawning
   │
   ▼
working ──────────────────────────────────────────────► stuck
   │                                                     ▲
   ▼                                                     │
pr_open ──────────────────────────────────────────────► stuck
   │
   ├──► ci_failed
   │
   ├──► review_pending
   │
   ├──► changes_requested
   │
   └──► approved
            │
            ▼
         mergeable
            │
            ▼
          merged ──► cleanup ──► done
```

Terminal statuses (session is dead and will no longer be polled): `killed`, `terminated`, `done`, `cleanup`, `errored`, `merged`.

| Status | Description |
|--------|-------------|
| `spawning` | Session is being created — worktree, branch, and tmux window are initialising |
| `working` | Agent is active; no PR yet |
| `pr_open` | Agent has pushed a PR; CI and reviews are pending |
| `ci_failed` | One or more CI checks on the PR are failing |
| `review_pending` | PR has been submitted for review; waiting for a decision |
| `changes_requested` | Reviewer(s) have requested changes |
| `approved` | PR is approved but not yet mergeable (e.g. still behind base) |
| `mergeable` | PR is approved, CI is green, and it can be merged |
| `merged` | PR has been merged (terminal) |
| `cleanup` | Post-merge cleanup in progress (terminal) |
| `done` | Session completed cleanly (terminal) |
| `needs_input` | Agent is waiting for a permission prompt or human input |
| `stuck` | Agent has been idle beyond the configured `agent-stuck` threshold |
| `errored` | Unexpected error — session is dead (terminal) |
| `killed` | Session was explicitly killed or the PR was closed (terminal) |
| `idle` | Agent process is alive but has not produced activity for an extended period |
| `terminated` | Session was terminated externally (terminal) |

### How transitions are determined

The lifecycle manager calls `determineStatus(session)` on every poll cycle. The logic follows this cascade:

1. **Runtime liveness** — If the runtime reports the session is not alive, return `killed`.
2. **Agent activity** — `getActivityState()` is called; `waiting_input` maps to `needs_input`, `exited` maps to `killed`, and idle beyond the configured threshold maps to `stuck`.
3. **PR auto-detection** — If no PR is recorded and the agent has a branch, `scm.detectPR()` is called once per cycle to catch PRs created without a metadata hook.
4. **PR state** — If a PR exists, the SCM plugin provides CI status, review decision, and merge readiness to determine `ci_failed`, `review_pending`, `changes_requested`, `approved`, `mergeable`, or `merged`.
5. **Default** — Fall back to `working` (or preserve `stuck`/`needs_input`).

---

## Event Bus

After each status transition, the lifecycle manager constructs a typed `OrchestratorEvent` and fans it out to all configured notifiers and reaction handlers. Events have four priority levels: `urgent`, `action`, `warning`, and `info`.

Priority is inferred by `inferPriority()` in `lifecycle-manager.ts`:

- **urgent** — events containing `stuck`, `needs_input`, or `errored`
- **action** — events containing `approved`, `ready`, `merged`, or `completed`
- **warning** — events containing `fail`, `changes_requested`, or `conflicts`
- **info** — everything else, including all `summary.*` events

| `event.type` | Priority | When emitted |
|---|---|---|
| `session.spawned` | info | Session transitions out of `spawning` |
| `session.working` | info | Session enters `working` |
| `session.exited` | info | Agent process exits |
| `session.killed` | info | Session is killed |
| `session.idle` | info | Session enters `idle` |
| `session.stuck` | urgent | Session exceeds the `agent-stuck` threshold |
| `session.needs_input` | urgent | Agent is waiting on a permission prompt |
| `session.errored` | urgent | Session enters `errored` |
| `pr.created` | info | Session transitions to `pr_open` |
| `pr.updated` | info | PR title or state changes |
| `pr.merged` | action | PR is merged |
| `pr.closed` | info | PR is closed without merging |
| `ci.passing` | action | CI checks recover from failing to passing |
| `ci.failing` | warning | Session enters `ci_failed` |
| `ci.fix_sent` | info | CI fix message sent to agent |
| `ci.fix_failed` | warning | CI fix attempt failed |
| `review.pending` | info | Session enters `review_pending` |
| `review.approved` | action | Session enters `approved` |
| `review.changes_requested` | warning | Session enters `changes_requested` |
| `review.comments_sent` | info | Review comments forwarded to agent |
| `review.comments_unresolved` | warning | Unresolved review comments still present |
| `automated_review.found` | warning | Bot/automated review comments detected |
| `automated_review.fix_sent` | info | Automated review fix sent to agent |
| `merge.ready` | action | Session enters `mergeable` |
| `merge.conflicts` | warning | PR has merge conflicts |
| `merge.completed` | action | Session enters `merged` |
| `reaction.triggered` | info | A configured reaction fired |
| `reaction.escalated` | urgent | A reaction exceeded its retry/escalation threshold |
| `summary.all_complete` | info | All sessions have reached terminal statuses |

For the webhook wire format, see [Webhook Notifier](/docs/plugins/notifiers/webhook). For configuring which events trigger automated reactions, see [Reactions](/docs/configuration/reactions).

---

## Poll Loop

The lifecycle manager runs a recurring poll loop. The default interval is **30 seconds** (configurable via `start(intervalMs)`). Each cycle:

1. Lists all active sessions via `sessionManager.list()`.
2. **Batch-fetches PR enrichment data** — a single GraphQL query retrieves CI status, review decision, and merge readiness for all open PRs at once, replacing N×3 individual REST calls with one request.
3. Checks each session concurrently — `checkSession(session)` calls `determineStatus()`, detects transitions, fires events, and evaluates reactions.
4. Prunes stale tracker entries for sessions that no longer exist.
5. Checks whether all sessions are complete and fires `summary.all_complete` if so (emitted once per batch, not repeatedly).

The dashboard then receives these state changes via SSE at a 5-second cadence. The poll loop and SSE cadence are independent — the dashboard may show state that is up to 5 seconds behind the last poll cycle.

---

## Prompt Assembly (3 Layers)

Every agent session is launched with a composed prompt built by `buildPrompt()` in `packages/core/src/prompt-builder.ts`. The three layers are always concatenated in order:

### Layer 1 — Base prompt (fixed)

`BASE_AGENT_PROMPT` provides identity, session lifecycle rules, git workflow guidance, and PR best practices. It is identical across all sessions. For projects without a remote repository, a trimmed variant (`BASE_AGENT_PROMPT_NO_REPO`) is used instead — it omits PR and CI instructions that do not apply.

### Layer 2 — Config context (per-project)

Built from the project configuration. Includes:

- Project name and ID
- Repository (`owner/repo`)
- Default branch
- Tracker plugin name
- Issue ID and issue body (when spawning from a tracker issue)
- Reaction hints — lists which events will auto-send instructions back to the agent

### Layer 3 — User rules (per-project)

Loaded from `agentRules` (inline string in `agent-orchestrator.yaml`) and/or `agentRulesFile` (path to a file, relative to the project root). Both are concatenated when present. If neither is provided, this layer is omitted.

An explicit `userPrompt` is appended after Layer 3 as "Additional Instructions" — it has the highest precedence and overrides anything above it.

### Orchestrator rules

The `orchestratorRules` field in `ProjectConfig` is reserved for orchestrator-role sessions but is not applied by `buildPrompt()`. Orchestrator sessions receive a completely different prompt generated by `generateOrchestratorPrompt()` — see the next section.

---

## Orchestrator Prompt

Orchestrator sessions do not receive the standard three-layer prompt. Instead, `generateOrchestratorPrompt()` in `packages/core/src/orchestrator-prompt.ts` builds a standalone prompt that provides:

- **Role rules** — read-only investigations only; never own a PR; never use `tmux send-keys` directly; always use `ao send` / `ao spawn` to delegate.
- **Project info** — name, repo, default branch, session prefix, local path, dashboard port.
- **Quick-start commands** — `ao status`, `ao spawn`, `ao batch-spawn`, `ao session claim-pr`, `ao send`, `ao open`.
- **Available `ao` commands table** — full reference adapted to whether a repo is configured.
- **Session management workflows** — spawning, monitoring, PR takeover, investigation workflow, cleanup.
- **Dashboard info** — URL and feature summary.
- **Automated reactions** — lists configured reactions so the orchestrator knows what the system will handle automatically.
- **Common workflows** — bulk issue processing, handling stuck agents, PR review flow, manual intervention.
- **Project-specific rules** — content of `orchestratorRules` from `ProjectConfig`, appended last.

For a guide on per-role agents, see [Per-Role Agents](/docs/guides/per-role-agents).

---

## Activity Detection

Every agent plugin must implement `getActivityState(session, readyThresholdMs?)`. This is the most critical method in the agent plugin — the dashboard, lifecycle manager, and stuck-detection all depend on it.

### The 6 activity states

| State | Meaning | When |
|---|---|---|
| `active` | Agent is processing — thinking, writing code, running tools | Activity within the last 30 seconds |
| `ready` | Agent finished its turn and is alive, waiting for input | 30 seconds – 5 minutes since last activity |
| `idle` | Agent has been quiet for an extended period | More than 5 minutes since last activity (default threshold) |
| `waiting_input` | Agent is at a permission prompt or asking a question | Permission request detected |
| `blocked` | Agent hit an error it cannot recover from on its own | Error state detected |
| `exited` | Agent process is no longer running | `isProcessRunning` returns false |

### The `getActivityState` cascade

Every agent plugin must implement this cascade in order:

```
1. PROCESS CHECK
   └─ isProcessRunning() → false → return { state: "exited" }

2. ACTIONABLE STATES
   └─ checkActivityLogState() → waiting_input or blocked → return immediately

3. NATIVE SIGNAL (agent-specific)
   └─ session list API, native JSONL timestamp, etc.
   └─ classify by age: active (<30s) / ready (30s–threshold) / idle (>threshold)

4. JSONL ENTRY FALLBACK (mandatory)
   └─ getActivityFallbackState(activityResult, activeWindowMs, threshold)
   └─ age-based decay: active→ready→idle (never promotes)
   └─ staleness cap: waiting_input/blocked entries expire after 5 minutes

5. Return null only when there is genuinely no data at all
```

<Callout type="warn">
Step 4 (the JSONL entry fallback) is mandatory. Skipping it means `getActivityState` returns `null` whenever the native API fails — the dashboard shows no activity state and stuck-detection breaks for the entire session lifetime. This was a real bug in the OpenCode plugin.
</Callout>

### Two JSONL patterns

| Pattern | Used by | How it works |
|---|---|---|
| **Agent-native JSONL** | Claude Code, Codex | The agent writes its own JSONL with rich state entries (`permission_request`, `tool_call`, `error`, etc.). `getActivityState` reads the last entry and maps it to activity states. |
| **AO activity JSONL** | Aider, OpenCode, new agents | The agent implements `recordActivity`, which calls `recordTerminalActivity()` → `classifyTerminalActivity()` → `appendActivityEntry()` to write to `{workspacePath}/.ao/activity.jsonl`. `getActivityState` reads from this file. |

### Thresholds

| Constant | Value | Purpose |
|---|---|---|
| `DEFAULT_ACTIVE_WINDOW_MS` | 30 seconds | Activity newer than this is `active`; older is `ready` |
| `DEFAULT_READY_THRESHOLD_MS` | 5 minutes | `ready` sessions older than this become `idle` |
| `ACTIVITY_INPUT_STALENESS_MS` | 5 minutes | Deprecated compatibility export. `waiting_input` / `blocked` entries no longer expire by wallclock; they persist until process death or a newer entry overrides them. |

---

## PATH Wrappers

When an agent creates a PR or switches a branch, AO needs to update the session metadata (e.g. write `pr=https://...` or `branch=feat/INT-123`) so the dashboard and lifecycle manager stay in sync. Two mechanisms exist:

**Claude Code — PostToolUse hooks**

Claude Code writes `.claude/settings.json` with a `PostToolUse` hook that fires after every `gh pr create` or `git checkout` command. The hook script calls `update_ao_metadata` directly.

**All other agents — PATH wrappers**

Agents without a native hook system (Codex, Aider, OpenCode, custom agents) use `~/.ao/bin/gh` and `~/.ao/bin/git` shell wrappers. These wrappers are installed to `~/.ao/bin/` by `setupPathWrapperWorkspace(workspacePath)` from `packages/core/src/agent-workspace-hooks.ts`. The function also writes session context to `{workspacePath}/.ao/AGENTS.md` (gitignored — does not touch tracked files).

The wrappers intercept:
- `gh pr create` — captures the PR URL from stdout and writes `pr=<url>` and `status=pr_open`
- `gh pr merge` — writes `status=merged`
- `git checkout -b <branch>` / `git switch -c <branch>` — writes `branch=<name>`

All other commands pass through transparently via `exec "$real_gh" "$@"` or `exec "$real_git" "$@"`.

For the user-facing storage summary, see [Configuration — Where data lives](/docs/configuration#where-data-lives).

---

## Observability

The lifecycle manager, session manager, and plugin registry emit structured telemetry using project observers created by `createProjectObserver()`. Each running process writes a JSON snapshot to:

```
~/.agent-orchestrator/{hash}-observability/processes/{component}-{pid}.json
```

The hash is the first 12 characters of the SHA-256 of the config directory path. The `{component}` segment matches the internal observer name (e.g. `lifecycle-manager`, `session-manager`).

The dashboard's `/api/observability` route reads and merges these per-process snapshots to produce a live observability view.

**Feedback reports** from the agent's `bug_report` and `improvement_suggestion` tools are written as flat key-value files at:

```
~/.agent-orchestrator/{hash}-{projectId}/feedback-reports/*.kv
```

---

## Data Flow Summary

```
agent-orchestrator.yaml ──► Config Loader (Zod) ──► Plugin Registry
                                                          │
                                          ┌───────────────┘
                                          │
                                          ▼
                                    Session Manager ◄─── ao spawn / ao session
                                          │
                                          ▼
                                  Lifecycle Manager ────► Events ────► Notifiers
                                          │                 │              │
                                          │           Reactions       Webhook
                                          │
                                          ▼
                                    Dashboard API
                                  (Next.js App Router)
                                          │
                              ┌───────────┴──────────────┐
                              │                           │
                              ▼                           ▼
                         SSE (5s)                  WebSocket (terminal)
                              │                           │
                              ▼                           ▼
                          React UI                   xterm.js
```

---

## Next Steps

<Cards>
  <Card title="Project Configuration" href="/docs/configuration/projects" description="Configure projects, agents, trackers, and reactions in agent-orchestrator.yaml." />
  <Card title="Authoring Plugins" href="/docs/plugins/authoring" description="Build custom runtime, agent, tracker, SCM, notifier, or terminal plugins." />
  <Card title="Reactions" href="/docs/configuration/reactions" description="Set up automated reactions to CI failures, review comments, and merge events." />
  <Card title="Configuration" href="/docs/configuration" description="Understand the global registry, local project config, and where AO stores runtime data." />
</Cards>
