1f70fd9a22
## 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>
282 lines
12 KiB
Markdown
282 lines
12 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
|
|
|
|
## 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
|