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.
5.8 KiB
claude-code-api
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_json—claude -pwith stream-json on stdin and stdout. Prompts go into a pipe, events come back as they happen, and a nativeresultrecord 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 1–3 are shared by both transports:
- The backend looks up a live session by
hash_history(messages[:-1]). If one matches, the new user message goes straight to it. - If nothing matches and
messages[:-1]is empty, a freshclaudeis spawned with a brand-new--session-id. - 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>.jsonland spawnsclaude --resume <id>. That is thenative_jsonlinjection mode; the fallback isconcat_message, which folds the prior history into one large first prompt.
Then, on stream_json:
- 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.
- stdout is read line by line and each record normalized into a typed
Event. Withinclude_partial_messagesthe raw Anthropicmessage_streamevents also come through asStreamEvent, withindexrebased across the turn's several API requests. - The turn closes on the native
resultrecord, which supplies theResultMessageverbatim — aggregate usage, real durations, cost.
Or, on pty:
- 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. - The turn closes on the first
assistantrecord withstop_reason ∈ {end_turn, max_tokens, stop_sequence, refusal}. AResultMessageis synthesized from itsusageand yielded last.
Examples
examples/basic_usage.py— one turn, realclaude.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.