Skip to main content
Glama
Hackerprod

Agent Orchestrator

by Hackerprod
README.md
# Agent Orchestrator

A shared MCP (Model Context Protocol) hub that lets multiple coding-agent CLIs register themselves and talk to each other — bidirectional, non-blocking, no fixed ports to remember.

Ships with adapters for [OpenCode](https://opencode.ai) and for Claude Code itself (via a companion `claude-channel` plugin, since Claude Code has no server of its own); the adapter interface is designed so support for other agent CLIs (Codex, Pi, Antigravity, ...) can be added without touching the core.

## Why This Exists

Bridges that spawn a subprocess, wait for it to finish, and collect the result don't let two agents actually converse — one side always blocks. Agent Orchestrator instead runs as a small **standalone daemon** that both agents connect to as MCP clients:

- Either side sends a message at any time with `agent_send`. Delivery is live for every adapter — the target starts processing it in its active turn immediately, the same way a human's message would. Nothing is queued.
- The reply comes back the same way: as the other agent's own `agent_send` call targeting you. There is no polling step in the middle — `agent_read` exists separately, for pulling a target's own past history (e.g. what it did during a long async turn), not for noticing new inbound messages.
- Registration is explicit and per-instance: you register only the specific agent window you want exposed, so a "working" window you never register stays completely invisible to the orchestrator.
- Every registration also names a `session` — an orchestrator-level group, agreed out of band with whoever you want to reach. Agents in different sessions can't see or address each other even if both are registered, which is what stops two unrelated conversations from crossing wires (a real failure mode this project hit and fixed — see [Troubleshooting](#troubleshooting)).

## Architecture

```
Claude Code ── stdio ──► claude-channel plugin ──┐
(this session)         (local :dynamic, REST+SSE)│
                                                    ├── HTTP (streamable MCP) ──► Agent Orchestrator daemon
opencode window A (registered) ────────────────────┘        :8765/mcp              (registry + per-agent adapter)
                                                                       │
opencode window B ─────────────────────────────────────────────  REST + SSE (per adapter)
(unregistered, never touched)                                         ▼
                                                              opencode A's own
                                                              HTTP API (:port), or
                                                              claude-channel's (:dynamic)
```

- **The daemon** (`agent-orchestrator`) is a long-running process exposing one MCP endpoint over Streamable HTTP. Claude Code and every opencode window connect to it as independent clients (stateful MCP sessions, `mcp-session-id` header).
- **The registry** tracks every registered agent (`{agentId, agentType, session, host, port, label}`) and, per agent, holds one persistent event stream plus a per-backend-session ring buffer (last 500 events, monotonic cursor — reading history never silently stalls once one passes the cap). Every lookup is scoped by `session`: an agentId that exists but belongs to a different session is treated as not found.
- **Adapters** translate the registry's generic calls into a specific backend's protocol. The `opencode` adapter talks to opencode's own REST + SSE API (`/session`, `/session/:id/prompt_async`, `/event`). The `claude` adapter talks the same REST + SSE shape, but to a local `claude-channel` companion process (see below), not to Claude Code directly.

### Reaching Claude Code

Claude Code, unlike opencode, has no server of its own to adapt to — so it can't be registered directly. `claude-channel` (`src/channel-plugin/claude-channel.ts`) closes that gap: it's a Claude Code ["channel" plugin](https://code.claude.com/docs/en/channels-reference) (research preview), spawned by Claude Code itself as a stdio MCP subprocess when the session starts with `--channels`/`--dangerously-load-development-channels`. It exposes a small local REST+SSE surface that mirrors the opencode adapter's shape, so the `claude` adapter can register and talk to it exactly like any other agent — inbound text gets pushed straight into the live session via the `claude/channel` notification (real push, not polling), and Claude's replies (via the plugin's `reply` tool) come back out over SSE.

The port is dynamic (OS-assigned, `0` by default) specifically so multiple Claude Code windows can each run their own instance without colliding — there's no fixed address to hardcode. Since there's nothing external to detect a not-yet-running process's port from, the instance reports its own to the model that owns it: baked into that session's connect-time system prompt, and re-checkable anytime via the plugin's `whoami` tool. Every live instance's `{pid, port, host, startedAt}` is also on disk at `runtime/claude-channel-instances/<pid>.json`, garbage-collected on each new startup (a file whose pid is no longer alive is a previous instance that crashed or was force-killed without cleanup, not a conflict — a dynamic port can't collide by construction). See [`skills/agent-orchestrator/references/claude-channel.md`](skills/agent-orchestrator/references/claude-channel.md) for setup.

With more than one `claude`-type agent registered in the same `session` (one per window), `list_agents` won't tell them apart beyond the `label` each was given at registration — always check it before assuming an `agentId` is yours. Windows on genuinely unrelated tasks should use different `session` names instead, so they can't see each other at all.

## Prerequisites

- Node.js >= 18.0.0
- [OpenCode CLI](https://opencode.ai) installed and authenticated (`npm install -g opencode-ai`, then `opencode login`)

## Installation

```bash
npm install
npm run build
```

## Running the daemon

```bash
npm start
# Agent Orchestrator listening on http://127.0.0.1:8765/mcp
```

It needs to be running before either Claude Code or opencode connects to it. Run it in its own terminal, or set it up as a background service — there's no auto-start built in on purpose, since it's a shared resource independent of any one agent's lifecycle.

### Supervised mode (recommended for background running)

`npm run start:supervised` runs the daemon under `scripts/supervisor.js`: stdout/stderr are captured to `runtime/daemon.out.log` / `runtime/daemon.err.log`, every start/exit is recorded in `runtime/daemon.supervisor.log` (exit code/signal), and the daemon is respawned with backoff after a crash. The daemon also installs crash guards that log `uncaughtException` / `unhandledRejection` stacks synchronously before exiting — a silent death becomes a diagnosable log line instead of a vanished hidden console. On Windows, pair it with a Task Scheduler entry at logon so it survives reboots.

### Environment variables

| Variable | Default | Description |
|---|---|---|
| `AGENT_ORCHESTRATOR_HOST` | `127.0.0.1` | Host the MCP endpoint binds to |
| `AGENT_ORCHESTRATOR_PORT` | `8765` | Port the MCP endpoint listens on |

### Persistence

Registrations and event history survive daemon restarts. Every change to the registry (an agent registering or unregistering, an event landing in a buffer) is written to `runtime/state.json`, debounced to at most one write every 500ms, with a final flush on shutdown. On boot the state is loaded back: each persisted agent is re-validated with a live health check and only re-registered when it's still reachable (a window that died while the daemon was down is skipped), and its per-session event buffers are restored so `agent_read` history survives. MCP client connections themselves are transient — each client must reconnect and re-initialize after a restart — but the registrations they rely on persist.

## Connecting Claude Code

```bash
claude mcp add --transport http agent-orchestrator -s user http://127.0.0.1:8765/mcp
```

## Connecting opencode

```bash
opencode mcp add
# follow the prompt: transport = http, url = http://127.0.0.1:8765/mcp
```

## Registering an agent instance

This is the deliberate, per-instance step that keeps unregistered windows invisible to the orchestrator — there's no automatic/background registration, no plugin hook, no polling for new instances. You decide, per window, when it joins.

The full operational guide (how to detect your own port safely, the complete tool reference, a worked example) lives in [`skills/agent-orchestrator/SKILL.md`](skills/agent-orchestrator/SKILL.md) — written to be followed by any agent CLI, not just Claude Code. `AGENTS.md` at the repo root points opencode (and other AGENTS.md-aware CLIs) at it automatically.

## MCP Tools

Every tool below takes a mandatory `session` — an orchestrator-level group name, not a backend chat id (that's `backendSessionId`, a separate thing). Only agents sharing the same `session` can see or address each other; agree on a name with whoever you want to reach before registering.

### `register_agent`
Register a running agent instance. Input: `{ agentType, session, port, host?, label? }`. Output: the stored `AgentRecord`. The first registration with a new `session` name creates it.

### `unregister_agent`
Input: `{ agentId, session }`. Stops its event stream and drops its buffers. Returns `false` if that `agentId` isn't registered in that `session` — even if it exists in a different one.

### `list_agents`
Input: `{ session }`. Returns every agent registered in that session — nothing from any other.

### `agent_create_backend_session`
Creates a chat thread on the target's own backend (e.g. a fresh opencode session) — not related to the orchestrator `session` beyond needing it to find the agent. Input: `{ agentId, session, title?, agent? }`. Output: `{ backendSessionId, title }`.

### `agent_list_backend_sessions`
Input: `{ agentId, session }`. Returns every chat thread known to that agent's backend (not just ones created through the orchestrator — opencode's session store is shared across all its own instances).

### `agent_send`
Input: `{ agentId, session, text, backendSessionId?, agent?, system? }`. Delivers live into the target's active turn for every adapter — this call itself returns as soon as the backend accepts it (`{ accepted: true }`), not once the target replies, but the reply itself also arrives live, as the target's own `agent_send` call back to you. Don't follow it with an `agent_read` loop; there's nothing there to wait for. Fails if `agentId` isn't registered in `session`. `backendSessionId` is optional: omit it to use the session's single canonical conversation — the daemon resolves (or creates) one backend session per (session, agent) automatically and persists it (claude → `'default'`; opencode → a dedicated session titled with the orchestrator session name).

### `agent_read`
Input: `{ agentId, session, backendSessionId, sinceCursor? }`. Output: `{ events, cursor, hasMore, droppedEvents? }`. Retrieves a target's **past** event history — useful for checking what an agent did during a long async turn, not for receiving new messages (those arrive live via `agent_send`, see above). `droppedEvents` appears if your cursor pointed at history already evicted from the 500-event ring buffer.

### `agent_status`
Input: `{ agentId, session }`. Returns the agent's record plus a live `reachable` health check.

### `user_send`
Send a message to the human user. Input: `{ session, text }`. Output: `{ accepted }`. The Telegram bot delivers it to the user's chat — it only ever reaches the human, never another agent.

### `user_send_file`
Send one or more local files (+ optional message) to the human user. Input: `{ session, paths, text? }`. Output: `{ accepted, files }`. The bot uploads each file to Telegram (`sendDocument`; `text` becomes the caption on the first file only). All paths must exist on the daemon's machine. Delivery is best-effort — if the bot isn't running, the event is not queued.

### `reply` (claude-channel only, not the orchestrator daemon)
A separate MCP server — the local `claude-channel` process, not `agent-orchestrator` itself. Input: `{ text }`. Writes to that channel's own local transcript, for a human directly watching it (e.g. via `/events`). It does **not** reach another orchestrator agent; to answer whoever messaged Claude Code, call `agent_send` targeting their `agentId`, same as any other agent.

## Adding a new agent type

Implement the `AgentAdapter` interface (`src/adapters/types.ts`) for the new backend and register it in `src/adapters/index.ts`. The registry, event buffering, cursor logic, and every MCP tool are already generic — they don't know or care which adapter they're talking to.

## Telegram bridge

A parallel, human-facing channel that runs completely outside the agent graph: a separate `telegram-bot/` process (grammY) talks to an internal `/bridge` REST + SSE surface on the daemon. It mirrors inter-agent messages live to a Telegram chat and lets a human send messages to any agent — without agents ever learning the channel exists. No new agent type, no registration, no changes to adapters or to the registry's event buffers; the daemon just emits a mirrored message onto an in-memory bus whenever an `agent_send` is delivered.

| Endpoint | Description |
|---|---|
| `GET /bridge/sessions` | List all orchestrator session names |
| `GET /bridge/agents?session=X` | List agents registered in session X |
| `GET /bridge/threads?session=X&agentId=A` | List A's backend chat threads |
| `GET /bridge/read?session=X&agentId=A&backendSessionId=B&sinceCursor=N` | Read A's past events for thread B since cursor N |
| `POST /bridge/send` | Send a message to an agent; body `{session, agentId, text, backendSessionId?, agent?, system?}` (`backendSessionId` omitted → the daemon resolves the agent's canonical conversation automatically) |
| `GET /bridge/events?session=X` | SSE stream of inter-agent messages in session X |

Every `/bridge` route requires the shared `BRIDGE_TOKEN`, sent either as `Authorization: Bearer <token>` or `?token=<token>`. Leave `BRIDGE_TOKEN` unset to disable the surface entirely (all routes return `403`).

| Variable | Default | Description |
|---|---|---|
| `BRIDGE_TOKEN` | — | Shared secret for `/bridge`; unset disables the surface |

For a noob-friendly setup, run `cd telegram-bot && npm run setup` — it asks for the bot token interactively (create the bot with @BotFather, message it once so your chat id can be detected), generates the shared `BRIDGE_TOKEN`, and writes both `telegram-bot/.env` and the root `.env` automatically. Then restart the daemon (it loads `.env` at startup) and run `npm run dev` in `telegram-bot/`.

Run and configure the Telegram side from `telegram-bot/` — see [telegram-bot/README.md](telegram-bot/README.md) for its env vars and start instructions. The daemon must be restarted (with `BRIDGE_TOKEN` set) to pick up the `/bridge` surface.

## Troubleshooting

**Port already in use** — set `AGENT_ORCHESTRATOR_PORT` to something else and re-register both clients against the new URL.

**`register_agent` fails with "Could not verify a reachable agent"** — the port doesn't respond, or it's not actually opencode. Re-run the port-detection step from inside the target window; ports change every time opencode restarts.

**Sent a message but the other agent never seemed to receive it** — check you called `agent_send`, not `agent_read` (there's nothing to receive by polling) or, on the Claude Code side, the channel's own `reply` tool (that only writes to its local transcript, never to another agent).

**Task finished but no completion report came back** — reporting back isn't automatic. The receiving agent has no guarantee it consulted the skill before acting, so its default is to answer wherever it normally would (its own console/human), not through the channel. Ask for the report explicitly in the message: "reply via agent_send to `<your agentId>` when done."

**A reply landed on the wrong agent, or `agent_send`/`list_agents` claims an agentId doesn't exist even though it was just registered** — almost always a `session` mismatch: either the two sides agreed on different names, or one side is reusing a memorized `agentId` from an earlier, unrelated `session` instead of re-checking `list_agents` for the current one. This is exactly the isolation working as intended (see [Why This Exists](#why-this-exists)) — re-confirm both sides are using the same `session` string, not just guessing.

**Logs** — `runtime/orchestrator.log` (created next to wherever you run the daemon from).

## Development

```bash
npm run typecheck
npm run lint
npm test
npm run build
```

Tests are network-free: adapter SSE parsing and the registry's cursor/ring-buffer logic are both tested against fakes, no real opencode instance required.

## License

MIT. See [LICENSE](./LICENSE) for details.