When we started building Operon, we made a choice that would either be our biggest advantage or our biggest mistake: support every major AI coding tool, not just one. Claude Code, Cursor, Codex, Gemini CLI, Aider. Each has a different output format, a different hook system (or no hook system at all), and a different mental model for how interactions work.
Five tools, five different data streams. But one UI that makes sense of all of them. This post is about how we did it.
The insight: every AI interaction is a Trace
Strip away the surface differences and every AI coding tool interaction follows the same structure: the user sends a prompt, the AI executes tool calls — read file, run command, edit file, search codebase — and eventually sends a final response. The intermediate tool calls are the interesting part: they're where the actual work happens.
We call one complete cycle — from user prompt to final AI response — a Trace. A Trace groups everything that happened in between: tool executions, sub-agent spawns, token usage, cost, duration, and the final response text. This is the core data model that everything else in Operon is built on.
// src/shared/events.ts
export interface Trace {
id: string;
sessionId: string;
projectId: string;
startedAt: number;
completedAt?: number;
status: "active" | "completed" | "error";
userPrompt: string;
responseText?: string;
toolExecutions: ToolExecution[];
subAgentRuns: SubAgentRun[];
tokenUsage: TokenUsage;
costUsd: number;
track: "hooks" | "pty" | "stream-json";
}
export interface ToolExecution {
id: string;
traceId: string;
toolName: string;
input: Record<string, unknown>;
output?: string;
durationMs?: number;
status: "pending" | "success" | "error";
}Three data tracks
Different tools expose their data at different fidelity levels. We built three data tracks that represent a spectrum from richest-but-tool-specific to universal-but-noisier.
Track 1 (Hooks) is the premium path for Claude Code. Claude Code has a subprocess hook system where it calls out to an HTTP endpoint before and after every tool execution, passing structured JSON with full context: tool name, input parameters, output, session ID, cost data. Our HookReceiver runs an HTTP server on 127.0.0.1:47777 and translates these calls directly into ToolExecution records. The fidelity is near-perfect — we get exact token counts, actual tool I/O, and sub-agent relationships.
Track 2 (PTY) is the universal path. Every CLI tool runs in a terminal and produces output. We use node-pty to wrap the tool process and capture all stdout and stderr. Our ConversationAnalyzer then applies a battery of regexes — ANSI escape sequence stripping, prompt boundary detection, tool call pattern matching — to reconstruct trace structure from raw text. Fidelity is lower, but it works with any CLI tool without any integration work.
Track 3 (Stream JSON) handles tools that support structured output modes — for example, claude --output-format stream-json. When available, this is more reliable than PTY regex parsing while requiring less setup than a full hook integration.
// src/shared/adapter-types.ts
export interface NormalizedEvent {
type:
| "prompt"
| "tool_start"
| "tool_end"
| "response"
| "token_usage"
| "sub_agent_start"
| "sub_agent_end";
sessionId: string;
traceId?: string; // assigned by EventPipeline if not present
timestamp: number;
payload: Record<string, unknown>;
track: "hooks" | "pty" | "stream-json";
toolName?: string; // for tool_start / tool_end
confidence: number; // 0-1, lower for PTY-derived events
}EventPipeline: the unified trace builder
All three tracks funnel into a single EventPipeline. The pipeline's job is to receive NormalizedEvent objects and assemble them into Traces. It maintains an in-memory map of active traces keyed by traceId, accumulates tool executions, and watches for completion signals.
Trace completion works differently per track. For hooks, we get an explicit PostToolUse event with stop_reason: end_turn. For PTY, we use an 8-second idle timeout — if no output arrives for 8 seconds after a response boundary is detected, we close the trace. This idle timeout is the single trickiest parameter in the system: too short and you split one trace into two; too long and your analytics lag.
AdapterRegistry: detecting and resolving tools
When a user starts a session, we need to know which AI tool they're running so we can activate the right adapter. AdapterRegistry handles detection: it checks for tool binaries in PATH, looks for config files like .claude/settings.json and .cursor/settings.json, and examines the shell command the user invoked. Each registered adapter reports which data tracks it supports and declares its detection confidence.
Resolution is straightforward: if Claude Code is detected, we activate Track 1 (hooks) as primary, with Track 2 (PTY) as fallback in case hooks aren't configured. For every other tool, Track 2 is primary. When Track 3 is available — the tool supports --output-format stream-json or equivalent — we prefer it over PTY regex.
The result: one UI for every tool
Because every adapter produces the same NormalizedEvent format and the EventPipeline produces the same Trace model, the rest of Operon — the Activity view, Timeline, Analytics, Tasks, Flight Plan — doesn't know or care which AI tool generated the data. The same trace card that shows Claude Code tool executions with exact I/O also shows Aider's file edits parsed from PTY output.
The fidelity difference is real: hooks-derived traces have token-exact costs and full tool I/O; PTY-derived traces have estimated costs and reconstructed tool calls. But the structure is identical, which means we can evolve the adapters independently without touching the UI. When Codex ships a hook system, we add Track 1 support for it without changing a single component.