7
3 Approaches
Dai Ha edited this page 2026-08-31 10:35:11 +07:00
This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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's fleet_reply (or the turn-done idle edge) resolves the primary's blocking fleet_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 fleetd (the async path — Mode 2 in Architecture).
  • North-face contract is MCP — and the sole gateway. Both primary and workers mount fleetd as an MCP server (one unified Claude setup); no Claude session ever addresses a broker or peer directly. The herdr injection here is the south side, orthogonal to it.
  • Completion signal: structured events — not the screen-stability guess AgentAPI makes. The worker can even pane.report_agent its own state via herdr's SKILL.md.
  • Multiplex + persist: a herd of workers as addressable panes; headless server survives detach/reattach over SSH.
  • Subscription-safe: only the worker pane launches with ANTHROPIC_BASE_URL; fleetd is 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 — so fleetd keeps 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 a final class — 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-host Stop-hook polling fleetd).
  • Completion signal: a screen-stability heuristic, not structured events.
  • Cross-host: native HTTP — its one edge over herdr, but fleetd already 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 fleetd already 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 its msgfmt reply 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 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 (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 Stop hook that long-polls the queue for ~30s. On a hit it returns {"decision":"block","reason":"<payload>"} — the reason becomes injected 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". (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." (fleetd 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.

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)
AgentAPI (never built — research) 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) ✅ — ✅ ✅ —

¹ 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 fleetd de-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; fleetd enqueues 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-keys unless herdr can't run on the host; it's the same idea with all the fragility left in.

Sources