diff --git a/1-Architecture.md b/1-Architecture.md
index b583dd9..ef1ddaa 100644
--- a/1-Architecture.md
+++ b/1-Architecture.md
@@ -1,187 +1,161 @@
# 1. 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.
+`claude-bridge` lets a **primary** Claude Code session — Opus 4.8 on your Pro/Max
+subscription — drive one or more **secondary worker** Claude Code sessions running a
+*different, cheaper/local* model, **without ever putting a proxy on the primary**. A
+standalone daemon, **`bridged`**, sits between them: it drives [herdr](https://herdr.dev) (an
+agent multiplexer) to inject turns and read live agent-status, and exposes an **MCP server**
+that every Claude session mounts.
-> **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 invariants define the whole design; everything else follows from them.
-Two modes of traffic cross that gateway, but both are pure MCP from the Claude side:
+> **1 — Subscription boundary.** Only a *worker* process ever sets `ANTHROPIC_BASE_URL`; the
+> primary never does, so it stays on Pro/Max. `bridged` is not a `claude` process and holds
+> zero Anthropic quota.
+>
+> **2 — Sole gateway.** Every Claude session talks *only* to `bridged`, over the MCP tools it
+> mounts. No session addresses a broker, a peer session, or the network directly.
-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
- until the worker's turn completes, then returns the reply as the tool result. This is the
- main channel; its full design is in **[Message Server](2-Message-Server)**. "Blocking" here
- 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** — 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
-subscription boundary, session lifecycle, status-gated delivery) and the client-facing
-contract — an **MCP server** both Claude sessions mount, with REST/SSE for non-Claude
-clients. (This supersedes the earlier plan, which used `coder/agentapi` as the
-sync transport and treated herdr as an optional ops layer — see [Approaches](3-Approaches) for why the
-positions swapped.)
-
-## Components
+## System overview
```mermaid
flowchart TB
- subgraph prim["PRIMARY — subscription (env CLEAN) · MCP client"]
- OPUS["Claude Code · Opus 4.8
leads, reviews, merges"]
+ subgraph herd["herdr — agent multiplexer (Claude sessions run as panes)"]
+ PP["primary pane · Opus 4.8
env CLEAN · MCP client"]
+ WP["worker pane(s) · claude
ANTHROPIC_BASE_URL set · MCP client"]
end
- subgraph work["SECONDARY worker host — off-subscription"]
- 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"]
- BROKER["broker / durable queue
(bridged-owned · below the gateway)"]
- CLI -->|"Unix socket
send_text · events.subscribe"| HERDR
- HERDR -->|"drives PTY"| WCC
- SRV -.->|"durability · cross-host (internal)"| BROKER
+ subgraph BD["bridged — standalone daemon · THE gateway (no Anthropic quota)"]
+ SRV["SERVER face
MCP server · REST/SSE · policy brain"]
+ CLI["CLIENT face
status-gated injector · herdr socket client"]
+ SRV --> CLI
end
- MODEL["ollama.ltms.dev /v1
or GX10 vLLM
(worker model)"]
+ Q["broker / queue
(bridged-owned · below the gateway)"]
+ M["worker model
ollama.ltms.dev · GX10 vLLM"]
- OPUS -->|"MCP bridge_send → reply in tool result"| SRV
- WCC -.->|"MCP bridge_reply / bridge_ask"| SRV
- SRV -.->|"SSE status (observers)"| OPUS
- WCC -->|"inference"| MODEL
+ PP -->|"MCP tools"| SRV
+ WP -->|"MCP tools"| SRV
+ CLI -->|"Unix socket · send_text · events.subscribe"| herd
+ SRV -.->|"durability · cross-host (internal)"| Q
+ WP -->|"inference"| M
- classDef sub fill:#2b6cb0,stroke:#1a365d,color:#ffffff;
- classDef pick fill:#2f855a,stroke:#22543d,color:#ffffff;
+ classDef ext fill:#2b6cb0,stroke:#1a365d,color:#ffffff;
+ classDef core fill:#2f855a,stroke:#22543d,color:#ffffff;
classDef warn fill:#b7791f,stroke:#7b341e,color:#ffffff;
- class OPUS sub
- class SRV,CLI,HERDR,WCC pick
- class BROKER warn
+ class PP ext
+ class SRV,CLI core
+ class Q,M warn
```
-*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.*
+*Figure: the Claude sessions are **herdr panes**; they reach *up* into `bridged`'s SERVER face
+over MCP, while `bridged`'s CLIENT face drives them *down* through herdr's socket. `bridged` is
+the only thing any session connects to — the broker sits below the gateway line, owned by
+`bridged`, touched by no session. Only worker panes carry `ANTHROPIC_BASE_URL`.*
-## Subscription boundary (the non-negotiable)
+## The two invariants
-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.
+### Subscription boundary — why the bridge exists
-- 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 —
- which also sidesteps Claude Code's lack of per-subagent provider routing (the worker is
- not a subagent).
-- **`bridged` is not a `claude` process** and consumes zero Anthropic quota, so it may
- busy-poll the broker and hold a permanent herdr event subscription with no policy concern.
- Its **subscription guard** refuses to spawn a *worker* pane without an off-subscription
- `ANTHROPIC_BASE_URL` and refuses to ever set it on a pane tagged *primary*. The guard only
- constrains panes `bridged` itself spawns; it **cannot** inspect a primary it does not host
- (e.g. an Opus on your Mac, split-host) — that primary's env cleanliness is the operator's
- responsibility, backed by a best-effort startup self-check (see [Message Server](2-Message-Server)).
-- **Worker → primary rides `bridged`'s MCP rendezvous, not a keystroke.** A blocking
- `bridge_send` is resolved by the worker's `bridge_reply` (or the `done` event) and the
- primary reads the answer as an ordinary MCP **tool result** — no typing into the primary
- pane, even single-host. MCP tool I/O never sets `ANTHROPIC_BASE_URL`, so it is
- 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* — 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.
+The point of the bridge is to keep the **primary** on Pro/Max while a **worker** runs a
+cheaper/local model, with no policy-violating proxy on the primary.
-> **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.
+- The **primary** `claude` **never** sets `ANTHROPIC_BASE_URL`. It authenticates to
+ `api.anthropic.com` on your subscription and reaches the worker *only* through `bridged`'s
+ MCP tools — never by re-pointing its own endpoint.
+- Only a **worker** `claude` launches with `ANTHROPIC_BASE_URL=https://ollama.ltms.dev`
+ (+ `ANTHROPIC_AUTH_TOKEN`) or a GX10 vLLM URL. Because model choice is just per-process env,
+ this also sidesteps Claude Code's lack of per-subagent provider routing — the worker is a
+ *separate process*, not a subagent.
+- **`bridged` holds no quota**, so it may hold a permanent herdr event subscription and poll
+ its own internal queue with no policy concern. Its **subscription guard** refuses to spawn a
+ worker pane whose resolved `ANTHROPIC_BASE_URL` host isn't on an off-subscription allowlist,
+ and refuses to ever set that var on a pane tagged *primary*. (The guard validates the
+ resolved host and confirms egress post-spawn — a substring check on the launch string is not
+ enough; see [Message Server](2-Message-Server).)
-## Channel 1 — `bridged` over herdr (blocking request/response, the main path)
+> **Rule:** anything that sets `ANTHROPIC_BASE_URL` is, by definition, a worker. If you are
+> ever tempted to set it on the primary, stop — that is the subscription line.
-`bridged` exposes an **MCP server** that both the primary and the workers mount (`bridge_send`,
-`bridge_reply`, `bridge_ask`, `bridge_status`), translates those tool calls into herdr socket
-calls, and — unlike a screen-stability heuristic — gates every injection on herdr's live
-`agent_status_changed` events. REST/SSE remains for non-Claude clients. One session per pane;
-many panes per herdr. Full contract, components, and the Go interface sketch are in
-**[Message Server](2-Message-Server)**.
+### Sole gateway — how sessions communicate
-The primary's `bridge_send` **blocks** until `bridged` resolves the reply — on the worker's
-`bridge_reply` (structured) or the `agent_status = done` event, whichever lands first — and
-returns it as the tool result. Because the reply rides `bridged`'s own state, worker → primary
-needs **no** keystroke into the primary pane, even single-host. SSE (`GET /events`) is a
-parallel channel for *observers* (a human, a dashboard) to watch `working`/`blocked`/`done`
-transitions; the primary does not need to hold it. For a task that may outrun a reasonable
-request timeout, prefer Channel 2 (fire-and-forget, reply comes back async).
+Because every session mounts `bridged` over MCP, `bridged` is the single chokepoint for *all*
+agent traffic. This is a deliberate simplification with real payoffs:
+
+- **No session-side transport.** A Claude session calls MCP tools and nothing else — no
+ `curl`, no broker client, no hand-rolled hook polling a queue. There is no shell step that
+ could leak env, so mounting the bridge is subscription-safe by construction.
+- **Async is a push, not a poll.** When a message arrives for an idle session, `bridged`
+ **injects it into that session's pane over herdr**, gated on the live `agent_status`. The
+ session is never asked to busy-poll anything; the old "primary must never perpetual-poll"
+ footgun disappears because there is nothing for it to poll.
+- **Guardrails are centrally enforced.** Round/turn budgets (anti ping-pong), rate limits,
+ per-session authz, and audit all live in `bridged` — one place — instead of cooperative
+ sentinels each session must honour.
+- **The broker is infrastructure, below the line.** If `bridged` needs durability or a host
+ hop it owns a broker/queue for that. No Claude session sees it.
+
+## Components
+
+| Component | Role | Notes |
+|---|---|---|
+| **`bridged` — SERVER face** | The gateway: **MCP server** (the contract every session mounts) + REST/SSE for non-Claude clients, over the **policy brain** — session tracker, subscription guard, reply rendezvous. | `bridge_send` · `bridge_reply` · `bridge_ask` · `bridge_status` · `bridge_poll` · `bridge_sessions`. |
+| **`bridged` — CLIENT face** | Drives herdr: a **status-gated injector** (per-pane FIFO, delivers only when `agent_status ∈ {idle, blocked}`) over a **herdr socket client** (NDJSON, id-correlated, live event stream). | Single writer per pane → no injector-vs-injector races. |
+| **herdr** | Agent multiplexer. Owns the PTYs, panes, persistence, and — crucially — **`agent_status_changed` events**. Claude sessions run here as panes. | Socket is **local-only**; young/single-dev → injector kept pluggable. |
+| **Worker `claude`** | A *real* Claude Code process (inherits `CLAUDE.md`, hooks, skills, MCP), pointed at a different model. Recyclable, not immortal. | Only these carry `ANTHROPIC_BASE_URL`. |
+| **Broker / queue** *(optional, internal)* | `bridged`-owned durability + cross-host transport, **below the gateway**. Enqueues async messages `bridged` will later inject. | Redis Streams / NATS JetStream, or an embedded queue for a single host. |
+| **AgentAPI** *(fallback)* | Swappable injector behind the CLIENT face if herdr is unavailable. | Screen-stability heuristic instead of events — see [Approaches](3-Approaches). |
+
+## Traffic: two modes across the gateway
+
+Both modes are pure MCP from the Claude side; they differ only in whether a caller waits on
+the connection.
+
+### Mode 1 — blocking request/response (the main path)
+
+The primary delegates with **one** `bridge_send` tool call. `bridged` holds it open (the
+primary is idle-waiting, spending no quota) and **resolves it on whichever lands first**: the
+worker's structured `bridge_reply`, or herdr's `agent_status = done` event. The reply comes
+back as the tool result — so worker → primary rides `bridged`'s state and needs **no keystroke
+into the primary pane**, even single-host.
```mermaid
sequenceDiagram
- participant P as "Primary (Opus)"
- participant S as "bridged"
- participant H as "herdr"
- participant W as "Worker claude (other model)"
-
- P->>S: "bridge_send(task, full context inlined) — tool call PARKS"
- S->>S: "await pane status = idle"
- S->>H: "pane.send_text + send_keys enter"
- H->>W: "inject into running session"
- activate W
- H-->>S: "event: agent_status_changed = working"
- S-->>P: "SSE: status working (observers only)"
- W->>S: "bridge_reply(result) — or agent_status=done (fallback)"
- H-->>S: "event: agent_status_changed = done"
- deactivate W
- S-->>P: "tool result = assistant reply (unparks the call)"
- Note over P: "review diff / result, merge"
- alt worker is blocked
- H-->>S: "event: agent_status_changed = blocked"
- S-->>P: "tool result = blocked + question"
- P->>S: "bridge_send(answer inlined) — new blocking call"
- end
-```
-
-*Figure: the primary's request blocks until the reply is ready and reads it from the response
-body; SSE carries status to observers, not the answer. `bridged` injects only when the pane is
-`idle`, and a blocked worker surfaces on a real herdr event (not a guess). A block returns to
-the primary as the call's result; the primary re-sends the answer with a fresh blocking call.*
-
-Why herdr over the earlier `agentapi` plan: structured **agent-status events** (vs a
-screen-stability heuristic), **symmetric** injection into either pane (single-host), native
-**multiplexing** of a herd of workers, and **persistence/detach**. AgentAPI is kept as a
-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 — bridged-mediated async (duplex)
-
-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 SRC as "Source (worker bridge_reply/ask · webhook · bus)"
+ participant P as "Primary (Opus) — MCP client"
participant S as "bridged (gateway)"
- participant Q as "broker / queue (internal)"
+ participant H as "herdr"
+ participant W as "Worker (other model)"
+
+ P->>S: "bridge_send(worker, task) — tool call PARKS"
+ S->>S: "await pane agent_status = idle"
+ S->>H: "pane.send_text + send_keys (inject)"
+ H->>W: "new turn"
+ activate W
+ H-->>S: "event: agent_status = working"
+ W->>S: "bridge_reply(result) — structured (preferred)"
+ H-->>S: "event: agent_status = done"
+ deactivate W
+ S-->>P: "tool result = reply (unparks the call)"
+ Note over P,W: "resolves on bridge_reply or the done event — whichever lands first.
a blocked worker returns as the tool result, then the primary re-answers"
+```
+
+*Figure: a single parked tool call, not a busy-poll. SSE (`GET /events`) carries status to
+*observers* (a human, a dashboard) in parallel; the primary never has to hold it. For work that
+may outrun a sane request timeout, use Mode 2.*
+
+### Mode 2 — asynchronous delivery (bridged-mediated)
+
+For traffic with no caller waiting — a detached progress report, an out-of-band question, a
+webhook injecting work — the recipient must be *woken*. `bridged` does the waking by
+**injecting the recipient's idle pane**, driven by the live `agent_status`, so delivery is
+event-driven rather than a timed poll. If durability or a host hop is needed, `bridged`
+enqueues internally first; the recipient still receives by injection.
+
+```mermaid
+sequenceDiagram
+ participant SRC as "Source: worker bridge_reply/ask · webhook · bus"
+ participant S as "bridged (gateway)"
+ participant Q as "queue (internal)"
participant H as "herdr"
participant R as "Recipient pane (idle Claude)"
@@ -192,87 +166,86 @@ sequenceDiagram
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"
+ H->>R: "new turn = the message"
+ Note over S,R: "recipient polled nothing — bridged pushed on the idle edge"
```
-*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.*
+*Figure: `bridged` mediates async in both directions. Sources reach it over the gateway (MCP
+for Claude, REST for external), it optionally parks the message on its internal queue, waits
+for the idle event, and injects.*
-### Guardrails (enforced centrally in `bridged`)
+> **The one exception.** A **split-host primary that is not a herdr pane** (e.g. Opus on your
+> Mac) is the sole session `bridged` cannot inject into. There, the primary runs a `Stop`-hook
+> that **long-polls `bridged`** for queued messages — still the gateway, **never** the broker.
+> The gateway invariant holds in every topology.
-Collapsing everything behind one gateway turns these from cooperative conventions into
-`bridged`-enforced policy — a real win over per-hook envelope sentinels:
+## Worker lifecycle — the Ralph loop
-| Risk | Mitigation |
-|------|------------|
-| **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)
+`bridged` treats a worker as a **recyclable** resource, not one immortal session: a long-lived
+pane fills its context window. On a context/idle cap it checkpoints and respawns fresh, with
+continuity carried by **artifacts on disk** (git commits, a `STATE.md`/task file the worker is
+told to keep current) — **not** `claude --resume`, which would reload the context you are
+trying to shed.
```mermaid
-flowchart LR
- subgraph mac["Your machine (subscription)"]
- OPUS["Primary Opus
(Claude Code)"]
- end
- subgraph host["Worker host (off-subscription, near model)"]
- direction TB
- 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
- ML["ollama.ltms.dev / GX10 vLLM"]
- 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;
- class OPUS sub
- class BD,HS pick
+stateDiagram-v2
+ [*] --> Spawning
+ Spawning --> Ready: "claude prompt detected"
+ Ready --> Working: "turn injected"
+ Working --> Blocked: "permission / question"
+ Blocked --> Working: "bridged answers (send_input)"
+ Working --> Ready: "agent_status = done"
+ Ready --> Recycling: "context / idle cap hit"
+ Recycling --> Spawning: "state persisted to disk"
+ Working --> Failed: "pane.exited (crash)"
+ Failed --> Spawning: "auto-restart + replay unacked"
+ Ready --> [*]: "drain / shutdown"
```
-*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.*
+*Figure: `Blocked`, `done`, and `exited` are real herdr events, not heuristics — the reason
+herdr-centric beats screen scraping. Full lifecycle detail is in
+[Message Server](2-Message-Server) → *Worker session lifecycle*.*
+
+## Topologies
+
+- **Same-host (default).** Primary, `bridged`, herdr, and workers on one off-subscription box.
+ Every session is a herdr pane, so `bridged` can inject *either* direction; a broker is
+ optional. Simplest to run and the focus of the design.
+- **Split-host.** Primary Opus on your Mac; `bridged` + herdr + workers on the GPU host near
+ the model. herdr's socket stays local, so the Mac reaches the worker host **only over
+ `bridged`'s MCP/HTTP endpoint**. The primary isn't a herdr pane, so async wake-ups use the
+ `Stop`-hook-polls-`bridged` path above. Deployment diagrams: [Message Server](2-Message-Server)
+ → *Deployment model*.
+
+**Security:** `bridged` is an agent-control surface — `bridge_send` runs arbitrary prompts and
+key-passthrough sends raw keystrokes into a live agent. Bind it to `localhost` + SSH tunnel, or
+front it with a bearer token + TLS; never expose the port unauthenticated. One `bridged` is
+today **one trust domain** (no per-session authz yet — an open item in
+[Message Server](2-Message-Server)).
## Failure modes & single points of failure
-Both channels have a distinct SPOF; neither is redundant in the target design, so degrade
+Making `bridged` the sole gateway buys a clean model at the cost of a real SPOF. Degrade
deliberately:
-| What dies | Effect | Degradation / recovery |
+| What dies | Effect | Recovery |
|---|---|---|
-| **`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. |
-
-The primary is deliberately **not** downstream of any bridge component: a total bridge
-outage costs you the workers, never your own session.
+| **`bridged`** | **All** agent comms stop — sync *and* async — since it is the only gateway; in-flight blocking calls error out. | herdr + workers keep running (state on disk / queue). systemd restarts `bridged`; it re-attaches to existing panes via `session.snapshot` and drains its queue. This restart path is load-bearing — harden it. |
+| **herdr** | No pane control; all delivery (sync + async injection) dead. | PTYs die with the *server* (only client detach survives). Respawn workers from persisted state (Ralph loop); replay unacked queue items. |
+| **Broker / queue** (internal) | Durability + cross-host async degrade; **same-host async still works** (idle-injection needs no queue). | `bridged` delivers locally without it; ack + visibility timeout re-deliver on recovery. Nothing silently dropped. |
+| **Model endpoint** | Workers stall or error mid-turn. | herdr status shows `working` stuck / `blocked`; `bridged` times out the blocking call and surfaces the error. |
+| **All of the above** | Full bridge outage. | The **primary is never downstream** of any bridge component — it stays fully usable on its own subscription. The bridge is additive, never on the primary's critical path. |
## Related pages
-- **[Message Server](2-Message-Server)** — the `bridged` design: herdr control contract, lifecycle, API, tech stack
-- **[Approaches](3-Approaches)** — why herdr-centric, and the full transport comparison (AgentAPI, SDK, Stop-hook, tmux)
-- **[Setup](4-Setup)** — running herdr + `bridged` + a worker pointed at `ollama.ltms.dev`
-- **[Operations](5-Operations)** — health, restart, model swaps, troubleshooting
+- **[Message Server](2-Message-Server)** — the `bridged` design in depth: MCP contract, herdr control, reply rendezvous, lifecycle, API, tech stack, milestones.
+- **[Approaches](3-Approaches)** — why herdr-centric, and the full transport comparison (AgentAPI, Agent SDK, queue+Stop-hook, tmux).
+- **[Team](6-Team)** — a team-lead orchestrating a mixed Claude + local-LLM worker fleet over the same gateway.
+- **[Setup](4-Setup)** · **[Operations](5-Operations)** — bring-up and the day-2 runbook.
## Sources
- [herdr — socket API](https://herdr.dev/docs/socket-api/) · [agents / state detection](https://herdr.dev/docs/agents/) · [GitHub](https://github.com/ogulcancelik/herdr)
- [coder/agentapi](https://github.com/coder/agentapi) — fallback injector
-- [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)
-
+- [Model Context Protocol](https://modelcontextprotocol.io) — the client contract both sessions mount
+- [Hooks reference — Claude Code Docs](https://code.claude.com/docs/en/hooks) — the split-host `Stop`-hook adapter