Files
byre/docs/claude-protocol.md
T

3.6 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).

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 \
  --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":"..."}.
  • 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).
  • The init event 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:

  • 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, 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.