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/project as a tool call card (collapsed)
  • 0.3s duration as a tool result card
  • The 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)   │
└──────────────────┘                     └──────────────────┘
  1. Build time — You compile src/ui-parser.ts to dist/ui-parser.js (zero runtime imports)
  2. Server startup — Plugin loader reads the file and caches it in memory
  3. UI load — When the user opens a run, the UI fetches the parser from GET /api/:type/ui-parser.js
  4. 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 expectsAdapter declaresResult
1.x1.0.0Parser loaded
1.x2.0.0Warning 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

  1. Zero runtime imports. Your file is loaded via URL.createObjectURL + dynamic import() in the browser. No import, no require, no top-level await.

  2. No DOM / Node.js APIs. Runs in a browser sandbox. Use only vanilla JS (ES2020+).

  3. No side effects. Module-level code must not modify globals, access window, or perform I/O. Only declare and export functions.

  4. Deterministic. Given the same (line, ts) input, the same output must be produced. This matters for log replay.

  5. Error-tolerant. Never throw. Return [{ kind: "stdout", ts, text: line }] for any line you can’t parse, rather than crashing the transcript.

  6. File size. Keep under 50 KB. This is served per-request and eval’d in the browser.

Lifecycle

EventWhat happens
Server startsPlugin loader reads exports["./ui-parser"], reads the file, caches in memory
UI opens rungetUIAdapter(type) called. If no built-in parser, kicks off async fetch(/api/:type/ui-parser.js)
First lines arriveGeneric process parser handles them immediately (no blocking). Dynamic parser loads in background
Parser loadsregisterUIAdapter() 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 restartIn-memory cache is repopulated from adapter packages

Error Behavior

FailureWhat happens
Module syntax error (import fails)Caught, logged, falls back to generic parser. No retries.
Returns wrong shapeIndividual entries with missing fields are silently ignored by the transcript builder.
Throws at runtimeCaught 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 mismatchServer logs warning, skips loading. Generic parser used.

Building

# Compile TypeScript to JavaScript
tsc src/ui-parser.ts --outDir dist --target ES2020 --module ES2020 --declaration false

Your 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