File size: 11,024 Bytes
b152fd5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
---
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