@buzzie-ai/claude-inject
v0.1.1
Published
Drive a long-lived Claude Code session from Node, with structured event streams instead of PTY scraping. Wraps the local claude CLI via stream-json, inheriting the user's existing auth and MCP/plugin config.
Maintainers
Readme
claude-inject
An SDK that launches a Claude Code session in a child process and lets you inject prompts into it programmatically — getting structured event streams and full text replies back.
Why
Drive a long-lived Claude Code session from Node, with structured event streams instead of PTY scraping.
The Claude Code CLI already supports streaming JSON I/O (--input-format stream-json --output-format stream-json), but using it correctly across many turns — keeping one subprocess alive, framing prompts as NDJSON, parsing partial-message events, tracking the session UUID for resume — is enough fiddly plumbing that most people give up and shell out to claude -p one-shot per turn.
claude-inject is that plumbing as a small SDK: your code owns a persistent claude process and feeds it prompts. You get tool-call events, streaming text chunks, and full replies back as typed events. Same authentication, same MCP/plugin config, and same tool permissions as the user's interactive Claude Code — no API key required.
How it works
Under the hood, the SDK spawns:
claude -p --input-format stream-json --output-format stream-json \
--include-partial-messages --verboseThat keeps claude alive across many user messages: each session.send(...) writes a single line of NDJSON to stdin, and the SDK parses the streamed events back out of stdout. There's no PTY scraping and no terminal escape-code parsing — everything is structured.
How this differs from the Claude Agent SDK
Anthropic ships an official Claude Agent SDK that talks to the Anthropic API directly. If you're building a standalone agent from scratch with your own MCP/tool wiring and you have an API key, that's the right tool.
claude-inject is for the case where you want to drive Claude Code itself — the user's already-installed, already-authenticated CLI — from code:
| | Claude Agent SDK | claude-inject |
|---|---|---|
| Talks to | Anthropic API | Local claude CLI binary |
| Auth | API key | Inherits user's existing Claude Code auth |
| MCP / plugins / tool permissions | You configure | Inherits user's existing config |
| Updates | SDK version bumps | Free — comes with claude CLI updates |
| Dependency | @anthropic-ai/sdk | claude on PATH + its stream-json protocol |
| Best for | Building a new agent end-to-end | Scripting / extending an existing Claude Code setup |
The tradeoff is that claude-inject is coupled to the CLI binary and its stream-json protocol. Pin the claude CLI version in CI if reproducibility matters.
Limitations
Worth knowing before you adopt this:
- No human in the loop. Permission prompts don't surface to a UI — the spawned
clauderuns headless under whateverpermissionMode/toolsconfig you pass. If you need per-tool-call approval, build that yourself. - One
send()in flight at a time per session. Subsequent calls queue FIFO. Spawn multipleClaudeSessions for parallelism. dangerouslySkipPermissionsis a foot-gun. Same caveat as the underlying CLI flag — only use it in trusted, sandboxed contexts.- Behavior depends on the installed
claudeCLI version. The stream-json event shapes can shift between CLI releases. Pin the version in CI. - Session resume requires a previous session UUID.
sessionIdis populated during the firstsend()(notstart()), because stream-json mode doesn't emitsystem/inituntil the first message arrives.
Install
npm install @buzzie-ai/claude-injectRequires Node ≥ 20 and the claude CLI (Claude Code) on your PATH (or pass claudePath explicitly).
Local development
git clone https://github.com/Buzzie-AI/claude-inject
cd claude-inject
npm install
npm run buildQuick start
import { ClaudeSession } from '@buzzie-ai/claude-inject';
const session = new ClaudeSession({
cwd: process.cwd(),
systemPrompt: 'You are a helpful assistant. Be concise.',
});
session.on('chunk', (text) => process.stdout.write(text));
session.on('tool_use', ({ name }) => console.log(`[${name}]`));
await session.start();
const reply = await session.send('Hello, what is 2 + 2?');
console.log('\nReply:', reply);
const reply2 = await session.send('And times 3?');
console.log('\nReply2:', reply2); // same session — claude remembers turn 1
await session.close();API
new ClaudeSession(options)
| Option | Type | Notes |
|---|---|---|
| cwd | string | Working dir. Defaults to process.cwd(). |
| systemPrompt | string | Sent on first start; ignored on resume. |
| claudePath | string | Path to claude. Defaults to PATH lookup. |
| tools | string | "" disables all, "default" enables all, or "Bash,Edit,Read". |
| allowedTools | string[] | Allowlist alternative to tools. |
| disallowedTools | string[] | Denylist. |
| permissionMode | 'acceptEdits' \| 'auto' \| 'bypassPermissions' \| 'default' \| 'dontAsk' \| 'plan' | |
| dangerouslySkipPermissions | boolean | Equivalent to --dangerously-skip-permissions. |
| model | string | Alias (opus, sonnet, haiku) or full ID. |
| sessionId | string | Pin the session UUID. |
| resumeSessionId | string | Resume an existing session by UUID. |
| persistSession | boolean | Defaults to true (matches the CLI). |
| env | NodeJS.ProcessEnv | Extra env vars. |
Methods
start(): Promise<void>— spawnclaudeand resolve as soon as the subprocess is running. (In stream-json input mode, claude doesn't emit anything until you send the first message, sosessionIdis populated during the firstsend(), not duringstart().)send(prompt: string): Promise<string>— inject a prompt, resolve with the full text reply. Multiple concurrent calls queue (FIFO).close(): Promise<void>— close stdin and wait for the subprocess to exit.
Getters
busy: boolean— true while asend()is in flight or queued.sessionId: string | null— captured from thesystem/initevent.
Events
| Event | Payload | When |
|---|---|---|
| chunk | string | Text deltas as they stream |
| tool_use | { id, name, input } | A tool call starts |
| tool_result | { toolUseId, content, isError } | A tool call's result is folded back in |
| assistant_message | { text } | Full assistant turn (concatenation of all text blocks) |
| result | { text, isError, sessionId } | A turn finishes |
| error | Error | Subprocess error |
| exit | { code, stderr } | Subprocess exited |
Concurrency
One send() is in flight at a time. Subsequent calls queue and resolve in order. Inspect with session.busy.
Examples
examples/simple/— 25-line "start → send twice → close"npm run demo:simpleexamples/two-claudes/— the original Director / Builder / Worker TUI demo, refactored to drive twoClaudeSessionsnpm run demo:two-claudes -- --builder --turns 6 --seed 'I want a program that prints hello world.'
Tests
npm run test:session # SDK end-to-end (spawn, send twice, assert)
npm run test:role-check # Builder-mode role check via examples/two-claudes
npm test # bothHistory
This started as a Director pattern experiment: two claude -p subprocesses conversing with each other, rendered side-by-side in a blessed TUI. The original code spawned a one-shot subprocess per turn and chained them by hand.
The SDK pivot replaces the one-shot pattern with a persistent claude -p --input-format stream-json subprocess that stays alive across many user messages — closer to what an interactive session feels like, but driven by code instead of a human keyboard. The original two-Claude TUI lives on as examples/two-claudes/.
