Files
byre/docs/claude-protocol.md
T

6.3 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] [--session-id=ID] [--resume=SESSION_ID] [--resume-session-at=UUID] [--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.
  • --resume-session-at is hidden from --help. With --resume it keeps the conversation only up to and including the entry with that uuid, the uuid of an assistant or user stream-json line. With --fork-session and --session-id too, the result is a new session with that id, and the original is untouched. Resuming by id works from any working directory, so a fork can run in another worktree.
  • 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.
  • Subagents (the Agent tool, listed in init tools as Task): system task_started (task_type local_agent, tool_use_id naming the Agent call, subagent_type, description, prompt, is_backgrounded), task_progress (description of what it is doing), task_updated (by task_id only), and task_notification (status, summary). assistant and user lines from inside the subagent carry parent_tool_use_id set to the Agent call's ID, plus agent_id, subagent_type and task_description. A foreground subagent (run_in_background: false) sends only its prompt this way, as a user text block, and its final report arrives as the Agent call's tool_result. A background one (the default) streams all its assistant and tool-result lines; the Agent call's tool_result comes back at once saying it launched, the turn can end with its own result while the subagent runs, and when the subagent finishes the CLI starts another turn by itself, ending in a second result carrying origin.kind task-notification.
  • 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.