hh 76799179d9 feat(stream): headless claude -p transport behind a transport flag
The PTY transport stands in for a protocol that did not exist when it
was written: it pastes bracketed text into claude's TUI, guesses when
the Ink render loop has settled, re-presses Enter when the paste is
swallowed, and tails the session JSONL at 100ms. `claude -p` with
stream-json on both pipes is that protocol, so add it as a second
transport and let callers pick with `BackendOptions.transport`.

Measured on one host, same model and prompt, cold single-reply turn:
pty burns 2.3s on TUI readiness before the prompt is even submitted
(first event 2.5s, turn 5.2s); stream_json burns none (first event
0.3s, first token 1.9s, turn 2.7s). The gap is what `startup_delay`'s
60s cap exists to survive on slow hardware.

Everything the old transport relies on carries over unchanged and was
verified against a real CLI: multi-turn over one live process, MCP via
--mcp-config, --resume, and `native_jsonl` history seeding. Two things
are new rather than equal: `result` is native (so usage is the turn's
aggregate and durations are real, not synthesized), and
`include_partial_messages` yields token-level `StreamEvent`s — which
the JSONL, holding only finished blocks, could never provide.

Notably, assistant records carry `stop_reason: null` in this mode even
at the end of a turn, so `result` is not just tidier than the PTY
path's terminal-stop_reason heuristic, it is the only correct signal.

Default stays `pty`; nothing changes for existing callers.

Also: extract process-group bookkeeping into `procgroup` so both
transports sweep claude's children on shutdown, and fix a stale
assertion in test_backend that still expected the `is_error: None` that
`_block_to_dict` deliberately stopped emitting.
2026-07-28 02:56:17 +02:00

claude-code-api

AI Slop Inside

Python wrapper around the claude CLI for subscription-mode (no API key) backends. Drives one long-running claude per conversation and yields typed events; the public surface is Anthropic-Messages-API shaped so a gateway in front of it is a one-liner serializer away.

Two transports, picked with BackendOptions.transport:

  • stream_jsonclaude -p with stream-json on stdin and stdout. Prompts go into a pipe, events come back as they happen, and a native result record closes each turn. Prefer this.
  • pty (default, for backwards compatibility) — drives the interactive TUI through a pseudo-tty and tails the session JSONL. It has to guess when the TUI is ready to accept a paste, re-press Enter when the paste is swallowed, and poll a file for output. Keep it only if you need something the TUI alone has (e.g. --remote-control).

Measured on the same host, same model, same prompt — cold turn, single reply: pty spends 2.3s on TUI readiness before the prompt is even submitted (first event 2.5s, turn 5.2s); stream_json spends none (first event 0.3s, first token 1.9s, turn 2.7s). The gap widens on slow hardware, which is what the PTY transport's 60s startup_delay cap exists to survive.

Not affiliated with Anthropic. You need a working subscription, the claude CLI on PATH, and to have run claude /login once.

Install

As a library inside another project:

uv add "claude-code-api @ git+https://git.kotikot.com/beaver/claude-code-api"

The runtime needs only ptyprocess.

Use

import asyncio
from claude_code_api import BackendOptions, ClaudeCodeBackend

async def main() -> None:
    opts = BackendOptions(
        cwd="/path/to/project",
        dangerously_skip_permissions=True,
        transport="stream_json",
        include_partial_messages=True,  # token-level deltas, stream_json only
    )
    async with ClaudeCodeBackend(opts) as backend:
        async for event in backend.complete(
            [{"role": "user", "content": "say hi"}]
        ):
            print(event)

asyncio.run(main())

Multi-turn works by construction — append the assistant reply + a fresh user message to the same messages list and call complete() again. The backend fingerprints messages[:-1], finds the live PTY from the previous turn, and reuses it (so the server-side prompt cache stays warm):

history = [{"role": "user", "content": "remember Beaver"}]
async for ev in backend.complete(history): ...

history += [
    {"role": "assistant", "content": [{"type": "text", "text": "OK"}]},
    {"role": "user", "content": "what was the codeword?"},
]
async for ev in backend.complete(history): ...

Public surface

Events (Anthropic-style, vendored to keep the dep tree empty): AssistantMessage, UserMessage, SystemMessage, ResultMessage, TextBlock, ThinkingBlock, ToolUseBlock, ToolResultBlock.

Errors: BackendError (root), AuthError, ProcessError, CLINotFoundError, RateLimitError, SessionError, MessageParseError.

Backend: ClaudeCodeBackend(opts).complete(messages) is an async generator of events. BackendOptions exposes model / system prompt / allowed-tools / mcp_servers / permission mode / history injection mode.

Lower layers (PtyClaudeProcess, JsonlWatcher, TurnManager, normalize) are re-exported for callers that want to assemble their own session orchestration.

How a turn works

Steps 13 are shared by both transports:

  1. The backend looks up a live session by hash_history(messages[:-1]). If one matches, the new user message goes straight to it.
  2. If nothing matches and messages[:-1] is empty, a fresh claude is spawned with a brand-new --session-id.
  3. If messages[:-1] is non-empty (a continuation we don't have a live process for — e.g. after restart), the backend writes a hand-crafted JSONL transcript at ~/.claude/projects/<key>/<id>.jsonl and spawns claude --resume <id>. That is the native_jsonl injection mode; the fallback is concat_message, which folds the prior history into one large first prompt.

Then, on stream_json:

  1. The prompt is written to stdin as one JSON line. No readiness wait — the CLI buffers stdin, so this works even before it has finished booting.
  2. stdout is read line by line and each record normalized into a typed Event. With include_partial_messages the raw Anthropic message_stream events also come through as StreamEvent, with index rebased across the turn's several API requests.
  3. The turn closes on the native result record, which supplies the ResultMessage verbatim — aggregate usage, real durations, cost.

Or, on pty:

  1. The PTY's stdout is drained continuously by a background thread; we never read events from there. The prompt is pasted in bracketed-paste framing followed by a separate Enter, and re-sent if the JSONL shows no sign it registered. The JSONL file is tailed at 100ms cadence and each new record is normalized into a typed Event.
  2. The turn closes on the first assistant record with stop_reason ∈ {end_turn, max_tokens, stop_sequence, refusal}. A ResultMessage is synthesized from its usage and yielded last.

Examples

  • examples/basic_usage.py — one turn, real claude.
  • examples/multi_turn.py — two turns sharing one live PTY.
  • examples/mcp_tool.py — wire up the bundled echo MCP server and let the model call it.

Tests

uv run pytest                  # unit tests (fast, no real claude)
RUN_CLAUDE_SMOKE=1 uv run pytest tests/test_pty.py tests/test_turn.py tests/test_backend.py tests/test_stream.py

The smoke-marked tests spawn a real claude process and need a logged-in subscription on the host.

S
Description
PTY-based wrapper around the claude CLI for subscription-mode backends
Readme
432 KiB
Languages
Python 100%