Files
paperclip/docs/adapters/creating-an-adapter.md
T
Devin Foley 1f70fd9a22 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

12 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:

  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

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 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.

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:

  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
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:

  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 restoreWorkspaceFromSshExecutionexportGitWorkspaceFromSshintegrateImportedGitHead. 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