Add Architecture page (channels, subscription boundary, broker, herdr)
+209
@@ -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<br/>leads, reviews, merges"]
|
||||
end
|
||||
|
||||
subgraph work["SECONDARY worker(s) — off-subscription"]
|
||||
AA["agentapi server<br/>HTTP :3284"]
|
||||
WCC["claude (worker)<br/>ANTHROPIC_BASE_URL set"]
|
||||
HOOK["Stop-hook<br/>long-poll client"]
|
||||
AA -->|"launches / drives PTY"| WCC
|
||||
WCC --- HOOK
|
||||
end
|
||||
|
||||
BROKER["Broker<br/>Redis Streams / NATS JetStream<br/>inbox-primary · inbox-worker"]
|
||||
MODEL["ollama.ltms.dev /v1<br/>or GX10 vLLM<br/>(worker model)"]
|
||||
HERDR["herdr server<br/>panes · state · socket API<br/>(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<br/>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 "<message>"` 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<br/>(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)
|
||||
Reference in New Issue
Block a user