Reverse-engineered from Claude Code CLI v2.1.37 (
cli.js) and Agent SDK v0.2.37 (sdk.mjs,sdk.d.ts)This document describes the undocumented WebSocket protocol that Claude Code CLI uses for programmatic control via the
--sdk-urlflag. This is the same NDJSON protocol used over stdin/stdout, but transported over WebSocket — enabling full bidirectional control without tmux or PTY hacks.
- Overview
- Launching Claude Code in WebSocket Mode
- Transport Architecture
- Connection Lifecycle
- Wire Protocol (NDJSON)
- Message Types — Complete Reference
- Control Protocol (13 Subtypes)
- Permission / Tool Approval Flow
- Session Management
- Reconnection & Resilience
- Environment Variables
- Transport Class Hierarchy
- Implementation Guide
Claude Code CLI has a hidden --sdk-url <ws-url> flag (.hideHelp() in Commander) that makes the CLI act as a WebSocket client, connecting to a server you control. The protocol is NDJSON (newline-delimited JSON) — the same format used over stdin/stdout by the official @anthropic-ai/claude-agent-sdk.
- No SDK dependency: The
--sdk-urlflag is baked into the CLI binary itself - Uses your Claude Code subscription: No API billing, uses your existing plan
- Full programmatic control: Send prompts, approve/deny permissions, interrupt execution
- Replaces tmux hacks: Clean WebSocket channel instead of PTY automation
- Same protocol as Claude Code Web: The web UI uses the same NDJSON-over-WebSocket approach
| Property | Value |
|---|---|
| Transport | WebSocket (ws:// or wss://) |
| Protocol | NDJSON (one JSON object per \n-terminated line) |
| Direction | CLI connects TO your server (CLI = client) |
| Auth | Authorization: Bearer <token> header on upgrade |
| First message | Server sends user message, CLI responds with system/init |
| Keepalive | keep_alive messages + WebSocket ping/pong every 10s |
claude --sdk-url ws://localhost:8765 \
--print \
--output-format stream-json \
--input-format stream-json \
--verbose \
-p "placeholder"| Flag | Required | Purpose |
|---|---|---|
--sdk-url <url> |
Yes | WebSocket URL to connect to |
--print (-p) |
Yes | Enables headless/non-interactive mode |
--output-format stream-json |
Yes | NDJSON output (validated by CLI) |
--input-format stream-json |
Yes | NDJSON input (validated by CLI) |
| Flag | Purpose |
|---|---|
--verbose |
Include stream_event messages (token-by-token streaming) |
--model <model> |
Override model (e.g., claude-opus-4-6) |
--permission-mode <mode> |
Set initial permission mode |
--allowedTools <tools> |
Auto-approve specific tools |
--resume <session-id> |
Resume a previous session |
--continue |
Continue the most recent session |
--max-turns <n> |
Limit conversation turns |
- The
-p "placeholder"prompt argument is ignored when--sdk-urlis used — the CLI waits for ausermessage over WebSocket instead - Both
--input-formatand--output-formatmust bestream-json(CLI exits with error otherwise) - The CLI will wait indefinitely for the first
usermessage after connecting
Six transport classes were deobfuscated from the minified CLI:
┌─────────────────────┐
│ ad1 (ProcessInput) │ Base: NDJSON parser + control request/response
│ - read() generator │
│ - sendRequest() │
│ - createCanUseTool() │
└──────────┬──────────┘
│ extends
┌──────────▼──────────┐
│ LQA (SdkUrl) │ --sdk-url mode ← OUR TARGET
│ - PassThrough bridge │
│ - transport delegate │
└──────────┬──────────┘
│ uses
┌─────────────────┼─────────────────┐
│ │
┌─────────▼─────────┐ ┌───────────▼───────────┐
│ sd1 (WebSocket) │ │ kQA (Hybrid) │
│ - WS send+receive │ │ extends sd1 │
│ - reconnect logic │ │ - WS receive only │
│ - message buffer │ │ - HTTP POST for send │
│ - ping/pong │ │ - retry with backoff │
└────────────────────┘ └────────────────────────┘
Web UI / Remote Sessions:
┌────────────────────┐ ┌─────────────────────────┐
│ MFA (SessionsWS) │◄────────│ WFA (RemoteSessionMgr) │
│ - Subscribe WS │ │ - Permission routing │
│ - API auth │ │ - HTTP POST for send │
└────────────────────┘ └─────────────────────────┘
Direct Connect (Browser):
┌────────────────────┐
│ fFA (DirectConnect)│ Simplified WS for browser use
│ - sendMessage() │
│ - respondToPermit │
│ - sendInterrupt() │
└────────────────────┘
function createTransport(url: URL, headers?: Record<string, string>, sessionId?: string) {
if (url.protocol === "ws:" || url.protocol === "wss:") {
if (process.env.CLAUDE_CODE_POST_FOR_SESSION_INGRESS_V2) {
return new HybridTransport(url, headers, sessionId); // WS receive + HTTP POST send
}
return new WebSocketTransport(url, headers, sessionId); // Pure WebSocket
}
throw Error(`Unsupported protocol: ${url.protocol}`);
} Your Server (WS) Claude Code CLI
│ │
│◄──────── WebSocket CONNECT ──────────────│ (with Auth header)
│ │
│ │──► system/init message
│◄──────── {"type":"system","subtype":"init",...} ──│
│ │
│──── {"type":"user","message":{...}} ────►│ (you send first prompt)
│ │
│ │──► LLM processing...
│ │
│◄──── {"type":"stream_event",...} ────────│ (if --verbose)
│◄──── {"type":"stream_event",...} ────────│
│◄──── {"type":"assistant",...} ───────────│ (full response)
│ │
│ (if tool needs permission) │
│◄──── {"type":"control_request", │
│ "request":{"subtype":"can_use_tool"│
│ ,"tool_name":"Bash",...}} ─────────│
│ │
│──── {"type":"control_response", │ (you approve/deny)
│ "response":{"subtype":"success", │
│ "request_id":"...","response": │
│ {"behavior":"allow",...}}} ─────────►│
│ │
│◄──── {"type":"assistant",...} ───────────│ (continues after approval)
│◄──── {"type":"result",...} ──────────────│ (query complete)
│ │
│──── {"type":"user","message":{...}} ────►│ (next turn, optional)
│ ... │
The CLI sends authentication via HTTP headers on the WebSocket upgrade request:
Authorization: Bearer <session_access_token>
X-Environment-Runner-Version: <version> (optional)
X-Last-Request-Id: <uuid> (on reconnect, for message replay)
Token sources (priority order):
CLAUDE_CODE_SESSION_ACCESS_TOKENenvironment variable- Internal session ingress token
- Token read from file descriptor specified by
CLAUDE_CODE_WEBSOCKET_AUTH_FILE_DESCRIPTOR
Each message is a single JSON object followed by \n (newline). Multiple messages can be sent in sequence.
{"type":"user","message":{"role":"user","content":"Hello"},"parent_tool_use_id":null,"session_id":""}\n
{"type":"assistant","message":{...},"session_id":"abc123","uuid":"..."}\n
| Direction | Types |
|---|---|
| Server → CLI | user, control_response, control_cancel_request, keep_alive, update_environment_variables |
| CLI → Server | system, assistant, result, stream_event, tool_progress, tool_use_summary, auth_status, control_request (can_use_tool, hook_callback), keep_alive, streamlined_text, streamlined_tool_use_summary |
| Bidirectional | control_request, control_response, keep_alive |
These types are present in the NDJSON stream but the official SDK filters them out:
control_request/control_response/control_cancel_request— handled internallykeep_alive— silently consumedstreamlined_text/streamlined_tool_use_summary— internal streamlined mode
Send a prompt or follow-up message to the Claude Code agent.
interface SDKUserMessage {
type: "user";
message: {
role: "user";
content: string | ContentBlock[]; // string for simple text, array for structured
};
parent_tool_use_id: string | null; // null for top-level, string for sub-agent
session_id: string; // "" for first message, then use session_id from init
uuid?: string; // optional
isSynthetic?: boolean; // true for internally-generated messages
}Example — Simple text prompt:
{
"type": "user",
"message": { "role": "user", "content": "What files are in this project?" },
"parent_tool_use_id": null,
"session_id": ""
}First message sent by the CLI after WebSocket connection. Contains full capability info.
interface SDKSystemMessage {
type: "system";
subtype: "init";
cwd: string;
session_id: string;
tools: string[]; // ["Task", "Bash", "Glob", "Grep", "Read", "Edit", "Write", ...]
mcp_servers: { name: string; status: string }[];
model: string; // "claude-sonnet-4-6"
permissionMode: PermissionMode;
apiKeySource: string;
claude_code_version: string; // "2.1.37"
slash_commands: string[];
agents?: string[];
skills?: string[];
plugins?: { name: string; path: string }[];
output_style: string;
uuid: string;
session_id: string;
}Full assistant message after LLM completes a response.
interface SDKAssistantMessage {
type: "assistant";
message: {
id: string; // "msg_01..."
type: "message";
role: "assistant";
model: string;
content: ContentBlock[]; // text blocks, tool_use blocks, thinking blocks
stop_reason: string | null; // "end_turn", "tool_use", etc.
usage: {
input_tokens: number;
output_tokens: number;
cache_creation_input_tokens: number;
cache_read_input_tokens: number;
};
};
parent_tool_use_id: string | null;
error?: "authentication_failed" | "billing_error" | "rate_limit" | "invalid_request" | "server_error" | "unknown";
uuid: string;
session_id: string;
}Token-by-token streaming events. Only sent when --verbose flag is used.
interface SDKPartialAssistantMessage {
type: "stream_event";
event: BetaRawMessageStreamEvent; // Anthropic streaming event
parent_tool_use_id: string | null;
uuid: string;
session_id: string;
}Sent when the query finishes (success or error).
// Success
interface SDKResultSuccess {
type: "result";
subtype: "success";
is_error: false;
result: string; // final text result
duration_ms: number;
duration_api_ms: number;
num_turns: number;
total_cost_usd: number;
stop_reason: string | null;
usage: {
input_tokens: number;
output_tokens: number;
cache_creation_input_tokens: number;
cache_read_input_tokens: number;
};
modelUsage: Record<string, {
inputTokens: number;
outputTokens: number;
cacheReadInputTokens: number;
cacheCreationInputTokens: number;
webSearchRequests: number;
costUSD: number;
contextWindow: number;
maxOutputTokens: number;
}>;
permission_denials: { tool_name: string; tool_use_id: string; tool_input: Record<string, unknown> }[];
structured_output?: unknown;
uuid: string;
session_id: string;
}
// Error
interface SDKResultError {
type: "result";
subtype: "error_during_execution" | "error_max_turns" | "error_max_budget_usd" | "error_max_structured_output_retries";
is_error: true;
errors: string[];
duration_ms: number;
duration_api_ms: number;
num_turns: number;
total_cost_usd: number;
stop_reason: string | null;
usage: NonNullableUsage;
modelUsage: Record<string, ModelUsage>;
permission_denials: SDKPermissionDenial[];
uuid: string;
session_id: string;
}interface SDKStatusMessage {
type: "system";
subtype: "status";
status: "compacting" | null; // null = compacting ended
permissionMode?: PermissionMode; // included when mode changes
uuid: string;
session_id: string;
}interface SDKCompactBoundaryMessage {
type: "system";
subtype: "compact_boundary";
compact_metadata: {
trigger: "manual" | "auto";
pre_tokens: number;
};
uuid: string;
session_id: string;
}interface SDKToolProgressMessage {
type: "tool_progress";
tool_use_id: string;
tool_name: string;
parent_tool_use_id: string | null;
elapsed_time_seconds: number;
uuid: string;
session_id: string;
}interface SDKToolUseSummaryMessage {
type: "tool_use_summary";
summary: string;
preceding_tool_use_ids: string[];
uuid: string;
session_id: string;
}interface SDKAuthStatusMessage {
type: "auth_status";
isAuthenticating: boolean;
output: string[];
error?: string;
uuid: string;
session_id: string;
}interface SDKTaskNotificationMessage {
type: "system";
subtype: "task_notification";
task_id: string;
status: "completed" | "failed" | "stopped";
output_file: string;
summary: string;
uuid: string;
session_id: string;
}interface SDKFilesPersistedEvent {
type: "system";
subtype: "files_persisted";
files: { filename: string; file_id: string }[];
failed: { filename: string; error: string }[];
processed_at: string;
uuid: string;
session_id: string;
}interface SDKHookStartedMessage {
type: "system";
subtype: "hook_started";
hook_id: string;
hook_name: string;
hook_event: string;
uuid: string;
session_id: string;
}
interface SDKHookProgressMessage {
type: "system";
subtype: "hook_progress";
hook_id: string;
hook_name: string;
hook_event: string;
stdout: string;
stderr: string;
output: string;
uuid: string;
session_id: string;
}
interface SDKHookResponseMessage {
type: "system";
subtype: "hook_response";
hook_id: string;
hook_name: string;
hook_event: string;
output: string;
stdout: string;
stderr: string;
exit_code?: number;
outcome: "success" | "error" | "cancelled";
uuid: string;
session_id: string;
}// Keep-alive (bidirectional)
interface SDKKeepAliveMessage {
type: "keep_alive";
}
// Streamlined text (CLI → Server, filtered by SDK)
interface SDKStreamlinedTextMessage {
type: "streamlined_text";
text: string;
session_id: string;
uuid: string;
}
// Streamlined tool use summary (CLI → Server, filtered by SDK)
interface SDKStreamlinedToolUseSummaryMessage {
type: "streamlined_tool_use_summary";
tool_summary: string;
session_id: string;
uuid: string;
}
// Update environment variables (Server → CLI, stdin only)
interface UpdateEnvironmentVariables {
type: "update_environment_variables";
variables: Record<string, string>;
}Control messages use a request/response pattern with correlated request_id fields.
// Request
interface SDKControlRequest {
type: "control_request";
request_id: string; // UUID for correlation
request: ControlRequestPayload; // discriminated by "subtype"
}
// Success Response
interface SDKControlResponse {
type: "control_response";
response: {
subtype: "success";
request_id: string; // matches the request
response?: Record<string, unknown>;
};
}
// Error Response
interface SDKControlErrorResponse {
type: "control_response";
response: {
subtype: "error";
request_id: string;
error: string;
pending_permission_requests?: SDKControlRequest[]; // queued permissions
};
}
// Cancel Request
interface SDKControlCancelRequest {
type: "control_cancel_request";
request_id: string; // request to cancel
}Register hooks, MCP servers, agents, system prompt. Must be sent before the first user message.
// Request
{
subtype: "initialize",
hooks?: Record<HookEvent, { matcher?: string; hookCallbackIds: string[]; timeout?: number }[]>,
sdkMcpServers?: string[],
jsonSchema?: Record<string, unknown>,
systemPrompt?: string,
appendSystemPrompt?: string,
agents?: Record<string, AgentDefinition>
}
// Response
{
commands: { name: string; description: string; argumentHint?: string }[],
output_style: string,
available_output_styles: string[],
models: { value: string; displayName: string; description: string }[],
account: { email?: string; organization?: string; subscriptionType?: string; apiKeySource?: string },
fast_mode?: boolean
}Error: "Already initialized" if called twice.
The most important control message. The CLI asks the server for permission to use a tool.
// Request (from CLI)
{
subtype: "can_use_tool",
tool_name: string, // "Bash", "Edit", "Write", "Read", etc.
input: Record<string, unknown>, // tool arguments
permission_suggestions?: PermissionUpdate[],
blocked_path?: string,
decision_reason?: string, // "hook"|"asyncAgent"|"sandboxOverride"|"classifier"|"workingDir"|"other"
tool_use_id: string,
agent_id?: string,
description?: string
}
// Response: Allow
{
behavior: "allow",
updatedInput: Record<string, unknown>, // REQUIRED — can modify tool args
updatedPermissions?: PermissionUpdate[], // save rules for future
toolUseID?: string
}
// Response: Deny
{
behavior: "deny",
message: string,
interrupt?: boolean, // true = abort entire session
toolUseID?: string
}Abort the current agent turn.
// Request
{ subtype: "interrupt" }
// Response: empty success// Request
{ subtype: "set_permission_mode", mode: PermissionMode }
// Response
{ mode: PermissionMode }Error: "Cannot set permission mode to bypassPermissions because it is disabled by settings or configuration"
// Request
{ subtype: "set_model", model?: string } // "default" to reset
// Response: empty success// Request
{ subtype: "set_max_thinking_tokens", max_thinking_tokens: number | null }
// Response: empty success// Request
{ subtype: "mcp_status" }
// Response
{
mcpServers: {
name: string,
status: "connected" | "failed" | "disabled" | "connecting",
serverInfo?: any,
error?: string,
config: { type: string; url?: string; command?: string; args?: string[] },
scope: string,
tools?: { name: string; annotations?: { readOnly?: boolean; destructive?: boolean; openWorld?: boolean } }[]
}[]
}Route JSON-RPC messages to/from MCP servers.
// Request
{ subtype: "mcp_message", server_name: string, message: JSONRPCMessage }
// Response: empty success (or { mcp_response: ... } from SDK side){ subtype: "mcp_reconnect", serverName: string }{ subtype: "mcp_toggle", serverName: string, enabled: boolean }{
subtype: "mcp_set_servers",
servers: Record<string, {
type: "stdio" | "sse" | "http" | "sdk",
command?: string,
args?: string[],
env?: Record<string, string>,
url?: string
}>
}// Request
{ subtype: "rewind_files", user_message_id: string, dry_run?: boolean }
// Response (success)
{ canRewind: true, filesChanged?: number, insertions?: number, deletions?: number }The CLI invokes a registered hook callback.
// Request (from CLI)
{
subtype: "hook_callback",
callback_id: string,
input: HookInput,
tool_use_id?: string
}
// Sync response
{
continue?: boolean,
suppressOutput?: boolean,
stopReason?: string,
decision?: "approve" | "block",
reason?: string,
systemMessage?: string,
hookSpecificOutput?: {
hookEventName: "PreToolUse" | "PostToolUse" | "PermissionRequest",
permissionDecision?: "allow" | "deny" | "ask",
permissionDecisionReason?: string,
updatedInput?: Record<string, unknown>,
additionalContext?: string,
decision?: { behavior: "allow" | "deny", ... }
}
}
// Async response
{ async: true, asyncTimeout?: number }| Subtype | Direction | Purpose |
|---|---|---|
initialize |
Server → CLI | Setup hooks, MCP, agents |
can_use_tool |
CLI → Server | Permission request |
interrupt |
Server → CLI | Abort current turn |
set_permission_mode |
Server → CLI | Change mode at runtime |
set_model |
Server → CLI | Change model at runtime |
set_max_thinking_tokens |
Server → CLI | Change thinking budget |
mcp_status |
Server → CLI | Get MCP server statuses |
mcp_message |
Bidirectional | Route JSON-RPC messages |
mcp_reconnect |
Server → CLI | Reconnect MCP server |
mcp_toggle |
Server → CLI | Enable/disable MCP server |
mcp_set_servers |
Server → CLI | Configure MCP servers |
rewind_files |
Server → CLI | Rewind files to checkpoint |
hook_callback |
CLI → Server | Invoke registered hook |
The CLI evaluates permissions through three layers before sending a can_use_tool over the wire:
Tool Use Request
│
├─► Layer 1: PreToolUse Hooks (local shell scripts)
│ ├─ allow → tool executes
│ ├─ deny → tool blocked
│ └─ ask → fall through
│
├─► Layer 2: Local Rule Evaluation (R5z)
│ ├─ Check deny rules → if match → DENIED
│ ├─ Check ask rules → if match → behavior="ask"
│ ├─ Check mode:
│ │ bypassPermissions → ALLOWED (never reaches wire)
│ │ dontAsk → DENIED (never reaches wire)
│ ├─ Check allow rules (incl. --allowedTools) → ALLOWED
│ └─ Default → behavior="ask"
│
└─► Layer 3: Remote Prompt (WebSocket)
└─ Sends control_request { subtype: "can_use_tool", ... }
├─ Response: { behavior: "allow", updatedInput: {...} } → EXECUTE
└─ Response: { behavior: "deny", message: "..." } → BLOCKED
When responding with { behavior: "allow" }, you must include updatedInput. This replaces the tool's input entirely. You can:
- Pass through unchanged:
updatedInput: original_input - Sanitize commands: Change
rm -rf /toecho "blocked" - Modify paths: Restrict file access
Return updatedPermissions to save rules for the session or settings:
type PermissionUpdate =
| { type: "addRules", rules: { toolName: string, ruleContent?: string }[], behavior: "allow"|"deny"|"ask", destination: PermissionDestination }
| { type: "replaceRules", rules: [...], behavior: "...", destination: "..." }
| { type: "removeRules", rules: [...], behavior: "...", destination: "..." }
| { type: "setMode", mode: PermissionMode, destination: PermissionDestination }
| { type: "addDirectories", directories: string[], destination: PermissionDestination }
| { type: "removeDirectories", directories: string[], destination: PermissionDestination }
type PermissionDestination = "userSettings" | "projectSettings" | "localSettings" | "session" | "cliArg";Example — Auto-approve future git commands:
{
"behavior": "allow",
"updatedInput": { "command": "git status" },
"updatedPermissions": [
{
"type": "addRules",
"rules": [{ "toolName": "Bash", "ruleContent": "git:*" }],
"behavior": "allow",
"destination": "session"
}
]
}- If the server never responds to
can_use_tool, the CLI blocks indefinitely - The CLI can send
control_cancel_requestto cancel its own pending request - On transport close, all pending requests are rejected with
"Tool permission stream closed before response received"
- Generated by CLI via
crypto.randomUUID()on startup - Included in every outgoing message (
session_idfield) - Stored in global state, accessible via
U6()internally - Use for session resume:
--resume <session-id>
The initialize control_request should be sent before the first user message. The exact sequence is:
Server CLI
| |
|<-- WS connect ----------------------|
| |
|-- control_request {initialize} ---->| (optional: register hooks, MCP, agents)
|<-- control_response {success} ------| (returns commands, models, account info)
| |
|-- user message --------------------->| (first prompt)
|<-- system/init ----------------------| (tools, model, session_id, etc.)
|<-- assistant ... --------------------|
|<-- result ----------------------------|
The initialize request lets you:
- Set a custom
systemPromptorappendSystemPrompt - Register hook callbacks (with
hookCallbackIdsthat map tohook_callbackcontrol requests) - Register SDK-side MCP server names
- Set a JSON schema for structured output
- Register custom agent definitions
After receiving a result message, you can send another user message to continue the conversation. Internally, user messages are queued in queuedCommands and processed in a loop by the CLI's c() function.
Server: {"type":"user","message":{"role":"user","content":"First question"},...}
CLI: {"type":"system","subtype":"init",...}
CLI: {"type":"assistant",...}
CLI: {"type":"result","subtype":"success",...}
Server: {"type":"user","message":{"role":"user","content":"Follow-up"},...}
CLI: {"type":"assistant",...}
CLI: {"type":"result","subtype":"success",...}
Duplicate detection: Messages with a uuid field are checked against a dedup function. Duplicates are skipped but acknowledged with isReplay: true if replayUserMessages is enabled.
Session end behavior:
- For single-turn queries: stdin/transport is closed after first result
- For multi-turn (streaming input): CLI keeps running, waiting for more user messages
- The CLI remains alive as long as the WebSocket connection is open
claude --sdk-url ws://localhost:8765 --print --output-format stream-json --input-format stream-json --resume <session-id> -p ""When resuming:
- CLI reads the transcript JSONL file from
~/.claude/projects/<project>/<sessionId>.jsonl - Loads and replays previous messages with
isReplay: true - Sets
sessionIdto the resumed session's ID - Then waits for new
usermessage
Resume at specific message: --resume-session-at <uuid> truncates history to that message.
claude --sdk-url ws://localhost:8765 --print --output-format stream-json --input-format stream-json --resume <session-id> --fork-session -p ""When forking:
- A NEW session ID is generated
- The old session's messages are loaded as context
- The new session gets a fresh UUID — this allows branching without modifying the original session
When the context window fills up:
- CLI sends
{"type":"system","subtype":"status","status":"compacting"} - After compaction:
{"type":"system","subtype":"compact_boundary","compact_metadata":{"trigger":"auto","pre_tokens":N}} - Then:
{"type":"system","subtype":"status","status":null}(compacting ended)
| Result Subtype | Trigger |
|---|---|
success |
Normal completion — assistant finished responding |
error_during_execution |
Unhandled error during tool execution |
error_max_turns |
Reached --max-turns limit |
error_max_budget_usd |
Exceeded USD budget |
error_max_structured_output_retries |
Failed structured output after N retries |
| Constant | Value |
|---|---|
| Max reconnect attempts | 3 |
| Base reconnect delay | 1000ms |
| Max reconnect delay | 30000ms |
| Backoff formula | min(1000 * 2^(attempt-1), 30000) |
| Ping interval | 10000ms |
| Circular buffer capacity | 1000 messages |
- WebSocket connection drops
- CLI attempts reconnect with exponential backoff
- Sends
X-Last-Request-Idheader with last sent message UUID - Server should replay messages sent after that UUID
- CLI replays buffered outgoing messages from circular buffer
- After 3 failed attempts → state = "closed", fires close callback
- CLI sends
{"type":"keep_alive"}periodically - WebSocket ping/pong every 10 seconds (Node.js only, skipped on Bun)
- If no pong received before next ping → connection considered dead → triggers reconnect
When CLAUDE_CODE_POST_FOR_SESSION_INGRESS_V2 is set:
| Constant | Value |
|---|---|
| Max POST retries | 10 |
| Base POST delay | 500ms |
| Max POST delay | 8000ms |
| Backoff formula | min(500 * 2^(attempt-1), 8000) |
URL conversion: wss://host/ws/path → https://host/session/path/events
POST body format:
{ "events": [<message>] }| Variable | Purpose |
|---|---|
CLAUDE_CODE_SESSION_ACCESS_TOKEN |
Bearer token for WebSocket auth (highest priority) |
CLAUDE_CODE_WEBSOCKET_AUTH_FILE_DESCRIPTOR |
File descriptor to read auth token from |
CLAUDE_CODE_POST_FOR_SESSION_INGRESS_V2 |
Enable hybrid transport (WS receive + HTTP POST send) |
CLAUDE_CODE_ENVIRONMENT_RUNNER_VERSION |
Sent as x-environment-runner-version header |
CLAUDE_CODE_REMOTE |
Indicates running in remote mode |
CLAUDE_CODE_REMOTE_SESSION_ID |
Remote session identifier |
CLAUDE_CODE_CONTAINER_ID |
Container ID for remote environments |
class ProcessInputTransport {
input: ReadableStream;
replayUserMessages: boolean;
pendingRequests: Map<string, PendingRequest>;
inputClosed: boolean;
unexpectedResponseCallback?: (msg: any) => Promise<void>;
async *read(): AsyncGenerator<ParsedMessage>;
async processLine(line: string): Promise<ParsedMessage | undefined>;
async write(message: any): Promise<void>;
async sendRequest(request: ControlRequestPayload, schema?: ZodSchema, signal?: AbortSignal): Promise<any>;
createCanUseTool(onPrompt?: () => void): CanUseToolFn;
createHookCallback(callbackId: string, timeout: number): HookCallback;
async sendMcpMessage(serverName: string, message: any): Promise<any>;
getPendingPermissionRequests(): ControlRequest[];
}class WebSocketTransport {
ws: WebSocket | null;
url: URL;
state: "idle" | "connecting" | "reconnecting" | "connected" | "closing" | "closed";
headers: Record<string, string>;
sessionId: string | undefined;
reconnectAttempts: number;
messageBuffer: CircularBuffer; // capacity: 1000
async connect(): Promise<void>;
sendLine(data: string): boolean;
async write(message: any): Promise<void>;
handleConnectionError(): void; // exponential backoff reconnect
replayBufferedMessages(lastReceivedId: string): void;
startPingInterval(): void; // 10s ping/pong
close(): void;
setOnData(cb: (data: string) => void): void;
setOnClose(cb: () => void): void;
}class HybridTransport extends WebSocketTransport {
postUrl: string; // wss://host/ws/path → https://host/session/path/events
async write(message: any): Promise<void>; // HTTP POST with retry
}class SdkUrlTransport extends ProcessInputTransport {
url: URL;
transport: WebSocketTransport | HybridTransport;
inputStream: PassThrough;
constructor(sdkUrl: string, replayStream?: AsyncIterable<string>, replayUserMessages?: boolean);
async write(message: any): Promise<void>; // delegates to transport
close(): void;
}This is the simplest client implementation — useful as a reference for building your own:
class DirectConnectWebSocket {
ws: WebSocket | null;
config: { wsUrl: string; authToken?: string };
callbacks: {
onMessage: (msg: any) => void;
onConnected?: () => void;
onDisconnected?: () => void;
onError?: (err: Error) => void;
onPermissionRequest: (request: CanUseToolRequest, requestId: string) => void;
};
connect(): void;
sendMessage(content: string): boolean;
respondToPermissionRequest(requestId: string, response: PermissionResponse): void;
sendInterrupt(): void;
disconnect(): void;
isConnected(): boolean;
}Key implementation details from fFA:
// Sending a user message
sendMessage(content) {
const msg = JSON.stringify({
type: "user",
message: { role: "user", content },
parent_tool_use_id: null,
session_id: ""
});
this.ws.send(msg);
}
// Responding to a permission request
respondToPermissionRequest(requestId, response) {
const msg = JSON.stringify({
type: "control_response",
response: {
subtype: "success",
request_id: requestId,
response: { behavior: response.behavior, ...response }
}
});
this.ws.send(msg);
}
// Sending an interrupt
sendInterrupt() {
const msg = JSON.stringify({
type: "control_request",
request_id: crypto.randomUUID(),
request: { subtype: "interrupt" }
});
this.ws.send(msg);
}const messages: any[] = [];
Bun.serve({
port: 8765,
fetch(req, server) {
if (server.upgrade(req)) return;
return new Response("WebSocket server for Claude Code", { status: 200 });
},
websocket: {
open(ws) {
console.log("[CONNECTED] Claude Code connected");
},
message(ws, data) {
const lines = data.toString().split("\n").filter(Boolean);
for (const line of lines) {
const msg = JSON.parse(line);
messages.push({ direction: "IN", ...msg });
// Handle system/init
if (msg.type === "system" && msg.subtype === "init") {
console.log(`[INIT] session=${msg.session_id} model=${msg.model}`);
// Send first user message
const userMsg = JSON.stringify({
type: "user",
message: { role: "user", content: "Hello! What can you do?" },
parent_tool_use_id: null,
session_id: msg.session_id
}) + "\n";
ws.send(userMsg);
}
// Handle permission requests
if (msg.type === "control_request" && msg.request?.subtype === "can_use_tool") {
console.log(`[PERMISSION] ${msg.request.tool_name}: ${JSON.stringify(msg.request.input)}`);
// Auto-approve everything (or add your logic here)
const response = JSON.stringify({
type: "control_response",
response: {
subtype: "success",
request_id: msg.request_id,
response: {
behavior: "allow",
updatedInput: msg.request.input
}
}
}) + "\n";
ws.send(response);
}
// Handle assistant messages
if (msg.type === "assistant") {
const text = msg.message?.content
?.filter((b: any) => b.type === "text")
.map((b: any) => b.text)
.join("");
console.log(`[ASSISTANT] ${text?.substring(0, 200)}`);
}
// Handle result
if (msg.type === "result") {
console.log(`[RESULT] ${msg.subtype} | cost=$${msg.total_cost_usd} | turns=${msg.num_turns}`);
}
// Ignore keep_alive
if (msg.type === "keep_alive") return;
}
},
close(ws) {
console.log("[DISCONNECTED]");
}
}
});
console.log("WebSocket server running on ws://localhost:8765");claude --sdk-url ws://localhost:8765 \
--print \
--output-format stream-json \
--input-format stream-json \
--verbose \
-p ""To build a production controller on top of this protocol:
- WebSocket Server: Accept connections, handle NDJSON messages
- Message Router: Dispatch by
typefield (system, assistant, result, control_request, etc.) - Permission Handler: Implement policy for
can_use_toolrequests (auto-approve, deny, or prompt user) - Session Manager: Track session_id, support resume via
--resume - Multi-Turn: Send additional
usermessages after eachresult - Streaming: Process
stream_eventmessages for real-time output - Error Handling: Handle
resultwithis_error: true, connection drops, reconnection - Lifecycle: Use
initializecontrol_request for hooks/MCP,interruptto abort
| Aspect | Filesystem Inbox | WebSocket Protocol |
|---|---|---|
| Transport | JSON files in ~/.claude/teams/ |
NDJSON over WebSocket |
| Permissions | Not natively routed through inbox | Full can_use_tool flow |
| Latency | Polling (500ms) | Real-time |
| Multi-turn | Send message → poll for response | Send message → stream response |
| Streaming | Not supported | stream_event messages |
| Session control | Limited (mode, shutdown) | Full (model, thinking, MCP, rewind) |
| Dependency | Teammate mode required | Standalone (--print mode) |
| Mode | can_use_tool sent? |
Behavior |
|---|---|---|
default |
Yes (when rules don't resolve) | Normal flow |
acceptEdits |
Yes (for non-edit tools) | Auto-approves file edits |
bypassPermissions |
Never | Everything auto-approved locally |
plan |
Yes (limited) | Read-only exploration mode |
delegate |
N/A | Restricted to coordination tools only |
dontAsk |
Never | Auto-denies unresolved permissions |
type PermissionMode = "default" | "acceptEdits" | "bypassPermissions" | "plan" | "delegate" | "dontAsk";
type HookEvent =
| "PreToolUse" | "PostToolUse" | "PostToolUseFailure"
| "Notification" | "UserPromptSubmit"
| "SessionStart" | "SessionEnd"
| "Stop" | "SubagentStart" | "SubagentStop"
| "PreCompact" | "PermissionRequest"
| "Setup" | "TeammateIdle" | "TaskCompleted";
type ContentBlock =
| { type: "text"; text: string }
| { type: "tool_use"; id: string; name: string; input: Record<string, unknown> }
| { type: "tool_result"; tool_use_id: string; content: string | ContentBlock[]; is_error?: boolean }
| { type: "thinking"; thinking: string; budget_tokens?: number };