From 5f5c84e5245861ea41e927234a5fea5c6191dbab Mon Sep 17 00:00:00 2001 From: Dai Ha Date: Wed, 8 Jul 2026 16:39:32 +0200 Subject: [PATCH] Add Architecture page (channels, subscription boundary, broker, herdr) --- Architecture.md | 209 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 209 insertions(+) create mode 100644 Architecture.md diff --git a/Architecture.md b/Architecture.md new file mode 100644 index 0000000..64b3339 --- /dev/null +++ b/Architecture.md @@ -0,0 +1,209 @@ +# Architecture + +`claude-bridge` connects a **primary** Claude Code session (Opus 4.8, on your Pro/Max +subscription) to one or more **secondary** Claude Code **workers** running a *different* +model. Two channels carry traffic between them: + +1. **Synchronous** — primary delegates a task and waits for the reply → **AgentAPI** (HTTP + over terminal emulation). This is the main channel. +2. **Asynchronous / duplex** — either side drops a message for the other to pick up when + idle → a **message broker** polled by a `Stop`-hook long-poll. This is the optional + layer for detached progress reports and out-of-band questions. + +A third piece, **herdr**, is *not* a transport — it is the human-facing ops/observability +layer (persistence, state sidebar, a readiness gate). + +## Components + +```mermaid +flowchart TB + subgraph prim["PRIMARY — subscription (env CLEAN)"] + OPUS["Claude Code · Opus 4.8
leads, reviews, merges"] + end + + subgraph work["SECONDARY worker(s) — off-subscription"] + AA["agentapi server
HTTP :3284"] + WCC["claude (worker)
ANTHROPIC_BASE_URL set"] + HOOK["Stop-hook
long-poll client"] + AA -->|"launches / drives PTY"| WCC + WCC --- HOOK + end + + BROKER["Broker
Redis Streams / NATS JetStream
inbox-primary · inbox-worker"] + MODEL["ollama.ltms.dev /v1
or GX10 vLLM
(worker model)"] + HERDR["herdr server
panes · state · socket API
(ops layer, optional)"] + + OPUS -->|"POST /message (sync)"| AA + AA -.->|"SSE /events (reply)"| OPUS + OPUS -.->|"write / long-poll (async)"| BROKER + HOOK -.->|"long-poll / write (async)"| BROKER + WCC -->|"inference"| MODEL + HERDR -.->|"send-keys · agent wait
pane read"| WCC + + classDef sub fill:#2b6cb0,stroke:#1a365d,color:#ffffff; + classDef pick fill:#2f855a,stroke:#22543d,color:#ffffff; + classDef warn fill:#b7791f,stroke:#7b341e,color:#ffffff; + class OPUS sub + class AA,WCC pick + class BROKER,HERDR warn +``` + +*Figure: solid arrows = synchronous AgentAPI channel; dotted = the optional async broker +and the herdr ops plane. The primary's env stays clean; only the worker sets +`ANTHROPIC_BASE_URL`.* + +## Subscription boundary (the non-negotiable) + +The whole design exists to keep the **primary** on Pro/Max while the **worker** runs a +cheaper/local model — without a policy-violating proxy on the primary. + +- The **primary** `claude` process **never** sets `ANTHROPIC_BASE_URL`. It talks to the + worker over **HTTP** (AgentAPI) or a **broker**, never by re-pointing its own endpoint. +- Only the **worker's** `claude` process launches with + `ANTHROPIC_BASE_URL=https://ollama.ltms.dev` (+ `ANTHROPIC_AUTH_TOKEN` bearer) or a GX10 + vLLM URL. Because it is a *separate process*, model selection is just per-process env — + which also sidesteps Claude Code's lack of per-subagent provider routing (the worker is + not a subagent). + +> **Rule:** anything that sets `ANTHROPIC_BASE_URL` is, by definition, the worker. If you +> ever feel tempted to set it on the primary, stop — that is the subscription line. + +## Channel 1 — AgentAPI (synchronous, the main path) + +[`coder/agentapi`](https://github.com/coder/agentapi) wraps the worker's `claude` **CLI** +as an HTTP server (`POST /message`, `GET /messages`, `GET /events` SSE, `GET /status`), +driving the CLI's terminal underneath. One session per server. + +```mermaid +sequenceDiagram + participant P as "Primary (Opus)" + participant A as "agentapi server" + participant W as "Worker claude (other model)" + + P->>A: "POST /message (task, full context inlined)" + A->>W: "inject into running session" + activate W + W-->>A: "streamed tokens" + A-->>P: "GET /events (SSE): assistant reply" + deactivate W + Note over P: "review diff / result, merge" + alt worker is blocked + W-->>A: "status = blocked (needs input)" + A-->>P: "GET /status: blocked" + P->>A: "POST /message (answer inlined)" + end +``` + +*Figure: the primary delegates and waits; replies stream back parsed over SSE. A blocked +worker surfaces on `/status`, and the primary re-sends with the answer inlined +(return-and-reinvoke) — the same pattern `crush-bridge` uses.* + +Why AgentAPI over raw `tmux send-keys`: it gives a clean request/reply contract and +**parsed** replies (SSE), instead of scraping a terminal buffer. See [[Approaches]] for the +full transport comparison. + +## Channel 2 — broker + Stop-hook long-poll (async, duplex) + +For traffic that has **no caller waiting on a connection** — a long-running worker posting +progress, an out-of-band question, another agent or a webhook injecting work — use a +broker. The mechanism is symmetric, so it carries **both** directions: each side +long-polls its **own** inbox stream and writes to the **other's**. + +```mermaid +sequenceDiagram + participant P as "Primary (Opus)" + participant B as "Broker (Redis / NATS)" + participant H as "Worker Stop-hook" + participant W as "Worker claude" + + W->>H: "turn ends → Stop fires" + H->>B: "long-poll inbox-worker (BRPOPLPUSH, ≤30s)" + alt message queued + B-->>H: "payload" + H-->>W: "{decision: block, reason: [payload]} → new turn" + else timeout + B-->>H: "(nothing)" + H-->>W: "allow stop → idle" + end + Note over W,B: "worker writes result/question → inbox-primary" + W->>B: "XADD inbox-primary (result / question)" + P->>B: "Stop-hook long-poll inbox-primary (ON IDLE ONLY)" + B-->>P: "payload → primary resumes" +``` + +*Figure: a `Stop` hook long-polls the broker and injects any message as the block +`reason`, forcing another turn; on timeout the session idles. Reversing it (worker → +primary) is the same hook on the primary's inbox.* + +### Guardrails (mandatory for the async layer) + +| Risk | Mitigation | +|------|------------| +| **Primary quota burn** | The primary must **never** perpetual-poll. Use the `Stop`-hook variant (fires only at a natural idle boundary) or pull on-demand. Free busy-polling is for the off-subscription **worker** only. | +| **Cross-agent ping-pong** | A→B→A→B can loop forever. `stop_hook_active` guards single-agent re-entry but **not** cross-agent. Carry a round/turn budget or a `no-reply-needed` sentinel in the message envelope. | +| **Lost / double-processed messages** | Use a broker with **ack + visibility timeout + consumer groups** (Redis Streams `XACK`, NATS JetStream). A crash mid-turn re-delivers instead of dropping. | +| **Context growth** | A perpetual worker's context window fills up. Cap idle cycles / tokens, then **stop and respawn fresh with state on the filesystem** (Ralph-loop). Perpetual != one infinite session. | + +## herdr — ops / observability plane (optional) + +[herdr](https://github.com/ogulcancelik/herdr) is a Rust **agent multiplexer** ("tmux for +agents"): a persistent headless server + attachable TUI, each agent in a real PTY pane, a +sidebar rolling every agent up to 🔴blocked / 🟡working / 🔵done / 🟢idle, all scriptable +over a Unix-socket JSON API. + +It is **not** the transport and has **no** Claude-specific inject API — sending input is +the generic `pane send-text` / `send-keys` / `run` PTY primitive, and its Claude state +detection is **screen-manifest scraping**, not hooks. What it adds: + +- **`herdr agent wait --status idle|blocked`** — a readiness/blocked gate. An external + driver can `wait agent-status blocked → pane run ""` instead of guessing when a + pane is ready. This is the fragility that raw `send-keys` lacks. +- **Persistent server, detach/reattach over SSH** — good for a long-lived off-subscription + worker on a remote box you occasionally eyeball. +- **Multiplexing + state sidebar** — pays off once there is a *herd* of mixed workers. + +**Positioning:** AgentAPI (or the broker) is the **machine transport**; herdr is the +**human ops layer**. You can run AgentAPI-wrapped or broker-driven workers *inside* herdr +panes and use `agent wait` as the readiness gate. Caveats: replies from herdr are scraped +buffer text (not parsed like AgentAPI's SSE), and it is a very young, single-developer +project — don't put a core transport on it. + +## Deployment shape (target) + +```mermaid +flowchart LR + subgraph mac["Your machine"] + OPUS["Primary Opus
(Claude Code)"] + end + subgraph host["Worker host (off-subscription)"] + direction TB + HS["herdr server (optional)"] + AA2["agentapi server"] + W2["worker claude"] + HS -.-> AA2 --> W2 + end + BR["Broker"] + ML["ollama.ltms.dev / GX10 vLLM"] + OPUS -->|"HTTP"| AA2 + OPUS -.->|"async"| BR + W2 -.-> BR + W2 --> ML + classDef sub fill:#2b6cb0,stroke:#1a365d,color:#ffffff; + class OPUS sub +``` + +*Figure: the worker (agentapi + optional herdr) lives on an off-subscription host near the +model; the primary reaches it over HTTP (sync) and the broker (async).* + +## Related pages + +- **[[Approaches]]** — why AgentAPI, and the full transport comparison (SDK, Stop-hook, tmux) +- **[[Setup]]** — running the worker agentapi server pointed at `ollama.ltms.dev` +- **[[Operations]]** — health, restart, model swaps, troubleshooting + +## Sources + +- [coder/agentapi](https://github.com/coder/agentapi) +- [herdr — GitHub](https://github.com/ogulcancelik/herdr) · [docs](https://herdr.dev/docs/) · [agents / state detection](https://herdr.dev/docs/agents/) · [CLI reference](https://herdr.dev/docs/cli-reference/) +- [Agent Room — Stop-hook async collaboration](https://dev.to/agent-room/how-a-claude-code-stop-hook-unlocks-async-multi-agent-collaboration-no-polling-required-2e0e) +- [Hooks reference — Claude Code Docs](https://code.claude.com/docs/en/hooks)