Skip to main content
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:
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

  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

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.

2. exports["./ui-parser"] — file path

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.

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.
If both are exported, createStdoutParser takes priority.

Contract: TranscriptEntry

Each entry must match one of these discriminated union shapes:

Linking tool calls to results

Use toolUseId to pair tool_call and tool_result entries. The UI renders them as collapsible cards.

Error handling

Set isError: true on tool results to show a red indicator:

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

Error Behavior

Building

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:
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