## 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>
10 KiB
title, summary
| title | summary |
|---|---|
| Creating an Adapter | Guide to building a custom adapter |
Build a custom adapter to connect Paperclip to any agent runtime.
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.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 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.
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: ...
`;
// 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:
- Read config using safe helpers (
asString,asNumber, etc.) from@paperclipai/adapter-utils/server-utils - Build environment with
buildPaperclipEnv(agent)plus context vars - Resolve session state from
runtime.sessionParams - Render prompt with
renderTemplate(template, data) - Spawn the process with
runChildProcess()or call viafetch() - Parse output for usage, costs, session state, errors
- 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
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
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 |
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 toTranscriptEntry[]for the run viewerbuild-config.ts— converts form values toadapterConfigJSON- 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.
Step 5: CLI Module
format-event.ts — pretty-prints stdout for paperclipai run --watch using picocolors.
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:
server/src/adapters/registry.tsui/src/adapters/registry.tscli/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:
- Return
sessionParamsfromexecute()(e.g.,{ sessionId: "abc123" }) - Read
runtime.sessionParamson the next wake to resume - Optionally implement a
sessionCodecfor validation and display
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
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:
- Best: tmpdir + flag — create tmpdir, symlink skills, pass via CLI flag, clean up after
- Acceptable: global config dir — symlink to the runtime's global plugins directory
- Acceptable: env var — point a skills path env var at the repo's
skills/directory - Last resort: prompt injection — include skill content in the prompt template
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 — build a standalone adapter plugin
- UI Parser Contract — ship a custom run-log parser
- How Agents Work — the heartbeat lifecycle