diff --git a/Approaches.md b/Approaches.md
index 6679f8f..c87cfe3 100644
--- a/Approaches.md
+++ b/Approaches.md
@@ -6,29 +6,35 @@ message into an **already-running Claude Code worker** — *without* spawning a
new process (cold context, no live session) for every turn, which defeats the point of a
persistent worker that holds `CLAUDE.md`, hooks, skills, MCP, and conversation state.
-That leaves four real transports. They differ on one decisive axis: **can the driver push
-into the session while it is idle/mid-run, or only at a turn boundary?**
+That leaves several real transports. They differ on one decisive axis: **can the driver
+push into the session while it is idle/mid-run, or only at a turn boundary?** — and, once
+that is solved, on *how reliably you know when the worker is done or blocked*.
```mermaid
flowchart TD
Q{"How is the message
delivered into a LIVE session?"}
+ Q -->|"structured socket → terminal, + status events"| HD["herdr socket
(via bridged)"]
Q -->|"HTTP request → terminal emulation"| AA["AgentAPI
(coder/agentapi)"]
Q -->|"streaming input generator (in-process)"| SDK["Agent SDK
streaming query()"]
Q -->|"worker PULLS on idle via a hook"| BUS["Message-queue
+ Stop-hook long-poll"]
Q -->|"raw keystrokes into the tmux pane"| TMUX["tmux send-keys
/ PTY paste"]
- AA --> V1["✅ our leading choice"]
+ HD --> V0["✅ our leading choice
(status events + symmetric + multiplex)"]
+ AA --> V1["◐ fallback injector
(swappable behind bridged)"]
SDK --> V2["✅ if driver is our own code"]
BUS --> V3["⚠ async bus events only
(lands at turn boundary)"]
- TMUX --> V4["⚠ fragile, but the raw primitive
AgentAPI wraps"]
+ TMUX --> V4["⚠ fragile — the raw primitive
herdr/AgentAPI productize"]
classDef pick fill:#2f855a,stroke:#22543d,color:#ffffff;
+ classDef alt fill:#2b6cb0,stroke:#1a365d,color:#ffffff;
classDef warn fill:#b7791f,stroke:#7b341e,color:#ffffff;
- class AA,V1 pick
+ class HD,V0 pick
+ class AA,V1 alt
class BUS,TMUX,V3,V4 warn
```
-*Figure: picking a delivery transport by how the message enters a running session.*
+*Figure: picking a delivery transport by how the message enters a running session — and how
+trustworthy the "done/blocked" signal is once it does.*
## The native gap (why workarounds exist)
@@ -36,39 +42,63 @@ Claude Code has **no first-class "inject a prompt into a running session"** API.
explicitly-requested, still-open feature:
[#27441 — inter-agent message injection](https://github.com/anthropics/claude-code/issues/27441)
and [#24947 — `claude inject`](https://github.com/anthropics/claude-code/issues/24947).
-Every approach below is a way around that gap. AgentAPI wins precisely because it
-productizes the sturdiest workaround (terminal emulation) behind a clean HTTP contract.
+Every approach below is a way around that gap. **herdr wins because it productizes the
+sturdiest workaround (terminal automation) as a structured socket API that *also* streams
+agent-status events** — so `bridged` gets reliable completion/blocked signals instead of
+scraping a screen.
-## 1. AgentAPI — HTTP over terminal emulation *(leading)*
+## 1. herdr socket API via `bridged` — structured injection + status events *(leading)*
+
+[herdr](https://herdr.dev) is a persistent agent multiplexer (a "tmux for agents") with a
+Unix-socket JSON API. `bridged` (see [[Message-Server]]) drives it: `pane.send_text` +
+`pane.send_keys` deliver a turn into the *running* pane, and `events.subscribe`
+(`pane.agent_status_changed`) reports **working / blocked / done** as real events.
+
+- **Injects into a live session:** yes — into *either* the worker **or** the primary pane
+ (typing keystrokes is subscription-safe), so both directions use one mechanism.
+- **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.
+
+## 2. AgentAPI — HTTP over terminal emulation *(fallback injector)*
[`coder/agentapi`](https://github.com/coder/agentapi) wraps the Claude Code **CLI** as an
-HTTP server and, under the hood, **drives the CLI's terminal** (it is essentially a
-hardened, stateful `tmux send-keys` with parsing). `POST /message` delivers a turn to the
-*running* session; `GET /events` (SSE) streams the reply. One session per server.
+HTTP server and drives the CLI's terminal underneath (essentially a hardened, stateful
+`tmux send-keys` with parsing): `POST /message`, `GET /events` (SSE), `GET /status`. It was
+the original leading choice; herdr now supersedes it, but it remains a **swappable fallback
+injector** behind `bridged`'s interface.
-- **Injects into a live session:** yes — this is its whole job.
-- **Subscription-safe:** the *primary* never sets `ANTHROPIC_BASE_URL`; only the worker's
- wrapped `claude` launches with `ANTHROPIC_BASE_URL=https://ollama.ltms.dev` + bearer.
-- **Why chosen:** clean request/response contract, stable session, reply parsing solved —
- none of the raw-keystroke fragility of doing it ourselves. See [[Architecture]].
+- **Injects into a live session:** yes — but only the *worker* (it wraps one CLI); the
+ primary direction still needs a broker.
+- **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.
+- **When to reach for it:** if herdr can't run, or as the second injector implementation to
+ de-risk herdr's immaturity. Its `msgfmt` reply parser is worth reusing regardless.
-## 2. Agent SDK — streaming input *(good if the driver is our own code)*
+## 3. Agent SDK — streaming input *(good if the driver is our own code)*
The Claude Agent SDK (TypeScript / Python) `query()` accepts a **streaming/async input
generator**, so you can feed additional messages into a live query without respawning the
-process. This is the cleanest option **when the driver is a program you control** rather
-than another interactive Claude session — you get typed streaming events and permission
-callbacks instead of scraping a terminal.
+process. Cleanest option **when the driver is a program you control** — typed streaming
+events and permission callbacks instead of scraping a terminal.
- **Injects into a live session:** yes, in-process.
-- **Trade-off:** the worker is an SDK-hosted loop, not a stock `claude` TUI — slightly
- further from "a real Claude Code process" than AgentAPI, and the driver must be code.
+- **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.
-## 3. Message-queue + Stop-hook long-poll *(the genuinely hook-based path)*
+## 4. Message-queue + Stop-hook long-poll *(the async layer — complementary)*
-This is 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.
+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.
```mermaid
sequenceDiagram
@@ -91,27 +121,24 @@ sequenceDiagram
- **Mechanism:** register a `Stop` hook that **long-polls** the queue for ~30s. On a hit it
returns `{"decision":"block","reason":""}` — the `reason` becomes injected
- context and forces another turn. On timeout it lets the session stop. The
- `stop_hook_active` flag guards against infinite loops.
+ context and forces another turn. On timeout it lets the session stop. `stop_hook_active`
+ guards against infinite loops.
- **Reference implementation:** **Agent Room** (`agent-room-mcp`) — `Stop` +
- `UserPromptSubmit` + `SessionStart` hooks over **Redis** "rooms" that Claude Code /
- Cursor / Gemini CLI publish-subscribe to.
+ `UserPromptSubmit` + `SessionStart` hooks over **Redis** "rooms".
([writeup](https://dev.to/agent-room/how-a-claude-code-stop-hook-unlocks-async-multi-agent-collaboration-no-polling-required-2e0e),
[DIY pattern](https://claudefa.st/blog/tools/hooks/stop-hook-task-enforcement)).
-- **Decisive limitation:** **pull-on-idle only** — the message lands at a **turn boundary**
- (when the worker would otherwise stop), never mid-turn. Fine for "bus event wakes an idle
- worker"; wrong for "interrupt a worker that's busy."
-- **Lighter cousin:** a `UserPromptSubmit` hook returning `{"additionalContext":"…"}`
- (e.g. [claude-mem](https://docs.claude-mem.ai/hooks-architecture) pulling from a vector
- DB) — but that fires only *when a prompt is submitted*, so it can't deliver an async push.
+- **Decisive limitation:** **pull-on-idle only** — the message lands at a **turn boundary**,
+ never mid-turn. Fine for "bus event wakes an idle worker"; wrong for "interrupt a busy
+ worker." (`bridged` injecting into an idle pane has the same idle-boundary property, but
+ with a real status gate.)
+- **Lighter cousin:** a `UserPromptSubmit` hook returning `{"additionalContext":"…"}` —
+ fires only *when a prompt is submitted*, so it can't deliver an async push.
-## 4. tmux `send-keys` / PTY paste *(the raw primitive)*
+## 5. tmux `send-keys` / PTY paste *(the raw primitive)*
-Inject keystrokes straight into the pane running `claude`. It is the most direct *push*
-into a live session and works today, but it is terminal automation — timing-sensitive,
-needs `capture-pane` to know when the worker is ready, and has no structured reply.
-Existing tools that do this (hooks only fire their *outbound* notification; injection is
-PTY/tmux):
+Inject keystrokes straight into the pane running `claude`. The most direct *push* into a
+live session, works today, but it is terminal automation — timing-sensitive, needs
+`capture-pane` to know when the worker is ready, and has no structured reply.
| Tool | Inbound injection | Hooks used for |
|---|---|---|
@@ -119,35 +146,43 @@ PTY/tmux):
| [OpenACP](https://dev.to/tigergethigher/how-to-control-claude-code-from-telegram-discord-or-slack-self-hosted-open-source-1jk8) (Slack/Discord/Telegram) | Agent Client Protocol bridge | — |
| [samwize Slack monitor](https://samwize.com/2026/03/14/how-i-got-claude-code-to-monitor-slack-while-i-was-on-holiday/) | `tmux send-keys` | — |
-**AgentAPI is essentially this, productized** — which is why we prefer it over hand-rolled
-`send-keys`.
+**herdr's `send_text`/`send_keys` is this, productized** — with a stable session, structured
+status events, and multiplexing — which is why we build on herdr rather than hand-rolled
+`send-keys` (and why AgentAPI, the other productized form, is only the fallback).
## Research matrix
-| Approach | Transport | Inject into running session? | Async push (mid-idle)? | Subscription-safe (primary) | Reply parsing | Fragility |
-|---|---|---|---|---|---|---|
-| **AgentAPI** ✅ | HTTP → terminal emulation | ✅ | ✅ (`POST /message`) | ✅ (worker-only env) | ✅ SSE `/events` | Low |
-| **Agent SDK streaming** | in-process generator | ✅ | ✅ | ✅ | ✅ typed events | Low (but driver = code) |
-| **Queue + Stop-hook** | hook long-poll | ✅ (turn boundary) | ⚠ pull-on-idle only | ✅ | via `reason` text | Medium |
-| **tmux `send-keys`** | keystrokes / PTY | ✅ | ✅ | ✅ | ❌ scrape `capture-pane` | High |
-| ~~`claude -p`~~ (excluded) | new process/turn | ❌ (fresh session) | — | ✅ | ✅ | — |
+| 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 | ✅ | via broker / `bridged` HTTP | ✅ (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 |
+| **tmux `send-keys`** | keystrokes / PTY | ✅ | ❌ scrape `capture-pane` | ✅ | ⚠ ssh | ✅ | High |
+| ~~`claude -p`~~ (excluded) | new process/turn | ❌ (fresh session) | ✅ | — | ✅ | ✅ | — |
## Recommendation
-- **Primary Opus → worker (the bridge's main channel):** **AgentAPI** — synchronous,
- parsed replies, clean subscription boundary. Selected. See [[Architecture]] / [[Setup]].
-- **External event bus → worker (async wake-ups):** layer a **Stop-hook long-poll** onto
- the worker so bus events injected as `reason` resume it on idle. Complementary to
- AgentAPI, not a replacement — different trigger shape.
-- **Avoid hand-rolled `tmux send-keys`** unless AgentAPI can't run; it's the same idea with
- all the fragility left in.
+- **Primary Opus → worker (the bridge's main channel):** **herdr via `bridged`** —
+ status-gated injection, structured completion/blocked events, symmetric, multiplexed,
+ persistent, with the subscription boundary enforced in code. Selected. See
+ [[Message-Server]] / [[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.
+- **Avoid hand-rolled `tmux send-keys`** unless neither herdr nor AgentAPI can run; it's the
+ same idea with all the fragility left in.
## Sources
+- [herdr — socket API](https://herdr.dev/docs/socket-api/) · [agent guide](https://herdr.dev/agent-guide.md) · [GitHub](https://github.com/ogulcancelik/herdr)
+- [coder/agentapi](https://github.com/coder/agentapi)
- [Hooks reference — Claude Code Docs](https://code.claude.com/docs/en/hooks)
- [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)
- [Stop-hook task-enforcement pattern](https://claudefa.st/blog/tools/hooks/stop-hook-task-enforcement)
- [claude-mem hooks architecture](https://docs.claude-mem.ai/hooks-architecture)
-- [coder/agentapi](https://github.com/coder/agentapi)
- [Issue #27441 — inter-agent message injection](https://github.com/anthropics/claude-code/issues/27441) · [Issue #24947 — `claude inject`](https://github.com/anthropics/claude-code/issues/24947)
- [Claude-Code-Remote](https://github.com/JessyTsui/Claude-Code-Remote) · [OpenACP guide](https://dev.to/tigergethigher/how-to-control-claude-code-from-telegram-discord-or-slack-self-hosted-open-source-1jk8)
+
diff --git a/Architecture.md b/Architecture.md
index 64b3339..c63c24c 100644
--- a/Architecture.md
+++ b/Architecture.md
@@ -4,14 +4,20 @@
subscription) to one or more **secondary** Claude Code **workers** running a *different*
model. Two channels carry traffic between them:
-1. **Synchronous** — primary delegates a task and waits for the reply → **AgentAPI** (HTTP
- over terminal emulation). This is the main channel.
+1. **Synchronous** — primary delegates a task and streams the reply → the **`bridged`
+ message server driving [herdr](https://herdr.dev)**. This is the main channel; its full
+ design is in **[[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. This is the optional
- layer for detached progress reports and out-of-band questions.
+ idle → a **message broker** polled by a `Stop`-hook long-poll (or injected by `bridged`
+ into an idle pane). This is the optional layer for detached progress reports and
+ out-of-band questions.
-A third piece, **herdr**, is *not* a transport — it is the human-facing ops/observability
-layer (persistence, state sidebar, a readiness gate).
+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
+HTTP/SSE contract. (This supersedes the earlier plan, which used `coder/agentapi` as the
+sync transport and treated herdr as an optional ops layer — see [[Approaches]] for why the
+positions swapped.)
## Components
@@ -21,36 +27,37 @@ flowchart TB
OPUS["Claude Code · Opus 4.8
leads, reviews, merges"]
end
- subgraph work["SECONDARY worker(s) — off-subscription"]
- AA["agentapi server
HTTP :3284"]
+ subgraph work["SECONDARY worker host — off-subscription"]
+ BD["bridged
message server
(not a claude process)"]
+ HERDR["herdr server
panes · agent-status"]
WCC["claude (worker)
ANTHROPIC_BASE_URL set"]
HOOK["Stop-hook
long-poll client"]
- AA -->|"launches / drives PTY"| WCC
+ BD -->|"Unix socket
send_text · events.subscribe"| HERDR
+ HERDR -->|"drives PTY"| WCC
WCC --- HOOK
end
BROKER["Broker
Redis Streams / NATS JetStream
inbox-primary · inbox-worker"]
MODEL["ollama.ltms.dev /v1
or GX10 vLLM
(worker model)"]
- HERDR["herdr server
panes · state · socket API
(ops layer, optional)"]
- OPUS -->|"POST /message (sync)"| AA
- AA -.->|"SSE /events (reply)"| OPUS
+ OPUS -->|"POST /message (sync)"| BD
+ BD -.->|"SSE /events (reply)"| OPUS
OPUS -.->|"write / long-poll (async)"| BROKER
HOOK -.->|"long-poll / write (async)"| BROKER
+ BD -.->|"bridge inbox ↔ session"| BROKER
WCC -->|"inference"| MODEL
- HERDR -.->|"send-keys · agent wait
pane read"| WCC
classDef sub fill:#2b6cb0,stroke:#1a365d,color:#ffffff;
classDef pick fill:#2f855a,stroke:#22543d,color:#ffffff;
classDef warn fill:#b7791f,stroke:#7b341e,color:#ffffff;
class OPUS sub
- class AA,WCC pick
- class BROKER,HERDR warn
+ class BD,HERDR,WCC pick
+ class BROKER warn
```
-*Figure: solid arrows = synchronous AgentAPI channel; dotted = the optional async broker
-and the herdr ops plane. The primary's env stays clean; only the worker sets
-`ANTHROPIC_BASE_URL`.*
+*Figure: solid arrows = the synchronous `bridged`/herdr 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.*
## Subscription boundary (the non-negotiable)
@@ -58,56 +65,75 @@ The whole design exists to keep the **primary** on Pro/Max while the **worker**
cheaper/local model — without a policy-violating proxy on the primary.
- The **primary** `claude` process **never** sets `ANTHROPIC_BASE_URL`. It talks to the
- worker over **HTTP** (AgentAPI) or a **broker**, never by re-pointing its own endpoint.
+ worker over **HTTP** (`bridged`) or a **broker**, never by re-pointing its own endpoint.
- Only the **worker's** `claude` process launches with
`ANTHROPIC_BASE_URL=https://ollama.ltms.dev` (+ `ANTHROPIC_AUTH_TOKEN` bearer) or a GX10
vLLM URL. Because it is a *separate process*, model selection is just per-process env —
which also sidesteps Claude Code's lack of per-subagent provider routing (the worker is
not a subagent).
+- **`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 `ANTHROPIC_BASE_URL`
+ and refuses to ever set it on a pane tagged *primary*.
+- Injecting keystrokes into the **primary** pane (worker → primary replies) is
+ subscription-safe: it is simulated typing, identical to the human at the keyboard — the
+ primary still authenticates to `api.anthropic.com` on Pro/Max.
> **Rule:** anything that sets `ANTHROPIC_BASE_URL` is, by definition, the worker. If you
> ever feel tempted to set it on the primary, stop — that is the subscription line.
-## Channel 1 — AgentAPI (synchronous, the main path)
+## Channel 1 — `bridged` over herdr (synchronous, the main path)
-[`coder/agentapi`](https://github.com/coder/agentapi) wraps the worker's `claude` **CLI**
-as an HTTP server (`POST /message`, `GET /messages`, `GET /events` SSE, `GET /status`),
-driving the CLI's terminal underneath. One session per server.
+`bridged` translates a clean north-side message API (`POST /message`, `GET /events` SSE,
+`GET /status`) into herdr socket calls, and — unlike a screen-stability heuristic — gates
+every injection on herdr's live `agent_status_changed` events. One session per pane; many
+panes per herdr. Full contract, components, and the Go interface sketch are in
+**[[Message-Server]]**.
```mermaid
sequenceDiagram
participant P as "Primary (Opus)"
- participant A as "agentapi server"
+ participant S as "bridged"
+ participant H as "herdr"
participant W as "Worker claude (other model)"
- P->>A: "POST /message (task, full context inlined)"
- A->>W: "inject into running session"
+ P->>S: "POST /message (task, full context inlined)"
+ S->>S: "await pane status = idle"
+ S->>H: "pane.send_text + send_keys enter"
+ H->>W: "inject into running session"
activate W
- W-->>A: "streamed tokens"
- A-->>P: "GET /events (SSE): assistant reply"
+ H-->>S: "event: agent_status_changed = working"
+ S-->>P: "SSE: status working"
+ W-->>H: "produces reply (writes envelope)"
+ H-->>S: "event: agent_status_changed = done"
deactivate W
+ S-->>P: "SSE: assistant reply"
Note over P: "review diff / result, merge"
alt worker is blocked
- W-->>A: "status = blocked (needs input)"
- A-->>P: "GET /status: blocked"
- P->>A: "POST /message (answer inlined)"
+ H-->>S: "event: agent_status_changed = blocked"
+ S-->>P: "SSE / GET /status: blocked"
+ P->>S: "POST /message (answer inlined)"
end
```
-*Figure: the primary delegates and waits; replies stream back parsed over SSE. A blocked
-worker surfaces on `/status`, and the primary re-sends with the answer inlined
-(return-and-reinvoke) — the same pattern `crush-bridge` uses.*
+*Figure: the primary delegates and streams status/reply over SSE; `bridged` injects only
+when the pane is `idle`, and a blocked worker surfaces on a real herdr event (not a guess).
+The primary re-sends with the answer inlined — the return-and-reinvoke pattern `crush-bridge`
+uses.*
-Why AgentAPI over raw `tmux send-keys`: it gives a clean request/reply contract and
-**parsed** replies (SSE), instead of scraping a terminal buffer. See [[Approaches]] for the
-full transport comparison.
+Why herdr over the earlier `agentapi` plan: structured **agent-status events** (vs a
+screen-stability heuristic), **symmetric** injection into either pane, native
+**multiplexing** of a herd of workers, and **persistence/detach**. AgentAPI is kept as a
+swappable *fallback injector* behind the same interface. See [[Approaches]] for the full
+transport comparison and [[Message-Server]] for the design.
## Channel 2 — broker + Stop-hook long-poll (async, duplex)
For traffic that has **no caller waiting on a connection** — a long-running worker posting
progress, an out-of-band question, another agent or a webhook injecting work — use a
-broker. The mechanism is symmetric, so it carries **both** directions: each side
-long-polls its **own** inbox stream and writes to the **other's**.
+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.
```mermaid
sequenceDiagram
@@ -131,79 +157,61 @@ sequenceDiagram
B-->>P: "payload → primary resumes"
```
-*Figure: a `Stop` hook long-polls the broker and injects any message as the block
-`reason`, forcing another turn; on timeout the session idles. Reversing it (worker →
-primary) is the same hook on the primary's inbox.*
+*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 `bridged` typing into the primary's idle pane.*
### Guardrails (mandatory for the async layer)
| Risk | Mitigation |
|------|------------|
-| **Primary quota burn** | The primary must **never** perpetual-poll. Use the `Stop`-hook variant (fires only at a natural idle boundary) or pull on-demand. Free busy-polling is for the off-subscription **worker** only. |
+| **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. |
-| **Context growth** | A perpetual worker's context window fills up. Cap idle cycles / tokens, then **stop and respawn fresh with state on the filesystem** (Ralph-loop). Perpetual != one infinite session. |
-
-## herdr — ops / observability plane (optional)
-
-[herdr](https://github.com/ogulcancelik/herdr) is a Rust **agent multiplexer** ("tmux for
-agents"): a persistent headless server + attachable TUI, each agent in a real PTY pane, a
-sidebar rolling every agent up to 🔴blocked / 🟡working / 🔵done / 🟢idle, all scriptable
-over a Unix-socket JSON API.
-
-It is **not** the transport and has **no** Claude-specific inject API — sending input is
-the generic `pane send-text` / `send-keys` / `run` PTY primitive, and its Claude state
-detection is **screen-manifest scraping**, not hooks. What it adds:
-
-- **`herdr agent wait --status idle|blocked`** — a readiness/blocked gate. An external
- driver can `wait agent-status blocked → pane run ""` instead of guessing when a
- pane is ready. This is the fragility that raw `send-keys` lacks.
-- **Persistent server, detach/reattach over SSH** — good for a long-lived off-subscription
- worker on a remote box you occasionally eyeball.
-- **Multiplexing + state sidebar** — pays off once there is a *herd* of mixed workers.
-
-**Positioning:** AgentAPI (or the broker) is the **machine transport**; herdr is the
-**human ops layer**. You can run AgentAPI-wrapped or broker-driven workers *inside* herdr
-panes and use `agent wait` as the readiness gate. Caveats: replies from herdr are scraped
-buffer text (not parsed like AgentAPI's SSE), and it is a very young, single-developer
-project — don't put a core transport on it.
+| **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]]). Perpetual != one infinite session. |
## Deployment shape (target)
```mermaid
flowchart LR
- subgraph mac["Your machine"]
+ subgraph mac["Your machine (subscription)"]
OPUS["Primary Opus
(Claude Code)"]
end
- subgraph host["Worker host (off-subscription)"]
+ subgraph host["Worker host (off-subscription, near model)"]
direction TB
- HS["herdr server (optional)"]
- AA2["agentapi server"]
- W2["worker claude"]
- HS -.-> AA2 --> W2
+ BD["bridged :8080
HTTP/SSE"]
+ HS["herdr server"]
+ W2["worker claude pane(s)"]
+ BD -->|"Unix socket"| HS --> W2
end
BR["Broker"]
ML["ollama.ltms.dev / GX10 vLLM"]
- OPUS -->|"HTTP"| AA2
+ OPUS -->|"HTTP + SSE (sync)"| BD
OPUS -.->|"async"| BR
- W2 -.-> BR
+ BD -.-> BR
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
```
-*Figure: the worker (agentapi + optional herdr) lives on an off-subscription host near the
-model; the primary reaches it over HTTP (sync) and the broker (async).*
+*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.*
## Related pages
-- **[[Approaches]]** — why AgentAPI, and the full transport comparison (SDK, Stop-hook, tmux)
-- **[[Setup]]** — running the worker agentapi server pointed at `ollama.ltms.dev`
+- **[[Message-Server]]** — the `bridged` design: herdr control contract, lifecycle, API, tech stack
+- **[[Approaches]]** — why herdr-centric, and the full transport comparison (AgentAPI, SDK, Stop-hook, tmux)
+- **[[Setup]]** — running herdr + `bridged` + a worker pointed at `ollama.ltms.dev`
- **[[Operations]]** — health, restart, model swaps, troubleshooting
## Sources
-- [coder/agentapi](https://github.com/coder/agentapi)
-- [herdr — GitHub](https://github.com/ogulcancelik/herdr) · [docs](https://herdr.dev/docs/) · [agents / state detection](https://herdr.dev/docs/agents/) · [CLI reference](https://herdr.dev/docs/cli-reference/)
+- [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)
+
diff --git a/Home.md b/Home.md
index af153d2..1abd84d 100644
--- a/Home.md
+++ b/Home.md
@@ -33,11 +33,14 @@ session talks to it over HTTP.
## Pages
-- **[[Architecture]]** — process model, the AgentAPI HTTP contract, subscription boundary
-- **[[Approaches]]** — AgentAPI vs Agent SDK streaming vs bus/tmux (research matrix)
-- **[[Setup]]** — running the worker AgentAPI server + pointing it at `ollama.ltms.dev`
+- **[[Message-Server]]** — 🟢 **`bridged`**, the herdr-centric message server (current primary approach)
+- **[[Architecture]]** — process model, subscription boundary, the two-channel model it refines
+- **[[Approaches]]** — herdr-centric vs AgentAPI vs Agent SDK vs bus/tmux (research matrix)
+- **[[Setup]]** — running herdr + `bridged` + a worker pointed at `ollama.ltms.dev`
- **[[Operations]]** — health, restart, model swaps, troubleshooting
## Status
-🟡 Planning — AgentAPI selected as the primary approach (2026-07-08).
+🟢 Design — **herdr-centric `bridged` message server** selected as the primary approach
+(2026-07-11), superseding the AgentAPI plan (2026-07-08). AgentAPI is retained as a fallback
+injector. See **[[Message-Server]]**.
diff --git a/Message-Server.md b/Message-Server.md
new file mode 100644
index 0000000..184c0e1
--- /dev/null
+++ b/Message-Server.md
@@ -0,0 +1,397 @@
+# Herdr Message Server (`bridged`)
+
+> **Status:** 🟢 Proposed primary approach (2026-07-11) — supersedes AgentAPI as the
+> centric transport. AgentAPI is retained only as a *fallback injector* (see [[Approaches]]).
+
+`bridged` is a small, always-on **message server that controls [herdr](https://herdr.dev)**
+and exposes a clean 2-way messaging API between a **primary** Claude Code session (Opus 4.8,
+on Pro/Max) and one or more **secondary worker** sessions running a different/cheaper model.
+It replaces the hand-rolled terminal emulation of AgentAPI by standing on herdr's structured
+socket API: herdr owns the PTYs, multiplexing, persistence, and — crucially — **agent-status
+events**; `bridged` owns the *policy* (subscription boundary, session lifecycle, delivery
+gating) and the *client-facing contract* (HTTP/SSE + optional broker).
+
+## Why herdr-centric (vs AgentAPI)
+
+AgentAPI re-implements, per process, an in-memory terminal emulator and a *screen-stability
+heuristic* to guess when the agent is done. herdr already provides all of that as a
+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 — **symmetric 2-way** |
+| "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 |
+| Persistence / detach-reattach over SSH | ❌ | ✅ headless server |
+
+The trade `bridged` accepts: **no synchronous call-and-await** — every exchange is async
+*return-and-reinvoke* (the same pattern `crush-bridge` uses). For a subscription-safe bridge
+that is the correct pattern anyway: a primary that blocks-and-waits either freezes the UI or
+busy-polls (quota burn). See **Trade-offs** below.
+
+## Architecture
+
+`bridged` is the hub. Its **south side** speaks herdr's Unix-socket JSON-RPC; its **north
+side** speaks HTTP/SSE to synchronous clients and (optionally) a broker for async duplex.
+herdr, `bridged`, the worker panes, and the model endpoint are colocated on the
+off-subscription **worker host**; the primary reaches in over HTTP and/or the broker.
+
+```mermaid
+flowchart TB
+ subgraph clients["NORTH — clients"]
+ OPUS["Primary Opus
(Claude Code, env CLEAN)"]
+ BUS["External event bus
(webhook / NATS / Redis)"]
+ HUMAN["Human / CLI / chat UI"]
+ end
+
+ subgraph server["bridged — message server (NOT a claude process)"]
+ API["API layer
REST + SSE (OpenAPI)"]
+ SESS["Session manager
spawn · health · recycle"]
+ INJ["Injector
queue + status gate"]
+ COLL["Reply collector
envelope | pane.read"]
+ GUARD["Subscription guard
enforces the boundary"]
+ HCL["herdr socket client
NDJSON, id-correlated + events"]
+ API --> SESS --> INJ --> HCL
+ HCL --> COLL --> API
+ SESS --> GUARD
+ HCL -->|"events.subscribe"| INJ
+ end
+
+ BROKER["Broker (optional)
Redis Streams / NATS JetStream
inbox-primary · inbox-worker"]
+ HERDR["herdr server
workspaces · panes · agent-status"]
+ WCC["worker claude pane(s)
ANTHROPIC_BASE_URL set"]
+ MODEL["ollama.ltms.dev /v1
or GX10 vLLM"]
+
+ OPUS -->|"POST /message · GET /events"| API
+ BUS -.->|"async"| BROKER
+ HUMAN --> API
+ BROKER -.-> API
+ HCL -->|"Unix socket"| HERDR
+ HERDR -->|"drives PTY"| WCC
+ WCC -->|"inference"| MODEL
+
+ classDef core fill:#2f855a,stroke:#22543d,color:#ffffff;
+ classDef ext fill:#2b6cb0,stroke:#1a365d,color:#ffffff;
+ classDef warn fill:#b7791f,stroke:#7b341e,color:#ffffff;
+ class API,SESS,INJ,COLL,GUARD,HCL core
+ class OPUS ext
+ class BROKER,HERDR,BUS warn
+```
+
+*Figure: `bridged` translates a clean north-side message API into herdr socket calls, gating
+every injection on live agent-status events. Only the worker panes carry
+`ANTHROPIC_BASE_URL`; `bridged` itself is a plain daemon and may poll/subscribe freely
+because it consumes no Anthropic quota.*
+
+### Components
+
+| Component | Responsibility |
+|---|---|
+| **herdr socket client** | NDJSON over `~/.config/herdr/herdr.sock`; correlates responses by `id`; maintains a long-lived `events.subscribe` stream. |
+| **Session manager** | Maps a logical session → herdr `workspace/tab/pane` id. Spawns the worker `claude` (env-prefixed launch line into a fresh pane's shell), health-checks, and **recycles on context ceiling** (Ralph loop, see below). |
+| **Injector** | Per-pane FIFO queue. Delivers `send_text` + `send_keys "enter"` **only when** that pane's `agent_status ∈ {idle, blocked}` — never mid-run. |
+| **Reply collector** | Preferred: worker writes a **structured envelope** (broker `XADD` or an HTTP callback to `bridged`). Fallback: `pane.read {source:"recent-unwrapped"}` scrape. |
+| **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`. |
+| **API layer** | REST + SSE, OpenAPI schema. Route names kept AgentAPI-shaped so existing clients drop in. |
+| **Broker connector** *(optional)* | Bridges `inbox-*` streams ↔ session messages for async, duplex, cross-host traffic. |
+
+## The herdr control contract (what `bridged` drives)
+
+Everything `bridged` needs is in herdr's socket API (verified against `herdr.dev/docs/socket-api`):
+
+```jsonc
+// spawn: create a pane, then launch the worker in its shell (env stays worker-only)
+{"id":"1","method":"workspace.create","params":{"cwd":"~/work","label":"worker-a"}}
+{"id":"2","method":"pane.send_text","params":{"pane_id":"w1:p1",
+ "text":"ANTHROPIC_BASE_URL=https://ollama.ltms.dev ANTHROPIC_AUTH_TOKEN=… claude"}}
+{"id":"3","method":"pane.send_keys","params":{"pane_id":"w1:p1","keys":"enter"}}
+
+// deliver a turn (gated on status=idle|blocked)
+{"id":"4","method":"pane.send_text","params":{"pane_id":"w1:p1","text":""}}
+{"id":"5","method":"pane.send_keys","params":{"pane_id":"w1:p1","keys":"enter"}}
+
+// readiness / completion — push, not polling
+{"id":"6","method":"events.subscribe","params":{"subscriptions":[
+ {"type":"pane.agent_status_changed","pane_id":"w1:p1"}]}}
+
+// answer a blocked worker / interrupt
+{"id":"7","method":"pane.send_input","params":{"pane_id":"w1:p1","keys":"ctrl+c"}}
+
+// fallback content read
+{"id":"8","method":"pane.read","params":{"pane_id":"w1:p1","source":"recent-unwrapped","lines":200}}
+```
+
+> **Implementation note (verify in the CLI reference):** herdr's socket may or may not expose
+> a direct "spawn command + env" primitive. `bridged` uses the robust path — create pane →
+> `send_text` the env-prefixed launch line — which guarantees `ANTHROPIC_BASE_URL` lands in
+> the **worker pane's shell only**. If a native spawn call exists, prefer it and pass env
+> explicitly; the guard invariant is unchanged.
+
+## Message flow
+
+### Synchronous delegation (primary → worker → primary)
+
+```mermaid
+sequenceDiagram
+ participant P as "Primary (Opus)"
+ participant S as "bridged"
+ participant H as "herdr"
+ participant W as "Worker claude"
+
+ P->>S: "POST /sessions/{id}/message (task, context inlined)"
+ S->>S: "await pane status = idle"
+ S->>H: "pane.send_text + send_keys enter"
+ H->>W: "inject turn"
+ activate W
+ H-->>S: "event: agent_status_changed = working"
+ S-->>P: "SSE: status working"
+ W-->>H: "produces reply (writes envelope)"
+ H-->>S: "event: agent_status_changed = done"
+ deactivate W
+ S->>S: "collect reply (envelope, or pane.read fallback)"
+ S-->>P: "SSE: assistant reply"
+ Note over P: "review diff / result, merge"
+```
+
+*Figure: `bridged` gates injection on `idle`, streams status transitions over SSE, and
+returns the reply from a structured envelope (falling back to a `recent-unwrapped` scrape).*
+
+### Async duplex via broker (event bus ↔ worker, worker → primary)
+
+```mermaid
+sequenceDiagram
+ participant BUS as "Event bus / webhook"
+ participant B as "Broker (Redis/NATS)"
+ participant S as "bridged"
+ participant H as "herdr"
+ participant W as "Worker 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 is woken on its own idle boundary
(Stop-hook long-poll OR bridged inject into primary pane)"
+```
+
+*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.*
+
+## Worker session lifecycle
+
+`bridged` treats a worker as a **recyclable** resource, not one immortal session — a
+long-lived pane fills its context window. When idle-cycle or token caps trip, `bridged`
+snapshots state to the filesystem, kills the pane, and respawns fresh (**Ralph loop**).
+
+```mermaid
+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: the state machine `bridged` drives per worker. `Blocked`, `done`, and `exited` are
+real herdr events, not heuristics — the reason herdr-centric beats screen scraping.*
+
+## Subscription boundary (enforced, not just documented)
+
+The invariant is unchanged from [[Architecture]] — **anything that sets
+`ANTHROPIC_BASE_URL` is, by definition, the worker** — but here it is *enforced in code*:
+
+- `bridged` is **not** a `claude` process. It consumes zero Anthropic quota, so it may
+ busy-poll the broker and hold a permanent herdr event subscription with no policy concern.
+- The **subscription guard** blocks any spawn of a *worker* pane whose launch line lacks
+ `ANTHROPIC_BASE_URL`, and blocks any attempt to set it on a pane tagged *primary*.
+- 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.
+- A startup self-check asserts the primary process env has **no** `ANTHROPIC_BASE_URL` and
+ logs the worker's resolved egress host.
+
+## API surface (north side)
+
+Kept intentionally AgentAPI-shaped so existing clients migrate without rewrites:
+
+| Method + path | Purpose |
+|---|---|
+| `POST /sessions` | Create a worker session `{model, base_url, cwd, label}` → returns `session_id` |
+| `GET /sessions` | List sessions + live `agent_status` |
+| `POST /sessions/{id}/message` | Deliver a turn `{content, type:"user"\|"raw"}` (queued, status-gated) |
+| `GET /sessions/{id}/events` | **SSE**: `status`, `message`, `blocked`, `exited` |
+| `GET /sessions/{id}/messages` | Conversation history |
+| `GET /sessions/{id}/status` | `idle` \| `working` \| `blocked` \| `done` |
+| `POST /sessions/{id}/keys` | Raw keys passthrough `{keys:"ctrl+c"}` — answer/interrupt |
+| `DELETE /sessions/{id}` | Drain + recycle |
+| `GET /healthz` · `GET /metrics` | Liveness + Prometheus |
+
+## Use cases
+
+1. **Subscription-safe delegation (the core case).** Primary Opus offloads bulk/routine
+ work — codegen, refactors, test writing, log triage — to a worker on a cheap/local model,
+ keeping Opus's context clean and its quota for review/merge decisions.
+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.
+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
+ 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.
+
+## Deployment model
+
+### Single-host (default — simplest, recommended to start)
+
+Primary, `bridged`, herdr, and workers all on one off-subscription box. `bridged` can inject
+into **both** the primary and the worker panes (both local to one herdr), so the broker is
+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.
+
+```mermaid
+flowchart LR
+ subgraph mac["Your Mac (subscription)"]
+ OPUS["Primary Opus
(Claude Code)"]
+ end
+ subgraph host["Worker host (off-subscription, near model)"]
+ direction TB
+ BD["bridged
:8080 HTTP/SSE"]
+ HS["herdr server"]
+ W2["worker claude pane(s)"]
+ BD -->|"Unix socket"| HS --> W2
+ end
+ BR["Broker
Redis / NATS"]
+ ML["ollama.ltms.dev / GX10 vLLM"]
+
+ OPUS -->|"HTTP + SSE (sync)"| BD
+ OPUS -.->|"async duplex"| BR
+ BD -.-> BR
+ W2 --> ML
+
+ 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 ext
+ class BD,HS core
+ 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`.*
+
+**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
+surface. (This closes the gap left by AgentAPI's open `:3284`.)
+
+## Proposed tech stack
+
+| Layer | Choice | Why | Alternative |
+|---|---|---|---|
+| **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** | **REST + SSE**, OpenAPI-generated | Drop-in for AgentAPI-shaped clients; SSE streams status/reply 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]] | NATS JetStream for multi-host scale / replay |
+| **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) |
+| **Testing** | **Mock herdr socket** server + golden transcripts | Deterministic CI without a real TTY | Dockerized herdr for e2e |
+
+**Recommendation: Go.** It gives the smallest operational footprint on the worker host, the
+cleanest concurrency story for this exact fan-in shape, and direct code reuse from
+`agentapi`. Pick **TypeScript** instead only if `bridged`'s primary *driver* is an Agent-SDK
+program and shared types outweigh the deploy simplicity.
+
+### Interface sketch (Go)
+
+```go
+// herdr socket client — one method, id-correlated; events on a separate stream.
+type Herdr interface {
+ Call(ctx context.Context, method string, params any) (json.RawMessage, error)
+ Subscribe(ctx context.Context, subs []Sub) (<-chan Event, error)
+}
+
+// Injector: deliver ONLY when the pane is safe to type into.
+func (i *Injector) Deliver(ctx context.Context, pane string, text string) error {
+ if st := i.status[pane]; st != Idle && st != Blocked {
+ i.queue[pane] = append(i.queue[pane], text) // hold until next idle event
+ return nil
+ }
+ if _, err := i.h.Call(ctx, "pane.send_text", P{"pane_id": pane, "text": text}); err != nil {
+ return err
+ }
+ _, err := i.h.Call(ctx, "pane.send_keys", P{"pane_id": pane, "keys": "enter"})
+ return err
+}
+
+// Subscription guard: the boundary, in code.
+func (g *Guard) AssertWorker(launch string) error {
+ if !strings.Contains(launch, "ANTHROPIC_BASE_URL=") {
+ return fmt.Errorf("refusing to spawn worker without ANTHROPIC_BASE_URL")
+ }
+ return nil
+}
+func (g *Guard) AssertPrimaryClean(env []string) error {
+ for _, e := range env {
+ if strings.HasPrefix(e, "ANTHROPIC_BASE_URL=") {
+ return fmt.Errorf("primary env is tainted — this is the subscription line")
+ }
+ }
+ return nil
+}
+```
+
+## Build plan (milestones)
+
+| Milestone | Deliverable | Proves |
+|---|---|---|
+| **M0 — Spike** | herdr client + spawn one worker pane + one `send_text`/`send_keys` round-trip | herdr socket drives a real `claude` |
+| **M1 — Status gate** | `events.subscribe` → Injector delivers only on `idle`/`blocked`; SSE status out | No mid-run corruption; real completion signal |
+| **M2 — Boundary + lifecycle** | Subscription guard + Ralph-loop recycle + reply envelope | Subscription-safe; survives context ceiling |
+| **M3 — Broker + duplex** | Redis Streams connector; worker→primary path; split-host deploy | Async, cross-host, 2-way |
+| **M4 — Harden** | Auth/TLS, metrics, mock-socket CI, systemd unit | Production shape |
+
+## Trade-offs & risks
+
+| Risk | Mitigation |
+|---|---|
+| **No synchronous reply** — all exchanges are return-and-reinvoke | Acceptable by design (sync waiting is quota-hostile); mirrors `crush-bridge`. SSE gives near-real-time status without blocking a turn. |
+| **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. |
+| **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. |
+
+## Related pages
+
+- **[[Architecture]]** — the two-channel model this refines; subscription boundary
+- **[[Approaches]]** — transport comparison; AgentAPI now the *fallback injector*
+- **[[Home]]** — project overview
+
+## Sources
+
+- [herdr — socket API](https://herdr.dev/docs/socket-api/) · [agent guide](https://herdr.dev/agent-guide.md) · [SKILL.md](https://raw.githubusercontent.com/ogulcancelik/herdr/master/SKILL.md) · [GitHub](https://github.com/ogulcancelik/herdr)
+- [coder/agentapi](https://github.com/coder/agentapi) — fallback injector; reference for `msgfmt` reply parsing
+- [Issue #27441 — inter-agent message injection](https://github.com/anthropics/claude-code/issues/27441) · [#24947 — `claude inject`](https://github.com/anthropics/claude-code/issues/24947)
+- [Redis Streams consumer groups](https://redis.io/docs/latest/develop/data-types/streams/) · [NATS JetStream](https://docs.nats.io/nats-concepts/jetstream)
+
+