mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 05:11:49 +01:00
fix: fail closed on unusable Codex auth during sandbox auth merge
When reusing a Codex sandbox, the workspace-restore step now performs an auth-merge check before adopting the sandbox's auth.json. The merge fails closed when: - host or sandbox Codex auth is unusable (missing token or account) - the subscription payload shapes differ between host and sandbox - subscription account identities do not match Additional hardening: node absence on PATH during the embedded decision script now exits non-zero instead of silently falling through. Co-change comments link parseAuth (the embedded script) to hasUsableAuthPayload (codex-home.ts) so a future auth-format change cannot silently diverge between the two implementations. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
commit
98df214917
3422 files changed
+1614966
No files matched your search
@@ -0,0 +1,287 @@
|
||||
---
|
||||
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
|
||||
@@ -0,0 +1,115 @@
|
||||
---
|
||||
title: Claude Code
|
||||
summary: Claude Code local adapter setup and configuration
|
||||
---
|
||||
|
||||
The `claude_local` adapter runs Anthropic's Claude Code CLI locally. It supports session persistence, skills injection, and structured output parsing.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Claude Code CLI installed (`claude` command available)
|
||||
- Either `ANTHROPIC_API_KEY` in adapter env/host env, or a Claude Code
|
||||
subscription login available to the execution target
|
||||
|
||||
## Configuration Fields
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `cwd` | string | Yes | Working directory for the agent process (absolute path; created automatically if missing when permissions allow) |
|
||||
| `model` | string | No | Claude model to use (e.g. `claude-opus-4-6`) |
|
||||
| `promptTemplate` | string | No | Prompt used for all runs |
|
||||
| `env` | object | No | Environment variables (supports secret refs) |
|
||||
| `timeoutSec` | number | No | Process timeout (0 = no timeout) |
|
||||
| `graceSec` | number | No | Grace period before force-kill |
|
||||
| `maxTurnsPerRun` | number | No | Max agentic turns per heartbeat (defaults to `300`) |
|
||||
| `dangerouslySkipPermissions` | boolean | No | Skip permission prompts (default: `true`); required for headless runs where interactive approval is impossible |
|
||||
|
||||
## Prompt Templates
|
||||
|
||||
Templates support `{{variable}}` substitution:
|
||||
|
||||
| Variable | Value |
|
||||
|----------|-------|
|
||||
| `{{agentId}}` | Agent's ID |
|
||||
| `{{companyId}}` | Company ID |
|
||||
| `{{runId}}` | Current run ID |
|
||||
| `{{agent.name}}` | Agent's name |
|
||||
| `{{company.name}}` | Company name |
|
||||
|
||||
## Session Persistence
|
||||
|
||||
The adapter persists Claude Code session IDs between heartbeats. On the next wake, it resumes the existing conversation so the agent retains full context.
|
||||
|
||||
Session resume is cwd-aware: if the agent's working directory changed since the last run, a fresh session starts instead.
|
||||
|
||||
If resume fails with an unknown session error, the adapter automatically retries with a fresh session.
|
||||
|
||||
### Poisoned `previous_message_id` (recovery)
|
||||
|
||||
Symptom in logs / issue thread:
|
||||
|
||||
```
|
||||
API Error: 400 diagnostics.previous_message_id: must be the `id` from a prior /v1/messages response (starts with `msg_`)
|
||||
```
|
||||
|
||||
What it means: the on-disk Claude Code transcript JSONL for that session contains a malformed (non-`msg_`-prefixed) `previous_message_id`. Anthropic's `/v1/messages` rejects every resume attempt against that transcript with a deterministic 400. Without guards, Paperclip would re-persist the same poisoned session id and the issue is stranded permanently — see [RED-976](../../../) / [RED-978](../../../).
|
||||
|
||||
What the adapter does automatically:
|
||||
|
||||
1. **Auto-rotate on resume.** If a `--resume` attempt returns this 400, the adapter retries once with a fresh session, deletes the poisoned `<session>.jsonl` from the local Claude config dir (best effort), and uses the fresh session id going forward.
|
||||
2. **Validate-before-persist.** A result that carries this 400 never gets its `session_id` written back to the task session store, even if Claude Code emits one in the result event. The adapter returns `sessionId: null`, `sessionParams: null`, and `errorCode: "claude_poisoned_previous_message_id"`.
|
||||
3. **Clear-on-error.** The adapter sets `clearSession: true` on the result, which causes the heartbeat service to drop any persisted session row for that issue (`clearTaskSessions`). The next continuation starts from a clean slate.
|
||||
|
||||
On-call checklist if you see this in production:
|
||||
|
||||
- Confirm `errorCode` is `claude_poisoned_previous_message_id` in the run row — that means the guards fired correctly and the issue auto-recovers on the next heartbeat.
|
||||
- If the same issue still loops after one heartbeat, check that `agentTaskSessions` for that `(agentId, taskKey)` was cleared. If not, the adapter return value was lost (e.g. a malformed run finalization) — escalate; do **not** manually edit the row, file a child issue with the run id.
|
||||
- For remote execution targets (sandbox/SSH), the poisoned JSONL is on the remote and the adapter only logs the cleanup intent. The fresh-session retry still succeeds because it uses a new session id, and the server-side `clearSession: true` is authoritative regardless of remote disk state.
|
||||
|
||||
## Skills Injection
|
||||
|
||||
The adapter creates a temporary directory with symlinks to Paperclip skills and passes it via `--add-dir`. This makes skills discoverable without polluting the agent's working directory.
|
||||
|
||||
## Remote credential ownership
|
||||
|
||||
`claude_local` uses a snapshot-owns-auth topology for managed sandbox execution
|
||||
targets. When the run uses a sandbox execution target and no explicit
|
||||
`CLAUDE_CONFIG_DIR` is configured, Paperclip creates a remote
|
||||
`CLAUDE_CONFIG_DIR` under the run's Claude runtime directory. It uploads
|
||||
sanitized host-side settings such as `settings.json` and `CLAUDE.md`, but the
|
||||
managed seed does not upload host Claude credential files.
|
||||
|
||||
After the seed is copied, the remote materialization command checks the
|
||||
execution target's own `$HOME/.claude` directory. For each missing credential
|
||||
file, it copies `.credentials.json` or `credentials.json` from that remote home
|
||||
into the managed `CLAUDE_CONFIG_DIR`. That means credentials baked into the
|
||||
sandbox image win for managed remote Claude runs.
|
||||
|
||||
Worked example: a sandbox image contains `$HOME/.claude/.credentials.json` from
|
||||
its own Claude Code login. Paperclip starts a managed remote `claude_local` run,
|
||||
uploads only the sanitized config seed, and sets `CLAUDE_CONFIG_DIR` to the
|
||||
remote runtime config path. Because the managed config has no credential file,
|
||||
the adapter copies the sandbox image's `$HOME/.claude/.credentials.json` into
|
||||
that path before invoking Claude. The sandbox snapshot owns the credential for
|
||||
the run.
|
||||
|
||||
This differs from [`codex_local`](/adapters/codex-local), where a
|
||||
Paperclip-managed sandbox run uploads a host-owned `CODEX_HOME/auth.json` and
|
||||
therefore shadows any Codex login already present inside the sandbox image.
|
||||
|
||||
For manual local CLI usage outside heartbeat runs (for example running as `claudecoder` directly), use:
|
||||
|
||||
```sh
|
||||
pnpm paperclipai agent local-cli claudecoder --company-id <company-id>
|
||||
```
|
||||
|
||||
This installs Paperclip skills in `~/.claude/skills`, creates an agent API key, and prints shell exports to run as that agent.
|
||||
|
||||
## Environment Test
|
||||
|
||||
Use the "Test Environment" button in the UI to validate the adapter config. It checks:
|
||||
|
||||
- Claude CLI is installed and accessible
|
||||
- Working directory is absolute and available (auto-created if missing and permitted)
|
||||
- API key/auth mode hints (`ANTHROPIC_API_KEY` vs subscription login)
|
||||
- A live hello probe (`claude --print - --output-format stream-json --verbose` with prompt `Respond with hello.`) to verify CLI readiness
|
||||
@@ -0,0 +1,162 @@
|
||||
---
|
||||
title: Codex
|
||||
summary: OpenAI Codex local adapter setup and configuration
|
||||
---
|
||||
|
||||
The `codex_local` adapter runs OpenAI's Codex CLI locally. It supports session persistence via `previous_response_id` chaining and skills injection through the global Codex skills directory.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Codex CLI installed (`codex` command available)
|
||||
- Either a host Codex login with `~/.codex/auth.json`, or a per-agent
|
||||
`OPENAI_API_KEY` configured in adapter env (Paperclip materializes this into
|
||||
`$CODEX_HOME/auth.json` for managed homes; Codex CLI reads credentials from
|
||||
`auth.json`, not directly from the process environment — for a self-managed
|
||||
external `CODEX_HOME`, write `auth.json` there directly instead)
|
||||
|
||||
## Configuration Fields
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `cwd` | string | Yes | Working directory for the agent process (absolute path; created automatically if missing when permissions allow) |
|
||||
| `model` | string | No | Model to use |
|
||||
| `promptTemplate` | string | No | Prompt used for all runs |
|
||||
| `env` | object | No | Environment variables (supports secret refs) |
|
||||
| `timeoutSec` | number | No | Process timeout (0 = no timeout) |
|
||||
| `graceSec` | number | No | Grace period before force-kill |
|
||||
| `fastMode` | boolean | No | Enables Codex Fast mode. Currently supported on `gpt-5.4` only and burns credits faster |
|
||||
| `dangerouslyBypassApprovalsAndSandbox` | boolean | No | Skip safety checks (dev only) |
|
||||
|
||||
## Session Persistence
|
||||
|
||||
Codex uses `previous_response_id` for session continuity. The adapter serializes and restores this across heartbeats, allowing the agent to maintain conversation context.
|
||||
|
||||
## Skills Injection
|
||||
|
||||
The adapter symlinks Paperclip skills into the global Codex skills directory (`~/.codex/skills`). Existing user skills are not overwritten.
|
||||
|
||||
## Fast Mode
|
||||
|
||||
When `fastMode` is enabled, Paperclip adds Codex config overrides equivalent to:
|
||||
|
||||
```sh
|
||||
-c 'service_tier="fast"' -c 'features.fast_mode=true'
|
||||
```
|
||||
|
||||
Paperclip currently applies that only when the selected model is `gpt-5.4`. On other models, the toggle is preserved in config but ignored at execution time to avoid unsupported runs.
|
||||
|
||||
## Managed `CODEX_HOME`
|
||||
|
||||
When Paperclip is running inside a managed worktree instance (`PAPERCLIP_IN_WORKTREE=true`), the adapter instead uses a worktree-isolated `CODEX_HOME` under the Paperclip instance so Codex skills, sessions, logs, and other runtime state do not leak across checkouts. It seeds that isolated home from the user's main Codex home for shared auth/config continuity.
|
||||
|
||||
### Per-agent isolation and auth seeding
|
||||
|
||||
For `codex_local` agents the server isolation guard pins each agent to a per-agent home (`<instance>/companies/<companyId>/agents/<agentId>/codex-home`) and sets `OPENAI_API_KEY=""` so an agent can never spend against the host API key or share another agent's Codex state.
|
||||
|
||||
A managed home is created empty, so the adapter must provision auth into it before launching Codex — otherwise the agent runs with zero credentials and the provider returns `401 Missing bearer`. The seeding contract:
|
||||
|
||||
- **Managed homes** (the default home and any configured `CODEX_HOME` under the company tree) are always seeded: the ChatGPT-subscription `auth.json` is symlinked from the host Codex home, or, when a per-agent `OPENAI_API_KEY` is configured, an API-key `auth.json` is written instead.
|
||||
- **Genuine external overrides** (a `CODEX_HOME` outside the Paperclip-managed company tree) are treated as self-managed and are never seeded or overwritten.
|
||||
- **Fail-fast guard:** if a managed home ends up with no usable `auth.json` and no configured API key, the run fails with an explicit `adapter_failed` ("no Codex credentials provisioned for managed home …") rather than emitting an unauthenticated request.
|
||||
|
||||
### Auth ownership and precedence
|
||||
|
||||
`codex_local` is host-owns-auth when Paperclip owns the effective
|
||||
`CODEX_HOME`. The winning credential file is:
|
||||
|
||||
1. **Per-agent API key:** when adapter env contains a non-empty
|
||||
`OPENAI_API_KEY`, Paperclip writes `$CODEX_HOME/auth.json` with only
|
||||
`{ "OPENAI_API_KEY": "..." }`. This overwrites any existing file or symlink
|
||||
at that path. Codex CLI versions that Paperclip supports read the key from
|
||||
`auth.json`, not directly from the process environment.
|
||||
2. **Host ChatGPT-subscription login:** when no per-agent key is configured,
|
||||
Paperclip symlinks `auth.json` from the shared host Codex home into the
|
||||
managed home. The symlink keeps rotating/single-use refresh tokens live
|
||||
instead of copying a stale token into the managed home.
|
||||
3. **External `CODEX_HOME`:** if adapter env points `CODEX_HOME` outside the
|
||||
Paperclip-managed company tree, that home is self-managed. Paperclip does
|
||||
not seed or overwrite it, so its own `auth.json` wins.
|
||||
|
||||
For sandbox or SSH execution, Paperclip uploads the effective managed
|
||||
`CODEX_HOME` and launches Codex with `CODEX_HOME` pointing at that uploaded
|
||||
directory. Any `auth.json` already baked into the sandbox image is shadowed in
|
||||
managed-home mode. If the host has no usable `auth.json` and no per-agent
|
||||
`OPENAI_API_KEY`, the managed run fails fast instead of falling back to an
|
||||
in-sandbox login.
|
||||
|
||||
Worked example: a worker runs in a sandbox image that already has
|
||||
`$HOME/.codex/auth.json`, and the Paperclip host is logged in with a ChatGPT
|
||||
subscription. For a managed `codex_local` agent, Paperclip symlinks the host
|
||||
`auth.json` into the agent's managed home, uploads that home to the sandbox,
|
||||
and sets `CODEX_HOME` to the uploaded path. Codex reads the host-owned
|
||||
uploaded file, so the sandbox image login does not win.
|
||||
|
||||
For high-concurrency sandbox fleets, prefer a per-agent `OPENAI_API_KEY` over a
|
||||
shared ChatGPT-subscription login. API-key mode produces a standalone
|
||||
`auth.json` for each managed home and avoids many concurrent sandboxes sharing
|
||||
one rotating subscription credential. The tradeoff is billing: API-key mode is
|
||||
metered per token through the OpenAI API, while ChatGPT-subscription auth uses
|
||||
the subscription's flat-plan economics and quota behavior. Pick the mode
|
||||
deliberately for the fleet's cost and concurrency profile.
|
||||
|
||||
### Deferred config-validation warning spec
|
||||
|
||||
This section specifies a warning that is not implemented yet. The warning should
|
||||
help operators notice the host-owns-auth topology before they run a sandbox
|
||||
fleet with ChatGPT-subscription credentials.
|
||||
|
||||
- **Source fields:** resolved Codex auth mode (`api` vs `subscription`) and
|
||||
execution target kind (`local`, `remote:ssh`, or `remote:sandbox`). Infer the
|
||||
auth mode from the final managed `$CODEX_HOME/auth.json` shape after seeding:
|
||||
`{ "OPENAI_API_KEY": ... }` means API-key mode; subscription-token-shaped
|
||||
host auth, including the symlinked host file, means ChatGPT-subscription
|
||||
mode. Use top-level shape only; do not read credential values into the
|
||||
warning. The target kind comes from `AdapterExecutionTarget`.
|
||||
- **Transformations:** during config preparation or home seeding for a run,
|
||||
classify `(subscription auth mode AND remote/sandbox execution target)` as a
|
||||
warning condition. This is classification only. Do not read credential values
|
||||
into the warning.
|
||||
- **Sink fields:** emit a warning log line through `onLog("stderr", ...)` and,
|
||||
when the config-validation surface supports it, a surfaced validation warning.
|
||||
The sink may include only the auth-mode label and target type.
|
||||
- **Retention:** warning text may appear in ephemeral run logs or validation
|
||||
results. Do not persist auth material.
|
||||
- **Attacker-observable IDs:** none new. The warning must not print token
|
||||
values, email addresses, or `auth.json` contents.
|
||||
- **Hook point:** wire the check at the `codex_local` execute/config-prep path
|
||||
around managed-home seeding: `execute.ts` already has `executionTarget` /
|
||||
target transport in scope, calls `seedManagedCodexHome`, and calls
|
||||
`evaluateCodexCredentialReadiness` before uploading `CODEX_HOME`. If the
|
||||
warning is factored into `codex-home.ts`, pass the resolved target
|
||||
classification in rather than making `codex-home.ts` inspect execution
|
||||
targets on its own.
|
||||
|
||||
Because the warning touches authentication behavior, implementation must go
|
||||
through the security-review gate. Treat this docs section as the follow-up
|
||||
implementation spec, not as authorization to add the warning in a docs-only
|
||||
change.
|
||||
|
||||
## Manual Local CLI
|
||||
|
||||
For manual local CLI usage outside heartbeat runs (for example running as `codexcoder` directly), use:
|
||||
|
||||
```sh
|
||||
pnpm paperclipai agent local-cli codexcoder --company-id <company-id>
|
||||
```
|
||||
|
||||
This installs any missing skills, creates an agent API key, and prints shell exports to run as that agent.
|
||||
|
||||
## Instructions Resolution
|
||||
|
||||
If `instructionsFilePath` is configured, Paperclip reads that file and prepends it to the stdin prompt sent to `codex exec` on every run.
|
||||
|
||||
This is separate from any workspace-level instruction discovery that Codex itself performs in the run `cwd`. Paperclip does not disable Codex-native repo instruction files, so a repo-local `AGENTS.md` may still be loaded by Codex in addition to the Paperclip-managed agent instructions.
|
||||
|
||||
## Environment Test
|
||||
|
||||
The environment test checks:
|
||||
|
||||
- Codex CLI is installed and accessible
|
||||
- Working directory is absolute and available (auto-created if missing and permitted)
|
||||
- Authentication signal (`OPENAI_API_KEY` presence)
|
||||
- A live hello probe (`codex exec --json -` with prompt `Respond with hello.`) to verify the CLI can actually run
|
||||
@@ -0,0 +1,281 @@
|
||||
---
|
||||
title: Creating an Adapter
|
||||
summary: Guide to building a custom adapter
|
||||
---
|
||||
|
||||
Build a custom adapter to connect Paperclip to any agent runtime.
|
||||
|
||||
<Tip>
|
||||
If you're using Claude Code, the `.agents/skills/create-agent-adapter` skill can guide you through the full adapter creation process interactively. Just ask Claude to create a new adapter and it will walk you through each step.
|
||||
</Tip>
|
||||
|
||||
## Two Paths
|
||||
|
||||
| | Built-in | External Plugin |
|
||||
|---|---|---|
|
||||
| Source | Inside `paperclip-fork` | Separate npm package |
|
||||
| Distribution | Ships with Paperclip | Independent npm publish |
|
||||
| UI parser | Static import | Dynamic load from API |
|
||||
| Registration | Edit 3 registries | Auto-loaded at startup |
|
||||
| Best for | Core adapters, contributors | Third-party adapters, internal tools |
|
||||
|
||||
For most cases, **build an external adapter plugin**. It's cleaner, independently versioned, and doesn't require modifying Paperclip's source. See [External Adapters](/adapters/external-adapters) for the full guide.
|
||||
|
||||
The rest of this page covers the shared internals that both paths use.
|
||||
|
||||
## Package Structure
|
||||
|
||||
```
|
||||
packages/adapters/<name>/ # built-in
|
||||
── or ──
|
||||
my-adapter/ # external plugin
|
||||
package.json
|
||||
tsconfig.json
|
||||
src/
|
||||
index.ts # Shared metadata
|
||||
server/
|
||||
index.ts # Server exports (createServerAdapter)
|
||||
execute.ts # Core execution logic
|
||||
parse.ts # Output parsing
|
||||
test.ts # Environment diagnostics
|
||||
ui/
|
||||
index.ts # UI exports (built-in only)
|
||||
parse-stdout.ts # Transcript parser (built-in only)
|
||||
build-config.ts # Config builder
|
||||
ui-parser.ts # Self-contained UI parser (external — see [UI Parser Contract](/adapters/adapter-ui-parser))
|
||||
cli/
|
||||
index.ts # CLI exports
|
||||
format-event.ts # Terminal formatter
|
||||
```
|
||||
|
||||
## Step 1: Root Metadata
|
||||
|
||||
`src/index.ts` is imported by all three consumers. Keep it dependency-free.
|
||||
|
||||
```ts
|
||||
export const type = "my_agent"; // snake_case, globally unique
|
||||
export const label = "My Agent";
|
||||
export const models = [
|
||||
{ id: "model-a", label: "Model A" },
|
||||
];
|
||||
export const agentConfigurationDoc = `# my_agent configuration
|
||||
Use when: ...
|
||||
Don't use when: ...
|
||||
Core fields: ...
|
||||
`;
|
||||
|
||||
// Required for external adapters (plugin-loader convention)
|
||||
export { createServerAdapter } from "./server/index.js";
|
||||
```
|
||||
|
||||
## Step 2: Server Execute
|
||||
|
||||
`src/server/execute.ts` is the core. It receives an `AdapterExecutionContext` and returns an `AdapterExecutionResult`.
|
||||
|
||||
Key responsibilities:
|
||||
|
||||
1. Read config using safe helpers (`asString`, `asNumber`, etc.) from `@paperclipai/adapter-utils/server-utils`
|
||||
2. Build environment with `buildPaperclipEnv(agent)` plus context vars
|
||||
3. Resolve session state from `runtime.sessionParams`
|
||||
4. Render prompt with `renderTemplate(template, data)`
|
||||
5. Spawn the process with `runChildProcess()` or call via `fetch()`
|
||||
6. Parse output for usage, costs, session state, errors
|
||||
7. Handle unknown session errors (retry fresh, set `clearSession: true`)
|
||||
|
||||
### Available Helpers
|
||||
|
||||
| Helper | Source | Purpose |
|
||||
|--------|--------|---------|
|
||||
| `runChildProcess(cmd, opts)` | `@paperclipai/adapter-utils/server-utils` | Spawn with timeout, grace, streaming |
|
||||
| `buildPaperclipEnv(agent)` | `@paperclipai/adapter-utils/server-utils` | Inject `PAPERCLIP_*` env vars |
|
||||
| `renderTemplate(tpl, data)` | `@paperclipai/adapter-utils/server-utils` | `{{variable}}` substitution |
|
||||
| `asString(v)` | `@paperclipai/adapter-utils` | Safe config value extraction |
|
||||
| `asNumber(v)` | `@paperclipai/adapter-utils` | Safe number extraction |
|
||||
|
||||
### AdapterExecutionContext
|
||||
|
||||
```ts
|
||||
interface AdapterExecutionContext {
|
||||
runId: string;
|
||||
agent: { id: string; companyId: string; name: string; adapterConfig: unknown };
|
||||
runtime: { sessionId: string | null; sessionParams: Record<string, unknown> | null };
|
||||
config: Record<string, unknown>; // agent's adapterConfig
|
||||
context: Record<string, unknown>; // task, wake reason, etc.
|
||||
onLog: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
||||
onMeta?: (meta: AdapterInvocationMeta) => Promise<void>;
|
||||
onSpawn?: (meta: { pid: number; startedAt: string }) => Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
### AdapterExecutionResult
|
||||
|
||||
```ts
|
||||
interface AdapterExecutionResult {
|
||||
exitCode: number | null;
|
||||
signal: string | null;
|
||||
timedOut: boolean;
|
||||
errorMessage?: string | null;
|
||||
usage?: { inputTokens: number; outputTokens: number };
|
||||
sessionParams?: Record<string, unknown> | null; // persist across heartbeats
|
||||
sessionDisplayId?: string | null;
|
||||
provider?: string | null;
|
||||
model?: string | null;
|
||||
costUsd?: number | null;
|
||||
clearSession?: boolean; // set true to force fresh session on next wake
|
||||
}
|
||||
```
|
||||
|
||||
## Step 3: Environment Test
|
||||
|
||||
`src/server/test.ts` validates the adapter config before running.
|
||||
|
||||
Return structured diagnostics:
|
||||
|
||||
| Level | Meaning | Effect |
|
||||
|-------|---------|--------|
|
||||
| `error` | Invalid or unusable setup | Blocks execution |
|
||||
| `warn` | Non-blocking issue | Shown with yellow indicator |
|
||||
| `info` | Successful check | Shown in test results |
|
||||
|
||||
```ts
|
||||
export async function testEnvironment(
|
||||
ctx: AdapterEnvironmentTestContext,
|
||||
): Promise<AdapterEnvironmentTestResult> {
|
||||
return {
|
||||
adapterType: ctx.adapterType,
|
||||
status: "pass", // "pass" | "warn" | "fail"
|
||||
checks: [
|
||||
{ level: "info", message: "CLI v1.2.0 detected", code: "cli_detected" },
|
||||
{ level: "warn", message: "No API key found", hint: "Set ANTHROPIC_API_KEY", code: "no_key" },
|
||||
],
|
||||
testedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Step 4: UI Module (Built-in Only)
|
||||
|
||||
For built-in adapters registered in Paperclip's source:
|
||||
|
||||
- `parse-stdout.ts` — converts stdout lines to `TranscriptEntry[]` for the run viewer
|
||||
- `build-config.ts` — converts form values to `adapterConfig` JSON
|
||||
- Config fields React component in `ui/src/adapters/<name>/config-fields.tsx`
|
||||
|
||||
For external adapters, use a self-contained `ui-parser.ts` instead. See the [UI Parser Contract](/adapters/adapter-ui-parser).
|
||||
|
||||
## Step 5: CLI Module
|
||||
|
||||
`format-event.ts` — pretty-prints stdout for `paperclipai run --watch` using `picocolors`.
|
||||
|
||||
```ts
|
||||
export function formatStdoutEvent(line: string, debug: boolean): void {
|
||||
if (line.startsWith("[tool-done]")) {
|
||||
console.log(chalk.green(` ✓ ${line}`));
|
||||
} else {
|
||||
console.log(` ${line}`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Step 6: Register (Built-in Only)
|
||||
|
||||
Add the adapter to all three registries:
|
||||
|
||||
1. `server/src/adapters/registry.ts`
|
||||
2. `ui/src/adapters/registry.ts`
|
||||
3. `cli/src/adapters/registry.ts`
|
||||
|
||||
For external adapters, registration is automatic — the plugin loader handles it.
|
||||
|
||||
## Session Persistence
|
||||
|
||||
If your agent runtime supports conversation continuity across heartbeats:
|
||||
|
||||
1. Return `sessionParams` from `execute()` (e.g., `{ sessionId: "abc123" }`)
|
||||
2. Read `runtime.sessionParams` on the next wake to resume
|
||||
3. Optionally implement a `sessionCodec` for validation and display
|
||||
|
||||
```ts
|
||||
export const sessionCodec: AdapterSessionCodec = {
|
||||
deserialize(raw) { /* validate raw session data */ },
|
||||
serialize(params) { /* serialize for storage */ },
|
||||
getDisplayId(params) { /* human-readable session label */ },
|
||||
};
|
||||
```
|
||||
|
||||
## Capability Flags
|
||||
|
||||
Adapters can declare what "local" capabilities they support by setting optional fields on the `ServerAdapterModule`. The server and UI use these flags to decide which features to enable for agents using the adapter (instructions bundle editor, skills sync, JWT auth, etc.).
|
||||
|
||||
| Flag | Type | Default | What it controls |
|
||||
|------|------|---------|------------------|
|
||||
| `supportsLocalAgentJwt` | `boolean` | `false` | Whether heartbeat generates a local JWT for the agent |
|
||||
| `supportsInstructionsBundle` | `boolean` | `false` | Managed instructions bundle (AGENTS.md) — server-side resolution + UI editor |
|
||||
| `instructionsPathKey` | `string` | `"instructionsFilePath"` | The `adapterConfig` key that holds the instructions file path |
|
||||
| `requiresMaterializedRuntimeSkills` | `boolean` | `false` | Whether runtime skill entries must be written to disk before execution |
|
||||
|
||||
These flags are exposed via `GET /api/adapters` in a `capabilities` object, along with a derived `supportsSkills` flag (true when `listSkills` or `syncSkills` is defined).
|
||||
|
||||
### Example
|
||||
|
||||
```ts
|
||||
export function createServerAdapter(): ServerAdapterModule {
|
||||
return {
|
||||
type: "my_k8s_adapter",
|
||||
execute: myExecute,
|
||||
testEnvironment: myTestEnvironment,
|
||||
listSkills: myListSkills,
|
||||
syncSkills: mySyncSkills,
|
||||
|
||||
// Capability flags
|
||||
supportsLocalAgentJwt: true,
|
||||
supportsInstructionsBundle: true,
|
||||
instructionsPathKey: "instructionsFilePath",
|
||||
requiresMaterializedRuntimeSkills: true,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
With these flags set, the Paperclip UI will automatically show the instructions bundle editor, skills management tab, and working directory field for agents using this adapter — no Paperclip source changes required.
|
||||
|
||||
If capability flags are not set, the server falls back to legacy hardcoded lists for built-in adapter types. External adapters that omit the flags will default to `false` for all capabilities.
|
||||
|
||||
## Skills Injection
|
||||
|
||||
Make Paperclip skills discoverable to your agent runtime without writing to the agent's working directory:
|
||||
|
||||
1. **Best: tmpdir + flag** — create tmpdir, symlink skills, pass via CLI flag, clean up after
|
||||
2. **Acceptable: global config dir** — symlink to the runtime's global plugins directory
|
||||
3. **Acceptable: env var** — point a skills path env var at the repo's `skills/` directory
|
||||
4. **Last resort: prompt injection** — include skill content in the prompt template
|
||||
|
||||
## Cross-run workspace persistence (no-remote-git contract)
|
||||
|
||||
The local execution-workspace cwd is the **only** persistence boundary across runs. No adapter may depend on a git remote for cross-run state.
|
||||
|
||||
The supported round-trip:
|
||||
|
||||
- **Per-run, on the remote side.** `prepareWorkspaceForSshExecution` (in `packages/adapter-utils/src/ssh.ts`) git-bundles the local worktree and ships it to the run's remote dir. No `git remote` is set anywhere; the bundle is the transport.
|
||||
- **End-of-run, in the adapter's `finally` block.** The adapter invokes `restoreRemoteWorkspace` (e.g. claude-local's `execute.ts`), which calls `restoreWorkspaceFromSshExecution` → `exportGitWorkspaceFromSsh` → `integrateImportedGitHead`. Remote commits made during the run land back in the local Mac worktree with no `git push` and no remote configured.
|
||||
|
||||
The invariant adapters must preserve:
|
||||
|
||||
- **Never `git push`** from adapter or runtime code. Operator-supplied configuration may opt in, but the default contract is no remote operations.
|
||||
- **Never assume a remote exists.** The local cwd is the source of truth between runs.
|
||||
- **Surface restore failures.** A failed sync-back must propagate as a run-level error, not a silent warning. The heartbeat records a `workspace_finalize` row (`succeeded`/`failed`) around `adapter.execute` so dependent issues do not wake on a stale worktree.
|
||||
|
||||
The invariant is pinned by the "no-remote-git contract" case in `packages/adapter-utils/src/ssh-fixture.test.ts`: it asserts `git remote` is empty before and after the round-trip and that a remote-only commit still lands locally via restore alone.
|
||||
|
||||
## Security
|
||||
|
||||
- Treat agent output as untrusted (parse defensively, never execute)
|
||||
- Inject secrets via environment variables, not prompts
|
||||
- Configure network access controls if the runtime supports them
|
||||
- Always enforce timeout and grace period
|
||||
- The UI parser module runs in a browser sandbox — zero runtime imports, no side effects
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [External Adapters](/adapters/external-adapters) — build a standalone adapter plugin
|
||||
- [UI Parser Contract](/adapters/adapter-ui-parser) — ship a custom run-log parser
|
||||
- [How Agents Work](/guides/agent-developer/how-agents-work) — the heartbeat lifecycle
|
||||
@@ -0,0 +1,408 @@
|
||||
---
|
||||
title: External Adapters
|
||||
summary: Build, package, and distribute adapters as plugins without modifying Paperclip source
|
||||
---
|
||||
|
||||
Paperclip supports external adapter plugins that can be installed from npm packages or local directories. External adapters work exactly like built-in adapters — they execute agents, parse output, and render transcripts — but they live in their own package and don't require changes to Paperclip's source code.
|
||||
|
||||
## Built-in vs External
|
||||
|
||||
| | Built-in | External |
|
||||
|---|---|---|
|
||||
| Source location | Inside `paperclip-fork/packages/adapters/` | Separate npm package or local directory |
|
||||
| Registration | Hardcoded in three registries | Loaded at startup via plugin system |
|
||||
| UI parser | Static import at build time | Dynamically loaded from API (see [UI Parser](/adapters/adapter-ui-parser)) |
|
||||
| Distribution | Ships with Paperclip | Published to npm or linked via `file:` |
|
||||
| Updates | Requires Paperclip release | Independent versioning |
|
||||
|
||||
### Built-in Hermes compatibility note
|
||||
|
||||
Hermes is built in with two stable adapter type keys:
|
||||
|
||||
- `hermes_local` starts the local Hermes CLI from
|
||||
`@paperclipai/hermes-paperclip-adapter`.
|
||||
- `hermes_gateway` calls an already-running Hermes API server through
|
||||
`@paperclipai/hermes-paperclip-adapter/gateway`.
|
||||
|
||||
The legacy `@paperclipai/adapter-hermes-gateway` package is a deprecated
|
||||
compatibility shim for one release. It preserves the old gateway exports while
|
||||
forwarding to the unified Hermes package. New external override packages should
|
||||
depend on or link `@paperclipai/hermes-paperclip-adapter` and declare the type
|
||||
they override (`hermes_local` or `hermes_gateway`); the type keys did not
|
||||
change.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Minimal Package Structure
|
||||
|
||||
```
|
||||
my-adapter/
|
||||
package.json
|
||||
tsconfig.json
|
||||
src/
|
||||
index.ts # Shared metadata (type, label, models)
|
||||
server/
|
||||
index.ts # createServerAdapter() factory
|
||||
execute.ts # Core execution logic
|
||||
parse.ts # Output parsing
|
||||
test.ts # Environment diagnostics
|
||||
ui-parser.ts # Self-contained UI transcript parser
|
||||
```
|
||||
|
||||
### package.json
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "my-paperclip-adapter",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"paperclip": {
|
||||
"adapterUiParser": "1.0.0"
|
||||
},
|
||||
"exports": {
|
||||
".": "./dist/index.js",
|
||||
"./server": "./dist/server/index.js",
|
||||
"./ui-parser": "./dist/ui-parser.js"
|
||||
},
|
||||
"files": ["dist"],
|
||||
"scripts": {
|
||||
"build": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@paperclipai/adapter-utils": "^2026.325.0",
|
||||
"picocolors": "^1.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Key fields:
|
||||
|
||||
| Field | Purpose |
|
||||
|-------|---------|
|
||||
| `exports["."]` | Entry point — must export `createServerAdapter` |
|
||||
| `exports["./ui-parser"]` | Self-contained UI parser module (optional but recommended) |
|
||||
| `paperclip.adapterUiParser` | Contract version for the UI parser (`"1.0.0"`) |
|
||||
| `files` | Limits what gets published — only `dist/` |
|
||||
|
||||
### tsconfig.json
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "Node16",
|
||||
"moduleResolution": "Node16",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"declaration": true,
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
```
|
||||
|
||||
## Server Module
|
||||
|
||||
The plugin loader calls `createServerAdapter()` from your package root. This function must return a `ServerAdapterModule`.
|
||||
|
||||
### src/index.ts
|
||||
|
||||
```ts
|
||||
export const type = "my_adapter"; // snake_case, globally unique
|
||||
export const label = "My Agent";
|
||||
|
||||
export const models = [
|
||||
{ id: "model-a", label: "Model A" },
|
||||
];
|
||||
|
||||
export const agentConfigurationDoc = `# my_adapter configuration
|
||||
Use when: ...
|
||||
Don't use when: ...
|
||||
`;
|
||||
|
||||
// Required by plugin-loader convention
|
||||
export { createServerAdapter } from "./server/index.js";
|
||||
```
|
||||
|
||||
### src/server/index.ts
|
||||
|
||||
```ts
|
||||
import type { ServerAdapterModule } from "@paperclipai/adapter-utils";
|
||||
import { type, models, agentConfigurationDoc } from "../index.js";
|
||||
import { execute } from "./execute.js";
|
||||
import { testEnvironment } from "./test.js";
|
||||
|
||||
export function createServerAdapter(): ServerAdapterModule {
|
||||
return {
|
||||
type,
|
||||
execute,
|
||||
testEnvironment,
|
||||
models,
|
||||
agentConfigurationDoc,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### src/server/execute.ts
|
||||
|
||||
The core execution function. Receives an `AdapterExecutionContext` and returns an `AdapterExecutionResult`.
|
||||
|
||||
```ts
|
||||
import type {
|
||||
AdapterExecutionContext,
|
||||
AdapterExecutionResult,
|
||||
} from "@paperclipai/adapter-utils";
|
||||
|
||||
import {
|
||||
runChildProcess,
|
||||
buildPaperclipEnv,
|
||||
renderTemplate,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
|
||||
export async function execute(
|
||||
ctx: AdapterExecutionContext,
|
||||
): Promise<AdapterExecutionResult> {
|
||||
const { config, agent, runtime, context, onLog, onMeta } = ctx;
|
||||
|
||||
// 1. Read config with safe helpers
|
||||
const cwd = String(config.cwd ?? "/tmp");
|
||||
const command = String(config.command ?? "my-agent");
|
||||
const timeoutSec = Number(config.timeoutSec ?? 300);
|
||||
|
||||
// 2. Build environment with Paperclip vars injected
|
||||
const env = buildPaperclipEnv(agent);
|
||||
|
||||
// 3. Render prompt template
|
||||
const prompt = config.promptTemplate
|
||||
? renderTemplate(String(config.promptTemplate), {
|
||||
agentId: agent.id,
|
||||
agentName: agent.name,
|
||||
companyId: agent.companyId,
|
||||
runId: ctx.runId,
|
||||
taskId: context.taskId ?? "",
|
||||
taskTitle: context.taskTitle ?? "",
|
||||
})
|
||||
: "Continue your work.";
|
||||
|
||||
// 4. Spawn process
|
||||
const result = await runChildProcess(command, {
|
||||
args: [prompt],
|
||||
cwd,
|
||||
env,
|
||||
timeout: timeoutSec * 1000,
|
||||
graceMs: 10_000,
|
||||
onStdout: (chunk) => onLog("stdout", chunk),
|
||||
onStderr: (chunk) => onLog("stderr", chunk),
|
||||
});
|
||||
|
||||
// 5. Return structured result
|
||||
return {
|
||||
exitCode: result.exitCode,
|
||||
timedOut: result.timedOut,
|
||||
// Include session state for persistence
|
||||
sessionParams: { /* ... */ },
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
#### Available Helpers from `@paperclipai/adapter-utils`
|
||||
|
||||
| Helper | Purpose |
|
||||
|--------|---------|
|
||||
| `runChildProcess(command, opts)` | Spawn a child process with timeout, grace period, and streaming callbacks |
|
||||
| `buildPaperclipEnv(agent)` | Inject `PAPERCLIP_*` environment variables |
|
||||
| `renderTemplate(template, data)` | `{{variable}}` substitution in prompt templates |
|
||||
| `asString(v)`, `asNumber(v)`, `asBoolean(v)` | Safe config value extraction |
|
||||
|
||||
### src/server/test.ts
|
||||
|
||||
Validates the adapter configuration before running. Returns structured diagnostics.
|
||||
|
||||
```ts
|
||||
import type {
|
||||
AdapterEnvironmentTestContext,
|
||||
AdapterEnvironmentTestResult,
|
||||
} from "@paperclipai/adapter-utils";
|
||||
|
||||
export async function testEnvironment(
|
||||
ctx: AdapterEnvironmentTestContext,
|
||||
): Promise<AdapterEnvironmentTestResult> {
|
||||
const checks = [];
|
||||
|
||||
// Example: check CLI is installed
|
||||
checks.push({
|
||||
level: "info",
|
||||
message: "My Agent CLI v1.2.0 detected",
|
||||
code: "cli_detected",
|
||||
});
|
||||
|
||||
// Example: check working directory
|
||||
const cwd = String(ctx.config.cwd ?? "");
|
||||
if (!cwd.startsWith("/")) {
|
||||
checks.push({
|
||||
level: "error",
|
||||
message: `Working directory must be absolute: "${cwd}"`,
|
||||
hint: "Use /home/user/project or /workspace",
|
||||
code: "invalid_cwd",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
adapterType: ctx.adapterType,
|
||||
status: checks.some(c => c.level === "error") ? "fail" : "pass",
|
||||
checks,
|
||||
testedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
Check levels:
|
||||
|
||||
| Level | Meaning | Effect |
|
||||
|-------|---------|--------|
|
||||
| `info` | Informational | Shown in test results |
|
||||
| `warn` | Non-blocking issue | Shown with yellow indicator |
|
||||
| `error` | Blocks execution | Prevents agent from running |
|
||||
|
||||
## Installation
|
||||
|
||||
### From npm
|
||||
|
||||
```sh
|
||||
# Via the Paperclip UI
|
||||
# Settings → Adapters → Install from npm → "my-paperclip-adapter"
|
||||
|
||||
# Or via API
|
||||
curl -X POST http://localhost:3102/api/adapters \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"packageName": "my-paperclip-adapter"}'
|
||||
```
|
||||
|
||||
### From local directory
|
||||
|
||||
```sh
|
||||
curl -X POST http://localhost:3102/api/adapters \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"localPath": "/home/user/my-adapter"}'
|
||||
```
|
||||
|
||||
Local adapters are symlinked into Paperclip's adapter directory. Changes to the source are picked up on server restart.
|
||||
|
||||
### Via adapter-plugins.json
|
||||
|
||||
For development, you can also edit `~/.paperclip/adapter-plugins.json` directly:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"packageName": "my-paperclip-adapter",
|
||||
"localPath": "/home/user/my-adapter",
|
||||
"type": "my_adapter",
|
||||
"installedAt": "2026-03-30T12:00:00.000Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## Optional: Session Persistence
|
||||
|
||||
If your agent runtime supports sessions (conversation continuity across heartbeats), implement a session codec:
|
||||
|
||||
```ts
|
||||
import type { AdapterSessionCodec } from "@paperclipai/adapter-utils";
|
||||
|
||||
export const sessionCodec: AdapterSessionCodec = {
|
||||
deserialize(raw) {
|
||||
if (typeof raw !== "object" || raw === null) return null;
|
||||
const r = raw as Record<string, unknown>;
|
||||
return r.sessionId ? { sessionId: String(r.sessionId) } : null;
|
||||
},
|
||||
serialize(params) {
|
||||
return params?.sessionId ? { sessionId: String(params.sessionId) } : null;
|
||||
},
|
||||
getDisplayId(params) {
|
||||
return params?.sessionId ? String(params.sessionId) : null;
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
Include it in `createServerAdapter()`:
|
||||
|
||||
```ts
|
||||
return { type, execute, testEnvironment, sessionCodec, /* ... */ };
|
||||
```
|
||||
|
||||
## Optional: Skills Sync
|
||||
|
||||
If your agent runtime supports skills/plugins, implement `listSkills` and `syncSkills`:
|
||||
|
||||
```ts
|
||||
return {
|
||||
type,
|
||||
execute,
|
||||
testEnvironment,
|
||||
async listSkills(ctx) {
|
||||
return {
|
||||
adapterType: ctx.adapterType,
|
||||
supported: true,
|
||||
mode: "ephemeral",
|
||||
desiredSkills: [],
|
||||
entries: [],
|
||||
warnings: [],
|
||||
};
|
||||
},
|
||||
async syncSkills(ctx, desiredSkills) {
|
||||
// Install desired skills into the runtime
|
||||
return { /* same shape as listSkills */ };
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## Optional: Model Detection
|
||||
|
||||
If your runtime has a local config file that specifies the default model:
|
||||
|
||||
```ts
|
||||
async function detectModel() {
|
||||
// Read ~/.my-agent/config.yaml or similar
|
||||
return {
|
||||
model: "anthropic/claude-sonnet-4",
|
||||
provider: "anthropic",
|
||||
source: "~/.my-agent/config.yaml",
|
||||
candidates: ["anthropic/claude-sonnet-4", "openai/gpt-4o"],
|
||||
};
|
||||
}
|
||||
|
||||
return { type, execute, testEnvironment, detectModel: () => detectModel() };
|
||||
```
|
||||
|
||||
## Publishing
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
npm publish
|
||||
```
|
||||
|
||||
Other Paperclip users can then install your adapter by package name from the UI or API.
|
||||
|
||||
## Security
|
||||
|
||||
- Treat agent output as untrusted — parse defensively, never `eval()` agent output
|
||||
- Inject secrets via environment variables, not in prompts
|
||||
- Configure network access controls if the runtime supports them
|
||||
- Always enforce timeout and grace period — don't let agents run forever
|
||||
- The UI parser module runs in a browser sandbox — it must have zero runtime imports and no side effects
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [UI Parser Contract](/adapters/adapter-ui-parser) — add a custom run-log parser so the UI renders your adapter's output correctly
|
||||
- [Creating an Adapter](/adapters/creating-an-adapter) — full walkthrough of adapter internals
|
||||
- [How Agents Work](/guides/agent-developer/how-agents-work) — understand the heartbeat lifecycle your adapter serves
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
title: Gemini CLI
|
||||
summary: Gemini CLI local adapter setup and configuration
|
||||
---
|
||||
|
||||
The `gemini_local` adapter runs Google's Gemini CLI locally. It supports session persistence with `--resume`, skills injection, and structured `stream-json` output parsing.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Gemini CLI installed (`gemini` command available)
|
||||
- `GEMINI_API_KEY` or `GOOGLE_API_KEY` set, or local Gemini CLI auth configured
|
||||
|
||||
## Configuration Fields
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `cwd` | string | Yes | Working directory for the agent process (absolute path; created automatically if missing when permissions allow) |
|
||||
| `model` | string | No | Gemini model to use. Defaults to `auto`. |
|
||||
| `promptTemplate` | string | No | Prompt used for all runs |
|
||||
| `instructionsFilePath` | string | No | Markdown instructions file prepended to the prompt |
|
||||
| `env` | object | No | Environment variables (supports secret refs) |
|
||||
| `timeoutSec` | number | No | Process timeout (0 = no timeout) |
|
||||
| `graceSec` | number | No | Grace period before force-kill |
|
||||
| `yolo` | boolean | No | Pass `--approval-mode yolo` for unattended operation |
|
||||
|
||||
## Session Persistence
|
||||
|
||||
The adapter persists Gemini session IDs between heartbeats. On the next wake, it resumes the existing conversation with `--resume` so the agent retains context.
|
||||
|
||||
Session resume is cwd-aware: if the working directory changed since the last run, a fresh session starts instead.
|
||||
|
||||
If resume fails with an unknown session error, the adapter automatically retries with a fresh session.
|
||||
|
||||
## Skills Injection
|
||||
|
||||
The adapter symlinks Paperclip skills into the Gemini global skills directory (`~/.gemini/skills`). Existing user skills are not overwritten.
|
||||
|
||||
## Environment Test
|
||||
|
||||
Use the "Test Environment" button in the UI to validate the adapter config. It checks:
|
||||
|
||||
- Gemini CLI is installed and accessible
|
||||
- Working directory is absolute and available (auto-created if missing and permitted)
|
||||
- API key/auth hints (`GEMINI_API_KEY` or `GOOGLE_API_KEY`)
|
||||
- A live hello probe (`gemini --output-format json "Respond with hello."`) to verify CLI readiness
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
title: HTTP Adapter
|
||||
summary: HTTP webhook adapter
|
||||
---
|
||||
|
||||
The `http` adapter sends a webhook request to an external agent service. The agent runs externally and Paperclip just triggers it.
|
||||
|
||||
## When to Use
|
||||
|
||||
- Agent runs as an external service (cloud function, dedicated server)
|
||||
- Fire-and-forget invocation model
|
||||
- Integration with third-party agent platforms
|
||||
|
||||
## When Not to Use
|
||||
|
||||
- If the agent runs locally on the same machine (use `process`, `claude_local`, or `codex_local`)
|
||||
- If you need stdout capture and real-time run viewing
|
||||
|
||||
## Configuration
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `url` | string | Yes | Webhook URL to POST to |
|
||||
| `headers` | object | No | Additional HTTP headers |
|
||||
| `timeoutSec` | number | No | Request timeout |
|
||||
|
||||
## How It Works
|
||||
|
||||
1. Paperclip sends a POST request to the configured URL
|
||||
2. The request body includes the execution context (agent ID, task info, wake reason)
|
||||
3. The external agent processes the request and calls back to the Paperclip API
|
||||
4. Response from the webhook is captured as the run result
|
||||
|
||||
## Request Body
|
||||
|
||||
The webhook receives a JSON payload with:
|
||||
|
||||
```json
|
||||
{
|
||||
"runId": "...",
|
||||
"agentId": "...",
|
||||
"companyId": "...",
|
||||
"context": {
|
||||
"taskId": "...",
|
||||
"wakeReason": "...",
|
||||
"commentId": "..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The external agent uses `PAPERCLIP_API_URL` and an API key to call back to Paperclip.
|
||||
@@ -0,0 +1,138 @@
|
||||
---
|
||||
title: Adapters Overview
|
||||
summary: What adapters are and how they connect agents to Paperclip
|
||||
---
|
||||
|
||||
Adapters are the bridge between Paperclip's orchestration layer and agent runtimes. Each adapter knows how to invoke a specific type of AI agent and capture its results.
|
||||
|
||||
## How Adapters Work
|
||||
|
||||
When a heartbeat fires, Paperclip:
|
||||
|
||||
1. Looks up the agent's `adapterType` and `adapterConfig`
|
||||
2. Calls the adapter's `execute()` function with the execution context
|
||||
3. The adapter spawns or calls the agent runtime
|
||||
4. The adapter captures stdout, parses usage/cost data, and returns a structured result
|
||||
|
||||
## Built-in Adapters
|
||||
|
||||
| Adapter | Type Key | Description |
|
||||
|---------|----------|-------------|
|
||||
| [Claude Code](/adapters/claude-local) | `claude_local` | Runs Claude Code CLI locally, with a native ACP engine when available |
|
||||
| [Codex](/adapters/codex-local) | `codex_local` | Runs OpenAI Codex CLI locally, with a native ACP engine when available |
|
||||
| [Gemini CLI](/adapters/gemini-local) | `gemini_local` | Runs Gemini CLI locally (experimental — adapter package exists, not yet in stable type enum) |
|
||||
| OpenCode | `opencode_local` | Runs OpenCode CLI locally (multi-provider `provider/model`) |
|
||||
| Cursor | `cursor` | Runs Cursor in background mode |
|
||||
| Pi | `pi_local` | Runs an embedded Pi agent locally |
|
||||
| Hermes | `hermes_local` | Runs the local Hermes CLI through `@paperclipai/hermes-paperclip-adapter` |
|
||||
| Hermes Gateway | `hermes_gateway` | Calls an already-running Hermes API server through `@paperclipai/hermes-paperclip-adapter/gateway` |
|
||||
| OpenClaw Gateway | `openclaw_gateway` | Connects to an OpenClaw gateway endpoint |
|
||||
| [Process](/adapters/process) | `process` | Executes arbitrary shell commands |
|
||||
| [HTTP](/adapters/http) | `http` | Sends webhooks to external agents |
|
||||
|
||||
## Credential ownership for sandbox targets
|
||||
|
||||
Local CLI adapters can run on the Paperclip host, SSH targets, or managed
|
||||
sandbox targets. The adapter decides which credential home is authoritative
|
||||
before the CLI starts:
|
||||
|
||||
| Adapter | Credential topology | Which credential file wins on managed sandbox targets |
|
||||
|---------|---------------------|-------------------------------------------------------|
|
||||
| [`codex_local`](/adapters/codex-local) | Host-owns-auth for Paperclip-managed `CODEX_HOME` | A host-owned `auth.json` is symlinked into the managed `CODEX_HOME` and uploaded to the sandbox. If a per-agent `OPENAI_API_KEY` is configured, Paperclip writes an API-key `auth.json` instead and that file wins. A login baked into the sandbox image is shadowed because Codex runs with Paperclip's uploaded `CODEX_HOME`. |
|
||||
| [`claude_local`](/adapters/claude-local) | Snapshot-owns-auth for managed remote Claude config | Paperclip uploads only sanitized settings and skill/runtime assets. When the remote managed config has no Claude credential files, it copies `.credentials.json` or `credentials.json` from the sandbox image's own `$HOME/.claude`, so the image's login wins. |
|
||||
|
||||
Worked examples:
|
||||
|
||||
- **Codex sandbox with host ChatGPT login:** the host `~/.codex/auth.json`
|
||||
is symlinked into the managed home, then uploaded as the sandbox
|
||||
`CODEX_HOME`. Codex reads that uploaded file and does not use any
|
||||
`auth.json` already present inside the sandbox image.
|
||||
- **Claude sandbox with image login:** Paperclip materializes a remote
|
||||
`CLAUDE_CONFIG_DIR`, then fills missing `.credentials.json` /
|
||||
`credentials.json` from the sandbox image's own `$HOME/.claude`. The
|
||||
snapshot's Claude login is the credential source for the run.
|
||||
|
||||
### Hermes local vs gateway
|
||||
|
||||
Use `hermes_local` when Paperclip should start the local `hermes` CLI on the
|
||||
same host for each heartbeat. Use `hermes_gateway` when Hermes is already
|
||||
running as an HTTP/SSE API server and Paperclip should call that server instead
|
||||
of spawning a process. Both type keys are stable built-ins.
|
||||
|
||||
The unified Hermes package owns both built-in adapters. The older
|
||||
`@paperclipai/adapter-hermes-gateway` package remains only as a deprecated
|
||||
compatibility shim that re-exports the gateway entrypoints for one release.
|
||||
New plugin overrides should target `@paperclipai/hermes-paperclip-adapter` and
|
||||
set the desired type key (`hermes_local` or `hermes_gateway`).
|
||||
|
||||
### External (plugin) adapters
|
||||
|
||||
These adapters ship as standalone npm packages and are installed via the plugin system:
|
||||
|
||||
| Adapter | Package | Type Key | Description |
|
||||
|---------|---------|----------|-------------|
|
||||
| Droid | `@henkey/droid-paperclip-adapter` | `droid_local` | Runs Factory Droid locally |
|
||||
|
||||
## External Adapters
|
||||
|
||||
You can build and distribute adapters as standalone packages — no changes to Paperclip's source code required. External adapters are loaded at startup via the plugin system.
|
||||
|
||||
```sh
|
||||
# Install from npm via API
|
||||
curl -X POST http://localhost:3102/api/adapters \
|
||||
-d '{"packageName": "my-paperclip-adapter"}'
|
||||
|
||||
# Or link from a local directory
|
||||
curl -X POST http://localhost:3102/api/adapters \
|
||||
-d '{"localPath": "/home/user/my-adapter"}'
|
||||
```
|
||||
|
||||
See [External Adapters](/adapters/external-adapters) for the full guide.
|
||||
|
||||
## Adapter Architecture
|
||||
|
||||
Each adapter is a package with modules consumed by three registries:
|
||||
|
||||
```
|
||||
my-adapter/
|
||||
src/
|
||||
index.ts # Shared metadata (type, label, models)
|
||||
server/
|
||||
execute.ts # Core execution logic
|
||||
parse.ts # Output parsing
|
||||
test.ts # Environment diagnostics
|
||||
ui-parser.ts # Self-contained UI transcript parser (for external adapters)
|
||||
cli/
|
||||
format-event.ts # Terminal output for `paperclipai run --watch`
|
||||
```
|
||||
|
||||
| Registry | What it does | Source |
|
||||
|----------|-------------|--------|
|
||||
| **Server** | Executes agents, captures results | `createServerAdapter()` from package root |
|
||||
| **UI** | Renders run transcripts, provides config forms | `ui-parser.js` (dynamic) or static import (built-in) |
|
||||
| **CLI** | Formats terminal output for live watching | Static import |
|
||||
|
||||
## Choosing an Adapter
|
||||
|
||||
- **Need a coding agent?** Use `claude_local`, `codex_local`, `opencode_local`, `hermes_local`, or install `droid_local` as an external plugin
|
||||
- **Need the richest live run feedback?** Use `claude_local`, `codex_local`, or `gemini_local` with `adapterConfig.engine` set to `acp` when the execution environment satisfies the ACP prerequisites — see [Feedback granularity](#feedback-granularity)
|
||||
- **Need Hermes on another host or already running as a service?** Use `hermes_gateway`
|
||||
- **Need to run a script or command?** Use `process`
|
||||
- **Need to call a custom external service?** Use `http`
|
||||
- **Need something custom?** [Create your own adapter](/adapters/creating-an-adapter) or [build an external adapter plugin](/adapters/external-adapters)
|
||||
|
||||
## Feedback Granularity
|
||||
|
||||
Adapter choice determines how much structured, live detail a run's transcript can show while the agent is still working. Every adapter's stdout is streamed to the run log and rendered live in the UI — including runs on sandbox execution targets, whose logs are tailed and delivered incrementally — but the *granularity* of what you see depends on the event stream the adapter emits.
|
||||
|
||||
Rough tiers, richest first:
|
||||
|
||||
1. **Native ACP engine (`claude_local`, `codex_local`, or `gemini_local` with `engine: "acp"`) — full structured event stream.** ACP emits a JSONL event per meaningful runtime moment: `acpx.session` (agent, mode, session identity), `acpx.status` (progress text plus context-window usage), `acpx.text_delta` (assistant/thinking token deltas), `acpx.tool_call` (tool title, call id, and status updates as the call progresses), `acpx.result` (stop reason summary), and `acpx.error` (code, message, retryability). The transcript renders these as live-updating message, thinking, tool, and status blocks, and repeated `acpx.tool_call` status updates fold into a single tool card instead of stacking duplicates.
|
||||
2. **CLI wrappers (`claude_local`, `codex_local`, `cursor`, `opencode_local`, …).** These parse each CLI's own streaming JSON output. You get assistant text, tool calls/results, and a final usage/cost summary, but granularity is limited to what the CLI prints — some emit tool progress, others only call/finish pairs.
|
||||
3. **Generic adapters (`process`, `http`).** Plain stdout/stderr lines with no structured transcript — you see raw output only.
|
||||
|
||||
**Recommendation:** use the native ACP engine on `claude_local`, `codex_local`, or `gemini_local` when the selected execution environment supports it. Rich ACP status events (including context usage) and incremental tool-call updates give the closest thing to watching the agent work locally.
|
||||
|
||||
## UI Parser Contract
|
||||
|
||||
External adapters can ship a self-contained UI parser that tells the Paperclip web UI how to render their stdout. Without it, the UI uses a generic shell parser. See the [UI Parser Contract](/adapters/adapter-ui-parser) for details.
|
||||
@@ -0,0 +1,50 @@
|
||||
---
|
||||
title: Process Adapter
|
||||
summary: Generic shell process adapter
|
||||
---
|
||||
|
||||
The `process` adapter executes arbitrary shell commands. Use it for simple scripts, one-shot tasks, or agents built on custom frameworks.
|
||||
|
||||
## When to Use
|
||||
|
||||
- Running a Python script that calls the Paperclip API
|
||||
- Executing a custom agent loop
|
||||
- Any runtime that can be invoked as a shell command
|
||||
|
||||
## When Not to Use
|
||||
|
||||
- If you need session persistence across runs (use `claude_local` or `codex_local`)
|
||||
- If the agent needs conversational context between heartbeats
|
||||
|
||||
## Configuration
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `command` | string | Yes | Shell command to execute |
|
||||
| `cwd` | string | No | Working directory |
|
||||
| `env` | object | No | Environment variables |
|
||||
| `timeoutSec` | number | No | Process timeout |
|
||||
|
||||
## How It Works
|
||||
|
||||
1. Paperclip spawns the configured command as a child process
|
||||
2. Standard Paperclip environment variables are injected (`PAPERCLIP_AGENT_ID`, `PAPERCLIP_API_KEY`, etc.)
|
||||
3. The process runs to completion
|
||||
4. Exit code determines success/failure
|
||||
|
||||
## Example
|
||||
|
||||
An agent that runs a Python script:
|
||||
|
||||
```json
|
||||
{
|
||||
"adapterType": "process",
|
||||
"adapterConfig": {
|
||||
"command": "python3 /path/to/agent.py",
|
||||
"cwd": "/path/to/workspace",
|
||||
"timeoutSec": 300
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The script can use the injected environment variables to authenticate with the Paperclip API and perform work.
|
||||
Reference in new issue
Block a user