Paperclip

Architecture deep dive into the Paperclip orchestration system.

1 min read Updated Apr 20, 2026

How Paperclip Orchestrates AI Companies

Paperclip mental model — company made of agentsPaperclip mental model — company made of agents

Paperclip is an open-source control plane for autonomous AI companies. You define a company, give it a goal, hire a tree of AI agents, and Paperclip coordinates their work through tickets, heartbeats, budgets, and board approvals. Unlike a chat framework or a workflow builder, Paperclip is unopinionated about how your agents run — Claude Code, Codex, Cursor, OpenClaw, a bare shell script — and opinionated about how they collaborate.

This guide walks through Paperclip's model, a worked example, the core architecture, the repo layout, and then dives into the three subsystems that do most of the heavy lifting: adapters, heartbeats, and tickets.

Verified against paperclipai/paperclip @ 1266954a (2026-04-20). File paths are stable, function line numbers drift — grep by name if they've moved.

The Mental Model: A Company Made of Agents

Paperclip models the real-world metaphor it's named after. A company has one human board on top, a single goal that gives everything meaning, and an org tree of AI agents hanging below. Agents report to other agents (agents.reportsTo), and every piece of work traces back through a chain of parent issues to the company's top-level goal. The board doesn't write prompts; it approves hires, sets budgets, and signs off on strategic directions.

Three properties are worth internalizing:

  • Every employee is an agent. The CEO is an agent. The CTO reporting to the CEO is an agent. The designer two levels down is an agent. They all have the same shape — name, adapterType, adapterConfig, reportsTo, budgetMonthlyCents — just different configurations.
  • Every ticket traces to the goal. An issue is linked to a goalId and can have a parentId pointing to a broader issue. Agents can always answer "why am I doing this?" by walking the chain up.
  • The board remains in control. Approvals gate hiring decisions and strategy changes. Budgets cap monthly spend and auto-pause agents at the ceiling. Every state change is audited via activity_log.

Key schema files to read if you want to go deeper:

  • packages/db/src/schema/companies.ts — company, budget, issue prefix
  • packages/db/src/schema/agents.ts — adapter config, reporting tree, budget
  • packages/db/src/schema/goals.ts — hierarchical goals (parentGoalId)
  • packages/db/src/schema/issues.ts — the work unit, linked to both goalId and parentId

Worked Example: "Ship a Facebook Ads Campaign"

End-to-end workflow exampleEnd-to-end workflow example

Abstract models don't stick — here's the same scenario end-to-end so you can see every subsystem fire.

  1. Board sets the goal. You create a company, give it the goal "$1M MRR in 3 months" with a monthly budget. The board creates a top-level issue: "Run Facebook ads campaign to hit 100 signups this week."
  2. CEO proposes the breakdown. The CEO agent's heartbeat fires, it reads the new issue, and proposes child issues: landing page, creative, ad copy, launch. It files an approvals row with status pending and stops.
  3. Board approves. You click approve in the UI. The child issues flip from drafts to todo, each assigned to a subordinate agent (CTO, designer, CMO).
  4. CMO is auto-waked. The issues.assigneeAgentId write triggers an assignment wake. The heartbeat service enqueues a new heartbeat_runs row with status='queued' and source='assignment'.
  5. Marketer gets the sub-issue. The CMO heartbeat runs, reads its top issue, decides it needs an operator, and creates a sub-issue assigned to the marketer agent. Another assignment wake fires.
  6. Scheduler dispatches. executeRun() pulls the queued run, atomically calls issues.checkout() to lock the issue against anyone else, and realizes an execution workspace (a git worktree or the shared repo).
  7. Adapter runs. getServerAdapter("claude_local") returns the Claude Local adapter; Paperclip builds an AdapterExecutionContext with the workspace path, decrypted secrets, relevant skills, and the session ID from the previous heartbeat. The adapter spawns Claude Code, streams stdout through onLog, and captures usage + cost.
  8. Result is recorded. Paperclip parses the AdapterExecutionResult, writes a cost_events row, checks the budget ceiling, posts a human-readable comment to the issue, and on success promotes the parent issue — which wakes the CMO again to review the work.

Every step in that narrative maps to a concrete function you can grep for. No magic, just bookkeeping.

The Core System: Control Plane at a Glance

Control plane architectureControl plane architecture

Paperclip is deliberately a control plane, not an execution plane. Agents run wherever they run — locally, in a container, behind a webhook — and Paperclip coordinates their schedule, context, cost, and state. The whole thing is one Node.js process plus an embedded Postgres database you never have to configure.

The stack, top to bottom:

  • Surface — React + Vite board UI (served by the same dev server), the paperclipai Commander CLI, and external agent webhooks.
  • API — Express REST endpoints in server/src/index.ts. Auth, rate limiting, and a live-event bus for UI streaming.
  • Services — the business logic layer under server/src/services/. Key ones: heartbeat, issues, costs, budgets, approvals, execution-workspaces, skills, activity-log, and the plugin event bus.
  • Dispatch — the adapter registry (server/src/adapters/registry.ts) turns an agent's adapterType string into an execute() function.
  • Execution — the actual agent processes: claude_local, codex_local, cursor, gemini_local, opencode_local, pi_local, hermes_local, openclaw_gateway, plus generic process and http fallbacks.
  • Storage — PostgreSQL via Drizzle ORM. In dev it's the bundled @embedded-postgres/darwin-arm64 running under ~/.paperclip/instances/default/db; in prod you point it at your own Postgres via DATABASE_URL.

The principle that ties it together: Paperclip never writes the agent's code or runs its loop — it just schedules, hands context, captures output, and writes the ledger.

Useful entry points:

  • server/src/index.ts — API bootstrap, service wiring, scheduler start
  • server/src/services/heartbeat.ts — the scheduler and execution engine
  • server/src/adapters/registry.ts — bundled adapter registration

Inside the Repo: High-Level System Design

Paperclip repo layoutPaperclip repo layout

Paperclip is a pnpm monorepo. The top-level pnpm-workspace.yaml pulls in three apps (server, ui, cli) and every packages/*, packages/adapters/*, and packages/plugins/* member.

Apps (entry points).

  • server/ — Express API, embedded Postgres, heartbeat scheduler, service layer, adapter registry. Start here at server/src/index.ts.
  • ui/ — React dashboard. In dev it's middleware-served by the same server on http://localhost:3100.
  • cli/ — the paperclipai Commander program. Covers onboard, doctor, configure, heartbeat-run, plus issue/agent/approval/company admin commands.

Shared packages.

  • packages/db/ — Drizzle schema and 60+ migration files. packages/db/src/schema/*.ts is the authoritative data model.
  • packages/shared/ — zod validators and shared constants like ISSUE_STATUSES and HEARTBEAT_RUN_STATUSES. Any type used across server/ui/cli goes here.
  • packages/adapter-utils/ — the interface every adapter implements (AdapterExecutionContext, AdapterExecutionResult, AdapterSessionCodec), plus billing helpers, log redaction, session compaction.
  • packages/plugins/sdk/ — the plugin SDK. Third parties call definePlugin() to contribute adapters, jobs, event handlers, and UI panels over a JSON-RPC bridge.
  • packages/mcp-server/ — a built-in MCP endpoint agents can hit to pull Paperclip context.

Bundled adapters live under packages/adapters/ — one sub-package per runtime (claude-local, codex-local, cursor-local, gemini-local, opencode-local, pi-local, openclaw-gateway). The generic process and http adapters are small enough to live inside server/src/adapters/.

Ops. doc/ has the authoritative design spec (PRODUCT.md, SPEC.md, execution-semantics.md). skills/ ships Claude Code skills Paperclip injects into agents at runtime. tests/ and evals/ hold Vitest, Playwright, and PromptFoo suites. scripts/ holds dev-runner and smoke tests. docker/ has a quickstart compose file.

Deep Dive: The Adapter System

Adapter system data flowAdapter system data flow

The adapter system is the single most important boundary in Paperclip. Everything above it is control plane; everything below it is someone else's agent. The only contract an adapter has to honor:

TypeScript
export interface AdapterExecutionContext {
  runId: string;
  agent: AdapterAgent;
  runtime: AdapterRuntime;
  config: Record<string, unknown>;
  context: Record<string, unknown>;
  onLog: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
  onMeta?: (meta: AdapterInvocationMeta) => Promise<void>;
  onSpawn?: (meta: { pid: number; processGroupId: number | null; startedAt: string }) => Promise<void>;
  authToken?: string;
}

Source: packages/adapter-utils/src/types.ts. The adapter gets everything it needs to do its job — the agent's config, the runtime's session state, the resolved workspace, skills, decrypted secrets — and three callbacks to report log lines, process metadata, and spawn info back to Paperclip. It returns an AdapterExecutionResult with exitCode, usage, sessionParams, costUsd, billingType, and a human-readable summary.

The registry is a mutable map. server/src/adapters/registry.ts imports every bundled adapter, wraps it in a ServerAdapterModule record, and writes it into a per-process map keyed by the adapterType string. registerServerAdapter(adapter) is also called by plugin-loader.ts when a plugin registers a new type at load time, so third-party runtimes slot in without recompiling the server. Look at server/src/adapters/builtin-adapter-types.ts for the canonical list.

Bundled adapters at a glance.

Adapter typeWhat it doesSession handling
claude_localSpawns the claude CLI in the workspace, streams JSON output, parses usage and costResumes via sessionId written into sessionParams
codex_localSame pattern for OpenAI CodexCodex-specific codec
cursorBridges to Cursor's CLICursor codec
gemini_local / opencode_local / pi_local / hermes_localOne process adapter per runtimeRuntime-specific codec
openclaw_gatewayPOSTs context to a managed OpenClaw webhook and handles async callbacksGateway tracks external run IDs
processRuns any configured shell command with the agent's envNone — stateless
httpPOSTs JSON to a configurable webhookNone — stateless

Session codecs are the trick that makes long-running, multi-heartbeat work possible. AdapterSessionCodec.serialize() takes the adapter's private session state and returns a JSON blob Paperclip stores as heartbeat_runs.sessionIdAfter/sessionParams. On the next heartbeat, deserialize() hydrates it back, and the Claude Code session (or Codex session, or …) picks up where it left off. Without this, every heartbeat would start fresh.

Plugin adapters register at load time through the plugin worker process. The SDK ships in packages/plugins/sdk and the full protocol is documented in adapter-plugin.md at the repo root — the short version is: plugin author writes a definePlugin() module, Paperclip spawns it as a Node child process, they talk JSON-RPC 2.0 over stdio, and the plugin's ctx.registerAdapter() call lands on the server-side registry.

Files worth reading:

  • packages/adapter-utils/src/types.ts — the contract (types only; no drizzle dependency)
  • server/src/adapters/registry.ts — wiring
  • packages/adapters/claude-local/src/server/execute.ts — a worked example with streaming, cost parsing, session management
  • adapter-plugin.md — plugin contribution path

Deep Dive: The Heartbeat System

Heartbeat lifecycleHeartbeat lifecycle

A heartbeat is a single agent execution. Heartbeats are the universal unit of work in Paperclip — timer-fired, assignment-fired, webhook-fired, or cron-fired, they all flow through the same pipeline and produce a heartbeat_runs row.

Wake reasons. Four sources (see services/heartbeat.ts around the source union):

  • timer — the scheduler's tickTimers() loop walks every agent and checks whether intervalSec has elapsed since lastHeartbeatAt.
  • assignment — writing issues.assigneeAgentId triggers an assignment wake so the agent picks up its new work immediately.
  • on_demand — the /api/agents/:id/wakeup HTTP endpoint (used by webhooks and manual "nudge" buttons in the UI).
  • automation — plugin jobs or scheduled routines.

Enqueue is atomic. enqueueWakeup(agentId, opts) (around line 5231 of services/heartbeat.ts) writes two rows in a transaction: an agent_wakeup_requests row that lets Paperclip deduplicate identical wakes, and a heartbeat_runs row with status='queued'. Nothing else executes agent code — everything else just reads from the queue.

executeRun() is the main loop (around line 3961). For each queued run, it:

  1. Claims the run by flipping status to running with a process lease.
  2. Calls issues.checkout() — an atomic write that sets checkoutRunId on the issue and flips it to in_progress. If another run has already claimed the issue, checkout fails and this run bails out cleanly.
  3. Realizes the execution workspace: either reuses the shared checkout or creates a git worktree, runs setupCommand, exports the project env.
  4. Builds the AdapterExecutionContext — workspace path, decrypted secrets, relevant skills, prior session params, task state.
  5. Calls adapter.execute(ctx) and streams logs via onLog into the live event bus.
  6. On return: recordCost() writes a cost_events row, checkBudgetAndEnforce() pauses the agent if the monthly ceiling is hit, buildHeartbeatRunIssueComment() posts a human-readable summary to the issue, and releaseIssueExecutionAndPromote() unblocks dependents or wakes the parent issue.

Terminal statuses are succeeded, failed, timed_out, cancelled (enforced by HEARTBEAT_RUN_STATUSES in packages/shared/src/constants.ts line 352).

Crash recovery is the piece most orchestrators get wrong. On boot, reconcileStrandedAssignedIssues() (line 3703) scans for issues in todo or in_progress that have no active or queued heartbeat run — these are the ones that fell through the cracks when the server died mid-execution. It queues exactly one auto-wake per stranded issue, tagged with retryReason='assignment_recovery' or retryReason='issue_continuation_needed'. If that recovery wake also fails, the issue transitions to blocked and a comment is posted so a human notices. The deliberate choice to surface stranded work instead of silently retrying forever is one of the most quietly important parts of Paperclip.

Files:

  • server/src/services/heartbeat.ts — the whole scheduler and execution engine (~6000 lines, single file by design)
  • packages/db/src/schema/heartbeat_runs.ts — the run row, including session fields, liveness, cost, log refs
  • packages/db/src/schema/agent_wakeup_requests.ts — wake queue
  • doc/execution-semantics.md — the canonical spec

Deep Dive: The Ticket System

Issue lifecycle and relationsIssue lifecycle and relations

Issues (Paperclip calls them issues, but they're tickets in the project-management sense) are the universal work unit. Every heartbeat's context ultimately resolves to an issue, and every piece of work an agent does is attached to one. The lifecycle:

  • backlog — not ready for work. Parked safely.
  • todo — actionable, assigned or not, not yet locked.
  • in_progress — checked out by a specific run. For agents this is execution-backed: an active or queued-continuation heartbeat_run must exist, or reconcileStrandedAssignedIssues will notice.
  • in_review — waiting for an approval gate. The next executor is paused until the board or a reviewing agent resolves it.
  • blocked — waiting on a relation (another issue, a human decision, a dependency).
  • done / cancelled — terminal states.

The set is defined and validated in packages/shared/src/constants.ts:

TypeScript
export const ISSUE_STATUSES = [
  "backlog",
  "todo",
  "in_progress",
  "in_review",
  "done",
  "blocked",
  "cancelled",
] as const;

Invariants worth understanding. Each issue has one assignee at a time — assigneeAgentId XOR assigneeUserId, enforced by the schema. Checkout is atomic: issues.checkout() sets checkoutRunId and flips status to in_progress in one transaction, and conflicting writers fail fast rather than quietly double-executing. Goal ancestry via goalId and sub-task threading via parentId (a self-FK) guarantee every issue traces up to the company goal.

Relations. An issue is the hub in a small constellation:

  • issue_comments — progress updates and the auto-posted heartbeat summaries, both tagged with createdByRunId so you can trace any comment back to the exact heartbeat that wrote it.
  • approvals — hires and strategy changes; an issue can be in_review pending approval.
  • issue_work_products — files, documents, previews, links, screenshots — the artifacts a run produced.
  • heartbeat_runs — pointed to by checkoutRunId (the current lock) and executionRunId (the current active run).
  • cost_events — one row per billable call, attributed back to the issue via billingCode.
  • execution_workspaces — the git worktree or shared checkout the work ran in.

The ticket system looks mundane — and it is, by design. The control plane's job is to be boring enough that you can trust it with 20 agents running unattended.

Files:

  • packages/db/src/schema/issues.ts — the root schema
  • packages/db/src/schema/issue_comments.ts — comment threading
  • packages/db/src/schema/approvals.ts — approval gates
  • packages/db/src/schema/issue_work_products.ts — artifacts
  • packages/shared/src/constants.tsISSUE_STATUSES, HEARTBEAT_RUN_STATUSES, priority enums
  • doc/execution-semantics.md — status transition rules with examples

Key Takeaways

  • Paperclip is a control plane, not a runtime. It schedules, provides context, captures output, and writes the ledger. It does not write your agent's loop.
  • Adapters are the boundary. A single typed contract (AdapterExecutionContextAdapterExecutionResult) lets Paperclip drive Claude Code, Codex, Cursor, OpenClaw, or anything else that can receive a shell command or a webhook.
  • Heartbeats are atomic. Every agent execution is one queued-then-executed heartbeat_runs row with a checkout lock, cost event, and terminal status. Budgets are enforced on the way out; stranded work is surfaced, not retried silently.
  • Tickets trace to the goal. Every piece of work is an issue linked to a goal and optionally a parent issue, with a single assignee and an atomic checkout. Agents can always explain what they're doing and why.
  • The board retains control. Approvals gate hires and strategy, budgets cap spend with hard stops, and the activity log records every state change.

Where to look in the code

TopicFile
Adapter contract (types only)packages/adapter-utils/src/types.ts
Adapter registry + bundled wiringserver/src/adapters/registry.ts
Builtin adapter type listserver/src/adapters/builtin-adapter-types.ts
Worked adapter implementationpackages/adapters/claude-local/src/server/execute.ts
Heartbeat scheduler + executorserver/src/services/heartbeat.ts
Crash recoveryserver/src/services/heartbeat.tsreconcileStrandedAssignedIssues
Issue schemapackages/db/src/schema/issues.ts
Heartbeat-run schemapackages/db/src/schema/heartbeat_runs.ts
Status constants + zod validatorspackages/shared/src/constants.ts, packages/shared/src/validators/issue.ts
Plugin SDK + adapter contributionpackages/plugins/sdk/, adapter-plugin.md
Canonical execution semantics specdoc/execution-semantics.md
Product model and scopedoc/PRODUCT.md, doc/SPEC.md