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`)
```json
{
"id": "observability",
"name": "Observability",
"description": "Token tracking, cost metrics, and agent performance instrumentation",
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
```typescript
// 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.
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:
```typescript
// modules/observability/src/schema.ts
import { pgTable, uuid, integer, text, timestamp } from "drizzle-orm/pg-core";
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:
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
```json
{
"modules": [
{
"id": "observability",
"name": "Observability",
"description": "Token tracking, cost metrics, and agent performance",
1.**Modules extend, never patch.** Modules add new routes, tables, and hook handlers. They never modify core tables or override core routes.
2.**Hooks are post-commit, fire-and-forget.** Module failures never break core operations.
3.**One-way dependency.** Modules depend on core. Core never depends on modules. Module tables can FK to core tables, not the reverse.
4.**Declarative manifest, imperative registration.** Static metadata in JSON (validated without running code). Runtime behavior registered via the API.
5.**Namespace isolation.** Module routes live under `/api/modules/<id>/`. Module tables are prefixed `mod_<id>_`. Module config is scoped to its ID.
6.**Graceful degradation.** If a module fails to load, log the error and continue. The rest of the system works fine.
7.**Data survives disable.** Disabling a module stops its code but preserves its data. Re-enabling picks up where it left off.