diff --git a/1-Architecture.md b/1-Architecture.md
index ef1ddaa..73bbb81 100644
--- a/1-Architecture.md
+++ b/1-Architecture.md
@@ -174,10 +174,18 @@ sequenceDiagram
for Claude, REST for external), it optionally parks the message on its internal queue, waits
for the idle event, and injects.*
-> **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.
+> **Where a hook is used.** A `Stop`-hook appears in exactly the spots where neither an MCP
+> tool call nor a pane injection can serve — and in **every** case it targets `bridged`, never
+> a broker:
+> - a **split-host primary that is not a herdr pane** (e.g. Opus on your Mac) — the only
+> session `bridged` cannot inject into — runs a `Stop`-hook that **long-polls `bridged`** for
+> queued messages; and
+> - a **non-MCP (herdr-only) worker** may run a `Stop`-hook that **POSTs its reply to
+> `bridged`** at turn end, a structured alternative to scraping the pane (see
+> [Message Server](2-Message-Server) → *Reply envelope*).
+>
+> The gateway invariant holds in every topology: a hook is just a transport adapter to
+> `bridged` for a session MCP/injection can't reach.
## Worker lifecycle — the Ralph loop
diff --git a/2-Message-Server.md b/2-Message-Server.md
index 6e11d92..a9b4e65 100644
--- a/2-Message-Server.md
+++ b/2-Message-Server.md
@@ -67,7 +67,7 @@ claude mcp add --transport http bridge http://127.0.0.1:8080/mcp
# or a project .mcp.json / CLAUDE.md entry that every session on the host inherits
```
-That is the point of the MCP north face: **one unified Claude setup**. Primary and workers
+That is the point of the MCP SERVER face: **one unified Claude setup**. Primary and workers
load the *same* server and differ only in which tools they call — no shell step that could
leak env, no hand-rolled HTTP client, no per-session bespoke wiring. REST/SSE (below) stays
for *non-Claude* callers (webhooks, dashboards, a human CLI); Claude ↔ Claude goes over MCP.
@@ -76,8 +76,8 @@ for *non-Claude* callers (webhooks, dashboards, a human CLI); Claude ↔ Claude
| Caller | Tool | Blocks? | Does |
|---|---|---|---|
-| **Primary** | `bridge_send(session, task, {mode})` | `"block"` → yes · `"async"` → no | Deliver a turn to a worker. Blocking form returns the worker's reply as the tool result; async form returns a `ticket`. |
-| **Primary** | `bridge_poll(ticket)` | no | Fetch the reply for an async task once it is ready. |
+| **Primary** | `bridge_send(session, task, {mode})` | `"block"` → yes · `"async"` → no | Deliver a turn to a worker. Blocking form returns the worker's reply as the tool result; async form returns a `ticket` and the reply is **injected into the primary's idle pane** when ready. |
+| **Primary** | `bridge_poll(ticket)` | no | Retrieve an async reply **when injection can't serve** — a split-host / non-pane primary pulls it from `bridged` (the gateway, never a broker) instead of being injected. |
| **Primary** | `bridge_status(session)` | no | Worker's live `agent_status` — `idle`\|`working`\|`blocked`\|`done`. |
| **Worker** | `bridge_reply(result)` | no | Emit a **structured** reply/payload to whoever awaits this turn. |
| **Worker** | `bridge_ask(question)` | yes | Worker-initiated question up the chain (true 2-way); parks the worker until the primary answers. |
@@ -131,7 +131,8 @@ it degrades cleanly if a worker is left unmodified:
| Tier | Worker setup | Reply channel | Worker can ask back? |
|---|---|---|---|
| **Unified (recommended)** | mounts `bridge` MCP (same one line) | structured `bridge_reply` | yes — `bridge_ask` |
-| **Herdr-only (fallback)** | unmodified `claude` | `pane.read` on `agent_status=done` | no |
+| **Hooked (no MCP)** | a `Stop`-hook installed | structured envelope POSTed to `bridged` (see [Reply envelope](#reply-envelope-how-a-worker-emits-a-structured-reply)) | no |
+| **Unmodified (last resort)** | stock `claude` | `pane.read` scrape on `agent_status=done` (lossy) | no |
The **primary-side contract is identical** in both tiers; only the worker's reply fidelity
changes. Ship the unified setup — one MCP line on every session — and keep herdr-only as the
@@ -143,11 +144,11 @@ bridge on either side is subscription-safe by construction (see
`bridged` is a **standalone daemon** — one component, two faces:
-- a **SERVER (north face)** — an **MCP server** the Claude Code sessions mount, plus REST/SSE
- for non-Claude clients, sitting over the session tracker, subscription guard, and reply
+- a **SERVER face** — an **MCP server** the Claude Code sessions mount, plus REST/SSE for
+ non-Claude clients, sitting over the session tracker, subscription guard, and reply
rendezvous (the policy brain); and
-- a **CLIENT (south face)** — a herdr socket client that injects turns (status-gated) and
- subscribes to agent-status.
+- a **CLIENT face** — a herdr socket client that injects turns (status-gated) and subscribes
+ to agent-status.
The Claude sessions themselves live **as panes inside herdr**. Each pane reaches *up* to
`bridged`'s MCP server (to send/reply); `bridged`'s herdr client reaches *down* through
@@ -163,12 +164,12 @@ flowchart TB
end
subgraph bridged["bridged — standalone daemon (NOT a claude process)"]
- subgraph srv["SERVER — north face"]
+ subgraph srv["SERVER face"]
MCP["MCP server
bridge_send · reply · ask · status"]
REST["REST / SSE
(non-Claude clients)"]
POL["policy brain
session tracker · subscription guard
· reply rendezvous"]
end
- subgraph cli["CLIENT — south face"]
+ subgraph cli["CLIENT face"]
INJ["injector
status-gated"]
HCL["herdr socket client
send_text · events · pane.read"]
end
@@ -213,7 +214,7 @@ primary out of herdr — see [Deployment model](#deployment-model).)*
| **Injector** | Per-pane FIFO queue. Delivers `send_text` + `send_keys "enter"` **only when** that pane's `agent_status ∈ {idle, blocked}` — never mid-run. |
| **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. |
+| **SERVER API** | **MCP server** — the Claude-facing contract both primary and workers mount (`bridge_send`/`reply`/`ask`/`status`/`poll`/`sessions`). Plus **REST + SSE** (OpenAPI, AgentAPI-shaped) for non-Claude clients — webhooks, dashboards, a human CLI. |
| **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)
@@ -430,7 +431,7 @@ The invariant is unchanged from [Architecture](1-Architecture) — **anything th
`ANTHROPIC_BASE_URL` and logs each worker's resolved egress host. It cannot self-check a
remote primary.
-## API surface (north side)
+## API surface (SERVER face)
**Two faces over one core.** Claude sessions use the **MCP tools**
([above](#the-client-contract--mcp-unified-for-primary--workers)); the **REST/SSE** routes
@@ -534,8 +535,8 @@ turns. Until then, treat one `bridged` as one trust domain.
|---|---|---|---|
| **Server core** | **Go** | Single static binary → trivial `scp`/systemd deploy to the worker host; goroutines fit the socket + HTTP + broker + SSE fan-in; mirrors `coder/agentapi` (can reuse its `msgfmt` reply parser). | Rust (matches herdr, slower to build); **TypeScript/Node** if the driver is the Agent SDK and you want shared types; Python for a quick spike. |
| **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) |
+| **SERVER 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 |
+| **SERVER 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) |
| **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 |
@@ -615,19 +616,19 @@ func (g *Guard) AssertLocalPrimaryClean(env []string) error {
| 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**, 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. |
+| **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 async delivery (Mode 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. |
| **herdr socket API is unversioned + single-dev churn** | Pin the herdr version in the systemd/Compose unit; keep the socket client behind the `Herdr` interface; probe `session.snapshot` shape on startup and fail fast on an unexpected schema. Don't build against `UNCERTAIN` primitives (e.g. native spawn-with-env) until confirmed in the running CLI. |
-| **SPOF per channel (bridged / herdr / broker)** | Documented in [Architecture](1-Architecture) → *Failure modes*. Key property: the **primary is never downstream** of a bridge component, so a total outage costs workers only, never the subscription session. |
+| **SPOF (bridged / herdr / queue)** | Documented in [Architecture](1-Architecture) → *Failure modes*. Key property: the **primary is never downstream** of a bridge component, so a total outage costs workers only, never the subscription session. |
| **Injection TOCTOU / shared pane** | Single-writer injector + serialized send; worker panes are bridged-owned. Residual collision corrupts a turn (recoverable), never the subscription boundary. See *Delivery gating & races*. |
## Related pages
-- **[Architecture](1-Architecture)** — the two-channel model this refines; subscription boundary
+- **[Architecture](1-Architecture)** — the two-invariant / two-mode model this refines; subscription boundary
- **[Approaches](3-Approaches)** — transport comparison; AgentAPI now the *fallback injector*
- **[Home](Home)** — project overview
diff --git a/3-Approaches.md b/3-Approaches.md
index eee72e7..2dbdeda 100644
--- a/3-Approaches.md
+++ b/3-Approaches.md
@@ -58,7 +58,8 @@ 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); a split-host primary wakes via its own `Stop`-hook polling `bridged` (Channel 2).
+ keystrokes); a split-host primary wakes via its own `Stop`-hook polling `bridged` (the async
+ path — Mode 2 in [Architecture](1-Architecture)).
- **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.
@@ -101,7 +102,7 @@ events and permission callbacks instead of scraping a terminal.
- **Trade-off:** the worker is an SDK-hosted loop, not a stock `claude` TUI — further from
"a real Claude Code process" than herdr/AgentAPI, and the driver must be code.
-## 4. Message-queue + Stop-hook long-poll *(the async layer — complementary)*
+## 4. Message-queue + Stop-hook long-poll *(raw async primitive — behind the gateway in our design)*
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
@@ -120,7 +121,7 @@ sequenceDiagram
H->>Q: long-poll (BRPOPLPUSH, ≤30s)
alt message arrives
Q-->>H: payload
- H-->>CC: {"decision":"block","reason":""}
+ H-->>CC: "{ decision: block, reason: payload }"
Note over CC: message injected as context → new turn
else timeout
Q-->>H: (nothing)
@@ -179,12 +180,13 @@ worker→primary goes through `bridged` all the same — the primary's `Stop`-ho
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.
+² **"Completion signal" for `bridged` is the timing signal; reply *content* rides the worker's
+structured `bridge_reply` (preferred), with a `Stop`-hook envelope as fallback** (see
+[Message Server](2-Message-Server)) — not the status event itself.
## Recommendation
-- **Primary Opus → worker (the bridge's main channel):** **herdr via `bridged`** —
+- **Primary Opus → worker (the bridge's main path):** **herdr via `bridged`** —
status-gated injection, structured completion/blocked events, symmetric (single-host), multiplexed,
persistent, with the subscription boundary enforced in code. Selected. See
[Message Server](2-Message-Server) / [Architecture](1-Architecture).
@@ -192,7 +194,7 @@ injection.
herdr's immaturity is a de-riskable risk rather than a load-bearing one.
- **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
+ queue-polling hook. Complementary to the sync path, 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/5-Operations.md b/5-Operations.md
index d7188ca..9ef218b 100644
--- a/5-Operations.md
+++ b/5-Operations.md
@@ -11,7 +11,7 @@ Day-2 runbook for a running bridge.
`agent_status` per session via `GET /sessions`, and confirming the **MCP endpoint** is
reachable from both the primary and the workers (`claude mcp list` shows `bridge` connected).
- **Restart & recovery** — ordered restart (herdr before `bridged`); how `bridged`
- re-attaches to existing panes via `session.snapshot`; broker replay of unacked items. See
+ re-attaches to existing panes via `session.snapshot`; internal broker/queue replay of unacked items. See
[Architecture](1-Architecture) → *Failure modes & single points of failure* for what each outage costs.
- **Model swaps** — repoint a worker to a different `base_url`/model by recycling its pane
(Ralph loop); the subscription guard re-validates the new host against the allowlist.
@@ -26,11 +26,13 @@ Day-2 runbook for a running bridge.
## Guardrails to watch (from [Architecture](1-Architecture))
-- The **primary must never perpetual-poll** — quota burn. Async wake-ups use the `Stop`-hook
- or `bridged` inject-on-idle only.
-- Cross-agent ping-pong needs a round/turn budget in the message envelope.
-- Broker must run with **ack + visibility timeout + consumer groups** so a mid-turn crash
- re-delivers instead of dropping.
+- The **primary must never perpetual-poll** — quota burn. Async wake-ups are `bridged`
+ inject-on-idle by default; the `Stop`-hook is only the split-host-primary exception, and it
+ polls `bridged` (never a broker).
+- Cross-agent ping-pong needs a round/turn budget — enforced centrally in `bridged` (sole
+ gateway), not per-session sentinels.
+- The **internal** broker/queue must run with **ack + visibility timeout + consumer groups**
+ so a mid-turn crash re-delivers instead of dropping.
## Related
diff --git a/6-Team.md b/6-Team.md
index 6de6017..74f41f4 100644
--- a/6-Team.md
+++ b/6-Team.md
@@ -32,7 +32,7 @@ alike. Scale each kind horizontally by adding panes.
flowchart TB
LEAD["lead — Opus
(Claude Code, env CLEAN)
MCP client"]
subgraph BD["bridged — standalone daemon"]
- SRV["SERVER face
MCP · REST/SSE · role router"]
+ SRV["SERVER face
MCP · REST/SSE · policy brain"]
CLI["CLIENT face
herdr socket"]
SRV --> CLI
end
@@ -49,10 +49,10 @@ flowchart TB
HERDR --> WC1 & WC2 & WL1 & WL2
WC1 -.->|"MCP bridge_reply"| SRV
WL1 -.->|"MCP bridge_reply"| SRV
- WC1 --> ANT
- WC2 --> ANT
- WL1 --> OLL
- WL2 --> OLL
+ WC1 -->|"inference"| ANT
+ WC2 -->|"inference"| ANT
+ WL1 -->|"inference"| OLL
+ WL2 -->|"inference"| OLL
classDef ext fill:#2b6cb0,stroke:#1a365d,color:#ffffff;
classDef core fill:#2f855a,stroke:#22543d,color:#ffffff;
@@ -88,24 +88,24 @@ different workers at once, then results are gathered.
```mermaid
sequenceDiagram
- participant L as lead (Opus)
- participant B as bridged
- participant WC as w-claude-1
- participant WL as w-local-1
+ participant L as "lead (Opus)"
+ participant B as "bridged"
+ participant WC as "w-claude-1"
+ participant WL as "w-local-1"
- Note over L: split job → subtask A (reasoning), subtask B (bulk)
+ Note over L: "split job → subtask A (reasoning), subtask B (bulk)"
par A → Claude worker
- L->>B: bridge_send {role: w-claude, prompt: A}
- B->>WC: send_text into running pane
- WC-->>B: bridge_reply (or status done)
- B-->>L: tool result reply A
+ L->>B: "bridge_send {role: w-claude, prompt: A}"
+ B->>WC: "send_text into running pane"
+ WC-->>B: "bridge_reply (or status done)"
+ B-->>L: "tool result reply A"
and B → local worker
- L->>B: bridge_send {role: w-local, prompt: B}
- B->>WL: send_text into running pane
- WL-->>B: bridge_reply (or status done)
- B-->>L: tool result reply B
+ L->>B: "bridge_send {role: w-local, prompt: B}"
+ B->>WL: "send_text into running pane"
+ WL-->>B: "bridge_reply (or status done)"
+ B-->>L: "tool result reply B"
end
- Note over L: reduce → integrate A + B into final answer
+ Note over L: "reduce → integrate A + B into final answer"
```
- **Map:** the lead issues N concurrent blocking `bridge_send` tool calls (one per subtask →
@@ -114,7 +114,7 @@ sequenceDiagram
- **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 `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
+ is delivered when ready by `bridged` injecting the lead's idle pane (Mode 2 in
[Architecture](1-Architecture)). The lead talks only to `bridged`, never a broker, and never
busy-polls across turns.
diff --git a/Home.md b/Home.md
index 19b0eea..b693f95 100644
--- a/Home.md
+++ b/Home.md
@@ -26,7 +26,7 @@ flowchart LR
OPUS["Opus — primary
(Claude Code, env CLEAN)
MCP client"]
subgraph BD["bridged — standalone daemon (not a claude process)"]
SRV["SERVER face
MCP · REST/SSE · policy"]
- CLI["CLIENT face
herdr socket"]
+ CLI["CLIENT face
status-gated injector · herdr socket"]
SRV --> CLI
end
HERDR["herdr
panes · agent-status"]
@@ -69,7 +69,7 @@ flowchart LR
Read in order (the sidebar mirrors this):
-1. **[Architecture](1-Architecture)** — process model, subscription boundary, the two-channel model
+1. **[Architecture](1-Architecture)** — process model, the two invariants, two traffic modes
2. **[Message Server](2-Message-Server)** — 🟢 **`bridged`**, the herdr-centric message server (primary approach)
3. **[Approaches](3-Approaches)** — herdr-centric vs AgentAPI vs Agent SDK vs bus/tmux (research matrix)
4. **[Setup](4-Setup)** — running herdr + `bridged` + a worker pointed at `ollama.ltms.dev`
diff --git a/_Sidebar.md b/_Sidebar.md
index b665466..c905fe9 100644
--- a/_Sidebar.md
+++ b/_Sidebar.md
@@ -4,7 +4,7 @@
**Chapters**
-1. [Architecture](1-Architecture) — system · invariant · 2 channels
+1. [Architecture](1-Architecture) — system · 2 invariants · 2 modes
2. [Message Server](2-Message-Server) — the `bridged` design
3. [Approaches](3-Approaches) — transports compared, why herdr
4. [Setup](4-Setup) — bring-up