How to Create a Custom Agent Adapter for a New Runtime or CLI Tool in Paperclip

Create a custom agent adapter in Paperclip by registering a new adapterType string, implementing a sessionCodec for state persistence, and wiring the execute function into the server's adapter dispatcher.

Paperclip's agent architecture relies on adapters — thin plugins that translate heartbeat prompts into concrete tool executions. Whether you need to integrate a custom build script, container runner, or proprietary CLI, creating a custom agent adapter follows a predictable pattern established by existing implementations like opencode-local, grok-local, hermes, and pi-local. This guide walks through the exact files and functions you must implement based on the Paperclip source code.

Define the Adapter Type and Configuration Schema

Every adapter starts with a registered identifier and validated configuration structure.

Add the Adapter Type String

First, add your new adapter's string identifier to the centralized whitelist in packages/shared/src/constants/adapter-type.ts:

export const ADAPTER_TYPES = [
  "opencode_local",
  "grok_local",
  "hermes",
  "pi_local",
  "mytool_local",  // <-- your new adapter type
] as const;

Extend the Validation Schema

In packages/shared/src/validators/agent.ts, extend the Zod schema to accept your adapter's configuration fields:

export const MytoolLocalConfigSchema = z.object({
  cwd: z.string().optional(),
  timeoutSec: z.number().positive().optional(),
  args: z.array(z.string()).optional(),
  env: z.record(z.string()).optional(),
});

// Merge into the union of adapter configs
export const AgentAdapterConfigSchema = z.discriminatedUnion("adapterType", [
  // ... existing schemas
  z.object({
    adapterType: z.literal("mytool_local"),
    config: MytoolLocalConfigSchema,
  }),
]);

Create the Adapter Package Structure

Under packages/adapters/, create a new folder following the monorepo convention:

packages/adapters/mytool-local/
├── src/
│   ├── cli/           # CLI entry points (optional)

│   ├── server/
│   │   ├── index.ts
│   │   ├── sessionCodec.ts
│   │   ├── execute.ts
│   │   ├── parse.ts
│   │   └── runtime-config.ts
│   └── ui/
│       ├── index.ts
│       ├── parse-stdout.ts
│       └── build-config.ts
├── package.json
└── tsconfig.json

Implement the Server-Side Core

The server implementation requires four essential components: session codec, runtime configuration schema, execution logic, and output parsing.

Step 1: Implement the Session Codec

The sessionCodec (packages/adapter-utils/src/types.ts) handles serialization and deserialization of persisted session state across heartbeats. Create src/server/sessionCodec.ts:

import type { AdapterSessionCodec } from "@paperclipai/adapter-utils";

function readNonEmptyString(v: unknown): string | null {
  return typeof v === "string" && v.trim().length ? v.trim() : null;
}

export const sessionCodec: AdapterSessionCodec = {
  deserialize(raw) {
    if (typeof raw !== "object" || raw === null) return null;
    const rec = raw as Record<string, unknown>;
    const sessionId = readNonEmptyString(rec.sessionId) ?? readNonEmptyString(rec.session_id);
    if (!sessionId) return null;
    const cwd = readNonEmptyString(rec.cwd);
    return { sessionId, ...(cwd ? { cwd } : {}) };
  },
  serialize(params) {
    if (!params) return null;
    const sessionId = readNonEmptyString(params.sessionId) ?? readNonEmptyString(params.session_id);
    if (!sessionId) return null;
    const cwd = readNonEmptyString(params.cwd);
    return { sessionId, ...(cwd ? { cwd } : {}) };
  },
  getDisplayId(params) {
    if (!params) return null;
    return readNonEmptyString(params.sessionId) ?? readNonEmptyString(params.session_id);
  },
};

Step 2: Define Runtime Configuration Schema

In src/server/runtime-config.ts, mirror your UI configuration with a Zod schema:

import { z } from "zod";

export const MytoolRuntimeConfigSchema = z.object({
  cwd: z.string().optional(),
  env: z.record(z.string()).optional(),
  args: z.array(z.string()).default([]),
  timeoutSec: z.number().positive().default(300),
});

export type MytoolRuntimeConfig = z.infer<typeof MytoolRuntimeConfigSchema>;

Step 3: Implement Execution Logic

The execute function in src/server/execute.ts is the adapter's heart. Use Paperclip's sandbox utilities for process management:

import { spawn } from "node:child_process";
import { sandboxShell } from "@paperclipai/adapter-utils";
import type { ExecuteParams } from "@paperclipai/adapter-utils";
import { MytoolRuntimeConfigSchema } from "./runtime-config";

export async function execute(params: ExecuteParams) {
  const parsed = MytoolRuntimeConfigSchema.parse(params.adapterConfig);
  const { cwd, env, args } = parsed;
  
  const command = "mytool";  // <-- your CLI binary
  const child = spawn(command, args, {
    cwd,
    env: { ...process.env, ...env },
  });

  const { stdoutStream, stderrStream } = sandboxShell(child);
  
  return {
    stdoutStream,
    stderrStream,
    pid: child.pid,
  };
}

Step 4: Add Output Parsing

In src/server/parse.ts, normalize tool-specific output into Paperclip's RunLogChunk format:

import type { RunLogChunk } from "@paperclipai/adapter-utils";

export function parseMytoolOutput(rawOutput: string): RunLogChunk[] {
  const chunks: RunLogChunk[] = [];
  const lines = rawOutput.split("\n");
  
  for (const line of lines) {
    if (!line.trim()) continue;
    
    try {
      const parsed = JSON.parse(line);
      chunks.push({
        type: "output",
        content: parsed.content ?? line,
        timestamp: parsed.timestamp ?? Date.now(),
        metadata: parsed,
      });
    } catch {
      // Treat non-JSON as plain text
      chunks.push({
        type: "output",
        content: line,
        timestamp: Date.now(),
      });
    }
  }
  
  return chunks;
}

Step 5: Export from Index

Wire everything together in src/server/index.ts:

import type { AdapterSessionCodec } from "@paperclipai/adapter-utils";
import { sessionCodec } from "./sessionCodec";

export { sessionCodec };
export { execute } from "./execute";
export { parseMytoolOutput as parse } from "./parse";
export { MytoolRuntimeConfigSchema } from "./runtime-config";

Build UI Helpers (Optional)

For full Paperclip integration, implement UI-side utilities in src/ui/:

parse-stdout.ts

import { Readable } from "node:stream";

export function parseMytoolStdout(stdout: Readable): AsyncGenerator<any> {
  return (async function* () {
    let buffer = "";
    for await (const chunk of stdout) {
      buffer += chunk.toString();
      const lines = buffer.split("\n");
      buffer = lines.pop() ?? "";
      
      for (const line of lines) {
        try {
          yield JSON.parse(line);
        } catch {
          yield { type: "text", content: line };
        }
      }
    }
    if (buffer) {
      try {
        yield JSON.parse(buffer);
      } catch {
        yield { type: "text", content: buffer };
      }
    }
  })();
}

build-config.ts

import type { AdapterConfig } from "@paperclipai/shared";

export function buildMytoolConfig(raw: unknown): AdapterConfig {
  const { cwd, args, env } = raw as any;
  return {
    adapterType: "mytool_local",
    config: {
      cwd: cwd ?? process.cwd(),
      args: Array.isArray(args) ? args : [],
      env: typeof env === "object" ? env : {},
    },
  };
}

Register in the Adapter Dispatcher

The server must resolve your adapterType string to the implemented module. In server/src/adapters/index.ts, add:

import * as mytoolLocal from "@paperclipai/mytool-local";

const adapterRegistry: Record<AgentAdapterType, AdapterModule> = {
  opencode_local: opencodeLocal,
  grok_local: grokLocal,
  hermes: hermes,
  pi_local: piLocal,
  mytool_local: mytoolLocal,  // <-- register your adapter
};

Update Type Declarations

In packages/shared/src/types/agent.ts, extend the public type:

export type AgentAdapterType = 
  | "opencode_local"
  | "grok_local"
  | "hermes"
  | "pi_local"
  | "mytool_local";  // <-- add your type

Type-Check and Test

Run the monorepo validation commands:

Command Purpose
pnpm -r typecheck Verify TypeScript across all packages
pnpm test Run the test suite
pnpm -r --filter @paperclipai/mytool-local test Test your adapter specifically

Place unit tests alongside implementation files:

// src/server/sessionCodec.test.ts
import { sessionCodec } from "./sessionCodec";
import { describe, it, expect } from "vitest";

describe("sessionCodec", () => {
  it("deserializes legacy session_id field", () => {
    const result = sessionCodec.deserialize({ session_id: "abc-123" });
    expect(result?.sessionId).toBe("abc-123");
  });

  it("returns null for invalid input", () => {
    expect(sessionCodec.deserialize(null)).toBeNull();
    expect(sessionCodec.deserialize("string")).toBeNull();
  });

  it("round-trips through serialize/deserialize", () => {
    const original = { sessionId: "test-789", cwd: "/tmp" };
    const serialized = sessionCodec.serialize(original);
    const deserialized = sessionCodec.deserialize(serialized);
    expect(deserialized).toEqual(original);
  });
});

Document Your Adapter

Add an entry to doc/spec/agents-runtime.md:


## mytool_local

Executes commands via the `mytool` CLI. Supports custom working directories,
environment variables, and argument passing.

**Configuration:**
- `cwd`: Working directory for execution (defaults to project root)
- `args`: Array of string arguments passed to `mytool`
- `env`: Key-value environment variable overrides
- `timeoutSec`: Maximum execution time before forced termination

**Example:**

```json
{
  "adapterType": "mytool_local",
  "config": {
    "cwd": "./scripts",
    "args": ["--verbose", "deploy"],
    "env": { "API_KEY": "${MYTOOL_API_KEY}" }
  }
}

## Summary

Creating a custom agent adapter in Paperclip requires touching these key integration points:

- **Define the adapter type** in [`packages/shared/src/constants/adapter-type.ts`](https://github.com/paperclipai/paperclip/blob/main/packages/shared/src/constants/adapter-type.ts)
- **Validate configuration** via Zod schemas in [`packages/shared/src/validators/agent.ts`](https://github.com/paperclipai/paperclip/blob/main/packages/shared/src/validators/agent.ts)
- **Implement server core**: `sessionCodec`, `execute`, and `parse` functions using sandbox utilities
- **Register the adapter** in [`server/src/adapters/index.ts`](https://github.com/paperclipai/paperclip/blob/main/server/src/adapters/index.ts) for dispatcher resolution
- **Export public types** in [`packages/shared/src/types/agent.ts`](https://github.com/paperclipai/paperclip/blob/main/packages/shared/src/types/agent.ts)
- **Add UI helpers** for log parsing and configuration building (optional but recommended)
- **Write tests** and **document** in [`doc/spec/agents-runtime.md`](https://github.com/paperclipai/paperclip/blob/main/doc/spec/agents-runtime.md)

The adapter architecture enforces clean separation between tool-specific execution logic and Paperclip's generic heartbeat engine, letting you integrate any runtime while maintaining type safety and session persistence guarantees.

## Frequently Asked Questions

### What is the minimum code required for a working Paperclip adapter?

You need three exports from your server package: `sessionCodec` (implementing `AdapterSessionCodec`), `execute` (an async function accepting `ExecuteParams`), and `parse` (to normalize output). Register these in [`server/src/adapters/index.ts`](https://github.com/paperclipai/paperclip/blob/main/server/src/adapters/index.ts) and add your `adapterType` to the constants and types files. The `sessionCodec` must handle at minimum a `sessionId` field for state tracking.

### How does Paperclip persist adapter state across heartbeats?

The `sessionCodec.serialize` function converts runtime parameters to a JSON-serializable object stored between heartbeats. When a heartbeat resumes, `sessionCodec.deserialize` reconstructs the session. This allows adapters to maintain working directories, process IDs, or partial results across executions. The codec interface is defined in [`packages/adapter-utils/src/types.ts`](https://github.com/paperclipai/paperclip/blob/main/packages/adapter-utils/src/types.ts).

### Can I reuse existing sandbox utilities for process isolation?

Yes. The `@paperclipai/adapter-utils` package provides `sandboxShell`, `local-process-sandbox`, and `command-managed-runtime` for standardized process spawning, timeout handling, environment injection, and log redaction. Reference [`packages/adapters/opencode-local/src/server/execute.ts`](https://github.com/paperclipai/paperclip/blob/main/packages/adapters/opencode-local/src/server/execute.ts) for concrete usage patterns.

### Where should I handle tool-specific output formatting?

Implement parsing logic in two places: [`src/server/parse.ts`](https://github.com/paperclipai/paperclip/blob/main/src/server/parse.ts) for server-side normalization to `RunLogChunk` format, and optionally [`src/ui/parse-stdout.ts`](https://github.com/paperclipai/paperclip/blob/main/src/ui/parse-stdout.ts) for client-side streaming display. The server parser ensures consistent storage; the UI parser enables live output panels with tool-specific formatting.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →