forked from farhoodlabs/paperclip
14d59da316
- Plugin loader: install/reload/remove/reinstall external adapters from npm packages or local directories - Plugin store persisted at ~/.paperclip/adapter-plugins.json - Self-healing UI parser resolution with version caching - UI: Adapter Manager page, dynamic loader, display registry with humanized names for unknown adapter types - Dev watch: exclude adapter-plugins dir from tsx watcher to prevent mid-request server restarts during reinstall - All consumer fallbacks use getAdapterLabel() for consistent display - AdapterTypeDropdown uses controlled open state for proper close behavior - Remove hermes-local from built-in UI (externalized to plugin) - Add docs for external adapters and UI parser contract
288 lines
11 KiB
Markdown
288 lines
11 KiB
Markdown
---
|
|
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
|