Files
paperclip/docs/adapters/external-adapters.md
HenkDz 14d59da316 feat(adapters): external adapter plugin system with dynamic UI parser
- Plugin loader: install/reload/remove/reinstall external adapters
  from npm packages or local directories
- Plugin store persisted at ~/.paperclip/adapter-plugins.json
- Self-healing UI parser resolution with version caching
- UI: Adapter Manager page, dynamic loader, display registry
  with humanized names for unknown adapter types
- Dev watch: exclude adapter-plugins dir from tsx watcher
  to prevent mid-request server restarts during reinstall
- All consumer fallbacks use getAdapterLabel() for consistent display
- AdapterTypeDropdown uses controlled open state for proper close behavior
- Remove hermes-local from built-in UI (externalized to plugin)
- Add docs for external adapters and UI parser contract
2026-04-03 21:11:20 +01:00

10 KiB

title, summary
title summary
External Adapters Build, package, and distribute adapters as plugins without modifying Paperclip source

Paperclip supports external adapter plugins that can be installed from npm packages or local directories. External adapters work exactly like built-in adapters — they execute agents, parse output, and render transcripts — but they live in their own package and don't require changes to Paperclip's source code.

Built-in vs External

Built-in External
Source location Inside paperclip-fork/packages/adapters/ Separate npm package or local directory
Registration Hardcoded in three registries Loaded at startup via plugin system
UI parser Static import at build time Dynamically loaded from API (see UI Parser)
Distribution Ships with Paperclip Published to npm or linked via file:
Updates Requires Paperclip release Independent versioning

Quick Start

Minimal Package Structure

my-adapter/
  package.json
  tsconfig.json
  src/
    index.ts            # Shared metadata (type, label, models)
    server/
      index.ts          # createServerAdapter() factory
      execute.ts        # Core execution logic
      parse.ts          # Output parsing
      test.ts           # Environment diagnostics
    ui-parser.ts        # Self-contained UI transcript parser

package.json

{
  "name": "my-paperclip-adapter",
  "version": "1.0.0",
  "type": "module",
  "license": "MIT",
  "paperclip": {
    "adapterUiParser": "1.0.0"
  },
  "exports": {
    ".": "./dist/index.js",
    "./server": "./dist/server/index.js",
    "./ui-parser": "./dist/ui-parser.js"
  },
  "files": ["dist"],
  "scripts": {
    "build": "tsc"
  },
  "dependencies": {
    "@paperclipai/adapter-utils": "^2026.325.0",
    "picocolors": "^1.1.0"
  },
  "devDependencies": {
    "@types/node": "^22.0.0",
    "typescript": "^5.7.0"
  }
}

Key fields:

Field Purpose
exports["."] Entry point — must export createServerAdapter
exports["./ui-parser"] Self-contained UI parser module (optional but recommended)
paperclip.adapterUiParser Contract version for the UI parser ("1.0.0")
files Limits what gets published — only dist/

tsconfig.json

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "outDir": "dist",
    "rootDir": "src",
    "declaration": true,
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src"]
}

Server Module

The plugin loader calls createServerAdapter() from your package root. This function must return a ServerAdapterModule.

src/index.ts

export const type = "my_adapter";     // snake_case, globally unique
export const label = "My Agent (local)";

export const models = [
  { id: "model-a", label: "Model A" },
];

export const agentConfigurationDoc = `# my_adapter configuration
Use when: ...
Don't use when: ...
`;

// Required by plugin-loader convention
export { createServerAdapter } from "./server/index.js";

src/server/index.ts

import type { ServerAdapterModule } from "@paperclipai/adapter-utils";
import { type, models, agentConfigurationDoc } from "../index.js";
import { execute } from "./execute.js";
import { testEnvironment } from "./test.js";

export function createServerAdapter(): ServerAdapterModule {
  return {
    type,
    execute,
    testEnvironment,
    models,
    agentConfigurationDoc,
  };
}

src/server/execute.ts

The core execution function. Receives an AdapterExecutionContext and returns an AdapterExecutionResult.

import type {
  AdapterExecutionContext,
  AdapterExecutionResult,
} from "@paperclipai/adapter-utils";

import {
  runChildProcess,
  buildPaperclipEnv,
  renderTemplate,
} from "@paperclipai/adapter-utils/server-utils";

export async function execute(
  ctx: AdapterExecutionContext,
): Promise<AdapterExecutionResult> {
  const { config, agent, runtime, context, onLog, onMeta } = ctx;

  // 1. Read config with safe helpers
  const cwd = String(config.cwd ?? "/tmp");
  const command = String(config.command ?? "my-agent");
  const timeoutSec = Number(config.timeoutSec ?? 300);

  // 2. Build environment with Paperclip vars injected
  const env = buildPaperclipEnv(agent);

  // 3. Render prompt template
  const prompt = config.promptTemplate
    ? renderTemplate(String(config.promptTemplate), {
        agentId: agent.id,
        agentName: agent.name,
        companyId: agent.companyId,
        runId: ctx.runId,
        taskId: context.taskId ?? "",
        taskTitle: context.taskTitle ?? "",
      })
    : "Continue your work.";

  // 4. Spawn process
  const result = await runChildProcess(command, {
    args: [prompt],
    cwd,
    env,
    timeout: timeoutSec * 1000,
    graceMs: 10_000,
    onStdout: (chunk) => onLog("stdout", chunk),
    onStderr: (chunk) => onLog("stderr", chunk),
  });

  // 5. Return structured result
  return {
    exitCode: result.exitCode,
    timedOut: result.timedOut,
    // Include session state for persistence
    sessionParams: { /* ... */ },
  };
}

Available Helpers from @paperclipai/adapter-utils

Helper Purpose
runChildProcess(command, opts) Spawn a child process with timeout, grace period, and streaming callbacks
buildPaperclipEnv(agent) Inject PAPERCLIP_* environment variables
renderTemplate(template, data) {{variable}} substitution in prompt templates
asString(v), asNumber(v), asBoolean(v) Safe config value extraction

src/server/test.ts

Validates the adapter configuration before running. Returns structured diagnostics.

import type {
  AdapterEnvironmentTestContext,
  AdapterEnvironmentTestResult,
} from "@paperclipai/adapter-utils";

export async function testEnvironment(
  ctx: AdapterEnvironmentTestContext,
): Promise<AdapterEnvironmentTestResult> {
  const checks = [];

  // Example: check CLI is installed
  checks.push({
    level: "info",
    message: "My Agent CLI v1.2.0 detected",
    code: "cli_detected",
  });

  // Example: check working directory
  const cwd = String(ctx.config.cwd ?? "");
  if (!cwd.startsWith("/")) {
    checks.push({
      level: "error",
      message: `Working directory must be absolute: "${cwd}"`,
      hint: "Use /home/user/project or /workspace",
      code: "invalid_cwd",
    });
  }

  return {
    adapterType: ctx.adapterType,
    status: checks.some(c => c.level === "error") ? "fail" : "pass",
    checks,
    testedAt: new Date().toISOString(),
  };
}

Check levels:

Level Meaning Effect
info Informational Shown in test results
warn Non-blocking issue Shown with yellow indicator
error Blocks execution Prevents agent from running

Installation

From npm

# Via the Paperclip UI
# Settings → Adapters → Install from npm → "my-paperclip-adapter"

# Or via API
curl -X POST http://localhost:3102/api/adapters \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{"packageName": "my-paperclip-adapter"}'

From local directory

curl -X POST http://localhost:3102/api/adapters \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{"localPath": "/home/user/my-adapter"}'

Local adapters are symlinked into Paperclip's adapter directory. Changes to the source are picked up on server restart.

Via adapter-plugins.json

For development, you can also edit ~/.paperclip/adapter-plugins.json directly:

[
  {
    "packageName": "my-paperclip-adapter",
    "localPath": "/home/user/my-adapter",
    "type": "my_adapter",
    "installedAt": "2026-03-30T12:00:00.000Z"
  }
]

Optional: Session Persistence

If your agent runtime supports sessions (conversation continuity across heartbeats), implement a session codec:

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

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

Include it in createServerAdapter():

return { type, execute, testEnvironment, sessionCodec, /* ... */ };

Optional: Skills Sync

If your agent runtime supports skills/plugins, implement listSkills and syncSkills:

return {
  type,
  execute,
  testEnvironment,
  async listSkills(ctx) {
    return {
      adapterType: ctx.adapterType,
      supported: true,
      mode: "ephemeral",
      desiredSkills: [],
      entries: [],
      warnings: [],
    };
  },
  async syncSkills(ctx, desiredSkills) {
    // Install desired skills into the runtime
    return { /* same shape as listSkills */ };
  },
};

Optional: Model Detection

If your runtime has a local config file that specifies the default model:

async function detectModel() {
  // Read ~/.my-agent/config.yaml or similar
  return {
    model: "anthropic/claude-sonnet-4",
    provider: "anthropic",
    source: "~/.my-agent/config.yaml",
    candidates: ["anthropic/claude-sonnet-4", "openai/gpt-4o"],
  };
}

return { type, execute, testEnvironment, detectModel: () => detectModel() };

Publishing

npm run build
npm publish

Other Paperclip users can then install your adapter by package name from the UI or API.

Security

  • Treat agent output as untrusted — parse defensively, never eval() agent output
  • Inject secrets via environment variables, not in prompts
  • Configure network access controls if the runtime supports them
  • Always enforce timeout and grace period — don't let agents run forever
  • The UI parser module runs in a browser sandbox — it must have zero runtime imports and no side effects

Next Steps