5.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. - Subagents (the
Agenttool, listed ininittools asTask):systemtask_started(task_typelocal_agent,tool_use_idnaming theAgentcall,subagent_type,description,prompt,is_backgrounded),task_progress(descriptionof what it is doing),task_updated(bytask_idonly), andtask_notification(status,summary).assistantanduserlines from inside the subagent carryparent_tool_use_idset to theAgentcall's ID, plusagent_id,subagent_typeandtask_description. A foreground subagent (run_in_background: false) sends only its prompt this way, as ausertext block, and its final report arrives as theAgentcall'stool_result. A background one (the default) streams all its assistant and tool-result lines; theAgentcall'stool_resultcomes back at once saying it launched, the turn can end with its ownresultwhile the subagent runs, and when the subagent finishes the CLI starts another turn by itself, ending in a secondresultcarryingorigin.kindtask-notification. - 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.