Files
byre/docs/claude-protocol.md
T

4.8 KiB

Claude Code stream-json protocol

How internal/claude drives the claude CLI. Worked out from the Agent SDKs (@anthropic-ai/claude-agent-sdk on npm, claude-agent-sdk on PyPI); the Python SDK's _internal/query.py and _internal/transport/subprocess_cli.py are the most readable references. Each test run writes its transcripts to internal/claude/testdata/transcripts/ (gitignored); the CLI version that run used is claude_code_version in the system/init event of any of them.

Transcripts (Options.Transcript) record every line in both directions as {"dir":"send"|"recv","msg":...}. The account email from the initialize response is replaced with [redacted] everywhere it appears.

Launch

claude --output-format stream-json --input-format stream-json --verbose \
  --include-partial-messages \
  --permission-mode bypassPermissions --allow-dangerously-skip-permissions \
  --settings '{"attribution":{"commit":"","pr":""}}' \
  --mcp-config '{"mcpServers":{"byre":{"type":"sdk","name":"byre"}}}' \
  [--model M] [--resume=SESSION_ID] [--fork-session]
  • No --print; stream-json input implies non-interactive.
  • --resume= uses the equals form so a dash-leading value can't be parsed as a flag.
  • Environment: CLAUDECODE is removed so the child doesn't think it is nested; GIT_AUTHOR_*/GIT_COMMITTER_* set the commit identity; attribution set to empty strings removes the co-author trailer.

Framing

NDJSON both ways: one JSON object per line on stdin and stdout. Non-JSON stdout lines are ignored. Stdin EOF ends the process.

Messages

  • User turn, written to stdin: {"type":"user","message":{"role":"user","content":"..."},"parent_tool_use_id":null,"session_id":"..."}. With images, content is an array of Anthropic content blocks instead: {"type":"image","source":{"type":"base64","media_type":"image/png","data":"..."}} for each image, then {"type":"text","text":"..."}.
  • Stdout events: system (subtype init carries session_id, model, tools, MCP server status), assistant and user (Anthropic message content blocks: text, tool_use, tool_result), result (ends a turn; subtype is success or error_during_execution after an interrupt, with cost, duration, turn count).
  • --include-partial-messages adds stream_event lines wrapping the raw API stream (message_start, content_block_start, content_block_delta with text_delta, content_block_stop, message_delta, message_stop), each with parent_tool_use_id (set inside subagents). The complete assistant event for a block still arrives, before that block's content_block_stop. Transcripts leave stream_event lines out.
  • The init event arrives with the first turn, not at launch, so the session ID is unknown until the first message is sent.
  • While a tool runs, tool_progress lines with "heartbeat": true, the tool's elapsed_time_seconds, and a parent_tool_use_id naming the tool call arrive every 30 seconds. A Bash call also brings system task_started and task_notification lines around it.
  • Lines that aren't a JSON object, don't parse, or have a type not in knownTypes (internal/claude/session.go) go to Options.OnBadLine; lines that parse are still delivered as events whatever their type.

Control channel

Either side sends {"type":"control_request","request_id":ID,"request":{"subtype":...}} and the other answers {"type":"control_response","response":{"subtype":"success"|"error","request_id":ID,"response":{...},"error":"..."}}.

Host → CLI:

  • initialize must be first. It carries hook registrations: {"hooks":{"Stop":[{"matcher":null,"hookCallbackIds":["hook_0"]}],...}}.
  • interrupt ends the current turn; the turn's result follows almost immediately and a running Bash tool is killed. The session stays usable.
  • set_model ({"subtype":"set_model","model":M}) switches the running process's model for later turns.
  • set_permission_mode, mcp_status, get_context_usage, rewind_files, stop_task exist and are not used yet.

CLI → host:

  • hook_callback with callback_id and input (the hook's input JSON). The response body is the hook output. A Stop hook returning {"decision":"block","reason":"..."} keeps the agent going with the reason fed back to it.
  • mcp_message with server_name and a JSON-RPC message for an sdk-type MCP server; answer with {"mcp_response": <JSON-RPC response>}. Notifications still need a response.
  • can_use_tool only arrives when a permission prompt would show; under bypassPermissions it should not.
  • control_cancel_request abandons an in-flight request.

MCP tool loading

By default the CLI hides MCP tool schemas behind its ToolSearch tool, so an agent spends a model turn on ToolSearch before it can call a byre tool. "alwaysLoad": true in the server's --mcp-config entry puts byre's tools in the prompt from the first turn.