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

> Learn how to create a custom agent adapter in Paperclip for new runtimes or CLI tools by registering adapter types, implementing session codecs, and wiring the execute function into the server.

- Repository: [Paperclip/paperclip](https://github.com/paperclipai/paperclip)
- Tags: how-to-guide
- Published: 2026-08-18

---

**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`](https://github.com/paperclipai/paperclip/blob/main/packages/shared/src/constants/adapter-type.ts):

```typescript
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`](https://github.com/paperclipai/paperclip/blob/main/packages/shared/src/validators/agent.ts), extend the Zod schema to accept your adapter's configuration fields:

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

```bash
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`](https://github.com/paperclipai/paperclip/blob/main/packages/adapter-utils/src/types.ts)) handles serialization and deserialization of persisted session state across heartbeats. Create [`src/server/sessionCodec.ts`](https://github.com/paperclipai/paperclip/blob/main/src/server/sessionCodec.ts):

```typescript
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`](https://github.com/paperclipai/paperclip/blob/main/src/server/runtime-config.ts), mirror your UI configuration with a Zod schema:

```typescript
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`](https://github.com/paperclipai/paperclip/blob/main/src/server/execute.ts) is the adapter's heart. Use Paperclip's sandbox utilities for process management:

```typescript
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`](https://github.com/paperclipai/paperclip/blob/main/src/server/parse.ts), normalize tool-specific output into Paperclip's `RunLogChunk` format:

```typescript
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`](https://github.com/paperclipai/paperclip/blob/main/src/server/index.ts):

```typescript
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

```typescript
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

```typescript
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`](https://github.com/paperclipai/paperclip/blob/main/server/src/adapters/index.ts), add:

```typescript
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`](https://github.com/paperclipai/paperclip/blob/main/packages/shared/src/types/agent.ts), extend the public type:

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

```typescript
// 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`](https://github.com/paperclipai/paperclip/blob/main/doc/spec/agents-runtime.md):

```markdown

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