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:
CLAUDECODEis removed so the child doesn't think it is nested;GIT_AUTHOR_*/GIT_COMMITTER_*set the commit identity;attributionset 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,contentis 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(subtypeinitcarriessession_id,model, tools, MCP server status),assistantanduser(Anthropic message content blocks:text,tool_use,tool_result),result(ends a turn;subtypeissuccessorerror_during_executionafter an interrupt, with cost, duration, turn count). --include-partial-messagesaddsstream_eventlines wrapping the raw API stream (message_start,content_block_start,content_block_deltawithtext_delta,content_block_stop,message_delta,message_stop), each withparent_tool_use_id(set inside subagents). The completeassistantevent for a block still arrives, before that block'scontent_block_stop. Transcripts leavestream_eventlines out.- The
initevent 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_progresslines with"heartbeat": true, the tool'selapsed_time_seconds, and aparent_tool_use_idnaming the tool call arrive every 30 seconds. A Bash call also bringssystemtask_startedandtask_notificationlines around it. - Lines that aren't a JSON object, don't parse, or have a
typenot inknownTypes(internal/claude/session.go) go toOptions.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:
initializemust be first. It carries hook registrations:{"hooks":{"Stop":[{"matcher":null,"hookCallbackIds":["hook_0"]}],...}}.interruptends the current turn; the turn'sresultfollows 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_taskexist and are not used yet.
CLI → host:
hook_callbackwithcallback_idandinput(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_messagewithserver_nameand a JSON-RPCmessagefor ansdk-type MCP server; answer with{"mcp_response": <JSON-RPC response>}. Notifications still need a response.can_use_toolonly arrives when a permission prompt would show; underbypassPermissionsit should not.control_cancel_requestabandons 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.