docs/adapters/creating-an-adapter.md
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>| 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.
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
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";
src/server/execute.ts is the core. It receives an AdapterExecutionContext and returns an AdapterExecutionResult.
Key responsibilities:
asString, asNumber, etc.) from @paperclipai/adapter-utils/server-utilsbuildPaperclipEnv(agent) plus context varsruntime.sessionParamsrenderTemplate(template, data)runChildProcess() or call via fetch()clearSession: true)| 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 |
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>;
}
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
}
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(),
};
}
For built-in adapters registered in Paperclip's source:
parse-stdout.ts — converts stdout lines to TranscriptEntry[] for the run viewerbuild-config.ts — converts form values to adapterConfig JSONui/src/adapters/<name>/config-fields.tsxFor external adapters, use a self-contained ui-parser.ts instead. See the UI Parser Contract.
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}`);
}
}
Add the adapter to all three registries:
server/src/adapters/registry.tsui/src/adapters/registry.tscli/src/adapters/registry.tsFor external adapters, registration is automatic — the plugin loader handles it.
If your agent runtime supports conversation continuity across heartbeats:
sessionParams from execute() (e.g., { sessionId: "abc123" })runtime.sessionParams on the next wake to resumesessionCodec for validation and displayexport const sessionCodec: AdapterSessionCodec = {
deserialize(raw) { /* validate raw session data */ },
serialize(params) { /* serialize for storage */ },
getDisplayId(params) { /* human-readable session label */ },
};
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).
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.
Make Paperclip skills discoverable to your agent runtime without writing to the agent's working directory:
skills/ directory