## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Paperclip runs across the CLI, server, adapters, plugins, CI, and container images. > - These surfaces declared different Node.js versions from 20 through 24. > - A newer `@types/node` major can expose APIs that the supported runtime does not provide. > - Node.js 20 is no longer a suitable project baseline, and Node.js 24 is the current LTS line. > - This pull request sets Node.js 24.11.0 as one repository-wide baseline, adds a drift check, and gives users actionable startup guidance when their runtime is too old. > - The benefit is one clear runtime contract for development, release, installation, and published packages. ## Linked Issues or Issue Description Refs #2734 Refs #11727 Refs #739 ## What Changed - Require Node.js 24.11.0 or newer in all 42 package manifests and runtime checks. - Use Node.js 24 in GitHub Actions, Docker images, smoke images, sandbox setup, portable installs, and esbuild targets. - Align every direct `@types/node` declaration on `^24.0.0`. - Prevent Dependabot from opening major `@types/node` upgrades without a matching runtime decision. - Add `.nvmrc` and a CI policy check for Node version drift. - Update ACP version gates, tests, and user documentation for the new minimum. - Print a non-blocking warning on CLI and server startup when Node is unsupported, with remediation through a version manager or the documented downloaded `install.sh` workflow. - Deduplicate that warning when `paperclipai run` boots the CLI and server in the same process. ## Verification - `node scripts/check-node-version-policy.mjs` - `node --check scripts/check-node-version-policy.mjs` - `node --check cli/esbuild.config.mjs` - `node --check scripts/generate-npm-package-json.mjs` - `bash -n scripts/install.sh scripts/test-install-sh-docker.sh scripts/e2e-install-lifecycle.sh` - Parsed all 42 package manifests and confirmed `engines.node` is `>=24.11.0`. - `git diff --check` - `vitest run packages/adapter-utils/src/sandbox-install-command.test.ts` passed with 3 tests. - `vitest run cli/src/node-version.test.ts` passed with 4 tests. - Directly exercised the shared warning helper for unsupported-version messaging and same-process deduplication. - The focused exe.dev suite could not resolve the locally unbuilt plugin SDK from this isolated worktree. A full offline workspace install was also blocked because the package-manager signature verifier requires registry access. The full suite was not run locally; draft CI performs a clean install and evaluates the wider impact. ## Risks - This is a breaking runtime change for users, plugins, and deployments that still use Node.js 20 or 22. - Published workspace packages will now produce an engine warning or failure in strict package managers on older Node.js releases. - Node.js 24 can reveal dependency, native module, Playwright, or agent CLI compatibility issues in CI. - The bootstrap installer now installs Node.js 24 when the current runtime is older than 24.11.0. - The portable sandbox fallback is pinned to Node.js 24.11.0 and depends on that upstream tarball remaining available. - Unsupported runtimes continue booting after a warning, so a later incompatibility can still fail at its point of use. - The CLI and server share the warning policy through the published `@paperclipai/shared` package; packaging checks must keep that subpath export available. - This PR does not commit `pnpm-lock.yaml` because repository policy assigns lockfile generation to CI. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex based on GPT-5. The exact deployment ID and context window are not exposed in this session. Reasoning, repository tools, shell execution, and GitHub tools were enabled. ## 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 searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
11 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 |
Built-in Hermes compatibility note
Hermes is built in with two stable adapter type keys:
hermes_localstarts the local Hermes CLI from@paperclipai/hermes-paperclip-adapter.hermes_gatewaycalls an already-running Hermes API server through@paperclipai/hermes-paperclip-adapter/gateway.
The legacy @paperclipai/adapter-hermes-gateway package is a deprecated
compatibility shim for one release. It preserves the old gateway exports while
forwarding to the unified Hermes package. New external override packages should
depend on or link @paperclipai/hermes-paperclip-adapter and declare the type
they override (hermes_local or hermes_gateway); the type keys did not
change.
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": "^24.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";
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
- UI Parser Contract — add a custom run-log parser so the UI renders your adapter's output correctly
- Creating an Adapter — full walkthrough of adapter internals
- How Agents Work — understand the heartbeat lifecycle your adapter serves