3.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. The CLI version the tests last passed against is in the system/init event of any transcript under internal/claude/testdata/transcripts/ (claude_code_version).
Launch
claude --output-format stream-json --input-format stream-json --verbose \
--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":"..."}. - 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). - The
initevent arrives with the first turn, not at launch, so the session ID is unknown until the first message is sent.
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,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 tools are deferred
The CLI hides MCP tool schemas behind its ToolSearch tool: an agent calls ToolSearch with select:mcp__byre__report before it can call report, costing an extra model turn every session.