When Paperclip runs an agent, stdout is streamed to the UI in real time. The UI needs a parser to convert raw stdout lines into structured transcript entries (tool calls, tool results, assistant messages, system events). Without a custom parser, the UI falls back to a generic shell parser that treats every non-system line as assistant output — tool commands leak as plain text, durations are lost, and errors are invisible.
The Problem
Most agent CLIs emit structured stdout with tool calls, progress indicators, and multi-line output. For example:
[hermes] Session resumed: abc123
┊ 💬 Thinking about how to approach this...
┊ $ ls /home/user/project
┊ [done] $ ls /home/user/project — /src /README.md 0.3s
┊ 💬 I see the project structure. Let me read the README.
┊ read /home/user/project/README.md
┊ [done] read — Project Overview: A CLI tool for... 1.2s
The project is a CLI tool. Here's what I found:
- It uses TypeScript
- Tests are in /tests
Without a parser, the UI shows all of this as raw assistant text — the tool calls and results are indistinguishable from the agent’s actual response.
With a parser, the UI renders:
Thinking about how to approach this...as a collapsible thinking block$ ls /home/user/projectas a tool call card (collapsed)0.3sduration as a tool result cardThe project is a CLI tool...as the assistant’s response
How It Works
┌──────────────────┐ package.json ┌──────────────────┐
│ Adapter Package │─── exports["./ui-parser"] ──→│ dist/ui-parser.js │
│ (npm / local) │ │ (zero imports) │
└──────────────────┘ └────────┬─────────┘
│ plugin-loader reads at startup
▼
┌──────────────────┐ GET /api/:type/ui-parser.js ┌──────────────────┐
│ Paperclip Server │◄────────────────────────────────│ uiParserCache │
│ (in-memory) │ └──────────────────┘
└────────┬─────────┘
│ serves JS to browser
▼
┌──────────────────┐ fetch() + eval ┌──────────────────┐
│ Paperclip UI │─────────────────────→│ parseStdoutLine │
│ (dynamic loader) │ registers parser │ (per-adapter) │
└──────────────────┘ └──────────────────┘
- Build time — You compile
src/ui-parser.tstodist/ui-parser.js(zero runtime imports) - Server startup — Plugin loader reads the file and caches it in memory
- UI load — When the user opens a run, the UI fetches the parser from
GET /api/:type/ui-parser.js - Runtime — The fetched module is eval’d and registered. All subsequent lines use the real parser
Contract: package.json
1. paperclip.adapterUiParser — contract version
{
"paperclip": {
"adapterUiParser": "1.0.0"
}
}The Paperclip host checks this field. If the major version is unsupported, the host logs a warning and falls back to the generic parser instead of executing potentially incompatible code.
| Host expects | Adapter declares | Result |
|---|---|---|
1.x | 1.0.0 | Parser loaded |
1.x | 2.0.0 | Warning logged, generic parser used |
1.x | (missing) | Parser loaded (grace period — future versions may require it) |
2. exports["./ui-parser"] — file path
{
"exports": {
".": "./dist/server/index.js",
"./ui-parser": "./dist/ui-parser.js"
}
}Contract: Module Exports
Your dist/ui-parser.js must export at least one of:
parseStdoutLine(line: string, ts: string): TranscriptEntry[]
Static parser. Called for each line of adapter stdout.
export function parseStdoutLine(line: string, ts: string): TranscriptEntry[] {
if (line.startsWith("[my-agent]")) {
return [{ kind: "system", ts, text: line }];
}
return [{ kind: "assistant", ts, text: line }];
}createStdoutParser(): { parseLine(line, ts): TranscriptEntry[]; reset(): void }
Stateful parser factory. Preferred if your parser needs to track multi-line continuation, command nesting, or other cross-call state.
let counter = 0;
export function createStdoutParser() {
let suppressContinuation = false;
function parseLine(line: string, ts: string): TranscriptEntry[] {
const trimmed = line.trim();
if (!trimmed) return [];
if (suppressContinuation) {
if (/^[\d.]+s$/.test(trimmed)) {
suppressContinuation = false;
return [];
}
return []; // swallow continuation lines
}
if (trimmed.startsWith("[tool-done]")) {
const id = `tool-${++counter}`;
suppressContinuation = true;
return [
{ kind: "tool_call", ts, name: "shell", input: {}, toolUseId: id },
{ kind: "tool_result", ts, toolUseId: id, content: trimmed, isError: false },
];
}
return [{ kind: "assistant", ts, text: trimmed }];
}
function reset() {
suppressContinuation = false;
}
return { parseLine, reset };
}If both are exported, createStdoutParser takes priority.
Contract: TranscriptEntry
Each entry must match one of these discriminated union shapes:
// Assistant message
{ kind: "assistant"; ts: string; text: string; delta?: boolean }
// Thinking / reasoning
{ kind: "thinking"; ts: string; text: string; delta?: boolean }
// User message (rare — usually from agent-initiated prompts)
{ kind: "user"; ts: string; text: string }
// Tool invocation
{ kind: "tool_call"; ts: string; name: string; input: unknown; toolUseId?: string }
// Tool result
{ kind: "tool_result"; ts: string; toolUseId: string; content: string; isError: boolean }
// System / adapter messages
{ kind: "system"; ts: string; text: string }
// Stderr / errors
{ kind: "stderr"; ts: string; text: string }
// Raw stdout (fallback)
{ kind: "stdout"; ts: string; text: string }Linking tool calls to results
Use toolUseId to pair tool_call and tool_result entries. The UI renders them as collapsible cards.
const id = `my-tool-${++counter}`;
return [
{ kind: "tool_call", ts, name: "read", input: { path: "/src/main.ts" }, toolUseId: id },
{ kind: "tool_result", ts, toolUseId: id, content: "const main = () => {...}", isError: false },
];Error handling
Set isError: true on tool results to show a red indicator:
{ kind: "tool_result", ts, toolUseId: id, content: "ENOENT: no such file", isError: true }Constraints
-
Zero runtime imports. Your file is loaded via
URL.createObjectURL+ dynamicimport()in the browser. Noimport, norequire, no top-levelawait. -
No DOM / Node.js APIs. Runs in a browser sandbox. Use only vanilla JS (ES2020+).
-
No side effects. Module-level code must not modify globals, access
window, or perform I/O. Only declare and export functions. -
Deterministic. Given the same
(line, ts)input, the same output must be produced. This matters for log replay. -
Error-tolerant. Never throw. Return
[{ kind: "stdout", ts, text: line }]for any line you can’t parse, rather than crashing the transcript. -
File size. Keep under 50 KB. This is served per-request and eval’d in the browser.
Lifecycle
| Event | What happens |
|---|---|
| Server starts | Plugin loader reads exports["./ui-parser"], reads the file, caches in memory |
| UI opens run | getUIAdapter(type) called. If no built-in parser, kicks off async fetch(/api/:type/ui-parser.js) |
| First lines arrive | Generic process parser handles them immediately (no blocking). Dynamic parser loads in background |
| Parser loads | registerUIAdapter() called. All subsequent line parsing uses the real parser |
| Parser fails (404, eval error) | Warning logged to console. Generic parser continues. Failed type is cached — no retries |
| Server restart | In-memory cache is repopulated from adapter packages |
Error Behavior
| Failure | What happens |
|---|---|
| Module syntax error (import fails) | Caught, logged, falls back to generic parser. No retries. |
| Returns wrong shape | Individual entries with missing fields are silently ignored by the transcript builder. |
| Throws at runtime | Caught per-line. That line falls back to generic. Parser stays registered for future lines. |
| 404 (no ui-parser export) | Type added to failed-loads set. Generic parser from first call onward. |
| Contract version mismatch | Server logs warning, skips loading. Generic parser used. |
Building
# Compile TypeScript to JavaScript
tsc src/ui-parser.ts --outDir dist --target ES2020 --module ES2020 --declaration falseYour tsconfig.json can handle this automatically — just make sure ui-parser.ts is included in the build and outputs to dist/ui-parser.js.
Testing
Test your parser locally by running it against sample stdout:
// test-parser.ts
import { createStdoutParser } from "./dist/ui-parser.js";
const parser = createStdoutParser();
const sampleLines = [
"[my-agent] Starting session abc123",
"Thinking about the task...",
"$ ls /home/user/project",
"[done] $ ls — /src /README.md 0.3s",
"I'll read the README now.",
"Error: file not found",
];
for (const line of sampleLines) {
const entries = parser.parseLine(line, new Date().toISOString());
for (const entry of entries) {
console.log(` ${entry.kind}:`, entry.text ?? entry.name ?? entry.content);
}
}Run with: npx tsx test-parser.ts
Skipping the UI Parser
If your adapter’s stdout is simple (no tool markers, no special formatting), you can skip the UI parser entirely. The generic process parser will handle it — every non-system line becomes assistant output. This is fine for:
- Agents that output plain text responses
- Custom scripts that just print results
- Simple CLIs without structured output
To skip it, simply don’t include exports["./ui-parser"] in your package.json.
Next Steps
- External Adapters — full guide to building adapter packages
- Creating an Adapter — adapter internals and built-in integration