50cd76d8a3
## 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>
265 lines
10 KiB
Markdown
265 lines
10 KiB
Markdown
---
|
|
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 (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:
|
|
|
|
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
|
|
|
|
## 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
|