🌉 Bridges & IDE Integration
A WebSocket reconnect drops to 0ms perceived latency for the user — but rebuilds the entire IDE state in 3 round trips. Here’s why that’s a design constraint, not a bug.
Claude Code runs the same QueryEngine whether you use it in a terminal, VS Code, JetBrains, or the web. The bridge pattern connects each frontend to this shared engine — same AI logic, different UI surfaces.
- Terminal: Ink renderer (React to ANSI), direct stdin/stdout
- IDE (VS Code, JetBrains): WebSocket bridge, permission dialogs rendered in IDE panels
- Web: Next.js app with Zustand, separate API client, ink-compat adapter for shared renderers
Bridge Architecture
What you are seeing
How one QueryEngine instance serves three different frontends. Each frontend connects through its own bridge adapter, but they all share the same session, tool execution, and streaming logic.
What to try
Trace the IDE path: an extension sends a user message over WebSocket, the bridge creates a session, the engine streams events back, and the extension renders them in its panel. Compare this to the terminal path where everything happens in-process.
The Intuition
Why Not Three Separate Codebases?
If you fix a bug in the terminal version, you would have to fix it again in the IDE version and again in the web version. The bridge pattern means ONE QueryEngine, ONE tool system, ONE permission system — the UI is just a thin rendering layer. New frontends cost one adapter, not a full reimplementation.
Why a Bridge Pattern?
An AI coding agent is complex: tool execution, context window management, permission enforcement, streaming, error recovery. If you build separate codebases for terminal, IDE, and web, you maintain 3 copies of this logic. Bugs fixed in terminal don't get fixed in IDE. The bridge pattern keeps one canonical engine and lets each frontend connect through a thin adapter layer.
The IDE Bridge
When Claude Code runs in VS Code, the extension spawns a headless CLI process and connects via WebSocket. The bridgeMain.ts entry point creates a session, initializes the REPL and QueryEngine, and routes messages between the WebSocket and the engine. Outbound events (assistant messages, tool calls, streaming tokens) are serialized and sent to the extension, which renders them in its webview panel.
Permission in Bridge Mode
In terminal mode, permission dialogs are simple stdin prompts. In IDE mode, the engine sends a permission_requestmessage over WebSocket with the tool name and input. The IDE extension renders a native dialog (VS Code's showInformationMessage), and sends back the user's decision. The engine's permission_callback is injected at session creation — it never knows which UI rendered the dialog.
The ink-compat Adapter
Tool output renderers (file diffs, search results, command output) are written using Ink components: Box, Text, Spinner, Static. In the terminal, Ink renders these to ANSI. In the web, the ink-compat adapter maps them to HTML: Box becomes a div with flexbox, Text becomes a span with CSS, Spinner becomes a CSS animation. This lets the same renderer code work in all three frontends without duplication.
The WebSocket Message Protocol
The bridge communicates via typed JSON messages over WebSocket — the same pattern as the Language Server Protocol (LSP). Each message has a type discriminant that drives routing on both sides. Upstream messages (engine → IDE) include: assistant_token (streaming text chunk), tool_call (tool name + input), tool_result (output after execution), permission_request (awaiting user approval), and session_end. Downstream messages (IDE → engine) include: user_message and permission_response. This typed protocol means the IDE extension never needs to parse raw ANSI or understand agent internals — it only handles the message types it cares about. New message types are additive and backward-compatible, the same evolution strategy as LSP.
Reconnection and State Recovery
WebSocket connections drop — flaky Wi-Fi, laptop sleep, network switches. The bridge handles this with exponential backoff reconnect: on disconnect, the IDE extension retries at 1s, 2s, 4s, 8s intervals up to a cap. But reconnection alone is not enough — the session must resume where it left off. The engine assigns each session a stable ID. On reconnect, the extension sends the session ID; the engine replays buffered events from the point of disconnection. This is analogous to HTTP range requests: the client tells the server how far it got, the server sends only what the client missed. Without replay, a reconnected session would show a blank panel — the user loses the conversation history and any in-progress tool output that arrived during the disconnection window.
What changes when Claude Code runs in VS Code vs terminal?
Key Code Patterns
Bridge (TypeScript pseudocode)
class Bridge {
// Connects IDE extension to CLI engine via WebSocket
private ws: WebSocket;
private engine: QueryEngine | null = null;
constructor(wsUrl: string) {
this.ws = new WebSocket(wsUrl);
}
onConnect(): void {
createSession();
this.engine = new QueryEngine({
permissionCallback: (tool, input) => this.idePermissionDialog(tool, input),
});
}
onMessage(userMsg: string): void {
for (const event of this.engine!.submit(userMsg)) {
this.ws.send(JSON.stringify(event));
}
}
async idePermissionDialog(tool: string, input: unknown): Promise<boolean> {
this.ws.send(JSON.stringify({ type: "permission_request", tool, input }));
// ws.receive() is pseudocode — real impl wraps ws.onmessage in a Promise
const response = await this.ws.receive();
return response.allowed;
}
}ink-compat Adapter (TypeScript pseudocode)
type InkNodeType = "Box" | "Text" | "Spinner" | "Static";
// Adapts Ink terminal components to browser HTML equivalents
const componentMap: Record<InkNodeType, (props: InkProps) => string> = {
Box: (props) => `<div style="display:flex; ${flexboxCss(props)}">`,
Text: (props) => `<span style="${textCss(props)}">`,
Spinner: (_props) => '<span class="css-spinner">',
Static: (_props) => '<div class="static-output">',
};
function* renderInkTree(inkTree: InkNode): Generator<string> {
// Walk Ink component tree, emit HTML
for (const node of walk(inkTree)) {
const adapter = componentMap[node.type as InkNodeType];
yield adapter(node.props);
yield* renderInkTree(node.children);
yield closingTag(node.type);
}
}
function flexboxCss(props: InkProps): string {
const direction = props.flexDirection ?? "column";
const justify = props.justifyContent ?? "flex-start";
const padding = props.padding ?? 0;
return `flex-direction:${direction}; justify-content:${justify}; padding:${padding}ch;`;
}Permission Callback Injection
type SessionMode = "terminal" | "ide" | "web" | "viewer";
type PermissionCallback = (tool: string, input: unknown) => Promise<boolean>;
// Factory: same engine, different permission UIs
function createSession(mode: SessionMode): QueryEngine {
let callback: PermissionCallback;
if (mode === "terminal") {
callback = terminalPermission; // stdin prompt
} else if (mode === "ide") {
callback = idePermission; // WebSocket dialog
} else if (mode === "web") {
callback = webPermission; // Zustand modal
} else {
callback = async () => false; // viewer — read-only
}
return new QueryEngine({ permissionCallback: callback });
}
async function terminalPermission(tool: string, _input: unknown): Promise<boolean> {
const answer = await readLine(`Allow ${tool}? [y/n] `);
return answer.toLowerCase() === "y";
}
async function idePermission(tool: string, input: unknown): Promise<boolean> {
ws.send(JSON.stringify({ type: "permission_request", tool, input }));
// ws.receive() is pseudocode — real impl: wrap ws.onmessage in a Promise
const response = await ws.receive();
return response.allowed;
}
async function webPermission(tool: string, _input: unknown): Promise<boolean> {
// store.dispatch/waitFor is pseudocode — real Zustand impl uses
// setState + a subscribe-based Promise wrapper (Zustand has no built-in waitFor)
store.dispatch({ type: "SHOW_PERMISSION_MODAL", tool });
return store.waitFor("PERMISSION_RESPONSE").then((r) => r.allowed);
}Break It — See What Happens
Real-World Numbers
| Aspect | Value |
|---|---|
| Frontends served | 3 (terminal, IDE, web) |
| Shared engine | 1 QueryEngine instance per session |
| IDE bridge transport | WebSocket with exponential backoff reconnect |
| ink-compat adapted components | 4 core (Box, Text, Spinner, Static) |
| Remote session modes | Full control, viewer-only (permission always denied) |
Key Takeaways
What to remember for interviews
- 1One QueryEngine serves terminal, IDE, and web — each frontend is a thin adapter, so bugs fixed in the engine are fixed everywhere simultaneously.
- 2The IDE bridge spawns a headless CLI process and communicates over WebSocket using typed JSON messages — the same pattern as the Language Server Protocol.
- 3Permission callbacks are injected at session creation, so the engine never knows which UI renders the dialog: stdin prompt, VS Code dialog, or React modal.
- 4The ink-compat adapter maps 4 Ink primitives (Box, Text, Spinner, Static) to HTML equivalents, letting tool renderers be written once and work across all frontends.
- 5On WebSocket disconnect, the engine buffers events by session ID and replays them on reconnect — preventing blank panels when the user's network drops.
Further Reading
- VS Code Extension API — Official docs for building VS Code extensions — the primary IDE integration surface for Claude Code.
- WebSocket RFC 6455 — The protocol spec underlying IDE-to-engine communication in bridge mode.
- Ink: React for interactive command-line apps — The terminal React renderer whose components are adapted by ink-compat for cross-platform rendering.
- VS Code Extension Host Architecture — How VS Code isolates extensions in a separate process — the same isolation model used by the Claude Code IDE bridge to sandbox the engine from the editor.
- Adapter Pattern (Refactoring Guru) — The structural design pattern at the heart of the bridge — converting one interface (QueryEngine) into multiple frontend-specific interfaces.
- React Native Architecture Overview — The gold standard for a single React tree rendering to multiple native targets — the same multi-renderer problem Claude Code's bridge solves for terminal, IDE, and web.
Interview Questions
Showing 4 of 4
Design a system where the same AI engine serves terminal, IDE, and web interfaces.
★★★How would you handle permission dialogs across different UI frontends?
★★☆What's the benefit of an ink-compat adapter layer?
★★☆What are the tradeoffs between IDE-native and stdio-based bridges for editor integration?
★★☆