33 KiB
Architecture Design — Agent Orchestrator
Compiled: 2026-02-13
Core Philosophy
Push, not pull. The human never polls. The human never checks a dashboard wondering "what's happening?" The system pushes notifications to the human exactly when their attention is needed — and stays silent otherwise.
The dashboard is a drill-down tool you open after receiving a notification, not something you sit and watch. The Notifier is the primary interface.
Interaction Model
Human spawns 20 agents → walks away → lives their life
│
┌───────────────────────────────┘
│
▼
Orchestrator runs autonomously:
├── Agents work on issues
├── CI fails? → auto-send fix to agent → resolved silently
├── Review comments? → auto-send to agent → resolved silently
├── Agent stuck? → NOTIFY HUMAN
├── Agent needs input? → NOTIFY HUMAN
├── PR ready to merge? → NOTIFY HUMAN (or auto-merge if configured)
├── Agent errored? → NOTIFY HUMAN
└── All done? → NOTIFY HUMAN with summary
Human only intervenes when notified. Everything else is handled.
Design Principles
- Push, not pull: Notifications are the primary interface. Dashboard is secondary drill-down.
- Server-centric: All agents report to a central server. The server coordinates everything.
- Plugin everything: 8 pluggable abstraction slots. Swap any component.
- Works out of the box: Default config (tmux + claude-code + worktree + github) requires zero setup beyond
npx agent-orchestrator init. - Silence by default, loud when needed: Auto-handle routine issues (CI failures, review comments). Only notify the human when their judgment or action is truly required.
- Runtime agnostic: tmux is just one way to run agents. Docker, K8s, cloud, SSH, child processes — all through the same interface.
Nomenclature
| Term | Definition | Examples |
|---|---|---|
| Orchestrator | The central server that manages everything | The Next.js app |
| Project | A configured repository to work on | my-app, backend-api |
| Session | A running agent instance working on a task | my-app-1, my-app-2 |
| Runtime | Where/how the session executes | tmux, docker, k8s, process |
| Agent | The AI coding tool being used | claude-code, codex, aider |
| Workspace | Isolated code copy for a session | git worktree, clone, volume |
| Tracker | Issue/task tracking system | github, linear, jira |
| SCM | Source code management platform | github, gitlab, bitbucket |
| Notifier | Communication/alert channel | slack, discord, desktop, webhook |
| Terminal | Human interaction interface | iterm2, web terminal, none |
System Architecture
┌──────────────────────────────────────┐
CLI ───REST───► │ Orchestrator Server │
│ (Next.js) │
Web ───REST/───► │ │
SSE │ ┌────────────┐ ┌────────────────┐ │
│ │ Session │ │ Plugin │ │
Agents ────────► │ │ Manager │ │ Registry │ │
(heartbeat/ │ └──────┬─────┘ └───────┬────────┘ │
webhook) │ │ │ │
│ ┌──────┴─────┐ ┌───────┴────────┐ │
│ │ Lifecycle │ │ Config │ │
│ │ Manager │ │ Manager │ │
│ └──────┬─────┘ └────────────────┘ │
│ │ │
│ ┌──────┴──────────────────────────┐ │
│ │ Event Bus │ │
│ │ (pub/sub + persistence) │ │
│ └──┬──────┬──────┬──────┬────────┘ │
└─────┼──────┼──────┼──────┼──────────┘
│ │ │ │
┌───────┘ │ │ └───────┐
▼ ▼ ▼ ▼
┌─────────┐ ┌────────┐ ┌────────┐ ┌─────────┐
│ SSE → │ │Notifier│ │Reaction│ │ Event │
│ Web UI │ │Plugins │ │ Engine │ │ Log │
└─────────┘ └────────┘ └────────┘ └─────────┘
Data Flow
- Agent → Server: Heartbeats, status updates, "need input" signals
- Server → Dashboard: SSE stream of session state changes
- Server → Notifiers: Alerts when human attention is needed
- Server → Agents: Commands via runtime-specific channels (tmux send-keys, docker exec, HTTP POST, etc.)
- CLI → Server: REST API calls for spawn, kill, send, status
- SCM → Server: PR state, CI checks, review comments (polled or webhooks)
The 8 Plugin Slots
1. Runtime — Where sessions execute
interface Runtime {
readonly name: string;
// Lifecycle
create(session: SessionConfig): Promise<RuntimeHandle>;
destroy(handle: RuntimeHandle): Promise<void>;
// Communication
sendMessage(handle: RuntimeHandle, message: string): Promise<void>;
getOutput(handle: RuntimeHandle, lines?: number): Promise<string>;
// Health
isAlive(handle: RuntimeHandle): Promise<boolean>;
getMetrics(handle: RuntimeHandle): Promise<RuntimeMetrics>;
// Optional: interactive access
attach?(handle: RuntimeHandle): Promise<AttachInfo>;
}
| Implementation | How it works | Best for |
|---|---|---|
tmux (default) |
tmux sessions + send-keys | Local development, interactive |
process |
Child processes + stdin/stdout | Headless, CI/CD, scripting |
docker |
Docker containers + exec | Isolation, reproducibility |
kubernetes |
K8s pods/jobs | Scale, enterprise |
ssh |
SSH to remote + tmux/process | Remote machines |
e2b |
E2B SDK (Firecracker microVMs) | Cloud sandboxes |
fly |
Fly.io Machines API | Cost-effective cloud |
modal |
Modal Sandboxes | GPU, autoscaling |
2. Agent — AI coding tool
interface Agent {
readonly name: string;
readonly processName: string; // for detection
// Launch
getLaunchCommand(session: SessionConfig, project: ProjectConfig): string;
getEnvironment(session: SessionConfig): Record<string, string>;
// Activity detection
detectActivity(session: Session): Promise<ActivityState>;
isProcessRunning(runtimeHandle: RuntimeHandle): Promise<boolean>;
// Introspection
introspect(session: Session): Promise<AgentIntrospection | null>;
// Optional
postLaunchSetup?(session: Session): Promise<void>;
estimateCost?(session: Session): Promise<CostEstimate>;
}
| Implementation | Launch command | Activity detection |
|---|---|---|
claude-code (default) |
claude --dangerously-skip-permissions |
JSONL mtime + process tree |
claude-headless |
claude -p --output-format stream-json |
stdout parsing |
codex |
codex |
Process detection |
aider |
aider --no-auto-commits |
Process detection |
goose |
goose session |
Process detection |
custom |
User-defined command | Configurable |
3. Workspace — Code isolation
interface Workspace {
readonly name: string;
create(project: ProjectConfig, session: SessionConfig): Promise<WorkspacePath>;
destroy(path: WorkspacePath): Promise<void>;
list(project: ProjectConfig): Promise<WorkspaceInfo[]>;
// Optional hooks
postCreate?(path: WorkspacePath, project: ProjectConfig): Promise<void>;
}
| Implementation | How | Tradeoff |
|---|---|---|
worktree (default) |
git worktree add |
Fast, shared objects, requires same repo |
clone |
git clone |
Full isolation, slower, more disk |
copy |
cp -r |
No git dependency, heaviest |
volume |
Docker/K8s volume mounts | For container runtimes |
4. Tracker — Issue/task tracking
interface Tracker {
readonly name: string;
getIssue(identifier: string): Promise<Issue>;
isCompleted(identifier: string): Promise<boolean>;
issueUrl(identifier: string): string;
branchName(identifier: string): string;
generatePrompt(identifier: string, project: ProjectConfig): string;
// Optional
listIssues?(filters?: IssueFilters): Promise<Issue[]>;
updateIssue?(identifier: string, update: IssueUpdate): Promise<void>;
createIssue?(input: CreateIssueInput): Promise<Issue>;
}
| Implementation | API | Auth |
|---|---|---|
github (default) |
gh CLI |
GitHub token |
linear |
GraphQL API | Linear API key |
jira |
REST API | Jira token |
plain |
Local files | None |
5. SCM — Source code platform (PR, CI, Reviews)
interface SCM {
readonly name: string;
// PR lifecycle
detectPR(session: Session): Promise<PRInfo | null>;
getPRState(pr: PRInfo): Promise<PRState>;
createPR(session: Session, title: string, body: string): Promise<PRInfo>;
mergePR(pr: PRInfo, method?: MergeMethod): Promise<void>;
closePR(pr: PRInfo): Promise<void>;
// CI tracking
getCIChecks(pr: PRInfo): Promise<CICheck[]>;
getCISummary(pr: PRInfo): Promise<CIStatus>;
// Review tracking
getReviews(pr: PRInfo): Promise<Review[]>;
getReviewDecision(pr: PRInfo): Promise<ReviewDecision>;
getPendingComments(pr: PRInfo): Promise<ReviewComment[]>;
getAutomatedComments(pr: PRInfo): Promise<AutomatedComment[]>;
// Merge readiness
getMergeability(pr: PRInfo): Promise<MergeReadiness>;
}
| Implementation | API | Features |
|---|---|---|
github (default) |
gh CLI + REST API |
Full PR/CI/review support |
gitlab |
REST API | MR/pipeline/review support |
bitbucket |
REST API | PR/pipeline support |
6. Notifier — THE PRIMARY INTERFACE
The notifier is not a nice-to-have — it is the primary way the system communicates with humans. The human walks away after spawning agents. Notifications bring them back only when needed.
interface Notifier {
readonly name: string;
// Core: push a notification to the human
notify(event: OrchestratorEvent): Promise<void>;
// Optional: actionable notifications (buttons/links)
notifyWithActions?(event: OrchestratorEvent, actions: NotifyAction[]): Promise<void>;
// Optional: richer communication (post to channel)
post?(message: string, context?: NotifyContext): Promise<string | null>;
}
// Notifications can include actions the human can take directly
interface NotifyAction {
label: string; // "Merge PR", "Open Dashboard", "Kill Session"
url?: string; // Deep link to dashboard action
callback?: string; // API endpoint to call
}
| Implementation | Channel | Best for | Actionable? |
|---|---|---|---|
desktop (default) |
OS notifications (clickable) | Solo developer | Click → opens dashboard |
slack |
Slack messages with buttons | Teams | Buttons → merge, review, kill |
discord |
Discord messages | Communities | Links |
webhook |
HTTP POST | Custom integrations | Custom |
email |
Email digest | Async | Links |
Multiple notifiers can be active simultaneously. E.g., desktop for immediate alerts + Slack for team visibility + email for daily digest.
7. Terminal — Human interaction interface
interface Terminal {
readonly name: string;
openSession(session: Session): Promise<void>;
openAll(sessions: Session[]): Promise<void>;
// Optional
isSessionOpen?(session: Session): Promise<boolean>;
}
| Implementation | How | Platform |
|---|---|---|
auto (default) |
Detect best available | Any |
iterm2 |
AppleScript API | macOS |
web |
xterm.js in browser | Any |
tmux-attach |
tmux attach in terminal |
Any with tmux |
none |
Headless | CI/CD |
8. Lifecycle Manager (Core — not pluggable)
The Lifecycle Manager is the orchestrator's brain. It:
- Polls SCM + Agent plugins on configurable intervals
- Maintains state machine per session
- Emits events on state transitions
- Runs configured reactions
- Feeds real-time data to dashboard via SSE
Session Lifecycle State Machine
┌──────────┐
│ SPAWNING │
└────┬─────┘
│ runtime.create() + agent launched
▼
┌──────────┐
┌─────│ WORKING │◄─────────────────────────┐
│ └────┬─────┘ │
│ │ PR detected │
│ ▼ │
│ ┌──────────────┐ │
│ │ PR_OPEN │ │
│ └────┬─────────┘ │
│ │ │
│ ┌────┴────────────┐ │
│ ▼ ▼ │
│ ┌──────────┐ ┌─────────────────┐ │
│ │ CI_FAILED│ │ REVIEW_PENDING │ │
│ └────┬─────┘ └────┬────────────┘ │
│ │ │ │
│ │ ┌──────────┴──────┐ │
│ │ ▼ ▼ │
│ │ ┌──────────────┐ ┌──────────┐ │
│ │ │CHANGES_REQ'D │ │ APPROVED │ │
│ │ └──────┬───────┘ └────┬─────┘ │
│ │ │ │ │
│ └────────┼───────────────┘ │
│ │ agent fixes │
│ └──────────────────────────┘
│
│ When approved + CI green + no conflicts:
│ ▼
│ ┌──────────┐
│ │MERGEABLE │──► auto-merge or notify human
│ └────┬─────┘
│ │
│ ▼
│ ┌──────────┐
│ │ MERGED │
│ └────┬─────┘
│ │
│ ▼
│ ┌──────────┐
│ │ CLEANUP │──► destroy workspace + archive metadata
│ └──────────┘
│
│ At any point:
│ ┌───────────────┐
├────►│ NEEDS_INPUT │──► notify human
│ └───────────────┘
│ ┌───────────────┐
├────►│ STUCK/IDLE │──► notify human after threshold
│ └───────────────┘
│ ┌───────────────┐
├────►│ ERRORED │──► notify human
│ └───────────────┘
│ ┌───────────────┐
└────►│ KILLED │──► cleanup
└───────────────┘
Human Attention Optimization
The system notifies the human. The human never polls.
The orchestrator operates on a simple principle: handle everything you can automatically, and push a notification to the human only when their judgment or action is truly required. The human spawns agents, walks away, and gets notified.
Two-Tier Event Handling
Tier 1: Auto-handled (human never sees these)
The orchestrator resolves these silently. The human is only notified if auto-resolution fails.
| Event | Auto-Response | Escalation |
|---|---|---|
| CI failed | Send fix prompt to agent | Notify after 2 failed attempts |
| Review comments | Send "address comments" to agent | Notify if unresolved after 30min |
| Bugbot/linter comments | Send fix prompt to agent | Notify if unresolved after 30min |
| Merge conflicts | Send "rebase" to agent | Notify if unresolved after 15min |
Tier 2: Notify human (requires human judgment)
These always push a notification. The human's phone buzzes, Slack pings, etc.
| Event | Priority | Notification |
|---|---|---|
| Agent needs input (permission, question, stuck) | URGENT | "Session X needs your input" + deep link |
| Agent errored (crashed, unrecoverable) | URGENT | "Session X crashed" + error context |
| PR ready to merge (approved + CI green) | ACTION | "PR #42 ready to merge" + merge button |
| Agent idle too long (no PR, no progress) | WARNING | "Session X idle for 15min, may need help" |
| Auto-fix failed (CI fix failed 2x, comments not addressed) | WARNING | "Session X couldn't resolve CI/review — needs you" |
| All work complete | INFO | "All 20 sessions done. 18 PRs merged, 2 need review." |
Escalation Chains
Events start at auto-handle and escalate through notification tiers:
Event detected
│
▼
Can auto-handle? ──yes──► Auto-respond (send to agent)
│ │
no Resolved? ──yes──► Done (silent)
│ │
▼ no (retry N times)
NOTIFY HUMAN │
│ ▼
│ NOTIFY HUMAN
│ "Tried to auto-fix, couldn't resolve"
▼
Human acts via:
├── Notification action button (merge, kill, open)
├── Dashboard deep link
├── CLI command
└── Direct tmux attach
Notification Channels (Priority-Based Routing)
Different priorities route to different channels:
notifications:
routing:
urgent: [desktop, slack, sms] # Agent stuck, errored, needs input
action: [desktop, slack] # PR ready to merge
warning: [slack] # Auto-fix failed, idle too long
info: [slack] # Summary, all done
Reactions (configurable auto-responses)
# agent-orchestrator.yaml
reactions:
ci-failed:
auto: true
action: send-to-agent
message: "CI is failing. Run `gh pr checks` to see failures, fix them, and push."
retries: 2
escalate-after: 2 # notify human after 2 failed auto-fix attempts
changes-requested:
auto: true
action: send-to-agent
message: "Review comments on your PR. Check with `gh pr view --comments` and address each one."
escalate-after: 30m
bugbot-comments:
auto: true
action: send-to-agent
message: "Automated review comments found. Fix the issues flagged by the bot."
escalate-after: 30m
merge-conflicts:
auto: true
action: send-to-agent
message: "Your branch has merge conflicts. Rebase on the default branch and resolve them."
escalate-after: 15m
approved-and-green:
auto: false # require human confirmation by default
action: notify
priority: action
message: "PR is ready to merge"
# Set auto: true + action: auto-merge for full automation
agent-stuck:
threshold: 10m
action: notify
priority: urgent
agent-needs-input:
action: notify
priority: urgent
agent-exited:
action: notify
priority: urgent
all-complete:
action: notify
priority: info
message: "All sessions complete"
include-summary: true # PRs merged, pending, failed
agent-idle-no-pr:
threshold: 30m # working for 30min with no PR
action: notify
priority: warning
message: "Agent has been working for 30min without creating a PR"
Dashboard (Secondary — Drill-Down Tool)
The dashboard exists for when you get a notification and need to drill down. It's organized by attention priority:
- Red zone (top): URGENT — sessions needing human input RIGHT NOW
- Orange zone: ACTION — PRs ready to merge, decisions needed
- Yellow zone: WARNING — auto-fix failed, agents idle too long
- Green zone: Sessions working normally (collapsed by default)
- Grey zone: Completed/merged (collapsed by default)
Clicking a notification deep-links directly to the relevant session/PR in the dashboard.
Configuration
Minimal Config (works out of the box)
# agent-orchestrator.yaml
projects:
my-app:
repo: org/repo
path: ~/my-app
Everything else uses sensible defaults:
- Runtime: tmux
- Agent: claude-code
- Workspace: worktree
- Tracker: github (inferred from repo)
- SCM: github (inferred from repo)
- Notifier: desktop
- Terminal: auto-detect
Full Config
# agent-orchestrator.yaml
dataDir: ~/.agent-orchestrator # metadata storage
worktreeDir: ~/.worktrees # workspace root
port: 3000 # web dashboard port
defaults:
runtime: tmux
agent: claude-code
workspace: worktree
notifiers: [desktop]
projects:
my-app:
name: My App
repo: org/repo
path: ~/my-app
defaultBranch: main
sessionPrefix: app
# Override defaults per project
agent: claude-code
runtime: tmux
# Issue tracker
tracker:
plugin: linear
teamId: "abc-123"
# SCM (usually inferred from repo)
scm:
plugin: github
# Symlinks to copy into workspaces
symlinks: [.env, .claude]
# Commands to run after workspace creation
postCreate:
- "pnpm install"
- "claude mcp add rube --transport http https://rube.app/mcp"
# Agent-specific config
agentConfig:
permissions: skip # --dangerously-skip-permissions
model: opus
# Reaction overrides
reactions:
approved-and-green:
auto: true # enable auto-merge for this project
# Notification channels
notifiers:
slack:
plugin: slack
webhook: ${SLACK_WEBHOOK_URL}
channel: "#agent-updates"
desktop:
plugin: desktop
# Reaction defaults (can be overridden per project)
reactions:
ci-failed:
auto: true
retries: 2
escalate-after: 2
changes-requested:
auto: true
escalate-after: 30m
approved-and-green:
auto: false
agent-stuck:
threshold: 10m
agent-needs-input:
priority: high
Tech Stack
| Segment | Choice | Why |
|---|---|---|
| Core library | TypeScript | Shared types across all packages |
| Web + API | Next.js 15 (App Router) | SSR + API routes in one process |
| Styling | Tailwind CSS | Dark theme, responsive |
| Real-time | Server-Sent Events | One-way push, auto-reconnect, simpler than WebSocket |
| CLI | TypeScript + Commander.js | Shares types with core |
| Config | YAML + Zod validation | Human-readable, type-safe |
| State | Flat metadata files + Event log (JSONL) | Stateless orchestrator, crash recovery |
| Package manager | pnpm workspaces | Fast, monorepo-native |
| Distribution | npm (npx agent-orchestrator) |
Zero install |
Why TypeScript Throughout
- One language — Plugin authors only need TypeScript/JavaScript
- Shared types — No serialization boundaries between core, web, CLI, plugins
- npm distribution —
npx agent-orchestratorworks everywhere - Next.js — Web + API server in one process, great DX
- Largest ecosystem — More packages on npm than any other registry
- Performance is fine — Bottleneck is AI agents, not orchestrator. We shell out to tmux/git/docker anyway.
Directory Structure
agent-orchestrator/
├── package.json
├── pnpm-workspace.yaml
├── tsconfig.base.json
├── agent-orchestrator.yaml.example
│
├── packages/
│ ├── core/ # @aoagents/ao-core
│ │ └── src/
│ │ ├── types.ts # All interfaces + types
│ │ ├── config.ts # YAML config loader + Zod validation
│ │ ├── session-manager.ts # Session CRUD
│ │ ├── lifecycle-manager.ts # State machine + reactions
│ │ ├── event-bus.ts # Pub/sub + JSONL persistence
│ │ ├── plugin-registry.ts # Plugin discovery + loading
│ │ ├── metadata.ts # Flat-file read/write
│ │ └── index.ts
│ │
│ ├── cli/ # @aoagents/ao-cli → `ao` binary
│ │ └── src/
│ │ ├── index.ts # Commander.js setup
│ │ └── commands/
│ │ ├── init.ts # ao init
│ │ ├── status.ts # ao status
│ │ ├── spawn.ts # ao spawn <project> [issue]
│ │ ├── batch-spawn.ts # ao batch-spawn <project> <issues...>
│ │ ├── session.ts # ao session [ls|kill|cleanup]
│ │ ├── send.ts # ao send <session> <message>
│ │ ├── review-check.ts # ao review-check [project]
│ │ ├── dashboard.ts # ao dashboard (starts web)
│ │ └── open.ts # ao open [session|all]
│ │
│ ├── web/ # @aoagents/ao-web
│ │ ├── next.config.ts
│ │ └── src/
│ │ ├── app/
│ │ │ ├── layout.tsx
│ │ │ ├── page.tsx # Dashboard (attention-prioritized)
│ │ │ └── sessions/[id]/
│ │ │ └── page.tsx # Session detail
│ │ ├── api/
│ │ │ ├── sessions/ # CRUD + actions
│ │ │ ├── spawn/ # POST spawn
│ │ │ ├── events/ # SSE stream
│ │ │ └── health/ # Server health
│ │ └── components/
│ │ ├── SessionCard.tsx
│ │ ├── AttentionZone.tsx
│ │ ├── PRStatus.tsx
│ │ ├── CIBadge.tsx
│ │ └── Terminal.tsx # xterm.js
│ │
│ └── plugins/ # Built-in plugins
│ ├── runtime-tmux/
│ ├── runtime-process/
│ ├── runtime-docker/
│ ├── agent-claude-code/
│ ├── agent-codex/
│ ├── agent-aider/
│ ├── workspace-worktree/
│ ├── workspace-clone/
│ ├── tracker-github/
│ ├── tracker-linear/
│ ├── scm-github/
│ ├── notifier-desktop/
│ ├── notifier-slack/
│ ├── terminal-iterm2/
│ └── terminal-web/
│
├── artifacts/ # Research + design docs
│ ├── competitive-research.md
│ └── architecture-design.md
│
├── scripts/ # Original bash scripts (reference)
│
└── CLAUDE.md
Implementation Phases
Phase 1: Foundation (Dog-food ready)
- Monorepo scaffolding
- Core types + interfaces
- Config loader
- Session manager + lifecycle manager + event bus
- tmux runtime, claude-code agent, worktree workspace
- GitHub SCM (PR/CI/review tracking)
- GitHub tracker
- Desktop notifier
- CLI (init, status, spawn, session, send, dashboard)
- Web dashboard with attention-prioritized view
- SSE real-time updates
- Reaction engine (CI failed, changes requested, agent stuck)
Phase 2: Multi-Runtime + More Plugins
- Process runtime (headless claude -p)
- Docker runtime
- Codex + Aider agent adapters
- Linear + Jira trackers
- Slack notifier
- Web terminal (xterm.js)
Phase 3: Cloud + Scale
- Kubernetes runtime
- E2B / Fly.io runtimes
- Cost tracking
- Webhook-triggered spawning
Phase 4: Team + Enterprise
- Dashboard auth
- Role-based access
- Remote session support
- Audit log