Planning artifacts (milestones v1.0-v1.2.1, v1.3 queue, PROJECT.md, STATE.md, config) now live alongside the code they describe. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
23 KiB
Architecture
Analysis Date: 2026-03-30
Codebase: Paperclip (nexus repo at /Volumes/UsbNvme/repos/nexus/)
Pattern Overview
Overall: Multi-package TypeScript monorepo. Server-rendered React SPA (Vite) + Express API server + CLI tool + adapter plugin system.
Key Characteristics:
- pnpm workspaces monorepo with five top-level workspace members:
server,ui,cli,packages/*,plugins/* - Express REST API with actor-based auth middleware (board users, agents, instance admin)
- React SPA using React Router v6 with company-prefixed URL scheme (e.g.,
/PAP/issues) - Agent execution driven by a heartbeat lifecycle: wakeup requests → heartbeat runs → adapter subprocess calls
- Plugin system with isolated worker processes and structured host/worker RPC
Monorepo Structure
nexus/
├── server/ # Express API + business logic
│ ├── src/
│ │ ├── routes/ # Express route handlers
│ │ ├── services/ # Business logic layer
│ │ ├── adapters/ # Agent execution adapter registry
│ │ ├── auth/ # BetterAuth integration
│ │ ├── middleware/ # auth, logging, validation, error handling
│ │ ├── realtime/ # WebSocket live-events bridge
│ │ ├── secrets/ # Secret provider abstraction
│ │ ├── storage/ # S3/local file storage abstraction
│ │ └── app.ts # Express app factory
├── ui/ # React SPA (Vite)
│ └── src/
│ ├── pages/ # Top-level page components
│ ├── components/ # Shared UI components
│ ├── context/ # React contexts (company, dialog, live updates)
│ ├── api/ # API client modules (one per domain)
│ ├── hooks/ # Custom React hooks
│ └── App.tsx # Router + route tree
├── cli/ # paperclipai CLI (Commander.js)
│ └── src/
│ ├── commands/ # CLI command implementations
│ ├── config/ # Config file read/write
│ └── prompts/ # @clack/prompts interactive wizards
├── packages/
│ ├── db/ # Drizzle ORM schema + migrations + DB client
│ ├── shared/ # Shared types, validators (Zod), constants
│ ├── adapter-utils/ # Shared adapter type contracts + session utils
│ └── adapters/ # Adapter packages (one per AI tool)
│ ├── claude-local/
│ ├── codex-local/
│ ├── cursor-local/
│ ├── gemini-local/
│ ├── opencode-local/
│ ├── pi-local/
│ └── openclaw-gateway/
└── plugins/
├── sdk/ # @paperclipai/plugin-sdk (worker-side SDK)
├── create-paperclip-plugin/ # Plugin scaffolding tool
└── examples/ # Example plugins
Layers
Database Layer:
- Purpose: Schema definitions, migrations, Drizzle ORM client
- Location:
packages/db/src/ - Contains: Drizzle table definitions in
schema/, migration SQL inmigrations/,client.ts(embedded Postgres or external URL) - Depends on: PostgreSQL (embedded via
embedded-postgresor external) - Used by: Server services exclusively
Services Layer:
- Purpose: All business logic; one service function per domain
- Location:
server/src/services/ - Contains:
agentService,companyService,heartbeatService,issueService,pluginLifecycleManager, etc. - Pattern: Each service is a factory function taking
db: Dband returning an object of async methods. No class instances. - Depends on:
@paperclipai/dbschema tables, other services - Used by: Route handlers
Route Layer:
- Purpose: HTTP request parsing, auth assertions, response shaping
- Location:
server/src/routes/ - Contains: One file per resource domain (companies, agents, issues, projects, routines, plugins, etc.)
- Pattern: Each route file exports a factory
function fooRoutes(db: Db): Router. Routes call service methods, assert auth viaassertBoard/assertCompanyAccesshelpers. - Depends on: Services layer, middleware
Middleware Layer:
- Purpose: Cross-cutting request processing
- Location:
server/src/middleware/ - Files:
auth.ts—actorMiddleware: resolvesreq.actoras board user, agent, or noneboard-mutation-guard.ts— blocks unsafe mutations on read-only mountsprivate-hostname-guard.ts— hostname allowlist enforcementvalidate.ts— Zod schema validation helperlogger.ts— pino loggererror-handler.ts— global Express error handler
Adapter Layer:
- Purpose: Execute agents via different AI tools (Claude, Codex, Cursor, etc.)
- Location:
server/src/adapters/(registry + process/http sub-adapters),packages/adapters/(per-tool implementations) - Pattern: Each adapter implements
ServerAdapterModulefrom@paperclipai/adapter-utils. The registry (registry.ts) maps adapter type strings to module instances.heartbeatServicecallsgetServerAdapter(type).execute(...)to run agents.
UI Layer:
- Purpose: React SPA for the board (human operators)
- Location:
ui/src/ - API communication:
ui/src/api/client.ts— simplefetchwrapper. All domain API modules (e.g.,ui/src/api/agents.ts) use thisapiobject. No external HTTP library. - State: TanStack Query (
@tanstack/react-query) for server state. React Context for cross-component state (company selection, dialogs, live updates, theme). - Real-time: SSE from
/api/realtimeconsumed byLiveUpdatesProviderinui/src/context/LiveUpdatesProvider.tsx.
CLI Layer:
- Purpose: Operator tooling — first-run setup, diagnostics, server run, one-off commands
- Location:
cli/src/ - Framework: Commander.js with
@clack/promptsfor interactive steps
Authentication Model
Two principal types flow through actorMiddleware (server/src/middleware/auth.ts):
Board (human):
local_trusteddeployment: automatically grantedisInstanceAdmin: true, no auth neededauthenticateddeployment: BetterAuth session cookie OR bearer API key (fromboard_api_keystable)req.actor.type === "board", carriesuserId,companyIds[],isInstanceAdmin
Agent:
- Bearer token: either a hashed API key from
agent_api_keystable, or a short-lived JWT (createLocalAgentJwt) req.actor.type === "agent", carriesagentId,companyId- Agents cannot access other companies; CEO agents get additional mutation rights (branding, portability)
Route authorization helpers live in server/src/routes/authz.ts:
assertBoard(req)— throws 403 if not boardassertCompanyAccess(req, companyId)— throws 403 if actor cannot access this companyassertInstanceAdmin(req)— throws 403 if not instance admin
Company Model
Companies are the top-level tenant container. They map to what will become "projects" in Nexus.
Schema: packages/db/src/schema/companies.ts
companies
id uuid (PK)
name text
description text
status text (active | paused | archived)
issuePrefix text UNIQUE // e.g. "PAP" — used in URLs and identifiers
issueCounter integer // auto-increment for issue numbers
budgetMonthlyCents integer
requireBoardApprovalForNewAgents boolean
brandColor text
Company creation flow (POST /api/companies):
- Board asserts instance admin
companyService.create(data)inserts companyaccessService.ensureMembership(...)adds creator as owner incompany_memberships- If budget set,
budgetService.upsertPolicy(...)creates budget policy - Activity logged
Company service: server/src/services/companies.ts — companyService(db) factory returning list, getById, create, update, archive, remove, stats.
Company portability: server/src/services/company-portability.ts — import/export bundles; CEO agents can run imports/exports on their own company.
Company members: packages/db/src/schema/company_memberships.ts — principalType (user|agent), principalId, companyId, role (owner|member), status (active|invited|etc.)
Agent Model
Agents are AI workers belonging to a company. CEO is a special role with elevated permissions.
Schema: packages/db/src/schema/agents.ts
agents
id uuid (PK)
companyId uuid (FK companies)
name text
role text // "ceo" | "general" | custom
title text
reportsTo uuid // self-referential FK for org chart
status text // idle | running | paused | terminated | pending_approval
adapterType text // "claude_local" | "codex_local" | "cursor" | etc.
adapterConfig jsonb // adapter-specific config (model, instructionsFilePath, etc.)
runtimeConfig jsonb // session compaction policy, max concurrent runs
budgetMonthlyCents integer
permissions jsonb // canCreateAgents, etc.
lastHeartbeatAt timestamp
Agent service: server/src/services/agents.ts — agentService(db) factory. Key methods:
create,update,pause,resume,terminate,removeorgForCompany(companyId)— builds hierarchical org tree fromreportsTolinksgetChainOfCommand(agentId)— walks upreportsTochaincreateApiKey,listKeys,revokeKeyrollbackConfigRevision— restores an agent config fromagent_config_revisions
CEO agents: Identified by role === "ceo". Only CEO agents can:
- Update company branding (
PATCH /api/companies/:id/branding) - Manage company portability (import/export)
- The default first task in onboarding is assigned to the CEO agent
Agent Heartbeat / Task Lifecycle
This is the core execution engine. An "agent run" is called a heartbeat run.
Flow:
1. Wake request queued
→ agent_wakeup_requests (status: queued)
→ agentWakeupRequest.source = "timer" | "assignment" | "on_demand" | "automation"
2. heartbeatService.runHeartbeat(agentId, options)
→ acquires per-agent start lock (Map<agentId, Promise>)
→ checks concurrent run limits (runtimeConfig.maxConcurrentRuns, default 1)
→ resolves execution workspace (project repo, task session, or agent home dir)
→ resolves session state (previous session params from agent_task_sessions)
→ checks session compaction (rotate if maxSessionRuns / maxRawInputTokens exceeded)
→ inserts heartbeat_runs row (status: queued → running)
3. getServerAdapter(adapterType).execute(context, meta)
→ adapter launches subprocess or HTTP call
→ streams stdout/stderr chunks back
→ heartbeatService writes live log chunks to run log store
4. On completion:
→ updates heartbeat_runs (status: done | error | cancelled)
→ persists session state to agent_task_sessions
→ writes cost event to cost_events
→ publishes live event to EventEmitter for SSE clients
→ updates issue status if issue was being worked
5. Wakeup request marked finished
Key tables:
heartbeat_runs— one row per execution (packages/db/src/schema/heartbeat_runs.ts)agent_wakeup_requests— queued wakeup requests, coalesced by idempotency keyagent_task_sessions— persisted session params per (agent, adapterType, taskKey)agent_runtime_state— current session ID for an agent
heartbeatService: server/src/services/heartbeat.ts (~1400 lines). The central orchestrator. Dependencies: adapter registry, workspace services, session codec, budget service, issue service, cost service.
Adapter System
Adapters wrap specific AI tools (Claude Code, OpenAI Codex, Cursor, Gemini, etc.).
Contract: ServerAdapterModule from packages/adapter-utils/. Key methods:
execute(context, meta): Promise<AdapterExecutionResult>— run the agenttestEnvironment(config): Promise<AdapterEnvironmentTestResult>— check prerequisiteslistSkills/syncSkills— skill managementsessionCodec— serialize/deserialize session stateonHireApproved(optional) — notify adapter when a hired agent is approved
Registry: server/src/adapters/registry.ts — maps type string to ServerAdapterModule. Falls back to processAdapter for unknown types.
Registered adapter types:
claude_local— Claude Code CLI subprocess (packages/adapters/claude-local/)codex_local— OpenAI Codex (packages/adapters/codex-local/)opencode_local— OpenCode (packages/adapters/opencode-local/)pi_local— Pi adapter (packages/adapters/pi-local/)cursor— Cursor IDE (packages/adapters/cursor-local/)gemini_local— Gemini CLI (packages/adapters/gemini-local/)openclaw_gateway— HTTP gateway adapter (packages/adapters/openclaw-gateway/)process— generic subprocess adapter (server/src/adapters/process/)http— generic HTTP adapter (server/src/adapters/http/)hermes_local— third-party Hermes adapter
Process adapter: server/src/adapters/process/ — launches a configured command as a child process, streams stdout/stderr, parses structured JSON event chunks from the process.
Plugin System
Plugins extend Paperclip with custom tools, UI slots, scheduled jobs, and webhooks.
Architecture:
- Each plugin runs as a separate worker process (isolated via
pluginWorkerManager) - Host (server) communicates with worker via JSON-RPC over stdin/stdout
- Host provides
PluginContextservices to the worker via the SDK
Key services:
plugin-lifecycle.ts— state machine:installed → ready → disabled/error/upgrade_pending → uninstalled. Starting/stopping worker processes on transitions.plugin-loader.ts— loads plugin packages from disk, validates manifestsplugin-worker-manager.ts— spawns/manages worker processesplugin-job-coordinator.ts+plugin-job-scheduler.ts— scheduled job executionplugin-tool-dispatcher.ts— routes tool invocations from agents to plugin workersplugin-event-bus.ts— dispatches Paperclip events (issue.created, agent.run.completed, etc.) to plugin workersplugin-host-services.ts— host-side service implementations exposed to workers via SDKplugin-registry.ts— DB CRUD for plugin records
Worker SDK: packages/plugins/sdk/src/ — @paperclipai/plugin-sdk. Plugin authors import this to define tools, jobs, webhooks, UI slots, and interact with Paperclip state.
Plugin manifest: PaperclipPluginManifestV1 from @paperclipai/shared — declares capabilities, tools, jobs, webhooks, UI slots.
DB tables: plugins, plugin_config, plugin_state, plugin_jobs, plugin_logs, plugin_entities, plugin_webhooks, plugin_company_settings
Issue / Task Model
Issues are work items assigned to agents or users.
Schema: packages/db/src/schema/issues.ts — key fields:
issues
id, companyId, projectId, goalId, parentId (self-ref)
title, description, status (backlog|todo|in_progress|in_review|blocked|done|cancelled)
priority (low|medium|high|urgent)
assigneeAgentId, assigneeUserId
checkoutRunId, executionRunId // FK to heartbeat_runs
executionWorkspaceId
issueNumber, identifier (e.g. "PAP-42")
originKind, originId // "manual" | "routine_execution" | "heartbeat"
requestDepth // for sub-issues created by agents
Identifier format: {issuePrefix}-{issueNumber} — e.g., PAP-42. The prefix is unique per company.
Sub-issues: parentId self-reference allows agent-created sub-issues. requestDepth tracks nesting.
Execution workspace: Each issue can have an executionWorkspaceId pointing to a specific git worktree or container workspace. Managed by execution-workspace-policy.ts.
Project Model
Projects group issues and have an optional lead agent and git repo.
Schema: packages/db/src/schema/projects.ts
projects
id, companyId, goalId
name, description, status (backlog|active|paused|done|archived)
leadAgentId
targetDate, color
executionWorkspacePolicy jsonb // repo URL, worktree settings
Projects will become the renamed entity in Nexus (replacing "companies" as the primary project container).
Goal Model
Goals are objectives that group projects and issues.
Schema: packages/db/src/schema/goals.ts — id, companyId, title, description, status, parentId (hierarchical)
Onboarding Flow
CLI Onboarding (paperclipai onboard)
Interactive wizard in cli/src/commands/onboard.ts driven by @clack/prompts:
- Mode selection — "quickstart" (embedded Postgres, sane defaults) or "advanced" (custom DB, S3, auth)
- Database — embedded Postgres (auto-configured) or external
DATABASE_URL - LLM — configure default LLM provider/API key
- Server — port, host, deployment mode (
local_trustedvsauthenticated), public URL - Storage — local filesystem or S3
- Secrets — local encrypted file or external secret manager
- Writes config to
~/.paperclip/config.yaml(or--configpath) - Optionally runs
auth bootstrap-ceoto generate first admin invite
UI Onboarding Wizard (OnboardingWizard.tsx)
4-step dialog at ui/src/components/OnboardingWizard.tsx:
- Step 1 — Company: enter company name + high-level goal. Creates company via
POST /api/companies, creates a Goal record. - Step 2 — Agent: name the CEO agent, select adapter type (claude_local, codex_local, cursor, etc.), configure model. Tests adapter environment. Creates agent via
POST /api/companies/:id/agents. - Step 3 — First Task: pre-filled task description (
"Hire a founding engineer, write a hiring plan, break the roadmap into concrete tasks"). Creates a project + issue assigned to the CEO agent. - Step 4 — Launch: confirms entities created, navigates to issue detail. Shows run transcript.
Trigger: openOnboarding() from DialogContext. Also triggered by route /onboarding or by navigating to company-prefixed routes when no companies exist.
Default task: The CEO agent's first issue is "Hire your first engineer and create a hiring plan" with the task description guiding it to hire a founding engineer and begin delegating work.
Real-time Updates
Server: server/src/services/live-events.ts — in-process EventEmitter. Services call publishLiveEvent({ companyId, type, payload }) after mutations. SSE endpoint in server/src/realtime/live-events-ws.ts streams events per company.
Client: ui/src/context/LiveUpdatesProvider.tsx — subscribes to SSE, calls queryClient.invalidateQueries(...) to refresh TanStack Query caches on relevant events. Also shows toast notifications for agent run completions and issue status changes.
CLI Commands
Entry point: cli/src/index.ts — Commander.js program named paperclipai.
Setup commands:
onboard— interactive first-run setup wizardrun— onboard + doctor + start serverconfigure— update config sections (llm, database, logging, server, storage, secrets)doctor— diagnostic checks + optional auto-repairenv— print deployment environment variablesallowed-hostname— add hostname to allowlistdb:backup— one-off database backup
Runtime commands:
heartbeat run --agent-id <id>— trigger one agent heartbeat and stream logs
Client commands (interact with a running Paperclip instance):
company list|create|get— CRUD on companiesissue list|create|get|update— CRUD on issuesagent list|create|get|update— CRUD on agentsapproval list|get|approve|reject— manage approvalsactivity list— view activity logdashboard— display dashboard statsplugin list|install|enable|disable|uninstall— plugin managementauth login|logout|whoami|bootstrap-ceo— auth operationsworktree ...— git worktree management for execution workspaces
Config/context for client commands loaded from ~/.paperclip/config.yaml or --config flag.
Routine Model
Routines are scheduled triggers that create issues on a cron-like cadence.
Schema: packages/db/src/schema/routines.ts
Routines fire at configured intervals, create issues with the configured title/description, and assign them to the specified agent. server/src/services/cron.ts drives the scheduling.
Execution Workspace Model
Execution workspaces provide isolated git checkouts per project or issue.
Schema: packages/db/src/schema/execution_workspaces.ts, project_workspaces.ts
Managed by server/src/services/execution-workspace-policy.ts and workspace-runtime.ts. On heartbeat start, heartbeatService calls realizeExecutionWorkspace(...) to ensure the workspace directory + git clone exist before passing cwd to the adapter.
Key Data Model Relationships
Instance
└── companies (1:N)
├── agents (1:N) — role: ceo | general | custom
│ └── reportsTo (self-ref tree)
├── projects (1:N)
│ └── issues (1:N)
├── goals (1:N, hierarchical)
├── routines (1:N)
├── heartbeat_runs (1:N, via agents)
│ └── heartbeat_run_events (1:N)
├── agent_wakeup_requests (1:N)
├── agent_task_sessions (1:N)
├── approvals (1:N)
├── cost_events (1:N)
├── company_secrets (1:N)
└── plugins (M:N via plugin_company_settings)
Entry Points
Server:
server/src/index.ts— boots Postgres, runs migrations, callscreateApp(db, opts), starts HTTP listenerserver/src/app.ts—createApp(db, opts)— wires Express middleware, mounts all routes, initializes plugin system, returnsapp
UI:
ui/src/main.tsx— React root, wraps<App />in TanStack Query provider,CompanyProvider, etc.ui/src/App.tsx— top-level router withCloudAccessGate, company-prefixed routes,<OnboardingWizard />
CLI:
cli/src/index.ts— Commander program, registers all commands, callsprogram.parseAsync()
Error Handling
Server: server/src/errors.ts exports notFound, conflict, forbidden, unprocessable helpers that throw typed errors. server/src/middleware/error-handler.ts catches them and returns structured JSON: { error: string }.
UI: ui/src/api/client.ts — ApiError class with status and body. Components check error instanceof ApiError and display error.message.
Deployment Modes
Controlled by PAPERCLIP_DEPLOYMENT_MODE:
local_trusted— single-user local, no auth required. All requests automatically getisInstanceAdmin: true.authenticated— multi-user, requires BetterAuth session or API key. Board API keys stored inboard_api_keys.
PAPERCLIP_DEPLOYMENT_EXPOSURE:
public— open to external trafficprivate— private-hostname-guard enforces allowlist
Architecture analysis: 2026-03-30