%%{init: {'theme': 'neutral', 'flowchart': {'useMaxWidth': false, 'htmlLabels': true, 'padding': 20, 'nodeSpacing': 30, 'rankSpacing': 40}, 'themeVariables': {'primaryColor': '#8B9DAF', 'primaryTextColor': '#ffffff', 'primaryBorderColor': '#6E7F91', 'secondaryColor': '#9CAF88', 'secondaryTextColor': '#ffffff', 'secondaryBorderColor': '#7A8D68', 'tertiaryColor': '#C2856E', 'tertiaryTextColor': '#ffffff', 'tertiaryBorderColor': '#A06A54', 'lineColor': '#B5A99A', 'textColor': '#4A4A4A', 'mainBkg': '#8B9DAF', 'nodeBorder': '#6E7F91', 'clusterBkg': 'rgba(139,157,175,0.12)', 'clusterBorder': '#B5A99A', 'edgeLabelBackground': 'transparent'}}}%%
flowchart TD
CLI["CLI Entry<br><i>cli.tsx (302 LOC)</i>"] --> INK["TUI Renderer"]
SDK["SDK Entry<br><i>structuredIO.ts (859 LOC)</i>"] --> SIO["StructuredIO<br>NDJSON Protocol"]
MCP["MCP Server<br><i>mcp.ts (196 LOC)</i>"] --> MCA["MCP SDK<br>Stdio Transport"]
INK --> ENGINE["<b>Agent Engine</b>"]
SIO --> ENGINE
MCA --> ENGINE
ENGINE --> QUERY["query.ts<br>Agent Loop"]
ENGINE --> TOOLS["Tool Registry<br>40 tools"]
ENGINE --> PERMS["Permission<br>System"]
style CLI fill:#8B9DAF,color:#fff,stroke:#6E7F91
style SDK fill:#9CAF88,color:#fff,stroke:#7A8D68
style MCP fill:#C2856E,color:#fff,stroke:#A06A54
style INK fill:#B39EB5,color:#fff,stroke:#8E7A93
style SIO fill:#C4A882,color:#fff,stroke:#A08562
style MCA fill:#8E9B7A,color:#fff,stroke:#6E7B5A
style ENGINE fill:#8B9DAF,color:#fff,stroke:#6E7F91
style QUERY fill:#9CAF88,color:#fff,stroke:#7A8D68
style TOOLS fill:#C2856E,color:#fff,stroke:#A06A54
style PERMS fill:#B39EB5,color:#fff,stroke:#8E7A93
Agent SDK & Structured I/O
How a single agent engine serves three entry modes – CLI, SDK, and MCP – through a data-plane / control-plane message protocol
1. Introduction: One Engine, Three Doors
Claude Code is not merely a terminal application. It is a programmatic agent runtime that happens to ship with a terminal frontend. The same agent engine – the AsyncGenerator loop, the tool registry, the permission system, the context compactor – is reachable through three distinct entry modes: an interactive CLI, a headless SDK, and an MCP server. This post examines the protocol layer that makes this convergence possible.
Most analyses of coding agents focus on the model loop or the tool system. But the question of how external consumers invoke and observe the agent is an equally consequential architectural decision. A terminal REPL, a CI/CD pipeline, and a VS Code extension have radically different requirements for input delivery, output consumption, and governance interaction. Claude Code solves this with a structured message protocol – defined via Zod schemas in coreSchemas.ts (1,889 LOC) and controlSchemas.ts (663 LOC) – that separates conversational data from governance events and provides machine-readable I/O for every entry mode.
The following diagram shows the three entry modes converging on the shared engine:
How to read this diagram. Start at the three top nodes representing the three entry modes: CLI, SDK, and MCP Server. Each passes through its own protocol adapter (TUI Renderer, StructuredIO NDJSON, or MCP Stdio Transport) before converging on the shared “Agent Engine” node in the center. Below the engine, three shared subsystems – the query loop, tool registry, and permission system – are available to all three entry modes identically. The key takeaway is that all paths lead to the same engine; the entry mode determines only how I/O is formatted.
This post sits alongside the agent loop analysis (Part II.1) and multi-agent orchestration (Part II.3) in the Agent Harness section. Where Part II.1 dissects what the loop does, this post examines how the loop is accessed. We begin with the three entry modes (Section 2), proceed to the SDK message schemas (Section 3), examine the control protocol (Section 4), analyze the StructuredIO adapter (Section 5), characterize the data-plane / control-plane split (Section 6), and close with the SDK message adapter for remote consumption (Section 7).
Source files covered in this post:
| File | Purpose | Size |
|---|---|---|
src/schemas/coreSchemas.ts |
Core SDK message schemas (23 message types, Zod v4) | ~1,889 LOC |
src/schemas/controlSchemas.ts |
Control protocol schemas (permissions, configuration) | ~663 LOC |
src/cli.tsx |
CLI entry point (REPL vs. SDK mode routing) | ~302 LOC |
src/remote/sdkMessageAdapter.ts |
SDK-to-REPL message adapter for remote consumption | ~302 LOC |
src/entrypoints/ |
Public SDK entry points and type exports | ~5 files |
src/services/mcp/SdkControlTransport.ts |
MCP-over-SDK control transport | ~200 LOC |
2. Three Entry Modes: CLI, SDK, MCP
The three entry modes differ in their surface protocol but converge on the same agent engine. Understanding their differences clarifies why the structured message protocol exists.
2.1 CLI Entry: React/Ink REPL
The CLI entry (cli.tsx, 302 LOC) is a bootstrap dispatcher that examines process.argv and routes to the appropriate handler. It uses dynamic imports extensively – the --version fast path requires zero module loading beyond the entry file itself. The full React / Ink REPL loads only when no fast path matches:
// Fast-path for --version/-v: zero module loading needed
if (args.length === 1 && (args[0] === '--version' || args[0] === '-v')) {
console.log(`${MACRO.VERSION} (Claude Code)`)
return
}
// ... other fast paths: --dump-system-prompt, daemon, bridge, bg sessions ...
// No special flags detected, load and run the full CLI
const { main: cliMain } = await import('../main.js')
await cliMain()The CLI entry handles a remarkable number of sub-modes before reaching the interactive REPL: daemon workers, bridge/remote-control, background sessions (ps, logs, attach, kill), template jobs, environment runners, self-hosted runners, and worktree/tmux orchestration. Each is guarded by a feature() gate for build-time dead code elimination. The interactive REPL – the mode most users experience – is the fallback path.
2.2 SDK Entry: Headless Structured I/O
The SDK mode is invoked when an external process (the Python SDK, a VS Code extension, a CI pipeline) spawns the Claude Code process with --output-format stream-json. In this mode, there is no Ink renderer. Instead, the agent reads NDJSON messages from stdin and writes NDJSON messages to stdout. The StructuredIO class (structuredIO.ts, 859 LOC) mediates this protocol.
The SDK mode enables programmatic agent control: send a user message as a JSON object on stdin, receive assistant messages, tool executions, stream events, and result summaries as JSON objects on stdout. Governance interactions (permission requests, elicitations) flow through the same channel as control protocol messages, distinguished by their type field.
2.3 MCP Server Entry: Tool Exposure
The MCP entry (mcp.ts, 196 LOC) runs Claude Code as an MCP (Model Context Protocol) server, exposing its tool registry to external MCP clients. This inverts the usual relationship: instead of Claude Code calling MCP servers (as it does when connecting to external tools), Claude Code becomes an MCP server, making its 40 built-in tools available to other agents.
const server = new Server(
{ name: 'claude/tengu', version: MACRO.VERSION },
{ capabilities: { tools: {} } },
)The MCP server registers two handlers: ListToolsRequestSchema (returns all tools with their Zod-to-JSON-Schema converted input schemas) and CallToolRequestSchema (executes a tool in a non-interactive context). Notably, the MCP mode creates a minimal ToolUseContext with isNonInteractiveSession: true and thinking disabled – it is a headless tool executor, not a conversational agent.
3. SDK Core Schemas: The Message Protocol
The SDK message protocol is defined by coreSchemas.ts (1,889 LOC) – a single file of Zod v4 schemas that serves as the single source of truth for all SDK data types. TypeScript types are generated from these schemas, not the other way around.
The schema file is organized into several domains, each defining the wire format for a category of SDK messages.
3.1 Message Type Taxonomy
The SDKMessageSchema is a union of 23 distinct message types. These can be grouped by function:
| Category | Message Types | Purpose |
|---|---|---|
| Conversational | user, assistant, stream_event |
The data plane – prompts, responses, streaming deltas |
| Result | result (success / error variants) |
Turn completion with cost, usage, and duration metrics |
| System | init, status, compact_boundary, api_retry, local_command_output |
Session lifecycle and infrastructure events |
| Hooks | hook_started, hook_progress, hook_response |
Lifecycle hook execution progress |
| Tasks | task_started, task_progress, task_notification |
Background task status |
| Progress | tool_progress, tool_use_summary |
Tool execution observability |
| Auth | auth_status |
Authentication state changes |
| Rate limits | rate_limit_event |
Subscription rate limit information |
| Session | session_state_changed, files_persisted |
Session state transitions |
| Suggestions | prompt_suggestion |
Predicted next user prompt |
3.2 The SDKSystemMessage: Session Initialization
The SDKSystemMessage (subtype init) is the first message emitted on every session. It carries the full session configuration: available tools, MCP server status, model identity, permission mode, slash commands, skills, plugins, API key source, betas, and output style. This single message gives the SDK consumer everything it needs to render a session UI or configure a programmatic client:
export const SDKSystemMessageSchema = lazySchema(() =>
z.object({
type: z.literal('system'),
subtype: z.literal('init'),
tools: z.array(z.string()),
model: z.string(),
permissionMode: PermissionModeSchema(),
mcp_servers: z.array(z.object({ name: z.string(), status: z.string() })),
slash_commands: z.array(z.string()),
skills: z.array(z.string()),
plugins: z.array(z.object({ name: z.string(), path: z.string(), ... })),
// ... apiKeySource, betas, output_style, fast_mode_state
uuid: UUIDPlaceholder(),
session_id: z.string(),
}),
)3.3 The Result Schema: Structured Turn Summaries
Turn completion produces an SDKResultMessage – either success or error. The success variant carries duration_ms, duration_api_ms, num_turns, total_cost_usd, per-model usage breakdowns, and the agent’s final text result. The error variant adds a subtype discriminant (error_during_execution, error_max_turns, error_max_budget_usd, error_max_structured_output_retries) and an errors array. This structured format enables CI pipelines to parse agent outcomes without scraping text.
3.4 Schema-Driven Design
A recurring pattern is the use of lazySchema() wrappers around every schema definition. This defers Zod schema construction until first access, avoiding circular reference issues and reducing startup cost. The schemas are the canonical definitions – TypeScript types are generated from them via scripts/generate-sdk-types.ts, ensuring that runtime validation and static typing can never drift.
4. Control Protocol: Out-of-Band Governance
The control protocol (controlSchemas.ts, 663 LOC) defines a request-response mechanism that runs alongside the conversational data stream. It handles governance events – permission requests, elicitations, session configuration changes – that require bidirectional communication between the agent engine and its host.
4.1 The Request-Response Pattern
Every control interaction follows the same envelope:
// Outbound: agent -> host
export const SDKControlRequestSchema = lazySchema(() =>
z.object({
type: z.literal('control_request'),
request_id: z.string(), // correlation ID
request: SDKControlRequestInnerSchema(),
}),
)
// Inbound: host -> agent
export const SDKControlResponseSchema = lazySchema(() =>
z.object({
type: z.literal('control_response'),
response: z.union([
ControlResponseSchema(), // { subtype: 'success', ... }
ControlErrorResponseSchema(), // { subtype: 'error', ... }
]),
}),
)The request_id is a correlation ID that links a response to its originating request. This is essential because multiple control requests can be in flight simultaneously – for example, two concurrent tool executions may each require permission.
4.2 Control Request Taxonomy
The SDKControlRequestInnerSchema is a union of 20 request subtypes:
| Subtype | Direction | Purpose |
|---|---|---|
initialize |
Host -> Agent | Session setup: hooks, MCP servers, agents, JSON schema |
can_use_tool |
Agent -> Host | Permission request: tool name, input, suggestions |
interrupt |
Host -> Agent | Cancel current conversation turn |
set_permission_mode |
Host -> Agent | Change permission enforcement mode |
set_model |
Host -> Agent | Switch model mid-session |
mcp_status |
Host -> Agent | Query MCP server connection states |
get_context_usage |
Host -> Agent | Context window usage breakdown |
hook_callback |
Agent -> Host | Deliver hook callback with input data |
elicitation |
Agent -> Host | MCP server requesting user input |
rewind_files |
Host -> Agent | Revert file changes to a message boundary |
mcp_message |
Host -> Agent | Forward JSON-RPC to an MCP server |
mcp_set_servers |
Host -> Agent | Replace dynamically managed MCP servers |
apply_flag_settings |
Host -> Agent | Merge settings into the flag layer |
get_settings |
Host -> Agent | Retrieve effective merged settings |
cancel_async_message |
Host -> Agent | Drop a queued async user message |
4.3 The Permission Request Flow
The most critical control interaction is permission prompting. When the agent wants to execute a tool that requires user approval, the engine emits a can_use_tool control request containing the tool name, its input, and optional permission_suggestions (pre-computed rules the host can adopt). The host presents this to the user (or to an automated policy), then returns a control_response with behavior: 'allow' or behavior: 'deny'.
The PermissionResultSchema encodes this decision with optional updatedInput (the host may modify tool arguments before allowing), updatedPermissions (new rules to persist), and decisionClassification (user_temporary, user_permanent, user_reject) for telemetry.
4.4 Stdin and Stdout Aggregates
The protocol defines two aggregate schemas that enumerate every valid message on each channel:
StdoutMessageSchema:SDKMessage | SDKControlResponse | SDKControlRequest | SDKControlCancelRequest | SDKKeepAliveMessage– everything the agent can write.StdinMessageSchema:SDKUserMessage | SDKControlRequest | SDKControlResponse | SDKKeepAliveMessage | SDKUpdateEnvironmentVariablesMessage– everything the host can write.
Note that both SDKControlRequest and SDKControlResponse appear in both directions. Control requests can originate from either side: the agent requests permission from the host; the host requests configuration changes from the agent.
5. StructuredIO: The Protocol Adapter
StructuredIO (structuredIO.ts, 859 LOC) is the bridge between the raw NDJSON stream and the agent engine’s typed interfaces. It transforms a stream of text lines into typed messages, manages pending control requests with correlation IDs, and handles the concurrency of racing permission decisions.
5.1 Architecture
The StructuredIO class maintains three key data structures:
structuredInput: An AsyncGenerator that yields parsedStdinMessage | SDKMessageobjects from the raw stdin line stream.pendingRequests: AMap<string, PendingRequest<T>>that tracks outstanding control requests by theirrequest_id. Each entry holds a Promise’s resolve/reject callbacks and an optional Zod schema for response validation.resolvedToolUseIds: ASet<string>that tracks tool use IDs whose permission flow has completed, preventing duplicatecontrol_responsemessages from corrupting the conversation.
export class StructuredIO {
readonly structuredInput: AsyncGenerator<StdinMessage | SDKMessage>
private readonly pendingRequests = new Map<string, PendingRequest<unknown>>()
private readonly resolvedToolUseIds = new Set<string>()
// ...
}5.2 The Read Loop: Line-Oriented NDJSON Parsing
The read() method is a private AsyncGenerator that consumes the raw input stream, splits it into lines, and dispatches each line through processLine(). The implementation handles partial lines (buffering across for await blocks), prepended synthetic user messages, and stream closure:
private async *read() {
let content = ''
const splitAndProcess = async function* (this: StructuredIO) {
for (;;) {
if (this.prependedLines.length > 0) {
content = this.prependedLines.join('') + content
this.prependedLines = []
}
const newline = content.indexOf('\n')
if (newline === -1) break
const line = content.slice(0, newline)
content = content.slice(newline + 1)
const message = await this.processLine(line)
if (message) yield message
}
}.bind(this)
// ...
}The prependUserMessage() method allows injecting synthetic user turns that are yielded before the next real stdin message. This is used by subsystems that need to inject messages into the conversation programmatically.
5.3 Control Request Resolution
When processLine() encounters a control_response, it looks up the matching PendingRequest by request_id, validates the response payload against the stored Zod schema, and resolves or rejects the Promise:
if (message.type === 'control_response') {
const request = this.pendingRequests.get(message.response.request_id)
if (!request) {
// Check resolvedToolUseIds to ignore duplicates
// ...
return undefined
}
this.trackResolvedToolUseId(request.request)
this.pendingRequests.delete(message.response.request_id)
if (message.response.subtype === 'error') {
request.reject(new Error(message.response.error))
} else {
request.resolve(request.schema.parse(result))
}
}5.4 Duplicate Prevention
The resolvedToolUseIds set is bounded to 1,000 entries (the MAX_RESOLVED_TOOL_USE_IDS constant) with FIFO eviction. This prevents unbounded memory growth in long sessions while maintaining enough history to catch duplicate control_response deliveries – which can occur from WebSocket reconnects in remote scenarios. Without this guard, duplicate responses would push duplicate assistant messages into mutableMessages, causing API 400 errors (“tool_use ids must be unique”).
5.5 Permission Racing: Hooks vs. SDK Host
The createCanUseTool() method implements a race pattern between PermissionRequest hooks and the SDK host’s permission prompt. Both start simultaneously:
const hookPromise = executePermissionRequestHooksForSDK(...)
.then(decision => ({ source: 'hook', decision }))
const sdkPromise = this.sendRequest<PermissionToolOutput>(...)
.then(result => ({ source: 'sdk', result }))
const winner = await Promise.race([hookPromise, sdkPromise])If a hook decides first (allow or deny), it aborts the pending SDK request via an AbortController. If the SDK host responds first, the hook result is ignored. This design ensures that automated policies (hooks) and interactive prompts (SDK host) can coexist without blocking each other.
6. Data Plane vs. Control Plane
The protocol exhibits a clean separation between data-plane messages (conversational content) and control-plane messages (governance events). This split mirrors network architecture patterns where the data path and the management path are isolated to prevent interference.
%%{init: {'theme': 'neutral', 'flowchart': {'useMaxWidth': false, 'htmlLabels': true, 'padding': 20, 'nodeSpacing': 30, 'rankSpacing': 40}, 'themeVariables': {'primaryColor': '#8B9DAF', 'primaryTextColor': '#ffffff', 'primaryBorderColor': '#6E7F91', 'secondaryColor': '#9CAF88', 'secondaryTextColor': '#ffffff', 'secondaryBorderColor': '#7A8D68', 'tertiaryColor': '#C2856E', 'tertiaryTextColor': '#ffffff', 'tertiaryBorderColor': '#A06A54', 'lineColor': '#B5A99A', 'textColor': '#4A4A4A', 'mainBkg': '#8B9DAF', 'nodeBorder': '#6E7F91', 'clusterBkg': 'rgba(139,157,175,0.12)', 'clusterBorder': '#B5A99A', 'edgeLabelBackground': 'transparent'}}}%%
flowchart LR
HOST["<b>SDK Host</b><br><i>VS Code, CI,<br>Python SDK</i>"]
AGENT["<b>Agent Engine</b><br><i>query loop + tools</i>"]
subgraph DATA ["Data Plane"]
direction LR
USER["user"] --> ASST["assistant"]
ASST --> STREAM["stream event"]
RESULT["result"]
SYSTEM["system (init)"]
end
subgraph CTRL ["Control Plane"]
direction LR
PERM["can use tool"]
ELICIT["elicitation"]
CONF["set model"]
INTR["interrupt"]
HOOK["hook callback"]
end
HOST -->|"data"| DATA
DATA -->|"data"| AGENT
HOST <-->|"control"| CTRL
CTRL <-->|"control"| AGENT
style HOST fill:#8B9DAF,color:#fff,stroke:#6E7F91
style AGENT fill:#9CAF88,color:#fff,stroke:#7A8D68
style USER fill:#C2856E,color:#fff,stroke:#A06A54
style ASST fill:#B39EB5,color:#fff,stroke:#8E7A93
style STREAM fill:#C4A882,color:#fff,stroke:#A08562
style RESULT fill:#8E9B7A,color:#fff,stroke:#6E7B5A
style SYSTEM fill:#8B9DAF,color:#fff,stroke:#6E7F91
style PERM fill:#9CAF88,color:#fff,stroke:#7A8D68
style ELICIT fill:#C2856E,color:#fff,stroke:#A06A54
style CONF fill:#B39EB5,color:#fff,stroke:#8E7A93
style INTR fill:#C4A882,color:#fff,stroke:#A08562
style HOOK fill:#8E9B7A,color:#fff,stroke:#6E7B5A
How to read this diagram. The SDK Host (left) and Agent Engine (right) communicate through two logically separated channels sharing the same NDJSON stream. The upper “Data Plane” group shows unidirectional conversational messages flowing from user to assistant to stream events, plus result and system init messages. The lower “Control Plane” group shows bidirectional governance messages – permission requests, elicitations, model changes, interrupts, and hook callbacks – indicated by the two-headed arrows. The separation illustrates that conversational content and governance events are multiplexed on one stream but never interfere with each other.
6.1 Data Plane Characteristics
Data-plane messages are unidirectional and fire-and-forget. The host sends user messages; the agent responds with assistant, stream_event, and result messages. No acknowledgment is required. The conversation is append-only: messages accumulate in the session transcript, and any participant can reconstruct the conversation state from the message sequence.
Data-plane messages carry uuid and session_id fields for global identification. The SDKUserMessage includes parent_tool_use_id (for tool result routing), isSynthetic (for programmatically injected messages), priority (now, next, later for message queue ordering), and an optional timestamp for cross-process clock reconciliation.
6.2 Control Plane Characteristics
Control-plane messages are bidirectional and request-response. They carry a request_id for correlation. They are transient – they affect the agent’s behavior but are not part of the conversation transcript. A permission decision, once made, modifies the permission context; the decision itself is not replayed on session resume.
The control plane supports cancellation via SDKControlCancelRequestSchema:
export const SDKControlCancelRequestSchema = lazySchema(() =>
z.object({
type: z.literal('control_cancel_request'),
request_id: z.string(),
}),
)This enables scenarios where the bridge (claude.ai forwarding) resolves a permission request before the SDK host does – the bridge injects a control_response and simultaneously sends a control_cancel_request to abort the SDK host’s pending callback.
6.3 Why One Stream?
Both planes share a single NDJSON stream (stdout for outbound, stdin for inbound) rather than using separate channels. This is a pragmatic choice: subprocess stdio is universally available across platforms and runtimes, and multiplexing on a single stream avoids the complexity of managing multiple file descriptors or sockets. The type field on each JSON object (user, assistant, control_request, control_response, keep_alive) provides sufficient discrimination.
7. SDK Message Adapter: Remote Consumption
The sdkMessageAdapter.ts (302 LOC) bridges the gap between SDK message format and the REPL’s internal Message types. It enables remote clients – particularly the Claude Code Remote (CCR) backend – to render agent output using the same React components as the local CLI.
7.1 The Conversion Function
The central function convertSDKMessage() takes an SDKMessage and returns a discriminated union:
export type ConvertedMessage =
| { type: 'message'; message: Message }
| { type: 'stream_event'; event: StreamEvent }
| { type: 'ignored' }The ignored case is significant. Many SDK message types (auth_status, tool_use_summary, rate_limit_event) are SDK-only events that have no REPL rendering equivalent. The adapter explicitly classifies these as ignorable rather than failing on unknown types, enabling forward compatibility as new message types are added.
7.2 Message Type Mapping
The adapter maps SDK types to REPL types:
assistantmaps toAssistantMessage(direct structural conversion).stream_eventmaps toStreamEvent(extracts theeventfield).resultmaps toSystemMessageonly for error results; success results are classified asignoredbecause the UI usesisLoading=falseas the success signal.system(init) maps to aSystemMessagewith the model name.system(status) maps to aSystemMessagefor compaction events.system(compact_boundary) maps to aSystemMessagewith compact metadata for session resume splicing.tool_progressmaps to aSystemMessagewith elapsed-time information.usermessages are context-dependent: tool results are converted whenconvertToolResultsis true (direct-connect mode), and user text messages are converted whenconvertUserTextMessagesis true (historical replay). In live WebSocket mode, both areignoredbecause the REPL adds them locally.
7.3 Graceful Degradation
The adapter’s default case logs unknown message types via logForDebugging and returns ignored:
default: {
logForDebugging(
`[sdkMessageAdapter] Unknown message type: ${(msg as { type: string }).type}`,
)
return { type: 'ignored' }
}This pattern is essential for forward compatibility. The backend may add new message types before clients are updated. Rather than crashing or losing the session, the adapter gracefully degrades – new message types are silently ignored until the client adds explicit support.
8. Synthesis: The Protocol as Architecture
The structured I/O layer reveals a key architectural principle: the agent engine is a protocol-defined service, not an application. The CLI, SDK, and MCP modes are not three different programs – they are three protocol adapters over a single engine.
This design has several consequences:
Testability: The SDK mode enables headless testing of the full agent stack. A test harness can send JSON messages and assert on JSON responses without rendering a terminal.
Composability: The MCP server mode makes Claude Code’s tools available to other agents. An orchestrating agent can invoke
BashTool,ReadTool, orEditToolthrough the standard MCP protocol without knowing they belong to Claude Code.Governance at the boundary: The control plane’s request-response pattern means that every permission decision is explicit and auditable. A CI pipeline can implement
dontAskmode (deny if not pre-approved); a VS Code extension can render a rich permission dialog; both use the samecan_use_toolprotocol.Forward compatibility: The schema-driven approach (Zod schemas as canonical definitions, generated types) means that adding a new message type requires only adding a schema variant and extending the union. Existing consumers that do not handle the new type default to
ignored.
The 25 data-plane message types and 21 control-plane request subtypes may appear excessive. But they reflect the genuine complexity of operating an AI agent as a service: session initialization, streaming, tool progress, hook lifecycle, task management, rate limiting, authentication, and configuration – each demands its own wire format. The alternative – a simpler protocol with opaque payloads – would push parsing complexity into every consumer. By making the protocol explicit and schema-validated, Claude Code ensures that the same agent engine can serve a terminal user, a Python script, and an MCP client with equal fidelity.
Appendix A: Full List of SDK Message Types
All data-plane messages are defined in src/schemas/coreSchemas.ts. Messages flow from agent to host (stdout) unless noted otherwise.
| # | Schema Name | Type / Subtype | Direction | Purpose |
|---|---|---|---|---|
| 1 | SDKUserMessage |
user |
stdin | User prompt or tool results |
| 2 | SDKAssistantMessage |
assistant |
stdout | Model response with text and tool calls |
| 3 | SDKPartialAssistantMessage |
stream_event |
stdout | Streaming delta from API |
| 4 | SDKResultMessage |
result / success |
stdout | Turn completed successfully (cost, usage, duration) |
| 5 | result / error_during_execution |
stdout | Error during conversation execution | |
| 6 | result / error_max_turns |
stdout | Hit max turn limit | |
| 7 | result / error_max_budget_usd |
stdout | Hit cost budget limit | |
| 8 | SDKSystemMessage |
system / init |
stdout | Session initialization (tools, model, MCP, permissions) |
| 9 | SDKStatusMessage |
system / status |
stdout | Status update (e.g., “compacting”) |
| 10 | SDKCompactBoundaryMessage |
system / compact_boundary |
stdout | Compaction boundary with token counts |
| 11 | SDKAPIRetryMessage |
system / api_retry |
stdout | API request failed, retrying after delay |
| 12 | SDKLocalCommandOutputMessage |
system / local_command_output |
stdout | Slash command output (e.g., /cost) |
| 13 | SDKPostTurnSummaryMessage |
system / post_turn_summary |
stdout | Background summary of turn outcome |
| 14 | SDKHookStartedMessage |
system / hook_started |
stdout | Hook execution began |
| 15 | SDKHookProgressMessage |
system / hook_progress |
stdout | Hook stdout/stderr output |
| 16 | SDKHookResponseMessage |
system / hook_response |
stdout | Hook completed with exit code |
| 17 | SDKTaskStartedMessage |
system / task_started |
stdout | Background task spawned |
| 18 | SDKTaskProgressMessage |
system / task_progress |
stdout | Background task progress (tokens, summary) |
| 19 | SDKTaskNotificationMessage |
system / task_notification |
stdout | Background task completed/failed |
| 20 | SDKSessionStateChangedMessage |
system / session_state_changed |
stdout | Session state transition (idle/running/requires_action) |
| 21 | SDKFilesPersistedEventSchema |
system / files_persisted |
stdout | Files persisted with IDs |
| 22 | SDKToolProgressMessage |
tool_progress |
stdout | Tool execution in progress (elapsed time) |
| 23 | SDKToolUseSummaryMessage |
tool_use_summary |
stdout | Cumulative tool call summary |
| 24 | SDKAuthStatusMessage |
auth_status |
stdout | Authentication state change |
| 25 | SDKRateLimitEventMessage |
rate_limit_event |
stdout | Rate limit status change |
| 26 | SDKElicitationCompleteMessage |
system / elicitation_complete |
stdout | MCP elicitation confirmed |
| 27 | SDKPromptSuggestionMessage |
prompt_suggestion |
stdout | Predicted next user prompt |
Wire-level wrapper types (both directions): SDKControlRequest, SDKControlResponse, SDKControlCancelRequest, SDKKeepAliveMessage, SDKUpdateEnvironmentVariablesMessage.
Appendix B: Full List of Control Request Subtypes
All control-plane request subtypes are defined in src/schemas/controlSchemas.ts. Requests can originate from either side; responses flow in the opposite direction.
| # | Subtype | Direction | Has Response? | Purpose |
|---|---|---|---|---|
| 1 | initialize |
host→agent | Yes | Initialize session (hooks, MCP, agents, system prompt) |
| 2 | interrupt |
host→agent | No | Interrupt current conversation turn |
| 3 | can_use_tool |
agent→host | Yes | Request permission to use a tool |
| 4 | set_permission_mode |
host→agent | No | Set permission mode (default/acceptEdits/bypassPermissions/plan/dontAsk) |
| 5 | set_model |
host→agent | No | Switch model for subsequent turns |
| 6 | set_max_thinking_tokens |
host→agent | No | Set extended thinking token budget |
| 7 | mcp_status |
host→agent | Yes | Query MCP server connection status |
| 8 | get_context_usage |
host→agent | Yes | Query context window token breakdown |
| 9 | hook_callback |
host→agent | No | Deliver hook callback with event data |
| 10 | mcp_message |
host→agent | No | Send JSON-RPC message to MCP server |
| 11 | mcp_set_servers |
host→agent | Yes | Replace dynamically managed MCP servers |
| 12 | mcp_reconnect |
host→agent | No | Reconnect a disconnected MCP server |
| 13 | mcp_toggle |
host→agent | No | Enable or disable an MCP server |
| 14 | rewind_files |
host→agent | Yes | Rewind file changes since a specific message |
| 15 | cancel_async_message |
host→agent | Yes | Drop pending async user message by UUID |
| 16 | seed_read_state |
host→agent | No | Seed file read cache with path + mtime |
| 17 | reload_plugins |
host→agent | Yes | Reload plugins, return refreshed session |
| 18 | stop_task |
host→agent | No | Stop a running background task by ID |
| 19 | apply_flag_settings |
host→agent | No | Merge settings into flag settings layer |
| 20 | get_settings |
host→agent | Yes | Return effective merged settings |
| 21 | elicitation |
agent→host | Yes | Request SDK consumer to handle MCP elicitation |
Note that can_use_tool (#3) and elicitation (#21) flow agent→host — the agent asks the host for a decision. All other requests flow host→agent.
Series: Inside Claude Code | Part II.2