How to Build a Custom Claude Code UI with Stream-JSON Protocol: A Developer's Guide
Problem
I was running five Claude Code instances in tmux—a 2x3 grid of terminals, each working on different parts of a monorepo. It looked productive. But I kept losing track of which session was waiting for approval, which was stuck on a long-running test, and which had already finished.
Every time I wanted to check status, I had to manually switch to each pane and scan the output. Tmux gives you terminal multiplexing, not session intelligence. I couldn’t tell at a glance which agent was thinking, which needed my input, or which had errored out.
I needed a UI that understood Claude Code’s state, not just displayed its output.
Investigation
I found a Reddit thread where developers were building custom interfaces for exactly this problem. User u/germanheller had built “PATAPIM”—a terminal IDE running up to 9 Claude Code sessions in a grid layout with actual state detection:
“I built a terminal IDE called PATAPIM that runs up to 9 Claude Code sessions in a grid. The main reason: tmux is fine, but you can’t see at a glance which agent is thinking, which is waiting for approval, or which is stuck. State detection via pattern matching on stream-json output.”
This was the insight I needed. Claude Code has a --output-format stream-json flag that outputs structured JSON events. If I could parse those events in real-time, I could build a UI that shows state, not just text.
Another user, u/h____, confirmed they were already running multiple Claude Code agents in parallel via tmux:
“I’m doing this with multiple Droid/Claude Code agents in parallel. It works, but visibility is the problem. You end up context-switching constantly to check on things.”
The technical stack became clear from the discussion:
- Electron for the desktop application framework
- xterm.js for terminal rendering (handles ANSI codes, cursor positioning)
- node-pty for pseudo-terminal process management
- Pattern matching on stream-json output for state detection
Solution
I built a custom Claude Code UI with three core components: process management, stream parsing, and state detection.
Step 1: Spawning Claude Code with Stream-JSON
First, I needed Claude Code to output structured JSON instead of human-readable text:
claude --output-format stream-jsonThe output looks like this:
{"type":"message","role":"assistant","content":"Reading file..."}{"type":"tool_use","name":"Read","input":{"file_path":"/src/index.ts"}}{"type":"tool_result","name":"Read","output":"file contents here..."}{"type":"status","state":"awaiting_approval","tool":"Bash"}Each line is a discrete event I can parse and react to.
Step 2: Setting Up Electron with node-pty
I created an Electron app that spawns Claude Code processes in pseudo-terminals:
import { app, BrowserWindow, ipcMain } from 'electron';import * as pty from 'node-pty';import { v4 as uuidv4 } from 'uuid';
interface ClaudeSession { id: string; pty: pty.IPty; state: SessionState; buffer: string;}
type SessionState = 'thinking' | 'tool-active' | 'awaiting-approval' | 'idle' | 'error';
const sessions = new Map<string, ClaudeSession>();
function spawnSession(cols: number, rows: number): string { const id = uuidv4(); const ptyProcess = pty.spawn('claude', ['--output-format', 'stream-json'], { name: 'xterm-256color', cols, rows, cwd: process.cwd(), env: process.env as Record<string, string>, });
const session: ClaudeSession = { id, pty: ptyProcess, state: 'idle', buffer: '', };
ptyProcess.onData((data) => { handleSessionData(id, data); });
ptyProcess.onExit(({ exitCode }) => { sessions.delete(id); // Notify renderer of session termination mainWindow?.webContents.send('session-exit', { id, exitCode }); });
sessions.set(id, session); return id;}Step 3: Parsing Stream-JSON Output
The tricky part: stream-json output isn’t always line-delimited. JSON objects can span multiple chunks. I had to buffer incomplete lines:
function handleSessionData(sessionId: string, data: string): void { const session = sessions.get(sessionId); if (!session) return;
// Append to buffer session.buffer += data;
// Split on newlines, keeping incomplete line in buffer const lines = session.buffer.split('\n'); session.buffer = lines.pop() || '';
for (const line of lines) { if (!line.trim()) continue;
try { const event = JSON.parse(line); processEvent(sessionId, event); } catch (e) { // Might be ANSI escape codes or partial JSON // Handle gracefully - emit as raw output emitRawOutput(sessionId, line); } }}
interface ClaudeEvent { type: string; role?: string; content?: string; name?: string; input?: Record<string, unknown>; output?: string; state?: string;}
function processEvent(sessionId: string, event: ClaudeEvent): void { const session = sessions.get(sessionId); if (!session) return;
switch (event.type) { case 'tool_use': session.state = 'tool-active'; // Track which tool is running emitStateChange(sessionId, 'tool-active', event.name); break;
case 'status': if (event.state === 'awaiting_approval') { session.state = 'awaiting-approval'; emitStateChange(sessionId, 'awaiting-approval', event.name); } break;
case 'message': if (event.role === 'assistant' && event.content) { session.state = 'thinking'; emitStateChange(sessionId, 'thinking'); } break;
case 'tool_result': session.state = 'thinking'; break;
case 'error': session.state = 'error'; emitStateChange(sessionId, 'error', event.content); break; }
// Always emit the raw event for the terminal display emitEvent(sessionId, event);}Step 4: State Detection Patterns
Not all state information comes through structured JSON. Sometimes I had to detect state from the raw terminal output:
function detectStateFromOutput(output: string): SessionState | null { // Explicit state markers if (output.includes('awaiting_approval')) return 'awaiting-approval'; if (output.includes('tool_use')) return 'tool-active';
// Pattern-based detection if (/thinking|processing|analyzing/i.test(output)) return 'thinking'; if (/error|failed|exception/i.test(output)) return 'error'; if (/completed|finished|done/i.test(output)) return 'idle';
// Waiting for input patterns if (/^\s*\?\s*/.test(output)) return 'awaiting-approval'; if (/proceed\?\s*\[y\/n\]/i.test(output)) return 'awaiting-approval';
return null;}
// Color coding for statesconst stateColors: Record<SessionState, string> = { 'thinking': '#3498db', // Blue 'tool-active': '#f39c12', // Orange 'awaiting-approval': '#e74c3c', // Red 'idle': '#2ecc71', // Green 'error': '#9b59b6', // Purple};
const stateIcons: Record<SessionState, string> = { 'thinking': '🧠', 'tool-active': '⚡', 'awaiting-approval': '⚠️', 'idle': '✓', 'error': '✗',};Step 5: Rendering Terminals with xterm.js
On the renderer side, I used xterm.js to display each session:
import { Terminal } from 'xterm';import { FitAddon } from 'xterm-addon-fit';import { WebLinksAddon } from 'xterm-addon-weblinks';import 'xterm/css/xterm.css';
interface SessionView { id: string; terminal: Terminal; container: HTMLElement; state: SessionState;}
function createTerminalView(sessionId: string, containerEl: HTMLElement): Terminal { const terminal = new Terminal({ theme: { background: '#1a1a2e', foreground: '#eaeaea', cursor: '#f39c12', }, fontFamily: 'JetBrains Mono, monospace', fontSize: 13, scrollback: 5000, });
terminal.loadAddon(new FitAddon()); terminal.loadAddon(new WebLinksAddon());
terminal.open(containerEl);
return terminal;}
// Handle incoming data from main processwindow.electron.onSessionData((sessionId: string, data: string) => { const view = sessionViews.get(sessionId); if (view) { view.terminal.write(data); }});
// Handle state changeswindow.electron.onStateChange((sessionId: string, state: SessionState, tool?: string) => { const view = sessionViews.get(sessionId); if (view) { view.state = state; updateSessionHeader(sessionId, state, tool); }});Step 6: Grid Layout for Multiple Sessions
I built a responsive grid that shows state at a glance:
interface SessionGridProps { sessions: SessionConfig[]; layout: '2x2' | '3x3';}
function SessionGrid({ sessions, layout }: SessionGridProps) { const gridCols = layout === '2x2' ? 2 : 3;
return ( <div className="session-grid" style={{ display: 'grid', gridTemplateColumns: `repeat(${gridCols}, 1fr)`, gap: '8px', height: '100vh', }}> {sessions.map((session) => ( <SessionPane key={session.id} session={session} onExpand={() => expandSession(session.id)} /> ))} </div> );}
function SessionPane({ session, onExpand }: SessionPaneProps) { const color = stateColors[session.state]; const icon = stateIcons[session.state];
return ( <div className="session-pane" style={{ borderColor: color }}> <div className="session-header" style={{ backgroundColor: color }}> <span className="state-icon">{icon}</span> <span className="session-name">{session.name}</span> <span className="session-state">{session.state}</span> <button onClick={onExpand}>Expand</button> </div> <div ref={terminalContainer} className="terminal-container" /> </div> );}Step 7: Handling Approval Prompts
One of the key features I needed was quick approval handling across sessions:
interface ApprovalQueue { sessionId: string; tool: string; input: Record<string, unknown>; timestamp: number;}
const pendingApprovals: ApprovalQueue[] = [];
function handleApprovalNeeded(sessionId: string, tool: string, input: Record<string, unknown>): void { pendingApprovals.push({ sessionId, tool, input, timestamp: Date.now(), });
// Notify UI showApprovalNotification(sessionId, tool, input);}
// Quick approve/deny from UIfunction approveAction(approvalId: string): void { const approval = findApproval(approvalId); if (approval) { // Send 'y' to the session's pty sessions.get(approval.sessionId)?.pty.write('y\n'); removeApproval(approvalId); }}
function denyAction(approvalId: string): void { const approval = findApproval(approvalId); if (approval) { sessions.get(approval.sessionId)?.pty.write('n\n'); removeApproval(approvalId); }}Common Mistakes I Made
Mistake 1: Not Buffering Partial JSON
My initial parser assumed each onData callback contained complete JSON objects. This broke immediately when large tool outputs split across multiple chunks.
claudeProcess.onData((data) => { // This fails when JSON spans multiple chunks const event = JSON.parse(data); // SyntaxError: Unexpected end of JSON input});Solution: Always buffer and split on newlines, keeping incomplete lines for the next chunk.
Mistake 2: Stripping ANSI Codes
I initially stripped ANSI escape codes to get “clean” output. But Claude Code uses formatting intentionally—colors, bold, progress indicators. Stripping these lost information.
Solution: Let xterm.js handle ANSI codes. That’s what it’s designed for. Only parse the JSON events, pass everything else through to the terminal.
Mistake 3: Over-Engineering State Detection
My first state detector tried to be too smart—using heuristics to guess state from natural language. It was fragile and constantly wrong.
Solution: Rely on the structured type field from stream-json first. Fall back to simple regex patterns only when needed. Don’t build ML classifiers for this.
Mistake 4: Forgetting Session Persistence
When my Electron app crashed (which happened often during development), I lost all session context. The terminals would close and I’d have to restart everything.
Solution: Log session output to files. On restart, offer to recover recent sessions:
import fs from 'fs';import path from 'path';
const LOG_DIR = path.join(app.getPath('userData'), 'session-logs');
function logSessionEvent(sessionId: string, event: ClaudeEvent): void { const logFile = path.join(LOG_DIR, `${sessionId}.jsonl`); fs.appendFileSync(logFile, JSON.stringify(event) + '\n');}
function recoverSession(sessionId: string): ClaudeEvent[] { const logFile = path.join(LOG_DIR, `${sessionId}.jsonl`); if (!fs.existsSync(logFile)) return [];
return fs.readFileSync(logFile, 'utf-8') .split('\n') .filter(Boolean) .map(line => JSON.parse(line));}Mistake 5: Ignoring Resource Limits
Running 9 concurrent Claude Code sessions at full tilt consumed enormous memory and API quota. My first version had no limits.
Solution: Implement resource management:
const MAX_CONCURRENT_SESSIONS = 5;const MEMORY_WARNING_THRESHOLD = 0.8; // 80% of available memory
function canSpawnSession(): boolean { if (sessions.size >= MAX_CONCURRENT_SESSIONS) { showWarning('Maximum concurrent sessions reached'); return false; }
const memoryUsage = process.memoryUsage(); const memoryRatio = memoryUsage.heapUsed / memoryUsage.heapTotal;
if (memoryRatio > MEMORY_WARNING_THRESHOLD) { showWarning('Memory usage high. Consider closing sessions.'); }
return true;}Architecture Overview
The complete system looks like this:
┌─────────────────────────────────────────────────────────────┐│ Electron Main Process │├─────────────────────────────────────────────────────────────┤│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ││ │ Session 1 │ │ Session 2 │ │ Session 3 │ ... ││ │ (node-pty) │ │ (node-pty) │ │ (node-pty) │ ││ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ ││ │ │ │ ││ └────────────────┼────────────────┘ ││ │ ││ v ││ ┌───────────────────────┐ ││ │ Stream Parser │ ││ │ - Buffer partial JSON│ ││ │ - Detect state │ ││ │ - Route events │ ││ └───────────┬───────────┘ ││ │ │└──────────────────────────┼──────────────────────────────────┘ │ v (IPC)┌─────────────────────────────────────────────────────────────┐│ Renderer Process │├─────────────────────────────────────────────────────────────┤│ ┌──────────────────────────────────────────────────────┐ ││ │ Session Grid │ ││ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ ││ │ │Session 1│ │Session 2│ │Session 3│ │ ││ │ │ 🧠 think│ │ ⚡ tool │ │ ⚠️ await │ │ ││ │ │xterm.js │ │xterm.js │ │xterm.js │ │ ││ │ └─────────┘ └─────────┘ └─────────┘ │ ││ └──────────────────────────────────────────────────────┘ ││ ││ ┌──────────────────────────────────────────────────────┐ ││ │ Approval Queue Panel │ ││ │ • [Approve] Bash: npm test (Session 2) │ ││ │ • [Approve] Edit: src/auth.ts (Session 3) │ ││ └──────────────────────────────────────────────────────┘ │└─────────────────────────────────────────────────────────────┘Implementation Checklist
If you’re building this yourself:
[ ] Set up Electron project with TypeScript[ ] Add node-pty for process spawning[ ] Add xterm.js and addons (fit, weblinks)[ ] Implement stream-json parser with buffering[ ] Build state detection logic (start simple, add patterns as needed)[ ] Create grid layout for multiple sessions[ ] Add color coding and icons for states[ ] Implement approval queue panel[ ] Add session persistence (log to files)[ ] Implement resource limits (max sessions, memory warning)[ ] Test with 3-5 concurrent sessions first[ ] Add keyboard shortcuts for common actionsRelated Knowledge: Terminal Emulation Challenges
xterm.js is powerful but has quirks I had to learn:
-
Resize handling: When the grid layout changes (user expands a session), you must call
term.resize(cols, rows)and the fit addon. Otherwise the terminal content gets misaligned. -
Performance with scrollback: Large scrollback buffers slow down rendering. I capped mine at 5000 lines.
-
Copy/paste: You need to handle this manually:
terminal.attachCustomKeyEventHandler((event) => { if (event.ctrlKey && event.key === 'c') { const selection = terminal.getSelection(); if (selection) { navigator.clipboard.writeText(selection); return false; // Prevent default } } if (event.ctrlKey && event.key === 'v') { navigator.clipboard.readText().then(text => { terminal.write(text); }); return false; } return true;});Summary
In this post, I showed how to build a custom Claude Code UI using the stream-json protocol. The key components are: Electron for the desktop framework, node-pty for process management, xterm.js for terminal rendering, and pattern matching for state detection.
The result transforms Claude Code from a single-threaded tool into a multi-agent orchestration platform. You can run parallel tasks—one agent refactoring, another writing tests, another updating docs—and see at a glance which needs attention.
Start with a 2x2 grid. Get the JSON parsing right (buffer those partial chunks). Build simple state patterns first. Only then scale to more sessions and smarter detection.
Final Words + More Resources
My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me
Here are also the most important links from this article along with some further resources that will help you in this scope:
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments