2026-03-03 15:49:43 -06:00
---
title: Creating an Adapter
summary: Guide to building a custom adapter
---
Build a custom adapter to connect Paperclip to any agent runtime.
2026-03-04 08:11:06 -06:00
< Tip >
2026-03-13 08:49:11 -05:00
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.
2026-03-04 08:11:06 -06:00
< / Tip >
2026-03-31 20:21:13 +01:00
## 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.
2026-03-03 15:49:43 -06:00
## Package Structure
```
2026-03-31 20:21:13 +01:00
packages/adapters/< name > / # built-in
── or ──
my-adapter/ # external plugin
2026-03-03 15:49:43 -06:00
package.json
tsconfig.json
src/
index.ts # Shared metadata
server/
2026-03-31 20:21:13 +01:00
index.ts # Server exports (createServerAdapter)
2026-03-03 15:49:43 -06:00
execute.ts # Core execution logic
parse.ts # Output parsing
test.ts # Environment diagnostics
ui/
2026-03-31 20:21:13 +01:00
index.ts # UI exports (built-in only)
parse-stdout.ts # Transcript parser (built-in only)
2026-03-03 15:49:43 -06:00
build-config.ts # Config builder
2026-03-31 20:21:13 +01:00
ui-parser.ts # Self-contained UI parser (external — see [UI Parser Contract ](/adapters/adapter-ui-parser ))
2026-03-03 15:49:43 -06:00
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 (local)";
export const models = [
{ id: "model-a", label: "Model A" },
];
export const agentConfigurationDoc = `# my_agent configuration
Use when: ...
Don't use when: ...
Core fields: ...
`;
2026-03-31 20:21:13 +01:00
// Required for external adapters (plugin-loader convention)
export { createServerAdapter } from "./server/index.js";
2026-03-03 15:49:43 -06:00
```
## Step 2: Server Execute
`src/server/execute.ts` is the core. It receives an `AdapterExecutionContext` and returns an `AdapterExecutionResult` .
Key responsibilities:
2026-03-31 20:21:13 +01:00
1. Read config using safe helpers (`asString` , `asNumber` , etc.) from `@paperclipai/adapter-utils/server-utils`
2026-03-03 15:49:43 -06:00
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` )
2026-03-31 20:21:13 +01:00
### 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
}
```
2026-03-03 15:49:43 -06:00
## Step 3: Environment Test
`src/server/test.ts` validates the adapter config before running.
Return structured diagnostics:
2026-03-31 20:21:13 +01:00
| 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(),
};
}
```
2026-03-03 15:49:43 -06:00
2026-03-31 20:21:13 +01:00
## Step 4: UI Module (Built-in Only)
For built-in adapters registered in Paperclip's source:
2026-03-03 15:49:43 -06:00
- `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`
2026-03-31 20:21:13 +01:00
For external adapters, use a self-contained `ui-parser.ts` instead. See the [UI Parser Contract ](/adapters/adapter-ui-parser ).
2026-03-03 15:49:43 -06:00
## Step 5: CLI Module
`format-event.ts` — pretty-prints stdout for `paperclipai run --watch` using `picocolors` .
2026-03-31 20:21:13 +01:00
```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)
2026-03-03 15:49:43 -06:00
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`
2026-03-31 20:21:13 +01:00
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 */ },
};
```
feat(adapters): add capability flags to ServerAdapterModule (#3540)
## Thinking Path
> - Paperclip orchestrates AI agents via adapters (`claude_local`,
`codex_local`, etc.)
> - Each adapter type has different capabilities — instructions bundles,
skill materialization, local JWT — but these were gated by 5 hardcoded
type lists scattered across server routes and UI components
> - External adapter plugins (e.g. a future `opencode_k8s`) cannot add
themselves to those hardcoded lists without patching Paperclip source
> - The existing `supportsLocalAgentJwt` field on `ServerAdapterModule`
proves the right pattern already exists; it just wasn't applied to the
other capability gates
> - This pull request replaces the 4 remaining hardcoded lists with
declarative capability flags on `ServerAdapterModule`, exposed through
the adapter listing API
> - The benefit is that external adapter plugins can now declare their
own capabilities without any changes to Paperclip source code
## What Changed
- **`packages/adapter-utils/src/types.ts`** — added optional capability
fields to `ServerAdapterModule`: `supportsInstructionsBundle`,
`instructionsPathKey`, `requiresMaterializedRuntimeSkills`
- **`server/src/routes/agents.ts`** — replaced
`DEFAULT_MANAGED_INSTRUCTIONS_ADAPTER_TYPES` and
`ADAPTERS_REQUIRING_MATERIALIZED_RUNTIME_SKILLS` hardcoded sets with
capability-aware helper functions that fall back to the legacy sets for
adapters that don't set flags
- **`server/src/routes/adapters.ts`** — `GET /api/adapters` now includes
a `capabilities` object per adapter (all four flags + derived
`supportsSkills`)
- **`server/src/adapters/registry.ts`** — all built-in adapters
(`claude_local`, `codex_local`, `process`, `cursor`) now declare flags
explicitly
- **`ui/src/adapters/use-adapter-capabilities.ts`** — new hook that
fetches adapter capabilities from the API
- **`ui/src/pages/AgentDetail.tsx`** — replaced hardcoded `isLocal`
allowlist with `capabilities.supportsInstructionsBundle` from the API
- **`ui/src/components/AgentConfigForm.tsx`** /
**`OnboardingWizard.tsx`** — replaced `NONLOCAL_TYPES` denylist with
capability-based checks
- **`server/src/__tests__/adapter-registry.test.ts`** /
**`adapter-routes.test.ts`** — tests covering flag exposure,
undefined-when-unset, and per-adapter values
- **`docs/adapters/creating-an-adapter.md`** — new "Capability Flags"
section documenting all flags and an example for external plugin authors
## Verification
- Run `pnpm test --filter=@paperclip/server -- adapter-registry
adapter-routes` — all new tests pass
- Run `pnpm test --filter=@paperclip/adapter-utils` — existing tests
still pass
- Spin up dev server, open an agent with `claude_local` type —
instructions bundle tab still visible
- Create/open an agent with a non-local type — instructions bundle tab
still hidden
- Call `GET /api/adapters` and verify each adapter includes a
`capabilities` object with the correct flags
## Risks
- **Low risk overall** — all new flags are optional with
backwards-compatible fallbacks to the existing hardcoded sets; no
adapter behaviour changes unless a flag is explicitly set
- Adapters that do not declare flags continue to use the legacy lists,
so there is no regression risk for built-in adapters
- The UI capability hook adds one API call to AgentDetail mount; this is
a pre-existing endpoint, so no new latency path is introduced
## Model Used
- Provider: Anthropic
- Model: Claude Sonnet 4.6 (`claude-sonnet-4-6`)
- Context: 200k token context window
- Mode: Agentic tool use (code editing, bash, grep, file reads)
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge
---------
Co-authored-by: Pawla Abdul (Bot) <pawla@groombook.dev>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-04-15 08:10:52 -04:00
## 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.
2026-03-03 15:49:43 -06:00
## 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
PAPA-430: workspace finalize gates + no-remote-git enforcement (#6969)
## Thinking Path
> - Paperclip orchestrates AI agents across isolated execution
workspaces; the local cwd is the only persistence boundary between runs.
> - Workspace lifecycle (worktree_prepare → execute →
workspace_finalize) and the wake/accept flow are what guarantee that
dependent issues see a consistent worktree.
> - PAPA-380 / PAPA-431 / PAPA-432 / PAPA-440 surfaced three holes in
that contract: silent env reuse across assignees, dependent wakes firing
before finalize, and `issue.interaction.accept` advancing before
finalize landed.
> - PAPA-441 / PAPA-442 then needed to document the "no remote git"
contract and prevent future adapter/runtime code from quietly
reintroducing `git push` as a backdoor sync.
> - This pull request lands those server fixes, the static
`check-no-git-push` enforcement, the AUTHORING.md cross-link, and the
Cody-review follow-ups on the PAPA-430 thread.
> - The benefit is that finalize is a real barrier — board accepts,
dependent wakes, and operator-set env all respect it — and adapter code
can't bypass it via raw `git push`.
## What Changed
- **server (PAPA-380, PAPA-431):** `execution-workspace-policy` refuses
silent env reuse when the assignee's resolved env disagrees with the
workspace it would inherit. The inheritance protection is now scoped to
the actual inheritance signal — explicit issue-level `environmentId` is
honored even when the agent's default env is `null`.
- **server (PAPA-432):** `heartbeat.ts` gates dependent wakes on
`listUnfinalizedExecutionWorkspaceIds`, and writes a
`workspace_finalize` row on the succeeded path. Write failures now
surface instead of being swallowed so dependents aren't silently
stranded behind a missing row.
- **server (PAPA-440):** `issue-thread-interactions.acceptInteraction`
adds a workspace_finalize precondition for `request_confirmation` (not
`suggest_tasks`). Accept returns 409 if finalize hasn't succeeded for
the latest workspace operation.
- **ci (PAPA-442):** new `scripts/check-no-git-push.mjs` static check
scans `packages/adapters/`, `packages/adapter-utils/`, `server/src/`,
and `cli/src/` for any `git push` invocation (string or args-array).
Wired into the `policy` PR job and `test:release-registry`. Operators
can opt in per-call with `// paperclip:allow-git-push: <reason>`.
Release scripts are out of scope by design.
- **docs (PAPA-441):** `AUTHORING.md` documents the no-remote-git
contract and cross-links the static check so adapter authors learn the
rule and the enforcement together.
- **review follow-up (PAPA-430, Cody):** three fixes — env resolver bug,
accept-gate scope (request_confirmation only), and finalize record write
on the succeeded path.
## Verification
- `pnpm exec vitest run
server/src/__tests__/execution-workspace-policy.test.ts
server/src/__tests__/issue-thread-interactions-service.test.ts` → 33/33
pass
- `node scripts/check-no-git-push.test.mjs` → check covers string form,
args-array form, comment exclusions, and per-line allow-comment.
- Manual: server compiles; the policy job runs the check in <1s before
heavier jobs.
## Risks
- **Behavioral shift in accept:** boards accepting
`request_confirmation` while finalize is in-flight now get 409s. This is
intentional — they can retry — but it changes timing on a hot path.
`suggest_tasks` is unaffected.
- **Workspace policy:** the env-reuse refusal is a new error path.
Issues that previously silently reused an env from a different-assignee
workspace will now fail-loud; the resolver still honors explicit
issue-level `executionWorkspaceSettings.environmentId`.
- **CI rule:** any future legitimate `git push` in scoped dirs must be
marked with the allow-comment, which is the intended ergonomic.
## Model Used
- Claude Opus 4.7 (`claude-opus-4-7`, extended thinking), via Claude
Code in the Paperclip executor adapter.
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots (N/A — server/CI/docs only)
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge
Closes related issues: PAPA-430, PAPA-380, PAPA-431, PAPA-432, PAPA-440,
PAPA-441, PAPA-442
---------
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-05-29 08:25:29 -07:00
## 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.
2026-03-03 15:49:43 -06:00
## 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
2026-03-31 20:21:13 +01:00
- 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