From 12d9e48bab2a8f1fc60dc1303d1195ae52229714 Mon Sep 17 00:00:00 2001 From: Dai Ha Date: Wed, 8 Jul 2026 16:25:36 +0200 Subject: [PATCH] Add Approaches page (transport comparison / research matrix) --- Approaches.md | 153 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 Approaches.md diff --git a/Approaches.md b/Approaches.md new file mode 100644 index 0000000..6679f8f --- /dev/null +++ b/Approaches.md @@ -0,0 +1,153 @@ +# 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 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?** + +```mermaid +flowchart TD + Q{"How is the message
delivered into a LIVE session?"} + 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"] + 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"] + + classDef pick fill:#2f855a,stroke:#22543d,color:#ffffff; + classDef warn fill:#b7791f,stroke:#7b341e,color:#ffffff; + class AA,V1 pick + class BUS,TMUX,V3,V4 warn +``` + +*Figure: picking a delivery transport by how the message enters a running session.* + +## 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](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. + +## 1. AgentAPI — HTTP over terminal emulation *(leading)* + +[`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. + +- **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]]. + +## 2. 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. + +- **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. + +## 3. Message-queue + Stop-hook long-poll *(the genuinely hook-based path)* + +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. + +```mermaid +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":""} + 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":""}` — 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. +- **Reference implementation:** **Agent Room** (`agent-room-mcp`) — `Stop` + + `UserPromptSubmit` + `SessionStart` hooks over **Redis** "rooms" that Claude Code / + Cursor / Gemini CLI publish-subscribe to. + ([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. + +## 4. 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): + +| Tool | Inbound injection | Hooks used for | +|---|---|---| +| [Claude-Code-Remote](https://github.com/JessyTsui/Claude-Code-Remote) (email/Telegram/Discord) | PTY smart-paste or `tmux send-keys` | outbound "task done" notify | +| [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`. + +## 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) | — | ✅ | ✅ | — | + +## 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. + +## Sources + +- [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)