React 19 + Vite frontend, Express REST API backend, PostgreSQL + Drizzle ORM persistence, and the adapter layer. How the four layers connect and where each concern lives.
The four-layer architecture of Paperclip AI, how each layer communicates, and where to look when something goes wrong.
┌─────────────────────────────────────────┐
│ Layer 1: React 19 + Vite Frontend │ Dashboard UI
│ localhost:5173 │
└──────────────────┬──────────────────────┘
│ HTTP/REST
┌──────────────────▼──────────────────────┐
│ Layer 2: Express.js REST API │ Business logic
│ localhost:3100 │
└──────────────────┬──────────────────────┘
│ Drizzle ORM queries
┌──────────────────▼──────────────────────┐
│ Layer 3: PostgreSQL (or PGlite dev) │ Persistence
│ 7 tables, all scoped by company_id │
└──────────────────┬──────────────────────┘
│ adapter.invoke()
┌──────────────────▼──────────────────────┐
│ Layer 4: Adapter Layer │ AI execution
│ Claude Code CLI / OpenAI API / │
│ HTTP webhook / Shell process │
└─────────────────────────────────────────┘
The frontend is a React 19 SPA built with Vite. It connects to the API via fetch calls and provides:
The frontend is read/write through the REST API. It never touches the database directly.
Key files:
apps/web/
src/
pages/ ← one component per page (Dashboard, Agents, Issues, …)
components/ ← shared UI pieces (AgentCard, IssueRow, CostChart, …)
api/ ← typed fetch wrappers for every API endpoint
The API is a Node.js Express server. It handles:
Every request is validated and scoped to a company. The API never returns data from one company to a request authenticated for another.
Key files:
apps/server/
src/
routes/ ← one router per entity (agents.ts, issues.ts, …)
services/ ← heartbeat.service.ts, cost.service.ts, …
adapters/ ← claude.adapter.ts, openai.adapter.ts, …
db/ ← drizzle schema and migration files
The persistence layer uses Drizzle ORM — a TypeScript-first ORM that generates type-safe queries from your schema definition. No raw SQL in application code.
The schema has 7 tables:
| Table | Purpose |
|---|---|
companies |
Workspace/tenant root |
agents |
Agent definitions with adapter config and budget |
issues |
Tasks with hierarchy, priority, and status |
heartbeat_runs |
One record per agent work cycle |
cost_events |
One record per token spend |
approvals |
Pending human approval gates |
activity_log |
Immutable audit trail |
Adapters are the translation layer between Paperclip's abstract task model and a real execution environment. Every adapter implements three methods:
interface Adapter {
invoke(task: Task): Promise<string> // start work, return runId
status(runId: string): Promise<RunStatus> // check if done
cancel(runId: string): Promise<void> // abort if needed
}
Current adapters:
- ClaudeCodeAdapter — spawns the claude CLI as a child process
- OpenAIAdapter — calls the OpenAI chat completions API
- HttpAdapter — POSTs to an external webhook and polls for completion
- ShellAdapter — runs an arbitrary shell command or script
User clicks "Run Now" in dashboard
↓
POST /api/heartbeat/trigger (Layer 2)
↓
SELECT agents WHERE status='active' AND next_run_at <= NOW() (Layer 3)
↓
SELECT issues WHERE assigned_to=agentId AND status='open' ORDER BY priority DESC (Layer 3)
↓
adapter.invoke(issue) (Layer 4)
↓
INSERT heartbeat_runs, cost_events, activity_log (Layer 3)
↓
WebSocket push → dashboard updates in real time (Layer 1)