Control Protocol
Understand the bidirectional JSON RPC between CLI and SDK.
What You’ll Learn
Claude Code is not a single process. It is two processes collaborating through a well-defined protocol. Understanding this split — and the messages that flow between the halves — unlocks the ability to build custom frontends, automate workflows, and debug unexpected behavior.
By the end, you’ll know:
- Why Claude Code is split into CLI and SDK processes
- How bidirectional JSON-RPC messages flow over stdin/stdout
- The three message categories: requests, responses, and notifications
- How streaming works at the protocol level
- How permission prompts travel through the protocol
- How to trace a single user message through the full round-trip
The Problem
Most CLI tools are monolithic: one process reads input, does work, and prints output. But Claude Code has two very different jobs:
- User interface — render a terminal UI, handle keyboard input, display streaming text
- AI engine — manage API calls, execute tools, enforce permissions, track context
Bundling both into one process creates tight coupling. Want to embed Claude Code in VS Code? You’d need to rewrite the UI layer. Want to drive it from a script? You’d need to simulate terminal input. Want to run the engine on a remote server? Impossible.
The solution is a clean split:
┌──────────────────┐ ┌──────────────────┐
│ CLI │ stdin │ SDK │
│ │ ──────► │ │
│ Terminal UI │ │ API Client │
│ User input │ stdout │ Tool Executor │
│ Permission UI │ ◄────── │ Context Manager │
│ Display │ │ Permission Logic │
└──────────────────┘ └──────────────────┘
The CLI handles everything the user sees. The SDK handles everything the AI does. They communicate through a control protocol: a bidirectional JSON-RPC stream over stdin and stdout.
How It Works
The Two-Process Architecture
When you launch claude, it starts two processes:
Terminal
│
▼
┌──────────────────────────────────────────────┐
│ CLI Process (Node.js) │
│ │
│ - Renders terminal UI (Ink/React) │
│ - Captures user keystrokes │
│ - Displays streaming assistant text │
│ - Shows permission prompts │
│ - Manages session history display │
│ │
│ stdin (pipe) ▼ ▲ stdout (pipe) │
│ │
│ ┌────────────────────────────────────────┐ │
│ │ SDK Process (Node.js, child process) │ │
│ │ │ │
│ │ - Sends requests to Claude API │ │
│ │ - Parses streaming API responses │ │
│ │ - Executes tool calls (Read, Edit, │ │
│ │ Bash, etc.) │ │
│ │ - Manages message history │ │
│ │ - Resolves permissions │ │
│ │ - Handles context compaction │ │
│ └────────────────────────────────────────┘ │
└──────────────────────────────────────────────┘
The SDK runs as a child process of the CLI. They talk over the child’s stdin and stdout pipes — no network sockets, no HTTP, just raw pipes with newline-delimited JSON messages.
Message Types
The protocol uses three message categories, inspired by JSON-RPC 2.0:
1. Requests — Expect a response. Carry an id field.
{
"jsonrpc": "2.0",
"id": 1,
"method": "user_message",
"params": {
"content": "Fix the bug in auth.ts",
"session_id": "abc-123"
}
}
2. Responses — Answer a request. Carry a matching id.
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"status": "completed",
"usage": { "input_tokens": 1200, "output_tokens": 350 }
}
}
3. Notifications — Fire-and-forget. No id field.
{
"jsonrpc": "2.0",
"method": "assistant_token",
"params": {
"token": "Let me ",
"message_id": "msg-456"
}
}
The distinction matters: requests create a conversation (one side waits for a response), while notifications are one-way broadcasts.
The Streaming Model
When the SDK receives an API response, it doesn’t wait for the full response. Each token streams as a notification:
SDK → CLI: {"method": "assistant_token", "params": {"token": "Let "}}
SDK → CLI: {"method": "assistant_token", "params": {"token": "me "}}
SDK → CLI: {"method": "assistant_token", "params": {"token": "check "}}
SDK → CLI: {"method": "assistant_token", "params": {"token": "the "}}
SDK → CLI: {"method": "assistant_token", "params": {"token": "file."}}
SDK → CLI: {"method": "tool_use_start", "params": {"name": "Read", ...}}
The CLI renders each token immediately, producing the typewriter effect you see in the terminal. This is analogous to Server-Sent Events (SSE) — a stream of small messages flowing in one direction.
Claude API (SSE) SDK Process CLI Process
│ │ │
│── token: "Let " ────►│ │
│ │── notification ──►│── render "Let "
│── token: "me " ─────►│ │
│ │── notification ──►│── render "me "
│── tool_use ─────────►│ │
│ │── tool_start ────►│── show tool badge
│ │ │
│ │── (execute tool) ──│
│ │ │
│ │── tool_result ───►│── show result
Permission Flow
When the SDK encounters a tool call that needs user approval, a request-response exchange handles it:
SDK → CLI: {
"jsonrpc": "2.0",
"id": 42,
"method": "permission_request",
"params": {
"tool": "Bash",
"command": "npm install express",
"risk_level": "medium"
}
}
(CLI shows permission prompt to user)
(User presses 'y')
CLI → SDK: {
"jsonrpc": "2.0",
"id": 42,
"result": {
"decision": "allow",
"scope": "session"
}
}
The SDK blocks on this request. It will not execute the tool until the CLI sends back a response. This is how the permission system works — the SDK is the authority on what needs permission, and the CLI is the authority on whether to grant it.
SDK CLI
│ │
│── permission_request ─────►│
│ (blocked) │── Show prompt
│ (waiting) │── User decides
│◄── permission_response ────│
│ │
│── execute tool │
│── tool_result ────────────►│── Display result
Protocol Versioning
The first message exchanged is always a capability negotiation:
CLI → SDK: {
"jsonrpc": "2.0",
"id": 0,
"method": "initialize",
"params": {
"protocol_version": "1.0",
"capabilities": {
"streaming": true,
"permissions": true,
"progress": true
}
}
}
SDK → CLI: {
"jsonrpc": "2.0",
"id": 0,
"result": {
"protocol_version": "1.0",
"capabilities": {
"tools": ["Read", "Edit", "Bash", "Glob", "Grep", ...],
"mcp_servers": 3
}
}
}
This handshake ensures both sides agree on which features are supported. If the CLI is an older version, the SDK can disable newer features. If the SDK introduces a new notification type, an older CLI can safely ignore it.
Key Insight
The control protocol is what makes Claude Code both a CLI tool AND an SDK. The same engine that powers the terminal UI can be driven by VS Code, a web interface, or a Python script. The protocol is the boundary.
This has profound implications:
- Custom frontends. Build a GUI that speaks the protocol and you get the full power of Claude Code without the terminal.
- Programmatic automation. The
@anthropic-ai/claude-codenpm package exposes the SDK directly — your code becomes the CLI, sending messages and handling responses. - Remote execution. The protocol is transport-agnostic. Today it runs over stdin/stdout pipes. It could run over WebSockets or TCP with minimal changes.
- Testing. You can test the SDK by sending scripted protocol messages without any UI at all.
The protocol is not an implementation detail — it is the architecture.
Hands-On Example
Tracing a Message Through the Full Stack
Let’s trace what happens when you type “What’s in README.md?” into Claude Code:
Step 1: CLI captures input
─────────────────────────
User types: "What's in README.md?"
CLI packages it into a JSON-RPC request.
CLI → SDK:
{ "id": 5, "method": "user_message",
"params": { "content": "What's in README.md?" } }
Step 2: SDK calls Claude API
─────────────────────────────
SDK builds the full messages array (system prompt + history + new message)
SDK sends to Claude API via streaming HTTP.
Step 3: API streams back tokens + tool call
───────────────────────────────────────────
API response includes text and a tool_use block:
"Let me read that file."
tool_use: Read { file_path: "README.md" }
SDK forwards each token as a notification:
SDK → CLI: { "method": "assistant_token", "params": { "token": "Let " } }
SDK → CLI: { "method": "assistant_token", "params": { "token": "me " } }
...
SDK → CLI: { "method": "tool_use_start",
"params": { "name": "Read", "input": { "file_path": "README.md" } } }
Step 4: SDK checks permissions
──────────────────────────────
Read tool for README.md → allowed by default (no prompt needed).
SDK executes the tool directly.
Step 5: SDK feeds result back to API
─────────────────────────────────────
SDK appends tool_result to messages array.
SDK calls Claude API again with the updated context.
Step 6: API streams final response
───────────────────────────────────
API → SDK → CLI (token by token):
"The README.md contains a project description for..."
SDK → CLI: { "method": "turn_complete",
"params": { "stop_reason": "end_turn" } }
Step 7: SDK sends response to original request
───────────────────────────────────────────────
SDK → CLI:
{ "id": 5, "result": { "status": "completed",
"usage": { "input_tokens": 2400, "output_tokens": 180 } } }
Using the SDK Programmatically
Because the protocol is well-defined, you can use Claude Code as a library:
import { claude } from "@anthropic-ai/claude-code";
// The SDK speaks the same protocol — your code is the "CLI"
const result = await claude("What's in README.md?", {
cwd: "/my/project",
allowedTools: ["Read", "Glob", "Grep"],
});
console.log(result.text);
// "The README.md contains..."
Under the hood, this spawns the SDK process and exchanges JSON-RPC messages over stdin/stdout — exactly as the terminal CLI does.
What Changed
| Monolithic CLI | Two-Process Protocol |
|---|---|
| UI and engine tightly coupled | Clean separation of concerns |
| One frontend only (terminal) | Any frontend that speaks JSON-RPC |
| Hard to automate | Programmatic SDK usage built in |
| Hard to test | Protocol messages can be scripted |
| Cannot run engine remotely | Transport-agnostic design |
| Permission logic mixed with UI | SDK decides what needs permission, CLI decides whether to grant |
Next Session
You now understand how the CLI and SDK communicate. But tools are not the only way to extend Claude Code. Session 14 covers MCP Integration — how the Model Context Protocol lets you plug in unlimited external tools through a standard protocol, making Claude Code infinitely extensible.