Add Approaches page (transport comparison / research matrix)
+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)
|
||||
Reference in New Issue
Block a user