diff --git a/1-Architecture.md b/1-Architecture.md
index 97eb1fc..b583dd9 100644
--- a/1-Architecture.md
+++ b/1-Architecture.md
@@ -2,7 +2,15 @@
`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:
+model.
+
+> **The gateway invariant.** `bridged` is the **sole communication gateway**: every Claude
+> session — primary and workers alike — talks *only* to `bridged`, over the MCP tools it
+> mounts. **No Claude session ever addresses the broker, a peer session, or the network
+> directly.** Anything else — the herdr socket, a durable queue, an event-bus, cross-host
+> transport — lives *inside or south of* `bridged` and is invisible to the Claude sessions.
+
+Two modes of traffic cross that gateway, but both are pure MCP from the Claude side:
1. **Request/response (blocking)** — the primary delegates a task with **one blocking MCP
tool call** (`bridge_send`) that `bridged` (driving [herdr](https://herdr.dev)) holds open
@@ -11,10 +19,13 @@ model. Two channels carry traffic between them:
means a single tool call parked on a result — *not* a busy-poll — so it costs the primary
no quota. Both the primary and the workers reach `bridged` by **mounting it as an MCP
server** — one unified Claude setup (see [Message Server](2-Message-Server)).
-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 (or injected by `bridged`
- into an idle pane). This is the layer for detached progress reports, out-of-band
- questions, and any task that outlives a sane request timeout.
+2. **Asynchronous / duplex** — for traffic with no caller waiting on a connection (a detached
+ progress report, an out-of-band question, a webhook injecting work), **`bridged` delivers
+ it by injecting the recipient's idle pane over herdr** — event-driven off the live
+ `agent_status`, not a poll loop. If `bridged` needs durability or a host hop, *it* owns a
+ broker for that, **below** the gateway line. A `Stop`-hook survives only as a split-host
+ escape hatch for a primary `bridged` cannot inject into — and even then it polls `bridged`,
+ never the broker (see [Channel 2](#channel-2--bridged-mediated-async-duplex)).
The engine of the sync channel is **herdr**, fronted by `bridged`: herdr owns the PTYs,
multiplexing, persistence, and **agent-status events**; `bridged` owns policy (the
@@ -28,33 +39,29 @@ positions swapped.)
```mermaid
flowchart TB
- subgraph prim["PRIMARY — subscription (env CLEAN)"]
+ subgraph prim["PRIMARY — subscription (env CLEAN) · MCP client"]
OPUS["Claude Code · Opus 4.8
leads, reviews, merges"]
end
subgraph work["SECONDARY worker host — off-subscription"]
- subgraph BD["bridged — standalone daemon (not a claude process)"]
+ subgraph BD["bridged — SOLE GATEWAY (standalone daemon, not a claude process)"]
SRV["SERVER face
MCP server · REST/SSE · policy"]
CLI["CLIENT face
herdr socket client"]
SRV --> CLI
end
HERDR["herdr
panes · agent-status"]
WCC["worker pane · claude
ANTHROPIC_BASE_URL set · MCP client"]
- HOOK["Stop-hook
long-poll client"]
+ BROKER["broker / durable queue
(bridged-owned · below the gateway)"]
CLI -->|"Unix socket
send_text · events.subscribe"| HERDR
HERDR -->|"drives PTY"| WCC
- WCC --- HOOK
+ SRV -.->|"durability · cross-host (internal)"| BROKER
end
- BROKER["Broker
Redis Streams / NATS JetStream
inbox-primary · inbox-worker"]
MODEL["ollama.ltms.dev /v1
or GX10 vLLM
(worker model)"]
OPUS -->|"MCP bridge_send → reply in tool result"| SRV
WCC -.->|"MCP bridge_reply / bridge_ask"| SRV
SRV -.->|"SSE status (observers)"| OPUS
- OPUS -.->|"write / long-poll (async)"| BROKER
- HOOK -.->|"long-poll / write (async)"| BROKER
- SRV -.->|"bridge inbox ↔ session"| BROKER
WCC -->|"inference"| MODEL
classDef sub fill:#2b6cb0,stroke:#1a365d,color:#ffffff;
@@ -65,19 +72,20 @@ flowchart TB
class BROKER warn
```
-*Figure: `bridged` is one **standalone daemon** split into a **SERVER face** (the MCP server
-the Claude sessions mount, plus REST/SSE + policy) and a **CLIENT face** (the herdr socket
-client). Solid arrows = the blocking MCP request/response channel; dotted = the optional async
-broker. The primary's env stays clean; only the worker sets `ANTHROPIC_BASE_URL`, and
-`bridged` — a plain daemon — enforces that boundary in code.*
+*Figure: every Claude session — primary and worker — connects **only** to `bridged`'s SERVER
+face over MCP; async delivery is `bridged` injecting an idle pane through its CLIENT face over
+herdr. The broker sits **below the gateway line**, owned by `bridged` for durability/cross-host
+and never touched by a Claude session. The primary's env stays clean; only the worker sets
+`ANTHROPIC_BASE_URL`, and `bridged` enforces that boundary in code.*
## 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** (`bridged`) or a **broker**, never by re-pointing its own endpoint.
+- The **primary** `claude` process **never** sets `ANTHROPIC_BASE_URL`. It reaches the worker
+ **only through `bridged`'s MCP tools** — never by re-pointing its own endpoint, and never by
+ addressing a broker or the worker directly.
- 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 —
@@ -97,8 +105,10 @@ cheaper/local model — without a policy-violating proxy on the primary.
subscription-safe by construction. Two fallbacks remain: (a) *single-host, non-MCP primary*
— `bridged` can type into the primary pane (simulated typing, identical to the human at the
keyboard; the primary still authenticates to `api.anthropic.com` on Pro/Max); (b)
- *split-host / detached* — worker → primary goes over the **broker + the primary's own
- `Stop`-hook** (Channel 2). Read every page with that topology split in mind.
+ *split-host / detached* — a primary `bridged` cannot inject into wakes via its own
+ `Stop`-hook, which **long-polls `bridged`** (not the broker) for queued messages (Channel 2).
+ In both fallbacks the Claude side still speaks only to `bridged`. Read every page with that
+ topology split in mind.
> **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.
@@ -157,48 +167,51 @@ screen-stability heuristic), **symmetric** injection into either pane (single-ho
swappable *fallback injector* behind the same interface. See [Approaches](3-Approaches) for the full
transport comparison and [Message Server](2-Message-Server) for the design.
-## Channel 2 — broker + Stop-hook long-poll (async, duplex)
+## Channel 2 — bridged-mediated 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**. `bridged` can also bridge a broker
-inbox directly onto a session (delivering into an idle pane) instead of a Stop-hook.
+For traffic with **no caller waiting on a connection** — a long-running worker posting
+progress, an out-of-band question, another agent or a webhook injecting work — the recipient
+must be *woken*. Under the gateway invariant, **`bridged` does the waking by injecting the
+recipient's idle pane over herdr** — driven by the live `agent_status`, so it delivers the
+instant the pane goes idle rather than on a timed poll. No Claude session polls, writes to, or
+even knows about a broker; if `bridged` needs durability or a host hop it enqueues internally
+(below the gateway) and still delivers by injection.
```mermaid
sequenceDiagram
- participant P as "Primary (Opus)"
- participant B as "Broker (Redis / NATS)"
- participant H as "Worker Stop-hook"
- participant W as "Worker claude"
+ participant SRC as "Source (worker bridge_reply/ask · webhook · bus)"
+ participant S as "bridged (gateway)"
+ participant Q as "broker / queue (internal)"
+ participant H as "herdr"
+ participant R as "Recipient pane (idle 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"
+ SRC->>S: "MCP bridge_reply / bridge_ask · or REST ingress"
+ opt durability / cross-host
+ S->>Q: "enqueue (ack + visibility timeout)"
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"
+ S->>H: "await recipient agent_status = idle"
+ H-->>S: "event: idle"
+ S->>H: "pane.send_text + send_keys (inject)"
+ H->>R: "new turn = the async message"
+ Note over S,R: "recipient polled nothing —
bridged pushed on the idle edge"
```
-*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 — or, *single-host only*, `bridged` typing into the
-primary's idle pane.*
+*Figure: `bridged` is the mediator for async too. It accepts the message over MCP (or REST for
+non-Claude sources), optionally parks it on its **internal** queue, waits for the recipient's
+idle event, and injects. The **only** exception is a primary `bridged` cannot inject into
+(split-host, off-herdr): that primary runs a `Stop`-hook which long-polls **`bridged`'s**
+inbox endpoint — still the gateway, still never the broker.*
-### Guardrails (mandatory for the async layer)
+### Guardrails (enforced centrally in `bridged`)
+
+Collapsing everything behind one gateway turns these from cooperative conventions into
+`bridged`-enforced policy — a real win over per-hook envelope sentinels:
| Risk | Mitigation |
|------|------------|
-| **Primary quota burn** | The primary must **never** perpetual-poll. Use the `Stop`-hook variant (fires only at a natural idle boundary), have `bridged` inject on idle, or pull on-demand. Free busy-polling is for `bridged` and 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. |
+| **Primary quota burn** | Structurally impossible now — the primary has no broker to poll and no perpetual loop. It either blocks on one MCP call (`bridged` holds it, idle-waiting) or is injected on its idle edge. The split-host `Stop`-hook fires only at a natural turn boundary, never in a spin. |
+| **Cross-agent ping-pong** | A→B→A→B can loop forever. `bridged` sees every hop (sole gateway), so it enforces a **round/turn budget centrally** and drops on breach — no reliance on a `no-reply-needed` sentinel each side must honor. |
+| **Lost / double-processed messages** | `bridged`'s internal queue uses **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. `bridged` caps idle cycles / tokens, then **recycles the pane fresh with state on the filesystem** (Ralph loop — see [Message Server](2-Message-Server)). Perpetual != one infinite session. |
## Deployment shape (target)
@@ -213,13 +226,12 @@ flowchart LR
BD["bridged :8080
MCP · REST/SSE"]
HS["herdr server"]
W2["worker claude pane(s)"]
+ BR["broker / queue
(bridged-owned, internal)"]
BD -->|"Unix socket"| HS --> W2
+ BD -.->|"durability / cross-host"| BR
end
- BR["Broker"]
ML["ollama.ltms.dev / GX10 vLLM"]
- OPUS -->|"MCP over HTTP (sync)"| BD
- OPUS -.->|"async"| BR
- BD -.-> BR
+ OPUS -->|"MCP over HTTP — the only link (sync + async)"| BD
W2 --> ML
classDef sub fill:#2b6cb0,stroke:#1a365d,color:#ffffff;
classDef pick fill:#2f855a,stroke:#22543d,color:#ffffff;
@@ -227,10 +239,12 @@ flowchart LR
class BD,HS pick
```
-*Figure: `bridged` + herdr + workers live on an off-subscription host near the model; the
-primary reaches it over HTTP (sync) and the broker (async). herdr's socket is local to the
-worker host — only the broker (or `bridged`'s HTTP) crosses the network. Bind `bridged` to
-localhost + tunnel, or front it with a token; never expose the port unauthenticated.*
+*Figure: `bridged` is the **only** thing the primary connects to. Sync replies come back on
+the blocking MCP call; because this split-host primary isn't a herdr pane, async wake-ups
+arrive via its `Stop`-hook polling **that same `bridged` endpoint** — never a broker. herdr's
+socket and the broker are local to the worker host and `bridged`-owned; neither crosses to the
+primary. Bind `bridged` to localhost + tunnel, or front it with a token; never expose the port
+unauthenticated.*
## Failure modes & single points of failure
@@ -239,9 +253,9 @@ deliberately:
| What dies | Effect | Degradation / recovery |
|---|---|---|
-| **`bridged`** | Sync channel down; no new delegations, in-flight blocking calls error out | herdr + workers keep running (state on disk / broker). systemd restarts `bridged`; it re-attaches to existing panes via `session.snapshot`. Async broker traffic is unaffected. |
-| **herdr** | No pane control at all; sync channel dead | Workers' PTYs die with the herdr server (no detach survives a *server* crash, only client detach). Respawn from persisted worker state (Ralph loop); replay unacked broker items. |
-| **Broker** | Async/duplex down; worker→primary (split-host) stalls | Sync channel still works. Buffer/ack semantics (visibility timeout) re-deliver on recovery; nothing is silently dropped. |
+| **`bridged`** | **The whole gateway is down** — no delegations, no async wake-ups, in-flight blocking calls error out (it is the sole gateway, so sync *and* async stop together) | herdr + workers keep running (state on disk / queue). systemd restarts `bridged`; it re-attaches to existing panes via `session.snapshot` and drains its queue. Nothing reaches a Claude session in the meantime — by design there is no side path. |
+| **herdr** | No pane control at all; delivery (sync and async injection) dead | Workers' PTYs die with the herdr server (no detach survives a *server* crash, only client detach). Respawn from persisted worker state (Ralph loop); replay unacked queue items. |
+| **Broker / queue** (internal) | Durability + cross-host async degrade; **same-host async still works** (direct idle-pane injection needs no queue) | `bridged` can deliver locally without it; only durable replay and host-hop traffic pause. Ack + visibility timeout re-deliver on recovery; nothing silently dropped. |
| **Model endpoint** (`ollama.ltms.dev` / vLLM) | Workers stall or error mid-turn | herdr status shows `working` stuck or `blocked`; `bridged` times out the blocking call and surfaces the error. Primary (subscription) is never affected. |
| **All three** | Full sync + async outage | Primary Opus remains fully usable on its own subscription — the bridge is additive, never on the primary's critical path. |
diff --git a/2-Message-Server.md b/2-Message-Server.md
index 3ee79db..6e11d92 100644
--- a/2-Message-Server.md
+++ b/2-Message-Server.md
@@ -23,7 +23,7 @@ persistent service, and adds three things AgentAPI cannot:
| Capability | AgentAPI | herdr (via `bridged`) |
|---|---|---|
| Inject a turn into a **worker** | ✅ terminal emulation | ✅ `pane.send_text` + `pane.send_keys` |
-| Inject a turn into the **primary** | ❌ (only wraps worker) | ◐ same primitive — **only when the primary is a herdr pane** (single-host); split-host uses the broker |
+| Inject a turn into the **primary** | ❌ (only wraps worker) | ◐ same primitive — **only when the primary is a herdr pane** (single-host); split-host wakes via the primary's `Stop`-hook polling `bridged` |
| "Done / blocked" signal | ⚠ screen-stability heuristic | ✅ `events.subscribe(pane.agent_status_changed)` |
| Worker self-reports state | ❌ | ✅ `pane.report_agent` (via herdr `SKILL.md`) |
| Multiplex a *herd* of workers + attach/observe | ❌ one server per session | ✅ native workspaces/tabs/panes |
@@ -44,10 +44,12 @@ model has **two** shapes, and the distinction is *cross-turn busy-polling* (forb
the primary needs. SSE (`GET /events`) is a *parallel observer channel* for
humans/dashboards — the primary never has to hold it.
- **Return-and-reinvoke (long/detached/async tasks).** When a task may outrun a sane request
- timeout, or is fire-and-forget, the primary's call returns immediately and the worker's
- reply comes back later over the **broker** — delivered to the primary by its own `Stop`-hook
- (split-host) or by `bridged` injecting the primary pane (single-host). This is the pattern
- `crush-bridge` uses, and the only correct one for work that outlives a connection.
+ timeout, or is fire-and-forget, the primary's call returns immediately and the reply comes
+ back later **through `bridged`** — `bridged` injects it into the primary's idle pane over
+ herdr (same-host), or a split-host primary's `Stop`-hook long-polls **`bridged`** for it. In
+ neither case does the primary touch a broker: if `bridged` needs durability it queues the
+ message internally (below the gateway) and still delivers by the same route. This is the
+ correct shape for work that outlives a connection.
What `bridged` does **not** offer is a *held-open bidirectional conversation* — each exchange
is one request in, one reply out. That is a feature for a subscription-safe bridge, not a
@@ -177,13 +179,13 @@ flowchart TB
end
MODEL["ollama.ltms.dev / GX10 vLLM
(worker model)"]
- BROKER["Broker (optional)
Redis / NATS — async duplex"]
+ BROKER["broker / queue (optional, internal)
Redis / NATS — durability · cross-host"]
PP -->|"MCP tools"| MCP
WP -->|"MCP tools"| MCP
HCL -->|"Unix socket · drive + status"| herd
WP -->|"inference"| MODEL
- POL -.->|"async"| BROKER
+ POL -.->|"async: enqueue / cross-host"| BROKER
classDef core fill:#2f855a,stroke:#22543d,color:#ffffff;
classDef ext fill:#2b6cb0,stroke:#1a365d,color:#ffffff;
@@ -198,7 +200,9 @@ Claude panes mount, over the policy brain) and a **CLIENT** face (the herdr sock
The Claude sessions are herdr **panes**: they call *up* into the MCP server, while `bridged`'s
client drives them *down* through herdr's socket and gates every injection on live
agent-status. Only worker panes carry `ANTHROPIC_BASE_URL`; `bridged` holds no quota, so it
-subscribes freely. (Split-host moves the primary out of herdr — see [Deployment model](#deployment-model).)*
+subscribes freely. The broker is **`bridged`-internal** (durability / cross-host) — no pane
+ever addresses it; async delivery is `bridged` injecting an idle pane. (Split-host moves the
+primary out of herdr — see [Deployment model](#deployment-model).)*
### Components
@@ -210,7 +214,7 @@ subscribes freely. (Split-host moves the primary out of herdr — see [Deploymen
| **Reply rendezvous** | Resolves an awaiting `bridge_send` on whichever lands first: a worker `bridge_reply` (structured, **preferred**), the `agent_status_changed = done` event (timing guarantee), or — worker-side hook path — a `Stop`-hook envelope; last-resort `pane.read {source:"recent-unwrapped"}` scrape. See [Reply envelope](#reply-envelope-how-a-worker-emits-a-structured-reply). |
| **Subscription guard** | Refuses to spawn a *worker* pane without `ANTHROPIC_BASE_URL`; refuses to *ever* set it on a pane designated *primary*; can assert egress host via `pane.process_info`. |
| **North API** | **MCP server** — the Claude-facing contract both primary and workers mount (`bridge_send`/`reply`/`ask`/`status`). Plus **REST + SSE** (OpenAPI, AgentAPI-shaped) for non-Claude clients — webhooks, dashboards, a human CLI. |
-| **Broker connector** *(optional)* | Bridges `inbox-*` streams ↔ session messages for async, duplex, cross-host traffic. |
+| **Broker connector** *(optional, internal)* | `bridged`-owned durability + cross-host transport, **below the gateway**. Enqueues async messages `bridged` will later inject into an idle pane. No Claude session ever connects to it. |
## The herdr control contract (what `bridged` drives)
@@ -258,7 +262,8 @@ envelope" resolves to **a worker-side hook that runs our code at turn end**:
1. A **`Stop`-hook** on the worker fires when its turn ends. The hook reads the **last
assistant message** from the session transcript (`~/.claude/projects//.jsonl`,
the path Claude Code exposes to hooks) and POSTs `{session_id, turn_id, status, text,
- artifacts}` to `bridged`'s callback (or `XADD inbox-primary`).
+ artifacts}` to **`bridged`'s callback endpoint** — the gateway, not a broker (the worker-side
+ hook never writes the broker directly; `bridged` queues internally if it must).
2. `bridged` correlates that envelope to the open blocking request by `session_id`/`turn_id`
and returns it as the response body. The `agent_status_changed = done` event is the
*timing* signal; the hook payload is the *content*.
@@ -324,28 +329,32 @@ sequenceDiagram
transitions over SSE for observers, and returns the reply — from the worker's `Stop`-hook
envelope, falling back to a `recent-unwrapped` scrape — as the blocking call's response body.*
-### Async duplex via broker (event bus ↔ worker, worker → primary)
+### Async duplex — bridged-mediated (event bus / worker → recipient)
```mermaid
sequenceDiagram
- participant BUS as "Event bus / webhook"
- participant B as "Broker (Redis/NATS)"
- participant S as "bridged"
+ participant SRC as "Source: webhook/bus (REST) · worker bridge_reply/ask (MCP)"
+ participant S as "bridged (gateway)"
+ participant Q as "broker / queue (internal)"
participant H as "herdr"
- participant W as "Worker claude"
+ participant R as "Recipient pane (idle Claude)"
- BUS->>B: "XADD inbox-worker (work item)"
- S->>B: "consume inbox-worker (group + XACK)"
- S->>H: "inject when pane idle"
- H->>W: "new turn"
- W-->>S: "reply envelope (callback / XADD inbox-primary)"
- S->>B: "XADD inbox-primary (result / question)"
- Note over S: "primary woken on its own idle boundary
(split-host: primary Stop-hook long-poll · single-host: bridged injects primary pane)"
+ SRC->>S: "REST ingress · or MCP bridge_reply / bridge_ask"
+ opt durability / cross-host
+ S->>Q: "enqueue (group + XACK)"
+ Q-->>S: "dequeue when ready"
+ end
+ S->>H: "await recipient agent_status = idle"
+ H-->>S: "event: idle"
+ S->>H: "pane.send_text + send_keys (inject)"
+ H->>R: "new turn = the message"
+ Note over S,R: "recipient polled nothing — bridged pushed on the idle edge.
split-host primary: its Stop-hook polls bridged, never the broker"
```
-*Figure: the broker is the durable, cross-host spine; `bridged` is the local actuator that
-turns queued messages into herdr injections and back. Content rides the broker as structured
-envelopes — herdr scrollback is never the source of truth.*
+*Figure: `bridged` mediates async in both directions. Every source reaches it over the gateway
+(MCP for Claude, REST for external), it optionally parks the message on its **internal** queue,
+waits for the recipient's idle event, and injects. The queue is a `bridged` implementation
+detail — no Claude session touches it, and herdr scrollback is never the source of truth.*
## Worker session lifecycle
@@ -411,7 +420,8 @@ The invariant is unchanged from [Architecture](1-Architecture) — **anything th
- The guard only constrains panes **`bridged` spawns**. A split-host primary on your Mac is
a process `bridged` never sees; it cannot inspect that env. There, subscription safety
rests on the operator (the Mac `claude` simply is never given the var) plus the fact that
- the *only* thing crossing to the worker host is HTTP/broker traffic, never an endpoint swap.
+ the *only* thing crossing to the worker host is **MCP/HTTP traffic to `bridged`**, never an
+ endpoint swap and never a broker connection.
- Injecting keystrokes into the **primary** pane is subscription-safe: it is simulated
typing, identical to the human at the keyboard — the primary still talks to
`api.anthropic.com` on Pro/Max. `bridged` never re-points the primary's endpoint. (This
@@ -449,11 +459,12 @@ thin wrappers over these routes — e.g. `bridge_send` → `POST /sessions/{id}/
2. **A herd of specialized workers.** One `bridged` + herdr multiplexes several workers
(e.g. a DeepSeek coder, a fast summarizer, a long-context reader), each its own pane,
each addressable by `session_id`. Rolls up to a single status sidebar.
-3. **Async event-bus automation.** A webhook/CI/NATS event drops a work item on
- `inbox-worker`; `bridged` wakes an idle worker, and the result flows back to the primary
- (or a Slack/Telegram bridge) on `inbox-primary` — no human in the loop.
+3. **Async event-bus automation.** A webhook/CI/NATS event hits `bridged`'s **REST ingress**;
+ `bridged` wakes an idle worker by injection, and the result flows back to the primary (or a
+ Slack/Telegram bridge) — all mediated by `bridged`, no human in the loop. The source never
+ addresses a worker or a broker directly.
4. **Human co-pilot from anywhere.** Because herdr persists and detaches, the same worker is
- reachable from a phone/chat bridge writing to the broker while you're away, and from the
+ reachable from a phone/chat bridge **posting to `bridged`** while you're away, and from the
attached TUI when you're back.
5. **Long-running "perpetual" workers.** The Ralph-loop lifecycle lets a worker run for hours
across many context recycles without a human respawning it, state carried on disk.
@@ -469,8 +480,9 @@ optional. Best for a workstation or a single dev box.
### Split-host (primary local, workers remote near the model)
Primary Opus runs on your Mac; `bridged` + herdr + workers run on the GPU host next to
-`ollama.ltms.dev` / GX10 vLLM. herdr's socket is **local-only**, so the two hosts are joined
-by the **broker** (or `bridged`'s HTTP), never by a remote herdr socket.
+`ollama.ltms.dev` / GX10 vLLM. herdr's socket is **local-only**, so the Mac reaches the worker
+host **only over `bridged`'s MCP/HTTP endpoint** — never a remote herdr socket and never the
+broker (the broker, if any, stays `bridged`-internal on the worker host).
```mermaid
flowchart LR
@@ -482,14 +494,13 @@ flowchart LR
BD["bridged
:8080 MCP · REST/SSE"]
HS["herdr server"]
W2["worker claude pane(s)"]
+ BR["broker / queue
(bridged-owned, internal)"]
BD -->|"Unix socket"| HS --> W2
+ BD -.->|"durability / cross-host"| BR
end
- BR["Broker
Redis / NATS"]
ML["ollama.ltms.dev / GX10 vLLM"]
- OPUS -->|"HTTP + SSE (sync)"| BD
- OPUS -.->|"async duplex"| BR
- BD -.-> BR
+ OPUS -->|"MCP/HTTP — the only link (sync + Stop-hook async)"| BD
W2 --> ML
classDef ext fill:#2b6cb0,stroke:#1a365d,color:#ffffff;
@@ -500,9 +511,11 @@ flowchart LR
class BR warn
```
-*Figure: only the broker (or `bridged`'s HTTP) crosses the network; herdr stays local to the
-worker host. The subscription boundary tracks the host boundary — nothing on the Mac ever
-sets `ANTHROPIC_BASE_URL`.*
+*Figure: the Mac's **only** link to the worker host is `bridged`'s MCP/HTTP endpoint — it
+carries sync replies, and (since the Mac primary isn't a herdr pane) its `Stop`-hook polls that
+same endpoint for async wake-ups. herdr's socket and the broker stay local and `bridged`-owned.
+The subscription boundary tracks the host boundary — nothing on the Mac ever sets
+`ANTHROPIC_BASE_URL`.*
**Security:** bind `bridged`'s HTTP to `localhost` and reach it over an SSH tunnel, or front
it with a bearer token + TLS. Never expose the port unauthenticated — it is an agent-control
@@ -523,7 +536,7 @@ turns. Until then, treat one `bridged` as one trust domain.
| **herdr transport** | Unix domain socket, **NDJSON**, `id`-correlated request/response + a persistent events stream | Native herdr contract | — |
| **North API — Claude** | **MCP server**, streamable-HTTP transport (`mcp-go` / official Go SDK) | The unified contract both primary and workers mount; native to Claude Code, no shell/`curl` step, subscription-safe by construction | stdio MCP adapter (per-session subprocess) if a long-lived HTTP endpoint is undesirable |
| **North API — others** | **REST + SSE**, OpenAPI-generated | Drop-in for AgentAPI-shaped/non-Claude clients; SSE streams status cheaply | gRPC (if callers are all code); WebSocket (bidi UI) |
-| **Async bus** *(optional)* | **Redis Streams** (consumer groups, `XACK`, visibility timeout) | Simplest durable duplex; satisfies the async guardrails in [Architecture](1-Architecture) | NATS JetStream for multi-host scale / replay |
+| **Internal queue** *(optional)* | **Redis Streams** (consumer groups, `XACK`, visibility timeout) — `bridged`-owned, below the gateway | Durability + cross-host for async; satisfies the guardrails in [Architecture](1-Architecture). Same-host can start with an in-process queue and add this only when durability/cross-host is needed | NATS JetStream for multi-host scale / replay; embedded (BadgerDB/SQLite) for a single host |
| **Config** | Env + YAML (`koanf`) | 12-factor; secrets via env only | — |
| **Observability** | `slog` + Prometheus `/metrics` + `/healthz` | Ops from day one | OpenTelemetry traces |
| **Process supervision** | **systemd** unit (or Docker Compose) colocating herdr + `bridged` | Restart-on-crash; ordered start (herdr before `bridged`) | k8s (overkill for one host) |
@@ -594,17 +607,17 @@ func (g *Guard) AssertLocalPrimaryClean(env []string) error {
| **M0 — Spike** | herdr client + spawn one worker pane + one `send_text`/`send_keys` round-trip | herdr socket drives a real `claude` |
| **M1 — Status gate + MCP** | `events.subscribe` → Injector delivers only on `idle`/`blocked`; **MCP `bridge_send`/`status` mounted on the primary**, blocking reply via the rendezvous; SSE status out | No mid-run corruption; real completion signal; primary drives over MCP |
| **M2 — Boundary + lifecycle + worker MCP** | Subscription guard + Ralph-loop recycle + worker `bridge_reply`/`bridge_ask` (unified mount) + envelope fallback | Subscription-safe; survives context ceiling; symmetric 2-way |
-| **M3 — Broker + duplex** | Redis Streams connector; worker→primary path; split-host deploy | Async, cross-host, 2-way |
+| **M3 — Async + durability + split-host** | Idle-injection async delivery; `bridged`-internal queue (Redis Streams) for durability/cross-host; split-host `Stop`-hook adapter that polls `bridged` | Detached/long work, cross-host, gateway-only (no Claude↔broker) |
| **M4 — Harden** | Auth/TLS, metrics, mock-socket CI, systemd unit | Production shape |
## Trade-offs & risks
| Risk | Mitigation |
|---|---|
-| **No held-open conversation** — each exchange is one request in, one reply out | By design. Short/medium tasks use a **single blocking call** (fine — no quota burn); long/detached tasks use **return-and-reinvoke** over the broker. What's excluded is a persistent bidirectional stream the primary must babysit. See *How the primary actually consumes a reply*. |
-| **Blocking call can outlive its timeout** on a very long task | Set a request deadline; on timeout `bridged` returns "still working, poll/await async" and the reply lands via the broker path instead of erroring the delegation. Pick Channel 2 up front for known-long work. |
-| **herdr is young / single-dev** — betting transport on it | The **durable spine is the broker** (mature); herdr carries only ephemeral delivery + status. The injector is a **pluggable interface** — fall back to `tmux send-keys` or AgentAPI without touching the bus. |
-| **herdr socket is local-only** | Broker (or `bridged` HTTP) is the sole cross-host link; herdr stays per-host. |
+| **No held-open conversation** — each exchange is one request in, one reply out | By design. Short/medium tasks use a **single blocking call** (fine — no quota burn); long/detached tasks use **return-and-reinvoke**, the reply delivered later **through `bridged`** (idle-pane injection, or a split-host `Stop`-hook polling `bridged`). What's excluded is a persistent bidirectional stream the primary must babysit. See *How the primary actually consumes a reply*. |
+| **Blocking call can outlive its timeout** on a very long task | Set a request deadline; on timeout `bridged` returns "still working, await async" and the reply lands via `bridged`'s async path (idle-injection) instead of erroring the delegation. Pick Channel 2 up front for known-long work. |
+| **herdr is young / single-dev** — betting transport on it | Durability lives in `bridged`'s **internal queue** (mature Redis/NATS), not herdr — herdr carries only ephemeral delivery + status. The injector is a **pluggable interface** — fall back to `tmux send-keys` or AgentAPI without touching the queue or the gateway contract. |
+| **herdr socket is local-only** | `bridged`'s **MCP/HTTP** is the sole cross-host link; herdr and the queue stay per-host and `bridged`-owned. |
| **Mid-run interrupt still unsolved** | Same as AgentAPI. Injection gates on status; `ctrl+c` via `pane.send_input` is the only (disruptive) interrupt. |
| **Spawn-with-env uncertainty in socket API** | Launch via `send_text` of the env-prefixed command → env is provably worker-only; verify native spawn in the CLI reference and prefer it if present. |
| **Reply-scrape fragility (fallback path)** | Prefer the structured **envelope** path; scrape `recent-unwrapped` only as a last resort. |
diff --git a/3-Approaches.md b/3-Approaches.md
index 3e09b64..eee72e7 100644
--- a/3-Approaches.md
+++ b/3-Approaches.md
@@ -58,18 +58,20 @@ Unix-socket JSON API. `bridged` (see [Message Server](2-Message-Server)) drives
**MCP rendezvous** — the worker's `bridge_reply` (or the `done` event) resolves the primary's
blocking `bridge_send` tool call, so no keystroke into the primary pane is needed, even
single-host. Fallbacks: herdr can type into a single-host non-MCP primary (subscription-safe
- keystrokes); split-host uses the broker + the primary's `Stop`-hook (Channel 2).
-- **North-face contract is MCP** — both primary and workers mount `bridged` as an MCP server
- (one unified Claude setup); the herdr injection here is the *south* side, orthogonal to it.
+ keystrokes); a split-host primary wakes via its own `Stop`-hook polling `bridged` (Channel 2).
+- **North-face contract is MCP — and the sole gateway.** Both primary and workers mount
+ `bridged` as an MCP server (one unified Claude setup); no Claude session ever addresses a
+ broker or peer directly. The herdr injection here is the *south* side, orthogonal to it.
- **Completion signal:** structured events — not the screen-stability *guess* AgentAPI makes.
The worker can even `pane.report_agent` its own state via herdr's `SKILL.md`.
- **Multiplex + persist:** a herd of workers as addressable panes; headless server survives
detach/reattach over SSH.
- **Subscription-safe:** only the worker pane launches with `ANTHROPIC_BASE_URL`; `bridged`
is a plain daemon (no quota) that enforces the boundary in code.
-- **Trade-off:** herdr's socket is **local-only** (broker spans hosts, not herdr), and it is
- a young, single-dev project — so `bridged` keeps the injector **pluggable** and the
- durable spine on the broker. Replies are best carried as a structured envelope, not scraped.
+- **Trade-off:** herdr's socket is **local-only** (`bridged`'s MCP/HTTP spans hosts, not
+ herdr), and it is a young, single-dev project — so `bridged` keeps the injector **pluggable**
+ and its durability in an **internal** queue behind the gateway. Replies are best carried as a
+ structured envelope, not scraped.
## 2. AgentAPI — HTTP over terminal emulation *(fallback injector)*
@@ -80,7 +82,8 @@ the original leading choice; herdr now supersedes it, but it remains a **swappab
injector** behind `bridged`'s interface.
- **Injects into a live session:** yes — but only the *worker* (it wraps one CLI); the
- primary direction still needs a broker.
+ primary direction still needs `bridged`'s async path (idle-injection, or a split-host
+ `Stop`-hook polling `bridged`).
- **Completion signal:** a **screen-stability heuristic**, not structured events.
- **Cross-host:** native HTTP — its one edge over herdr, but `bridged` already provides the
HTTP layer on top of herdr, so that edge is neutralized.
@@ -100,10 +103,13 @@ events and permission callbacks instead of scraping a terminal.
## 4. Message-queue + Stop-hook long-poll *(the async layer — complementary)*
-The **only pure-hooks** way to pull an external message into the **same** session, and the
-right fit when the trigger is an **asynchronous event bus** (NATS / Redis / webhook) rather
-than a synchronous driver waiting on a reply. Used as `claude-bridge`'s **Channel 2**
-alongside the herdr sync channel, not as a replacement.
+The **only pure-hooks** way to pull an external message into the **same** session. In
+`claude-bridge` this is **not** how a Claude session normally receives async work — under the
+sole-gateway rule `bridged` delivers async by **injecting an idle pane**, and no Claude session
+polls a queue. The Stop-hook survives in exactly one place: a **split-host primary** that isn't
+a herdr pane, where the hook long-polls **`bridged`** (not the queue) for wake-ups. The raw
+mechanism below is shown for the comparison; note the poll target is the gateway, and the queue
+itself sits *behind* `bridged`.
```mermaid
sequenceDiagram
@@ -159,7 +165,7 @@ status events, and multiplexing — which is why we build on herdr rather than h
| Approach | Transport | Inject into running session? | Completion signal | Symmetric (both panes)? | Cross-host | Subscription-safe | Fragility |
|---|---|---|---|---|---|---|---|
-| **herdr via `bridged`** ✅ | socket → terminal + events | ✅ (idle-gated) | ✅ status events² | ◐ single-host¹ | via broker / `bridged` HTTP | ✅ (guard in code) | Low–Med (herdr young) |
+| **herdr via `bridged`** ✅ | socket → terminal + events | ✅ (idle-gated) | ✅ status events² | ◐ single-host¹ | via `bridged` MCP/HTTP (sole gateway) | ✅ (guard in code) | Low–Med (herdr young) |
| **AgentAPI** ◐ (fallback) | HTTP → terminal emulation | ✅ (worker only) | ⚠ screen-stability | ❌ | ✅ native HTTP | ✅ (worker-only env) | Low |
| **Agent SDK streaming** | in-process generator | ✅ | ✅ typed events | n/a | ✅ | ✅ | Low (driver = code) |
| **Queue + Stop-hook** | hook long-poll | ✅ (turn boundary) | via turn end | ✅ (symmetric) | ✅ | ✅ | Medium |
@@ -168,8 +174,10 @@ status events, and multiplexing — which is why we build on herdr rather than h
¹ **Symmetric only single-host.** herdr can type into *either* pane, but the primary is a
herdr pane only when it runs on the herdr host. In the split-host target (primary on a Mac),
-worker→primary is **not** symmetric — it goes via the broker + the primary's `Stop`-hook. The
-"one mechanism, both directions" story holds for a single-box setup, not the distributed one.
+worker→primary goes through `bridged` all the same — the primary's `Stop`-hook long-polls
+`bridged` (never a broker) for the wake-up. The "one mechanism, both directions via herdr"
+story holds for a single-box setup; across hosts the gateway is still `bridged`, just not by
+injection.
² **"Completion signal" for `bridged` is the timing signal; reply *content* rides a worker
`Stop`-hook envelope** (see [Message Server](2-Message-Server)), not the status event itself.
@@ -182,9 +190,10 @@ worker→primary is **not** symmetric — it goes via the broker + the primary's
[Message Server](2-Message-Server) / [Architecture](1-Architecture).
- **Keep AgentAPI as a swappable fallback injector** behind `bridged`'s interface, so
herdr's immaturity is a de-riskable risk rather than a load-bearing one.
-- **External event bus → worker (async wake-ups):** layer a **Stop-hook long-poll** (or
- `bridged` inbox bridging) onto the worker so bus events resume it on idle. Complementary
- to the sync channel, not a replacement — different trigger shape.
+- **External event bus → worker (async wake-ups):** the bus hits **`bridged`'s REST ingress**;
+ `bridged` enqueues internally if needed and **injects the idle worker** — the worker runs no
+ queue-polling hook. Complementary to the sync channel, not a replacement — different trigger
+ shape, same single gateway.
- **Avoid hand-rolled `tmux send-keys`** unless neither herdr nor AgentAPI can run; it's the
same idea with all the fragility left in.
diff --git a/4-Setup.md b/4-Setup.md
index dc17c7d..825f098 100644
--- a/4-Setup.md
+++ b/4-Setup.md
@@ -22,10 +22,12 @@ This page will cover standing up the bridge on an off-subscription worker host.
`claude mcp add --transport http bridge http://127.0.0.1:8080/mcp` (or a shared
`.mcp.json` / `CLAUDE.md` entry every session inherits). The primary then delegates via the
`bridge_send` tool (single blocking call per delegation) and workers reply via
- `bridge_reply`. For split-host, also add the primary's `Stop`-hook against the broker for
- detached work.
+ `bridge_reply`. Same-host needs nothing more — `bridged` delivers async by injecting an idle
+ pane. Only for a **split-host** primary (not a herdr pane) add a `Stop`-hook that long-polls
+ **`bridged`** (never a broker) for detached wake-ups.
6. **Topology choice** — single-host vs split-host (see [Message Server](2-Message-Server) → *Deployment
- model*), and the broker (Redis Streams / NATS) if async/duplex is needed.
+ model*). If durability or cross-host async is needed, configure `bridged`'s **internal** queue
+ (Redis Streams / NATS); it stays behind the gateway — no Claude session connects to it.
## Non-negotiable during setup
diff --git a/6-Team.md b/6-Team.md
index 3e20420..6de6017 100644
--- a/6-Team.md
+++ b/6-Team.md
@@ -113,8 +113,10 @@ sequenceDiagram
worker's turn completes (status-gated) and returns the reply as the tool result.
- **Reduce:** the lead collects the N replies and integrates. A slow local worker never
blocks a fast Claude worker — wall-clock ≈ the slowest single subtask, not the sum.
-- **Detached / long jobs** use the async broker path instead of a held request (Channel 2 in
- [Architecture](1-Architecture)), so the lead never busy-polls across turns.
+- **Detached / long jobs** use `bridged`'s async path instead of a held request — the result
+ is delivered when ready by `bridged` injecting the lead's idle pane (Channel 2 in
+ [Architecture](1-Architecture)). The lead talks only to `bridged`, never a broker, and never
+ busy-polls across turns.
Fan-out is bounded by the herd size (pane count) and `bridged`'s concurrency policy, not by
the lead.
diff --git a/Home.md b/Home.md
index d3bf979..19b0eea 100644
--- a/Home.md
+++ b/Home.md
@@ -13,8 +13,9 @@ session drive a **secondary Claude agent running a different model** via its own
A small always-on message server, **`bridged`**, controls
[herdr](https://herdr.dev) (an agent multiplexer, "tmux for agents") over its Unix-socket
API and exposes a clean 2-way messaging API as an **MCP server that both the primary and the
-workers mount** — one unified Claude setup (REST/SSE stays for non-Claude clients, plus an
-optional broker). herdr owns the PTYs, multiplexing, persistence, and **agent-status
+workers mount** — one unified Claude setup and the **sole communication gateway** (REST/SSE
+stays for non-Claude clients; any broker is `bridged`-internal, below the gateway). herdr owns
+the PTYs, multiplexing, persistence, and **agent-status
events**; `bridged` owns policy (subscription boundary, session lifecycle, status-gated
delivery) and the client contract. The worker `claude` launches with
`ANTHROPIC_BASE_URL=https://ollama.ltms.dev` + a bearer token; the primary Opus stays
@@ -47,15 +48,17 @@ flowchart LR
- **Subscription boundary:** the *primary* never sets `ANTHROPIC_BASE_URL` (stays on
Pro/Max). Only the *secondary* process is off-subscription; `bridged` is a plain daemon
(no Anthropic quota), so it may poll/subscribe freely.
-- **Unified MCP setup:** both the primary and the workers mount `bridged` as an MCP server
- (one `claude mcp add` line). Claude ↔ Claude goes over MCP tools; there is no `curl` or
- bespoke client to maintain.
+- **One gateway:** `bridged` is the **sole communication path** for every Claude session. Both
+ the primary and the workers mount it as an MCP server (one `claude mcp add` line) and talk
+ *only* to it — **no Claude session ever addresses a broker, a peer, or the network directly.**
+ Any broker/queue is `bridged`-internal, below the gateway.
- **How the primary gets a reply:** it makes **one blocking MCP tool call** (`bridge_send`)
that `bridged` holds open until the worker replies (`bridge_reply`) or its turn completes,
then returns the reply as the tool result. Worker → primary rides `bridged`'s state, so no
- keystroke into the primary pane is needed even single-host. Long/detached work instead uses
- the async broker path (see [Architecture](1-Architecture)) — the primary never busy-polls
- across turns.
+ keystroke into the primary pane is needed even single-host. Long/detached work comes back the
+ same way — `bridged` **injects the primary's idle pane** when the result is ready (a
+ split-host primary's `Stop`-hook polls `bridged`, not a broker). The primary never busy-polls
+ and never touches a broker.
- **Different model** per worker process sidesteps Claude Code's lack of per-subagent
provider routing — the worker isn't a subagent, it's its own configured process.
- **AgentAPI** ([`coder/agentapi`](https://github.com/coder/agentapi)) is retained only as a