Building a Browser UI for Claude Code by Reverse-Engineering Its Hidden WebSocket Protocol
Hook
The Claude Code CLI has a secret flag—--sdk-url—that redirects its entire WebSocket stream to a custom server. The Vibe Companion exploits this undocumented feature to build what Anthropic never intended: a full browser-based UI with session management and tool approval controls.
Context
Claude Code is Anthropic’s agentic coding assistant, designed as a CLI tool that executes bash commands, edits files, and searches the web to complete coding tasks. It’s powerful, but locked into terminal workflows. If you want to monitor multiple sessions, review tool execution requests in a visual interface, or share your screen during a demo without exposing terminal chaos, you’re out of luck—the official tooling stops at the command line.
The Vibe Companion emerged from a simple observation: CLI tools communicate somewhere, and if you can intercept that communication, you can build alternative interfaces. The developers discovered that Claude Code’s architecture separates the agent logic (which runs in Anthropic’s infrastructure) from the local CLI client via WebSocket. By reverse-engineering the NDJSON protocol and documenting thirteen control request subtypes, they built a proxy server that translates this internal protocol into something a React frontend can consume. The result is a web UI that launches Claude Code sessions, streams token-by-token responses, and provides granular approval controls for every tool the agent wants to execute—all without touching Anthropic’s official APIs.
Technical Insight
The architecture is a three-layer system: the Claude CLI (unmodified), a Bun-powered WebSocket proxy, and a React 19 frontend. The proxy server exploits the --sdk-url flag to hijack the CLI’s network traffic. When you start a session through the web UI, the server spawns a Claude Code process with this flag pointed at itself:
const cliProcess = Bun.spawn([
'claude',
'--sdk-url', `ws://localhost:${WS_PORT}`,
'--project', projectPath
], {
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env }
});
Every message the CLI sends to Anthropic now routes through the proxy first. The protocol itself is NDJSON (newline-delimited JSON), with each message containing a type field and payload. The proxy intercepts these, transforms them into clean JSON objects, and broadcasts to connected browser clients via Server-Sent Events. Here’s a simplified message flow:
// Incoming from Claude CLI (NDJSON)
{"type":"control","subtype":"tool_request","tool":"bash","command":"rm -rf node_modules","requestId":"req_123"}
// Transformed for browser (JSON)
{
"sessionId": "session_abc",
"messageType": "tool_request",
"tool": { "name": "bash", "command": "rm -rf node_modules" },
"requestId": "req_123",
"requiresApproval": true
}
The browser UI displays this as a card with syntax-highlighted command preview and approve/deny buttons. When you click approve, the frontend sends a control message back through the proxy, which forwards it to the CLI in the original NDJSON format. This bidirectional translation is where the proxy earns its keep—it’s not just logging traffic, it’s actively mediating the conversation.
The permission flow is particularly elegant. Claude Code has built-in concepts of “dangerous” operations (file deletions, network requests, package installations) that require explicit approval. The proxy intercepts these permission_required messages, pauses the CLI’s execution, and queues them for browser review. The frontend uses Zustand for state management to track pending approvals across multiple sessions:
interface ApprovalState {
pending: Map<string, ToolRequest>;
approved: Set<string>;
denied: Set<string>;
approveRequest: (id: string, modified?: string) => void;
}
const useApprovalStore = create<ApprovalState>((set, get) => ({
pending: new Map(),
approved: new Set(),
denied: new Set(),
approveRequest: (id, modified) => {
const request = get().pending.get(id);
if (!request) return;
// Send approval back through WebSocket
ws.send(JSON.stringify({
type: 'approval',
requestId: id,
decision: 'approve',
modifiedCommand: modified
}));
set(state => ({
pending: new Map([...state.pending].filter(([k]) => k !== id)),
approved: new Set([...state.approved, id])
}));
}
}));
One clever detail: you can edit commands before approving them. If Claude wants to run npm install but you want npm ci instead, you modify the input and send the altered command back. The CLI executes your version, and Claude’s agent context updates accordingly—it sees the output of the command you approved, not what it originally requested.
The streaming visualization handles nested subagent tasks by parsing the protocol’s task_start and task_end events. When Claude spawns a subagent to research API documentation while the parent agent waits, the UI renders this as nested, collapsible cards with independent token counters. The rendering logic tracks depth and relationship:
type AgentTask = {
id: string;
parentId?: string;
depth: number;
tokens: { input: number; output: number };
status: 'active' | 'complete' | 'failed';
children: AgentTask[];
};
// Recursive rendering preserves hierarchy
const TaskCard = ({ task }: { task: AgentTask }) => (
<div style={{ marginLeft: `${task.depth * 20}px` }}>
<div className="task-header">
{task.status === 'active' && <SpinnerIcon />}
Agent {task.id} • {task.tokens.input + task.tokens.output} tokens
</div>
{task.children.map(child => <TaskCard key={child.id} task={child} />)}
</div>
);
The entire protocol is documented in WEBSOCKET_PROTOCOL_REVERSED.md, a 400-line specification that details every message type, permission flow, and error code. This is the project’s most valuable artifact—it transforms tribal knowledge into a public resource that could enable alternative clients, logging tools, or safety monitors built by the community.
Gotcha
This is fundamentally a house built on sand. Anthropic never documented the --sdk-url flag or the WebSocket protocol because they’re internal implementation details, not public contracts. A routine CLI update could rename the flag, change message formats, or remove the feature entirely, instantly breaking Companion. The project’s GitHub already has issues tracking protocol drift between Claude CLI versions—users on v1.2.3 see different message structures than v1.1.8. There’s no semver guarantee, no deprecation policy, no migration path.
The security model is also troubling for anything beyond single-user local development. The proxy server has no authentication—anyone who can reach localhost:3000 can launch Claude sessions using your subscription, approve arbitrary bash commands, or read your project files. The docs suggest running it behind a VPN or SSH tunnel for remote access, which is reasonable for hobbyist use but insufficient for team deployments. There’s also no audit logging of who approved what command, making it unsuitable for environments where you need compliance trails. If you’re considering self-hosting this for a team, budget significant engineering time to add auth, RBAC, and audit capabilities that aren’t in the box.
Verdict
Use if you’re a Claude Code CLI subscriber who wants visual session management, prefers browser UIs to terminal workflows, and needs granular control over tool execution—especially if you’re demoing Claude’s capabilities or running it on sensitive codebases where you want human-in-the-loop approval. The real-time streaming, nested task visualization, and command editing features are genuinely useful, and the protocol documentation is a public good. Skip if you need production stability guarantees, official vendor support, or API-key-only access without local CLI installation. Also skip if you’re looking for multi-user collaboration features or enterprise security controls—this is explicitly a power-user tool for individuals. Consider it a sophisticated debugging interface for Claude Code rather than a replacement for official tooling, and maintain a terminal workflow as your fallback for when the next CLI update inevitably breaks compatibility.