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

sdk
structured-io
entry-modes
control-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:

%%{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
Figure 1: Three entry modes converge on a shared agent engine. The CLI renders via a TUI framework; the SDK uses structured NDJSON over stdio; the MCP server exposes tools via the Model Context Protocol. All three share the same query loop, tool registry, and permission system.

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:

Table 1: SDK message type taxonomy. The 23 types span conversational data, system lifecycle, governance, and observability.
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:

Table 2: Control protocol request subtypes. The protocol governs permissions, configuration, MCP management, and session lifecycle.
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:

  1. structuredInput: An AsyncGenerator that yields parsed StdinMessage | SDKMessage objects from the raw stdin line stream.
  2. pendingRequests: A Map<string, PendingRequest<T>> that tracks outstanding control requests by their request_id. Each entry holds a Promise’s resolve/reject callbacks and an optional Zod schema for response validation.
  3. resolvedToolUseIds: A Set<string> that tracks tool use IDs whose permission flow has completed, preventing duplicate control_response messages 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
Figure 2: Data plane vs. control plane message flow. Conversational messages (user, assistant, stream events, results) flow on the data plane. Governance messages (permission requests, elicitations, configuration changes) flow on the control plane. Both share the same physical NDJSON stream but are logically separated by message type.

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:

  • assistant maps to AssistantMessage (direct structural conversion).
  • stream_event maps to StreamEvent (extracts the event field).
  • result maps to SystemMessage only for error results; success results are classified as ignored because the UI uses isLoading=false as the success signal.
  • system (init) maps to a SystemMessage with the model name.
  • system (status) maps to a SystemMessage for compaction events.
  • system (compact_boundary) maps to a SystemMessage with compact metadata for session resume splicing.
  • tool_progress maps to a SystemMessage with elapsed-time information.
  • user messages are context-dependent: tool results are converted when convertToolResults is true (direct-connect mode), and user text messages are converted when convertUserTextMessages is true (historical replay). In live WebSocket mode, both are ignored because 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:

  1. 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.

  2. Composability: The MCP server mode makes Claude Code’s tools available to other agents. An orchestrating agent can invoke BashTool, ReadTool, or EditTool through the standard MCP protocol without knowing they belong to Claude Code.

  3. 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 dontAsk mode (deny if not pre-approved); a VS Code extension can render a rich permission dialog; both use the same can_use_tool protocol.

  4. 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