| name | claude-ws-app |
| description | Build applications using Claude Code CLI over WebSocket (--sdk-url). Covers NDJSON protocol, message formats, process lifecycle, permissions, streaming, and known pitfalls. Use when spawning Claude CLI instances, implementing WebSocket bridges, building multi-agent orchestrators, or working with headless Claude, sdk-url, or NDJSON protocol.
|
Building Apps with Claude Code CLI + WebSocket
Overview
This skill covers building applications that spawn Claude Code CLI instances in headless WebSocket mode using claude --sdk-url ws://host:port/path. The CLI connects to your server as a WebSocket client, exchanging NDJSON (newline-delimited JSON) messages for full programmatic control — sending prompts, handling tool permissions, streaming responses, and managing the process lifecycle.
The typical architecture is: one or more Claude CLI instances connected via WebSocket to a server (Hono/Bun or similar), which also serves a browser UI over a separate WebSocket channel. The server acts as orchestrator — routing messages between CLI agents, managing turns, and forwarding state to the browser for human oversight.
This protocol is undocumented. Everything here was reverse-engineered from working implementations and validated through 13 bug discoveries. Follow this skill to avoid the same pitfalls.
Quick Start Checklist
Before writing any code, verify your implementation covers these 8 items. Each corresponds to a real bug that caused silent failures, crashes, or multi-hour debugging sessions.
NDJSON Message Formats
This is the #1 source of bugs. The CLI expects exact nested structures. Getting these wrong causes TypeError crashes or silent timeouts with no error messages.
Sending a user message (Server -> CLI)
const userMessage = {
type: "user",
message: {
role: "user",
content: "Your prompt text here",
},
parent_tool_use_id: null,
session_id: "",
};
Sending a control_response (Server -> CLI)
const controlResponse = {
type: "control_response",
response: {
subtype: "success",
request_id: originalRequest.request_id,
response: {
behavior: "allow",
},
},
};
TypeScript types (copy-pasteable)
interface CLIUserMessage {
type: "user";
message: { role: "user"; content: string };
parent_tool_use_id: string | null;
session_id: string;
}
interface CLIControlResponse {
type: "control_response";
response: {
subtype: "success";
request_id: string;
response: {
behavior: "allow" | "deny";
updatedInput?: unknown;
};
};
}
interface CLIControlRequest {
type: "control_request";
request_id: string;
request: {
subtype: "can_use_tool" | "interrupt" | string;
tool_name?: string;
input?: unknown;
tool_use_id?: string;
};
session_id?: string;
}
interface CLIAssistantMessage {
: ;
: {
: ;
: ;
: <{
: | | ;
?: ;
?: ;
?: ;
?: ;
}>;
?: ;
?: ;
};
: ;
}
{
: ;
: ;
: ;
?: ;
?: ;
?: ;
?: ;
?: ;
?: ;
}
{
: ;
: ;
?: ;
}
{
: ;
: ;
: ;
?: [];
?: ;
}
{
: ;
}
For the full protocol specification with all message types, see protocol-reference.md.
CLI Process Spawning
Correct Bun.spawn configuration
const args = [
"--sdk-url", `ws://localhost:${port}/ws/cli/${sessionId}/${role}`,
"--print",
"--output-format", "stream-json",
"--input-format", "stream-json",
"--verbose",
];
for (const tool of allowedTools) {
args.push("--allowedTools", tool);
}
args.push("-p", "");
const childEnv = { ...process.env };
delete childEnv.CLAUDECODE;
const proc = Bun.spawn(["claude", ...args], {
cwd: process.cwd(),
env: childEnv,
stdout: "pipe",
stderr: "pipe",
});
Connection gating
wss.on("connection", (ws) => {
agent.connected = true;
});
Timeout
Use 30s+ for connection timeout. The CLI needs time for hooks to execute before sending system/init.
Handling control_request
When the CLI needs tool permission, it sends a control_request. The critical gotcha is the nested field path.
function handleControlRequest(role: string, msg: CLIControlRequest) {
const req = msg.request;
if (req.subtype === "can_use_tool" && req.tool_name) {
if (SAFE_TOOLS.has(req.tool_name)) {
sendToCLI(role, makeControlResponse(msg.request_id, true));
return;
}
forwardToUI(role, req.tool_name, req.input, msg.request_id);
}
}
Streaming vs Assistant Messages
The CLI sends the same text content through TWO paths:
stream_event (content_block_delta): Incremental text chunks, real-time
assistant: Complete accumulated text after LLM finishes
Rule: Pick one for UI display. Never render both.
- Use
stream_event deltas for real-time streaming UI (append each delta)
- Use
assistant messages for server-side text extraction (final complete text)
- The
assistant handler should update internal state only — NOT broadcast to browser
if (msg.event_type === "content_block_delta") {
const text = msg.data?.delta?.text;
if (text) broadcastToBrowsers({ type: "stream_delta", role, text });
}
function handleAssistantMessage(role, msg) {
const text = msg.message.content
.filter(b => b.type === "text" && b.text)
.map(b => b.text)
.join("\n");
agent.streamingText = text;
}
Tool Activity Detection
Tools passed via --allowedTools are auto-approved internally by the CLI. No control_request is sent over WebSocket for these tools.
Three-layer detection approach:
for (const block of msg.message.content) {
if (block.type === "tool_use" && block.name) {
broadcastToolActivity(role, block.name, block.input);
}
}
if (msg.event_type === "content_block_start" &&
msg.data?.content_block?.type === "tool_use") {
broadcastToolActivity(role, msg.data.content_block.name);
}
setInterval(() => {
broadcastActivity(role, agent.currentActivity || "Working", elapsed);
}, 3000);
Activity detail persistence
When broadcasting tool activity, store the extracted detail on the agent state. The periodic ticker should read the stored detail, not overwrite it with empty string.
function broadcastToolActivity(role, toolName, input?) {
agent.currentActivity = TOOL_LABELS[toolName] ?? `Using ${toolName}`;
const detail = extractToolDetail(toolName, input);
if (detail) agent.currentDetail = detail;
broadcast({ activity: agent.currentActivity, detail: agent.currentDetail });
}
setInterval(() => {
broadcast({ activity: agent.currentActivity, detail: agent.currentDetail });
}, 3000);
Turn Timer Best Practices
- Default timeout: 300s (5 minutes). Many tool operations (deep code search, file analysis) take 30-60s each, and agents may chain 5-10 tool calls per turn.
- Reset on ANY CLI message: Not just streaming text. Tools like Read, Grep, Glob produce no streaming output. Reset the timer on
assistant, control_request, stream_event, and result messages. Only skip keep_alive and system.
- Pause during permission requests: When waiting for human to approve/deny a tool, pause the timer. Resume after response.
function handleCLIMessage(role, msg) {
if (msg.type !== "keep_alive" && msg.type !== "system") {
resetTurnTimer(role);
}
}
Dev Server Configuration
When agents write files inside the project directory, dev servers detect changes and trigger hot/full reloads. This causes the browser UI to flash back to its initial state.
Vite
export default defineConfig({
server: {
watch: {
ignored: [
"**/sessions/**",
"**/server/**",
"**/dist/**",
"**/.ignore/**",
],
},
},
});
Webpack / Next.js
watchOptions: {
ignored: [/sessions/, /\.ignore/],
}
Per-App Design Checklist
Every new app using this architecture must answer these questions before implementation:
1. Agent Roles
- How many CLI agents? What is each agent's role/persona?
- Do agents interact with each other, the user, or both?
2. Tool Permissions
- What tools does each agent need? List explicitly per agent.
- Which tools are auto-approved (
SAFE_TOOLS / --allowedTools)?
- Which tools require human approval via the UI?
- Smoke test: Can each agent actually do its core task with granted permissions?
3. Completion Detection
- How does the system know the interaction is done?
- Magic string marker? (e.g.,
NO_MORE_ISSUES, DEBATE_CONCLUDED)
- Round counter? (after N rounds, stop)
- User action? (user clicks "End" / "Approve")
- What happens when agents don't emit the expected signal?
- Heuristic fallback patterns?
- Timeout with graceful termination?
- Escalate to user?
4. File System Impact
- Will agents write files? Where?
- Are those directories excluded from the dev server file watcher?
- Are those directories in
.gitignore?
5. Turn Management
- How long might a single turn take? Is 300s sufficient?
- Are there intermediate checkpoints where the user should be notified?
Known Bugs Reference
For the symptom-first diagnostic reference (all 13 bugs, organized by what you see), see known-bugs.md.
Generic bugs (affect any WS + CLI app): #1, #2, #3, #4, #5, #8, #10, #12
Project-specific bugs: #6, #7, #9, #11, #13
Supporting Files