Spaces:
Paused
Paused
| title: Adapter UI Parser Contract | |
| summary: Ship a custom run-log parser so the Paperclip UI renders your adapter's output correctly | |
| 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 | |
| ```json | |
| { | |
| "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 | |
| ```json | |
| { | |
| "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. | |
| ```ts | |
| 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. | |
| ```ts | |
| 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: | |
| ```ts | |
| // 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. | |
| ```ts | |
| 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: | |
| ```ts | |
| { 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 | |
| | 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 | |
| ```sh | |
| # 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: | |
| ```ts | |
| // 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](/adapters/external-adapters) β full guide to building adapter packages | |
| - [Creating an Adapter](/adapters/creating-an-adapter) β adapter internals and built-in integration | |