Architecture for extending Paperclip with plugins: manifest-based modules with routes, UI pages, database tables, and hook subscriptions. Also covers company templates, the Company Store, and a phased implementation plan. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
21 KiB
Paperclip Module System
Overview
Paperclip's module system lets you extend the control plane with new capabilities — revenue tracking, observability, notifications, dashboards — without forking core. Modules are self-contained packages that register routes, UI pages, database tables, and lifecycle hooks.
Separately, Company Templates are code-free data packages (agent teams, org charts, goal hierarchies) that you can import to bootstrap a new company.
Both are discoverable through the Company Store.
Concepts
| Concept | What it is | Contains code? |
|---|---|---|
| Module | A package that extends Paperclip's API, UI, and data model | Yes |
| Company Template | A data snapshot — agents, projects, goals, org structure | No (JSON only) |
| Company Store | Registry for browsing/installing modules and templates | — |
| Hook | A named event in the core that modules can subscribe to | — |
| Slot | An exclusive category where only one module can be active (e.g., observability) |
— |
Module Architecture
File Structure
modules/
observability/
paperclip.module.json # manifest (required)
src/
index.ts # entry point — exports register function
routes.ts # Express router
hooks.ts # hook handlers
schema.ts # Drizzle table definitions
migrations/ # SQL migrations (generated by drizzle-kit)
ui/ # React components (lazy-loaded by the shell)
index.ts # exports page/widget definitions
TokenDashboard.tsx
Modules live in a top-level modules/ directory. Each module is a pnpm workspace package.
Manifest (paperclip.module.json)
{
"id": "observability",
"name": "Observability",
"description": "Token tracking, cost metrics, and agent performance instrumentation",
"version": "0.1.0",
"author": "paperclip",
"slot": "observability",
"hooks": [
"agent:heartbeat",
"agent:created",
"issue:status_changed",
"budget:threshold_crossed"
],
"routes": {
"prefix": "/observability",
"entry": "./src/routes.ts"
},
"ui": {
"pages": [
{
"path": "/observability",
"label": "Observability",
"entry": "./src/ui/index.ts"
}
],
"widgets": [
{
"id": "token-burn-rate",
"label": "Token Burn Rate",
"placement": "dashboard",
"entry": "./src/ui/index.ts"
}
]
},
"schema": "./src/schema.ts",
"configSchema": {
"type": "object",
"properties": {
"retentionDays": { "type": "number", "default": 30 },
"enablePrometheus": { "type": "boolean", "default": false },
"prometheusPort": { "type": "number", "default": 9090 }
}
},
"requires": {
"core": ">=0.1.0"
}
}
Key fields:
id: Unique identifier, used as the npm package name suffix (@paperclip/mod-observability)slot: Optional exclusive category. If set, only one module with this slot can be active. Omit for modules that can coexist freely.hooks: Which core events this module subscribes to. Declared upfront so the core knows what to emit.routes.prefix: Mounted under/api/modules/<prefix>. The module owns this namespace.ui.pages: Adds entries to the sidebar. Lazy-loaded React components.ui.widgets: Injects components into existing pages (e.g., dashboard cards).schema: Drizzle table definitions for module-owned tables. Prefixed withmod_<id>_to avoid collisions.configSchema: JSON Schema for module configuration. Validated before the module loads.
Entry Point
The module's src/index.ts exports a register function that receives the module API:
import type { ModuleAPI } from "@paperclip/core";
import { createRouter } from "./routes.js";
import { onHeartbeat, onBudgetThreshold } from "./hooks.js";
export default function register(api: ModuleAPI) {
// Register route handler
api.registerRoutes(createRouter(api.db, api.config));
// Subscribe to hooks
api.on("agent:heartbeat", onHeartbeat);
api.on("budget:threshold_crossed", onBudgetThreshold);
// Register a background service (optional)
api.registerService({
name: "metrics-aggregator",
interval: 60_000, // run every 60s
async run(ctx) {
await aggregateMetrics(ctx.db);
},
});
}
Module API Surface
interface ModuleAPI {
// Identity
moduleId: string;
config: Record<string, unknown>; // validated against configSchema
// Database
db: Db; // shared Drizzle client
// Routes
registerRoutes(router: Router): void;
// Hooks
on(event: HookEvent, handler: HookHandler): void;
// Background services
registerService(service: ServiceDef): void;
// Logging (scoped to module)
logger: Logger;
// Access core services (read-only helpers)
core: {
agents: AgentService;
issues: IssueService;
projects: ProjectService;
goals: GoalService;
activity: ActivityService;
};
}
Modules get a scoped logger, access to the shared database, and read access to core services. They register their own routes and hook handlers. They do NOT monkey-patch core — they extend through defined interfaces.
Hook System
Core Hook Points
Hooks are the primary integration point. The core emits events at well-defined moments. Modules subscribe in their register function.
| Hook | Payload | When |
|---|---|---|
server:started |
{ port } |
After the Express server begins listening |
agent:created |
{ agent } |
After a new agent is inserted |
agent:updated |
{ agent, changes } |
After an agent record is modified |
agent:deleted |
{ agent } |
After an agent is removed |
agent:heartbeat |
{ agentId, timestamp, meta } |
When an agent checks in. meta carries tokens_used, cost, latency, etc. |
agent:status_changed |
{ agent, from, to } |
When agent status transitions (idle→active, active→error, etc.) |
issue:created |
{ issue } |
After a new issue is inserted |
issue:status_changed |
{ issue, from, to } |
When issue moves between statuses |
issue:assigned |
{ issue, agent } |
When an issue is assigned to an agent |
goal:created |
{ goal } |
After a new goal is inserted |
goal:completed |
{ goal } |
When a goal's status becomes complete |
budget:spend_recorded |
{ agentId, amount, total } |
After spend is incremented |
budget:threshold_crossed |
{ agentId, budget, spent, percent } |
When an agent crosses 80%, 90%, or 100% of budget |
Hook Execution Model
// In the core — hook emitter
class HookBus {
private handlers = new Map<string, HookHandler[]>();
register(event: string, handler: HookHandler) {
const list = this.handlers.get(event) ?? [];
list.push(handler);
this.handlers.set(event, list);
}
async emit(event: string, payload: unknown) {
const handlers = this.handlers.get(event) ?? [];
// Run all handlers concurrently. Failures are logged, never block core.
await Promise.allSettled(
handlers.map(h => h(payload))
);
}
}
Design rules:
- Hooks are fire-and-forget. A failing hook handler never crashes or blocks the core operation.
- Hooks are concurrent. All handlers for an event run in parallel via
Promise.allSettled. - Hooks are post-commit. They fire after the database write succeeds, not before. No vetoing.
- Hooks receive immutable snapshots. Handlers get a copy of the data, not a mutable reference.
This keeps the core fast and resilient. If you need pre-commit validation (e.g., "reject this budget change"), that's a different mechanism (middleware/interceptor) we can add later if needed.
Observability Hook Example
// modules/observability/src/hooks.ts
import type { Db } from "@paperclip/db";
import { tokenMetrics } from "./schema.js";
export function createHeartbeatHandler(db: Db) {
return async (payload: {
agentId: string;
timestamp: Date;
meta: { tokensUsed?: number; costCents?: number; model?: string };
}) => {
const { agentId, timestamp, meta } = payload;
if (meta.tokensUsed != null) {
await db.insert(tokenMetrics).values({
agentId,
tokensUsed: meta.tokensUsed,
costCents: meta.costCents ?? 0,
model: meta.model ?? "unknown",
recordedAt: timestamp,
});
}
};
}
Every heartbeat, the observability module records token usage into its own mod_observability_token_metrics table. The core doesn't know or care about this table — it just emits the hook.
Database Strategy for Modules
Table Namespacing
Module tables are prefixed with mod_<moduleId>_ to avoid collisions with core tables and other modules:
// modules/observability/src/schema.ts
import { pgTable, uuid, integer, text, timestamp } from "drizzle-orm/pg-core";
export const tokenMetrics = pgTable("mod_observability_token_metrics", {
id: uuid("id").primaryKey().defaultRandom(),
agentId: uuid("agent_id").notNull(),
tokensUsed: integer("tokens_used").notNull(),
costCents: integer("cost_cents").notNull().default(0),
model: text("model").notNull(),
recordedAt: timestamp("recorded_at", { withTimezone: true }).notNull().defaultNow(),
});
export const alertRules = pgTable("mod_observability_alert_rules", {
id: uuid("id").primaryKey().defaultRandom(),
agentId: uuid("agent_id"),
metricName: text("metric_name").notNull(),
threshold: integer("threshold").notNull(),
enabled: boolean("enabled").notNull().default(true),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});
Migration Strategy
Each module manages its own migrations in src/migrations/. The core migration runner discovers and applies them:
- Core migrations run first (always)
- Module migrations run in dependency order
- Each module's migrations are tracked in a
mod_migrationstable with the module ID pnpm db:migrateruns everything.pnpm db:migrate --module observabilityruns one.
Modules can reference core tables via foreign keys (e.g., agent_id → agents.id) but core tables never reference module tables. This is a strict one-way dependency.
Module Loading & Lifecycle
Discovery
On server startup:
1. Scan modules/ directory for paperclip.module.json manifests
2. Validate each manifest (JSON Schema check on configSchema, required fields)
3. Check slot conflicts (error if two active modules claim the same slot)
4. Topological sort by dependencies (if module A requires module B)
5. For each module in order:
a. Validate module config against configSchema
b. Run pending migrations
c. Import entry point and call register(api)
d. Mount routes at /api/modules/<prefix>
e. Start background services
6. Emit server:started hook
Configuration
Module config lives in the server's environment or a config file:
// paperclip.config.json (or env vars)
{
"modules": {
"enabled": ["observability", "revenue", "notifications"],
"config": {
"observability": {
"retentionDays": 90,
"enablePrometheus": true
},
"revenue": {
"stripeSecretKey": "$STRIPE_SECRET_KEY"
}
}
}
}
$ENV_VAR references are resolved at load time. Secrets never go in the config file directly.
Disabling a Module
Setting a module's enabled state to false:
- Stops its background services
- Unmounts its routes (returns 404)
- Unsubscribes its hook handlers
- Does NOT drop its database tables (data is preserved)
UI Integration
How Module UI Works
The core UI shell provides:
- A sidebar with slots for module-contributed nav items
- A dashboard with widget mount points
- A module settings page
Modules declare pages and widgets in the manifest. The shell lazy-loads them:
// ui/src/modules/loader.ts
// At build time or runtime, discover module UI entries and create lazy routes
import { lazy } from "react";
// Generated from manifests
export const modulePages = [
{
path: "/observability",
label: "Observability",
component: lazy(() => import("@paperclip/mod-observability/ui")),
},
];
export const dashboardWidgets = [
{
id: "token-burn-rate",
label: "Token Burn Rate",
placement: "dashboard",
component: lazy(() => import("@paperclip/mod-observability/ui").then(m => ({ default: m.TokenBurnRateWidget }))),
},
];
Module UI Contract
A module's UI entry exports named components:
// modules/observability/src/ui/index.ts
export { default } from "./ObservabilityPage";
export { TokenBurnRateWidget } from "./TokenBurnRateWidget";
Module UI components receive a standard props interface:
interface ModulePageProps {
moduleId: string;
config: Record<string, unknown>;
}
interface ModuleWidgetProps {
moduleId: string;
config: Record<string, unknown>;
className?: string;
}
Module UI hits the module's own API routes (/api/modules/observability/*) for data.
Company Templates
Format
A company template is a JSON file describing a full company structure:
{
"id": "startup-in-a-box",
"name": "Startup in a Box",
"description": "A 5-agent startup team with engineering, product, and ops",
"version": "1.0.0",
"author": "paperclip",
"agents": [
{
"ref": "ceo",
"name": "CEO Agent",
"role": "pm",
"budgetCents": 100000,
"metadata": { "responsibilities": "Strategy, fundraising, hiring" }
},
{
"ref": "eng-lead",
"name": "Engineering Lead",
"role": "engineer",
"reportsTo": "ceo",
"budgetCents": 50000
},
{
"ref": "eng-1",
"name": "Engineer",
"role": "engineer",
"reportsTo": "eng-lead",
"budgetCents": 30000
},
{
"ref": "designer",
"name": "Designer",
"role": "designer",
"reportsTo": "ceo",
"budgetCents": 20000
},
{
"ref": "ops",
"name": "Ops Agent",
"role": "devops",
"reportsTo": "ceo",
"budgetCents": 20000
}
],
"goals": [
{
"ref": "north-star",
"title": "Launch MVP",
"level": "company"
},
{
"ref": "build-product",
"title": "Build the product",
"level": "team",
"parentRef": "north-star",
"ownerRef": "eng-lead"
},
{
"ref": "design-brand",
"title": "Establish brand identity",
"level": "agent",
"parentRef": "north-star",
"ownerRef": "designer"
}
],
"projects": [
{
"ref": "mvp",
"name": "MVP",
"description": "The first shippable version"
}
],
"issues": [
{
"title": "Set up CI/CD pipeline",
"status": "todo",
"priority": "high",
"projectRef": "mvp",
"assigneeRef": "ops",
"goalRef": "build-product"
},
{
"title": "Design landing page",
"status": "todo",
"priority": "medium",
"projectRef": "mvp",
"assigneeRef": "designer",
"goalRef": "design-brand"
}
]
}
Templates use ref strings (not UUIDs) for internal cross-references. On import, the system maps refs to generated UUIDs.
Import Flow
1. Parse and validate the template JSON
2. Check for ref uniqueness and dangling references
3. Insert agents (topological sort by reportsTo)
4. Insert goals (topological sort by parentRef)
5. Insert projects
6. Insert issues (resolve projectRef, assigneeRef, goalRef to real IDs)
7. Log activity events for everything created
Export Flow
You can also export a running company as a template:
GET /api/templates/export → downloads the current company as a template JSON
This makes companies shareable and clonable.
Company Store
The Company Store is a registry for discovering and installing modules and templates. For v1, it's a curated GitHub repo with a JSON index. Later it could become a hosted service.
Index Format
{
"modules": [
{
"id": "observability",
"name": "Observability",
"description": "Token tracking, cost metrics, and agent performance",
"repo": "github:paperclip/mod-observability",
"version": "0.1.0",
"tags": ["metrics", "monitoring", "tokens"]
}
],
"templates": [
{
"id": "startup-in-a-box",
"name": "Startup in a Box",
"description": "5-agent startup team",
"url": "https://store.paperclip.dev/templates/startup-in-a-box.json",
"tags": ["startup", "team"]
}
]
}
CLI Commands
pnpm paperclip store list # browse available modules and templates
pnpm paperclip store install <module-id> # install a module
pnpm paperclip store import <template-id> # import a company template
pnpm paperclip store export # export current company as template
Module Candidates
Tier 1 — Build first (core extensions)
| Module | What it does | Key hooks |
|---|---|---|
| Observability | Token usage tracking, cost metrics, agent performance dashboards, Prometheus export | agent:heartbeat, budget:spend_recorded |
| Revenue Tracking | Connect Stripe/crypto wallets, track income, show P&L against agent costs | budget:spend_recorded |
| Notifications | Slack/Discord/email alerts on configurable triggers | All hooks (configurable) |
Tier 2 — High value
| Module | What it does | Key hooks |
|---|---|---|
| Analytics Dashboard | Burn rate trends, agent utilization over time, goal velocity charts | agent:heartbeat, issue:status_changed, goal:completed |
| Workflow Automation | If/then rules: "when issue is done, create follow-up", "when budget at 90%, pause agent" | issue:status_changed, budget:threshold_crossed |
| Knowledge Base | Shared document store, vector search, agents read/write organizational knowledge | agent:heartbeat (for context injection) |
Tier 3 — Nice to have
| Module | What it does | Key hooks |
|---|---|---|
| Audit & Compliance | Immutable audit trail, approval workflows, spend authorization | All write hooks |
| Agent Logs / Replay | Full execution traces per agent, token-by-token replay | agent:heartbeat |
| Multi-tenant | Separate companies/orgs within one Paperclip instance | server:started |
Implementation Plan
Phase 1: Core infrastructure
Add to @paperclip/server:
- HookBus — Event emitter with
register()andemit(), usingPromise.allSettled - Module loader — Scans
modules/, validates manifests, callsregister(api) - Module API object —
registerRoutes(),on(),registerService(), logger, core service access - Module config —
paperclip.config.jsonwith per-module config, env var interpolation - Module migration runner — Extends
db:migrateto discover and run module migrations - Emit hooks from core services — Add
hookBus.emit()calls to existing CRUD operations
Add to @paperclip/ui:
- Module page loader — Reads module manifests, generates lazy routes
- Dashboard widget slots — Render module-contributed widgets on the Dashboard page
- Sidebar extension — Dynamically add module nav items
Add new package:
@paperclip/module-sdk— TypeScript types forModuleAPI,HookEvent,HookHandler, manifest schema
Phase 2: First module (observability)
- Build
modules/observabilityas the reference implementation - Token metrics table + migration
- Heartbeat hook handler recording token usage
- Dashboard widget showing burn rate
- API routes for querying metrics
Phase 3: Templates
- Template import endpoint (
POST /api/templates/import) - Template export endpoint (
GET /api/templates/export) - First template: "Startup in a Box"
Phase 4: Company Store
- GitHub-based store index
- CLI commands for browse/install/import
- UI page for browsing the store
Design Principles
-
Modules extend, never patch. Modules add new routes, tables, and hook handlers. They never modify core tables or override core routes.
-
Hooks are post-commit, fire-and-forget. Module failures never break core operations.
-
One-way dependency. Modules depend on core. Core never depends on modules. Module tables can FK to core tables, not the reverse.
-
Declarative manifest, imperative registration. Static metadata in JSON (validated without running code). Runtime behavior registered via the API.
-
Namespace isolation. Module routes live under
/api/modules/<id>/. Module tables are prefixedmod_<id>_. Module config is scoped to its ID. -
Graceful degradation. If a module fails to load, log the error and continue. The rest of the system works fine.
-
Data survives disable. Disabling a module stops its code but preserves its data. Re-enabling picks up where it left off.