Skip to content

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:

Running Claude Code with stream-json output
claude --output-format stream-json

The output looks like this:

Sample stream-json output
{"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:

main.ts - Process spawning
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:

stream-parser.ts - JSON parsing with buffering
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:

state-detector.ts - Pattern matching for state
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 states
const 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:

renderer.ts - xterm.js setup
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 process
window.electron.onSessionData((sessionId: string, data: string) => {
const view = sessionViews.get(sessionId);
if (view) {
view.terminal.write(data);
}
});
// Handle state changes
window.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:

SessionGrid.tsx - Grid component
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:

approval-handler.ts
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 UI
function 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.

Wrong: Assuming complete JSON per chunk
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:

session-persistence.ts
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:

resource-manager.ts
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:

Custom Claude Code UI Architecture
┌─────────────────────────────────────────────────────────────┐
│ 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:

Custom UI implementation checklist
[ ] 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 actions

xterm.js is powerful but has quirks I had to learn:

  1. 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.

  2. Performance with scrollback: Large scrollback buffers slow down rendering. I capped mine at 5000 lines.

  3. Copy/paste: You need to handle this manually:

Clipboard handling
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