Architecture
Three deployments and a Postgres. They are independent, and the only thing they must agree on is DATABASE_URL and BETTER_AUTH_SECRET.
| Path | What it is |
|---|---|
apps/app | Next.js front end · :3000 |
apps/api | NestJS — HTTP, auth, tRPC, Google sync · :3001 |
apps/agent | The research agent — tools, skills, schedules, sandbox · :2000 |
packages/db | Prisma schema, migrations, shared Postgres client |
packages/auth | Auth config and the sign-in allow-list |
packages/ui | Shared components and the theme |
packages/env | Finds and loads the root .env |
Intelligence never lives in the API
This is an agentic-first platform. The API serves HTTP, auth, tRPC and the Google sync. It does not research, enrich, score, summarize, match identities, or decide anything about a person or a company — not as a fallback, not "just the cheap bit", not behind a flag.
Nest's half of the contract is to report that something happened — a thread was ingested, a company was created, an attendee is unknown — and let the agent decide what it means. It writes a task row rather than making an HTTP call: the agent leases work from that table already, so the row is the message, and it survives the agent being down, redeployed, or slower than the request that produced it.
The reason is in the git history: two copies of an identity matcher, one in the API and one in the agent, silently drifted until one matched every employer on earth.
There are no organizations
Single tenant, deliberately. There is no organization-id on any CRM record, no org context, no org-scoped cache keys. What exists is a singleton workspace — one row whose id is the literal string workspace — there to answer what the company is called, who works there, and what it sells, and for nothing else. An organizationId that is always the same value is a column, an index and a permissions check that buys nothing and reads like a real one at review time.
The work queue
lib/tasks.ts is the queue. claimDue leases rows with FOR UPDATE SKIP LOCKED, so two dispatchers take disjoint work and a run that dies frees its row when the lease expires. The task rows carry contactId and companyId as plain columns with no foreign key — they outlive the records they name on purpose, so the queue survives a redeploy — which means a record delete has to clear them itself.
The bridge
The Agent tab talks to the running agent through the app, which is an enforcement point, not a passthrough:
browser → /eve/v1/* (same origin, session cookie)
→ app route: checks the session, strips the cookie,
mints a 2-minute token naming the rep + the record id
→ AGENT_URL/eve/v1/* (verifies the token)
The agent never sees the session cookie. The record travels in the token, never in the message.
The sandbox
The agent's shell tools run in a /workspace with deny-all egress set on the backend factory so it cannot be forgotten per session. That costs nothing — web fetch runs in the app runtime and web search at the model provider, so retrieval is unaffected. The sandbox is never given DATABASE_URL: CRM access is authored tools in the app runtime, and a shell with credentials and network is exfiltration-shaped even in an internal tool. See Security model.