Add Approaches page (transport comparison / research matrix)

2026-07-08 16:25:36 +02:00
parent e973975ae8
commit 12d9e48bab
+153
@@ -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<br/>delivered into a LIVE session?"}
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"]
AA --> V1["✅ our leading choice"]
SDK --> V2["✅ if driver is our own code"]
BUS --> V3["⚠ async bus events only<br/>(lands at turn boundary)"]
TMUX --> V4["⚠ fragile, but the raw primitive<br/>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":"<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. 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)