Table of Contents
- 3. Approaches
- The native gap (why workarounds exist)
- 1. herdr socket API via fleetd — structured injection + status events (leading)
- 2. AgentAPI — HTTP over terminal emulation (never built — research only)
- 3. Agent SDK — streaming input (good if the driver is our own code)
- 4. Message-queue + Stop-hook long-poll (raw async primitive — behind the gateway in our design)
- 5. tmux send-keys / PTY paste (the raw primitive)
- Research matrix
- Recommendation
- Sources
3. Approaches
How does a driver (the primary Opus session, or an external event bus) deliver a
message into an already-running Claude Code worker — without spawning a fresh
claude -p per message? claude -p is deliberately out of scope here: it starts 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 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.
flowchart TD
Q{"How is the message<br/>delivered into a LIVE session?"}
Q -->|"structured socket → terminal, + status events"| HD["herdr socket<br/>(via fleetd)"]
Q -->|"HTTP request → terminal emulation"| AA["AgentAPI<br/>(coder/agentapi)"]
Q -->|"streaming input generator (in-process)"| SDK["Agent SDK<br/>streaming query()"]
Q -->|"worker PULLS on idle via a hook"| BUS["Message-queue<br/>+ Stop-hook long-poll"]
Q -->|"raw keystrokes into the tmux pane"| TMUX["tmux send-keys<br/>/ PTY paste"]
HD --> V0["✅ our leading choice<br/>(status events + multiplex;<br/>symmetric single-host)"]
AA --> V1["✕ never built<br/>(discarded research)"]
SDK --> V2["✅ if driver is our own code"]
BUS --> V3["⚠ async bus events only<br/>(lands at turn boundary)"]
TMUX --> V4["⚠ fragile — the raw primitive<br/>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 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 — and how trustworthy the "done/blocked" signal is once it does.
The native gap (why workarounds exist)
Claude Code has no first-class "inject a prompt into a running session" API. It is an
explicitly-requested, still-open feature:
#27441 — inter-agent message injection
and #24947 — claude inject.
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 fleetd gets reliable completion/blocked signals instead of
scraping a screen.
1. herdr socket API via fleetd — structured injection + status events (leading)
herdr is a persistent agent multiplexer (a "tmux for agents") with a
Unix-socket JSON API. fleetd (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 / idle as real events (turn-done
= the working → idle edge; herdr has no done status).
- Injects into a live session: yes into the worker. Worker → primary rides
fleetd's MCP rendezvous — the worker'sfleet_reply(or the turn-done idle edge) resolves the primary's blockingfleet_sendtool 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 ownStop-hook pollingfleetd(the async path — Mode 2 in Architecture). - North-face contract is MCP — and the sole gateway. Both primary and workers mount
fleetdas an MCP server (one unified Claude setup); no Claude session ever addresses a broker or peer directly. The herdr injection here is the south side, orthogonal to it. - Completion signal: structured events — not the screen-stability guess AgentAPI makes.
The worker can even
pane.report_agentits own state via herdr'sSKILL.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;fleetdis a plain daemon (no quota) that enforces the boundary in code. - Trade-off: herdr's socket is local-only (
fleetd's MCP/HTTP spans hosts, not herdr), and it is a young, single-dev project — sofleetdkeeps delivery durability in an internal queue behind the gateway. The shipped injector is the herdr one: a status-gated, single writer per worker (Injector.java:48), and it is afinalclass — not a pluggable interface a second injector could slot into. Replies are best carried as a structured envelope, not scraped.
2. AgentAPI — HTTP over terminal emulation (never built — research only)
coder/agentapi wraps the Claude Code CLI as an
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, until herdr superseded it. It was never built: a
case-insensitive search for agentapi under fleetd/src/main/java returns nothing, and
both Profile.kind values (claude-code, the default, and opencode,
FleetConfig.java:265-270) run through HerdrPeerLauncher
(ClaudeCodeLauncher.java:43, OpenCodeLauncher.java:54) — herdr is the only injection
path that ships. The mechanism below describes the external project, not this codebase.
- Injects into a live session: in its own design, yes — but only the worker (it wraps
one CLI); the primary direction still needs
fleetd's async path (idle-injection, or a split-hostStop-hook pollingfleetd). - Completion signal: a screen-stability heuristic, not structured events.
- Cross-host: native HTTP — its one edge over herdr, but
fleetdalready provides the HTTP layer on top of herdr, so that edge is neutralized. - Why it was dropped: herdr's structured status events beat a screen-stability
heuristic, and
fleetdalready covers the cross-host part, so that one edge buys nothing here. It stays as discarded research: a second injector implementation to de-risk herdr, and the possible reuse of itsmsgfmtreply parser, which is not in this codebase.
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. 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
claudeTUI — further from "a real Claude Code process" than herdr/AgentAPI, and the driver must be code.
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
fleet this is not how a Claude session normally receives async work — under the
sole-gateway rule fleetd delivers async by injecting an idle pane, and no Claude session
polls a queue. The Stop-hook survives in exactly one place: a split-host primary that isn't
a herdr pane, where the hook long-polls fleetd (not the queue) for wake-ups. The raw
mechanism below is shown for the comparison; note the poll target is the gateway, and the queue
itself sits behind fleetd.
sequenceDiagram
participant CC as "Claude worker"
participant H as "Stop hook"
participant Q as "Queue (Redis / NATS)"
CC->>H: turn ends → Stop fires
H->>Q: long-poll (BRPOPLPUSH, ≤30s)
alt message arrives
Q-->>H: payload
H-->>CC: "{ decision: block, reason: payload }"
Note over CC: message injected as context → new turn
else timeout
Q-->>H: (nothing)
H-->>CC: allow stop (session idles cleanly)
end
Figure: a Stop hook long-polls the bus and injects any message as the block reason.
- Mechanism: register a
Stophook that long-polls the queue for ~30s. On a hit it returns{"decision":"block","reason":"<payload>"}— thereasonbecomes injected context and forces another turn. On timeout it lets the session stop.stop_hook_activeguards against infinite loops. - Reference implementation: Agent Room (
agent-room-mcp) —Stop+UserPromptSubmit+SessionStarthooks over Redis "rooms". (writeup, DIY pattern). - 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." (
fleetdinjecting into an idle pane has the same idle-boundary property, but with a real status gate.) - Lighter cousin: a
UserPromptSubmithook returning{"additionalContext":"…"}— fires only when a prompt is submitted, so it can't deliver an async push.
5. tmux send-keys / PTY paste (the raw primitive)
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 |
|---|---|---|
| Claude-Code-Remote (email/Telegram/Discord) | PTY smart-paste or tmux send-keys |
outbound "task done" notify |
| OpenACP (Slack/Discord/Telegram) | Agent Client Protocol bridge | — |
| samwize Slack monitor | tmux 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 (AgentAPI is the other productized form of the same idea; it was never built
and is research only).
Research matrix
| Approach | Transport | Inject into running session? | Completion signal | Symmetric (both panes)? | Cross-host | Subscription-safe | Fragility |
|---|---|---|---|---|---|---|---|
herdr via fleetd ✅ |
socket → terminal + events | ✅ (idle-gated) | ✅ status events² | ◐ single-host¹ | via fleetd MCP/HTTP (sole gateway) |
✅ (guard in code) | Low–Med (herdr young) |
| 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 |
new process/turn | ❌ (fresh session) | ✅ | — | ✅ | ✅ | — |
¹ Symmetric only single-host. herdr can type into either pane, but the primary is a
herdr pane only when it runs on the herdr host. In the split-host target (primary on a Mac),
worker→primary goes through fleetd all the same — the primary's Stop-hook long-polls
fleetd (never a broker) for the wake-up. The "one mechanism, both directions via herdr"
story holds for a single-box setup; across hosts the gateway is still fleetd, just not by
injection.
² "Completion signal" for fleetd is the timing signal; reply content rides the worker's
structured fleet_reply (preferred), with a Stop-hook envelope as fallback (see
Message Server) — not the status event itself.
Recommendation
- Primary Opus → worker (the main path of this system): herdr via
fleetd— status-gated injection, structured completion/blocked events, symmetric (single-host), multiplexed, persistent, with the subscription boundary enforced in code. Selected. See Message Server / Architecture. - AgentAPI — never built: considered and dropped. This page keeps it as research
(section 2), not as a fallback an operator can select; herdr's immaturity stands as a
known risk, and
fleetdde-risks it with the internal queue and status-gated injection instead of a second injector. - External event bus → worker (async wake-ups): the bus hits
fleetd's REST ingress;fleetdenqueues internally if needed and injects the idle worker — the worker runs no queue-polling hook. Complementary to the sync path, not a replacement — different trigger shape, same single gateway. - Avoid hand-rolled
tmux send-keysunless herdr can't run on the host; it's the same idea with all the fragility left in.
Sources
- herdr — socket API · agent guide · GitHub
- coder/agentapi
- Hooks reference — Claude Code Docs
- Agent Room — Stop-hook async collaboration
- Stop-hook task-enforcement pattern
- claude-mem hooks architecture
- Issue #27441 — inter-agent message injection · Issue #24947 —
claude inject - Claude-Code-Remote · OpenACP guide
📖 fleet
Home — overview & the decision
Chapters
- Architecture — system · 2 invariants · 2 modes
- Message Server — the
fleetddesign - Approaches — transports compared, why herdr
- Setup — ⚫ superseded by 13
- Operations — ⚫ superseded by 13
- Team — orchestrating a mixed fleet
- Use Cases — the review scenario + mechanisms
- Roadmap — delivery record: what is live, what is off, what was dropped
- Implementation — as-built code map · classes · flows · state machines
- Cross-Host Messaging — broker topology · exchanges · queues per entity
- Features — what it can do · the knob that turns it on · why · the gotcha
- Claude → OpenCode — porting a workspace to a second host
- User Guide — 🟢 install · configure · run · delegate · the traps
- Fleet Manager — many fleets on one host, over REST
- REST API Reference — all 14 routes, roles, and bodies
- Security & Trust Boundary — the guard · authz · what a member inherits
Design proposals (not built)
- CB-548 Lead Quorum — a deterministic decision procedure around a lead's judgment
🟢 herdr-centric fleetd · AgentAPI = research, never built