Technical Documentation: trycompai/crm
ℹ️ Provenance: Multi-Tier Ingestion · CodeWiki Reference
Overview (`README.md`)
CRM
What this is
Most CRMs are a database with a form in front of it. The AI ones bolt a chat box onto
the side of that form. Both leave the actual work — finding out what is true, and
writing it down — to a human who has better things to do.
This is built the other way round. The agent is not a feature of the CRM; the CRM is
where the agent keeps its notes. It runs on its own deployment, on its own schedule,
against its own work queue. It decides what to look at next, books its own follow-ups,
spends a research budget, and stops when the budget runs out. Nothing about it is
request-response: close the browser and it keeps going.
The rule the agent itself never breaks: nothing about a person is guessed. No tool
accepts a confidence score, because a model asked to grade its own certainty will, and
it will be wrong in the direction that makes it look useful. Tools report what they
observed — crm.signature-block, github.account-identity — and a ledger prices the
evidence. Strong evidence writes to the record. Weak evidence becomes a suggestion a
human settles. A confidently wrong fact about a customer is worse than a blank field,
because nobody can tell it is wrong.
Screenshots
The agent
apps/agent is its own deployment, built on
eve — Vercel's filesystem-first framework for durable agents.
A tool is a file, a skill is a markdown file, a schedule is a file, and the runtime
handles the durable part: sessions that survive a redeploy, work that resumes where it
stopped.
| 18 authored tools | read_crm_history, search_crm, identify_contact, research_person, enrich_company, record_fact, schedule_recheck… |
| 4 skills | evidence.md, identity-matching.md, data-boundaries.md, writing-a-brief.md — prose the agent reads, versioned like code |
| 1 schedule | dispatch.ts, which decides nothing. It leases what is due and starts a session per row. |
| A sandbox | bash, grep, glob and a /workspace, with deny-all egress |
It runs itself. lib/tasks.ts is the work queue: claimDue leases rows withFOR UPDATE SKIP LOCKED, so two dispatchers take disjoint work and a run that dies
frees its row when the lease expires. Anything that looks like "every N minutes, the
oldest ten contacts" belongs in a task's dueAt, not in a cron expression. When the
agent wants another look at somebody it calls schedule_recheck and says why — and
the reason is shown to the rep, because an agent that cannot say why it will be back
in fourteen days does not have a reason, it has a default.
Every outside source is optional, and it is designed to run with none of them.
With no API keys at all it still works: read_crm_history reads your own threads,
meetings and signature blocks, which is free and is the best evidence there is — no
data vendor can sell you a reply from the person's own address. Each key opens one
more place to look. It is told at the start of every session which ones this install
has, so it plans around what it actually has rather than discovering the gaps one
failed call at a time, and it prints the list at startup:
[agent] off Web research (PERPLEXITY_API_KEY)
[agent] on Company brand data (Settings → General)
[agent] on LinkedIn (Settings → General)Company brand data and LinkedIn are both Context,
and both come from the same key. The first is the logo, the colours, the industry and
the real name behind a domain, which is the difference between an account that arrives
as itself and one that arrives as a grey square with its initials in it. The second
reads a person back from a LinkedIn URL already on their record — their real name,
current title, employer and every earlier role — which is what settles who an address
belongs to. It is the one key that is asked for rather than configured: it lives in a
row, the onboarding asks for it, and Settings → General changes it afterwards,
because a self-hoster's admin cannot redeploy to set an environment variable.
The sandbox has no network and no database. Turning it on is what gives the model
a shell — the difference between a tool-caller and something that can keep a dossier,
diff this month's profile against last month's, and grep a thread for a signature
block. deny-all egress costs nothing, because web_fetch runs in the app runtime
and web_search at the model provider. What it removes is the only path by which a
customer's email body could leave through a shell command. The other half of that rule
is an absence: the sandbox is never given DATABASE_URL. A shell with credentials
and egress is exfiltration-shaped even in an internal tool; a shell with neither is a
text processor.
You can talk to it, and watch it work. Every contact, company and deal has an
Agent tab — the steps as it takes them, the leads it throws away and why, and its
questions answered in place when it cannot decide between two people. Conversations
are durable and survive a reload; the record travels in a signed token rather than
being bolted onto the front of your message. Set AGENT_BRIDGE_SECRET to the same
value in both processes to turn it on. Without it the tab reports that it is not
configured, and the agent carries on running its own schedule.
docs/agent.md is the full write-up.
The stack
A Turborepo monorepo on Bun, deployed on
Vercel.
| Agent | eve — durable sessions, tools, skills, schedules, sandboxes |
| Model | Vercel AI Gateway — no provider SDK, and OIDC on Vercel means no key to manage |
| Sandbox | Vercel Sandbox in production, Docker or microsandbox locally |
| Front end | Next.js App Router · shadcn/ui · nuqs for URL state |
| API | NestJS with nestjs-trpc — HTTP, auth, tRPC, mailbox sync |
| Data | Prisma · Postgres (Neon) · optional Redis (Upstash) |
| Auth | Better Auth — Google, Microsoft, or your own IdP; one allow-list |
| Files | Vercel Blob — mirrors profile pictures so they survive the source going away |
| Tooling | Biome · TypeScript everywhere |
The app talks to the API over tRPC, and the router type is generated from the
NestJS routers — so the front end is type-safe from the Prisma row to the table cell.
List state (filters, sort, page) lives in the URL, so copying the address bar
reproduces the view.
Layout
| Path | |
|---|---|
apps/agent |
The research agent — tools, skills, schedules, sandbox |
apps/app |
Next.js front end · :3000 |
apps/api |
NestJS API — HTTP, auth, tRPC, mailbox sync · :3001 |
packages/db |
Prisma schema, migrations, shared Postgres client |
packages/auth |
Better Auth config and the sign-in allow-list |
packages/ui |
shadcn/ui components, the Tailwind theme |
packages/env |
Finds and loads the root .env |
Three rules the codebase holds to
Written up where the work happens, not in a style guide:
- Intelligence never lives in the API (docs/api.md). Nest reports
that something happened; the agent decides what it means. Two copies of an identity
matcher once drifted until one matched every employer on earth. packages/uiis the only source of UI (docs/design.md). No
overriding styles at the call site.- There are no organizations. Single tenant, deliberately. 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.
Quick start
You need Bun and Docker.
git clone https://github.com/trycompai/crm.git && cd crm
cp .env.example .env # then fill in the values below
bun install
docker compose up -d # Postgres on :5432
bun run db:deploy # apply migrations
bun run db:seed # optional: a believable pipeline to look at
bun run devThe app is on localhost:3000, the API on
localhost:3001.
That clone gives you release, the default branch and the last tagged release — what you
want if you are running this. main is where unreleased work lands: green, but not cut
yet. If you are here to send a pull request, git switch main first and read
CONTRIBUTING.
The values to set
Open .env and set these. Everything else in the file is optional and commented out.
| Variable | What to put in it |
|---|---|
BETTER_AUTH_SECRET |
openssl rand -base64 32 |
ALLOWED_SIGN_IN |
Your email domain, e.g. acme.com. Or one address, e.g. [email protected]. |
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET |
A Google OAuth client — 2 minutes, below. Both or neither. |
MICROSOFT_CLIENT_ID / MICROSOFT_CLIENT_SECRET |
A Microsoft Entra app registration — below. Both or neither. |
Pick at least one of Google and Microsoft, or add your own identity provider on
Settings → SSO once you are in. Setting both is fine and common: the sign-in page
offers both buttons, and each rep's mail is read from whichever they signed in with.
DATABASE_URL already matches the docker compose Postgres, so leave it alone unless
you brought your own.
Getting the Google OAuth client
- Google Cloud console → Credentials → Create credentials → OAuth client ID → Web application.
- Under Authorised redirect URIs, add
http://localhost:3001/api/auth/callback/google. - Enable the Gmail API and the Calendar API for the project.
- Copy the client ID and secret into
.env.
Google is the sign-in method a clone starts with, and the same client reads Gmail and
Calendar — so most installs want it. The API will nonetheless boot without it: an
install that signs in with Microsoft, or through its own identity provider added on
Settings → SSO, leaves both empty and gets no Google button and no Gmail sync. Set
them together or not at all; half a pair is a button that fails at Google. If your
account is on a Google Workspace domain, set the consent screen to Internal and
nobody outside your org can even reach the prompt.
Getting the Microsoft app registration
- Azure portal → Microsoft Entra ID → App
registrations → New registration. Microsoft's own walkthrough is
Register an application. - Under Supported account types, pick Accounts in this organizational directory
only if everyone signing in is on your tenant. Anything wider is fine too —ALLOWED_SIGN_INstill decides who gets an account — but the narrow choice turns
outsiders away at Microsoft rather than at our door. - Set the Redirect URI to Web and
http://localhost:3001/api/auth/callback/microsoft. In production this ishttps://<your-api-host>/api/auth/callback/microsoft— the API's origin, not the
app's, because the API is what serves/api/auth/*. - API permissions → Add a permission → Microsoft Graph → Delegated
permissions → tick User.Read and Mail.Read.offline_accessis requested
at sign-in and needs no entry here. If your tenant requires admin consent, click
Grant admin consent so reps are not each asked. - Certificates & secrets → New client secret. Copy the Value, not the
Secret ID — the value is shown once and never again. - Overview → copy the Application (client) ID.
- Put the two into
.envasMICROSOFT_CLIENT_IDandMICROSOFT_CLIENT_SECRET.
Mail.Read is read-only: the CRM can list and read messages and can never send,
reply, move or delete. Reading is forward-only — the first check records the current
time and imports nothing, so connecting an old mailbox does not dump years of mail into
the CRM.
To refuse other tenants at Microsoft rather than relying on ALLOWED_SIGN_IN, setMICROSOFT_TENANT_ID to your tenant's GUID (Overview → Directory (tenant) ID).
It defaults to common, which accepts any work, school or personal Microsoft account
and leaves the allow-list to sort them out. organizations is the middle setting: any
work or school account, no personal ones.
Microsoft client secrets expire — 24 months at most, and 6 months by default. Note the
expiry somewhere, because the symptom of a lapsed one is every rep's mail quietly
failing to sync.
ALLOWED_SIGN_IN is the entire authorisation model — an unset value means nobody can
sign in, which is the safe direction to fail. It takes whole domains, individual
addresses, or a mix:
ALLOWED_SIGN_IN="acme.com" # everyone at your company
ALLOWED_SIGN_IN="acme.com,[email protected]" # …plus one outsider
ALLOWED_SIGN_IN="[email protected]" # a one-person installConfiguration
There is one .env, at the root of the repo, read by all three processes. Real
environment variables always win, so on a hosting platform you configure it there and
the file is purely a local convenience.
Beyond the values above, everything is optional and the app runs without any
of it. .env.example is the full list with a note on each; the
short version:
API_URL / APP_URL |
Where the two halves are served. Only needed off localhost. |
PERPLEXITY_API_KEY |
Lets the agent search the open web, with citations. |
AGENT_BRIDGE_SECRET |
Lets a rep talk to the agent from a contact's Agent tab. |
REDIS_URL |
A shared cache. Without it, per-instance and in-memory. |
CRON_SECRET |
Guards the mailbox sync route. Required to use it. |
CRM_TELEMETRY_DISABLED |
Set to 1 and this install reports nothing. DO_NOT_TRACK too. |
Tasks
| Command | |
|---|---|
bun run dev |
Prepare the local database, then run everything in dependency-aware watch mode |
bun run build |
Build all apps and packages |
bun run test |
Run the test suite |
bun run check-types |
tsc --noEmit everywhere |
bun run lint / format |
Biome |
bun run db:migrate |
Create and apply a migration |
bun run db:seed |
Top up the demo pipeline (idempotent) |
bun run db:studio |
Prisma Studio |
bun run --filter=api trpc:generate |
Regenerate the AppRouter type |
bun run --filter=api dev:session |
Print a session cookie for a local user |
Scope any of them with a Turborepo filter: bun run dev --filter=api.
Because sign-in goes through an identity provider, there is no way to get a session from a terminal —dev:session writes the rows Better Auth would have written and prints the cookie it
would have set. It refuses to run with NODE_ENV=production.
Deploying
Three deployments and a Postgres: the Next.js app, the NestJS API, and the agent.
They are independent, and the only thing they must agree on is DATABASE_URL andBETTER_AUTH_SECRET — the API mints the session cookie and the app verifies it, so a
mismatch is a redirect loop rather than an error.
Set API_URL and APP_URL to the real origins, and if the two are on different
subdomains of one parent, set AUTH_COOKIE_DOMAIN to the parent so one cookie covers
both. Add http://your-api-host/api/auth/callback/google — and, if you use Microsoft,http://your-api-host/api/auth/callback/microsoft — to the provider's
redirect URIs. Set CRON_SECRET and point a scheduler atPOST /internal/sync/mailboxes to keep the mailbox sync running.
apps/api/src/generated/server.ts is committed and build must never regenerate it —
the generator needs a newer GLIBC than most build images have. Regenerate locally and
commit it with the router change that caused it.
Contributing
We'd rather have a paragraph you wrote than a pull request an agent wrote. See
CONTRIBUTING.md.
Security issues go through SECURITY.md, privately, not a public
issue.
Licence
MIT.
Overview (`adrs/README.md`)
adrs
Proposals live here. One file per idea, .md or .txt, named after the thing you want to change.
Write it yourself, in your own words, at whatever length the idea actually needs — a paragraph is a
perfectly good ADR. See CONTRIBUTING.md for why we'd rather have that than a
pull request full of generated code.
Roughly:
- what you want to change
- why the current behaviour is a problem, ideally with the case that made you notice
- what you'd do instead, and what it would break
Nothing to record after the fact. These are for deciding, and they stay as the record of what was
decided.
Overview (`apps/api/README.md`)
`api`
NestJS API for the CRM. Runs on Bun, backed by @crm/db and @crm/auth.
Running it
cp .env.example .env # at the repo root — one file for the whole monorepo
openssl rand -base64 32 # -> BETTER_AUTH_SECRET
bun run dev # watch mode on http://localhost:3001
bun run test
bun run build && bun run start:prodThree values are required and the process refuses to boot without them, naming
the one it is missing: DATABASE_URL, BETTER_AUTH_SECRET andALLOWED_SIGN_IN. GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET are the fourth
value almost every install wants — they are both the sign-in button and the
Gmail and Calendar sync — but they are optional and set as a pair, because an
install that signs in through its own identity provider on Settings → SSO
needs neither. With them, registerhttp://localhost:3001/api/auth/callback/google as an authorised redirect URI.src/config/env.validation.ts is the full list of what this process reads;docs/environment.md explains where the file is
found.
Bun is the runtime, not just the package manager: @crm/db and @crm/auth
ship TypeScript sources, so tsc/node cannot run this app directly. tsc is
used for type checking only (bun run check-types).
Routes
| Route | Auth | Notes |
|---|---|---|
/api/auth/* |
anonymous | Mounted by @thallesp/nestjs-better-auth |
/auth/me |
required | Cached profile of the signed-in user |
/auth/session |
optional | Whether the caller is signed in |
/health |
anonymous | 200 with a database round-trip, 503 otherwise |
/internal/sync/google |
CRON_SECRET bearer |
Vercel Cron entrypoint for Gmail/Calendar sync. Fails closed when the secret is unset. |
How auth is wired
This process owns authentication. It mounts /api/auth/* and is the only one
that writes session cookies; the Next.js app in apps/app reads those sessions
straight from Postgres via @crm/auth and calls the routes above withcredentials: "include".
AuthModule.forRoot({ auth }) mounts the Better Auth handler and registers a
global AuthGuard, so every route is protected unless it opts out:
@Get('public')
@AllowAnonymous() // no session required
@OptionalAuth() // session optional; @Session() may be undefinedIt also calls enableCors({ origin: trustedOrigins, credentials: true }) for
the whole app, which is what lets the browser at localhost:3000 talk to it —APP_URL is the single knob for that, comma-separated if the app is served from
more than one origin.
main.ts creates the app with bodyParser: false — Better Auth needs the raw
request body, and the library installs its own parsers around the auth routes.
AuthHooksService uses @AfterUpdate("user") to drop a cached profile the
moment its row changes. Database hooks require databaseHooks: {} in the Better
Auth options and endpoint hooks require hooks: {} (both set in @crm/auth);
the library throws at startup without them.
Caching
AppCacheModule registers @nestjs/cache-manager globally. It uses REDIS_URL
when set and otherwise falls back to a per-instance in-memory store — fine for
local development, not for more than one API instance.
AuthService.getProfile is the reference pattern: read through the cache, write
with an explicit TTL, invalidate on change.
Notes
- Better Auth stores rate limits in the database (
rateLimit.storage), so every
request to/api/auth/*needs a reachable Postgres. Moving this tosecondaryStoragebacked by Redis would remove that dependency. - Environment variables are validated at boot by
src/config/env.validation.ts.
The process refuses to start on a bad config.
Overview (`apps/app/README.md`)
This is a Next.js project bootstrapped with create-next-app.
Getting Started
First, run the development server:
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun devOpen http://localhost:3000 with your browser to see the result.
You can start editing the page by modifying app/page.tsx. The page auto-updates as you edit the file.
This project uses next/font to automatically optimize and load Geist, a new font family for Vercel.
Learn More
To learn more about Next.js, take a look at the following resources:
- Next.js Documentation - learn about Next.js features and API.
- Learn Next.js - an interactive Next.js tutorial.
You can check out the Next.js GitHub repository - your feedback and contributions are welcome!
Deploy on Vercel
The easiest way to deploy your Next.js app is to use the Vercel Platform from the creators of Next.js.
Check out our Next.js deployment documentation for more details.
Overview (`packages/auth/README.md`)
`@crm/auth`
Better Auth configuration for the monorepo, backed by@crm/db.
Enabled: Google sign-in, account linking, cookie-cached sessions, and
database-backed rate limiting. This is an internal, single-tenant app — there
are no organizations, and email + password is switched off so the only way in
is a Google account.
Topology
The NestJS API (apps/api, port 3001) mounts /api/auth/* via@thallesp/nestjs-better-auth and is the only process that writes session
cookies. The Next.js app (apps/app, port 3000) imports this package on the
server to read sessions straight from Postgres, and points its browser client
at the API for sign-in and sign-out.
Both processes therefore need the same BETTER_AUTH_SECRET and DATABASE_URL,
or the cookie one writes will not verify in the other.
Usage
Server
import { auth } from "@crm/auth";
// Session for an incoming request
const session = await auth.api.getSession({ headers: request.headers });auth.handler is a standard (Request) => Promise<Response> function, so it
mounts anywhere. In a Next.js App Router route (app/api/auth/[...all]/route.ts):
import { auth } from "@crm/auth";
export const GET = auth.handler;
export const POST = auth.handler;Client
import { signIn, signOut, useSession } from "@crm/auth/client";
await signIn.social({ provider: "google", callbackURL: "/" });NEXT_PUBLIC_API_URL decides which origin the client talks to. It must point at
whichever process mounts the handler — the NestJS API. The Next.js app inlines
it at build time from API_URL, in next.config.ts, so there is one variable
rather than two spellings of one origin. Unset, the client uses the current
origin.
The client plugin list mirrors the server plugin list. Keep them in sync or the
inferred client API will drift from the routes the server exposes.
Setup
cp .env.example .env # at the repo root — there is no per-package env file
openssl rand -base64 32 # -> BETTER_AUTH_SECRETThis package is a library and never loads an env file of its own; it reads
whatever process.env its host process has, and src/env.ts imports@crm/env/load so the repo-root .env is picked up even when the Better Auth
CLI loads auth.ts directly. Seedocs/environment.md.
Create an OAuth client in the Google Cloud console and add<API_URL>/api/auth/callback/google — http://localhost:3001/api/auth/callback/google
in development — as an authorised redirect URI.
ALLOWED_SIGN_IN decides who may sign in, and an empty value admits nobody. It
is the whole authorisation model: there are no roles and no organizations, sosrc/workspace.ts is worth reading before you change anything here.
Changing the schema
Adding a plugin or an additional user field changes the database schema. After
editing src/auth.ts:
bun run auth:generate # rewrites packages/db/prisma/schema.prisma
bun run db:migrate # create the migrationNotes
- JIT package.
exportspoint at TypeScript sources, which keeps Better
Auth's inferred types intact — declaration emit tends to break them.
Turbopack transpiles workspace packages automatically, so a Next.js app needs
notranspilePackagesentry. - Next.js server actions need the
nextCookies()plugin (frombetter-auth/next-js) as the last entry in the plugin array. It is omitted
here so the package stays framework-agnostic; add it — along withnextas a
dependency — once an app relies on setting cookies from server actions. - Cross-origin cookies. On localhost the API and the app differ only by
port, which cookies ignore. Deployed on separate subdomains they needAUTH_COOKIE_DOMAINset to the shared parent (.example.com).
Overview (`packages/db/README.md`)
`@crm/db`
PostgreSQL access for the monorepo: the Prisma schema, migrations, and a sharedPrismaClient instance.
Usage
import { db } from "@crm/db";
const users = await db.user.findMany({ take: 10 });Types and query helpers come from the same entrypoint:
import { Prisma, type User } from "@crm/db";Setup
docker compose up -d # Postgres matching the DATABASE_URL in .env.example
cp .env.example .env # at the repo root
bun run db:generate # generate Prisma Client
bun run db:deploy # apply the migrationsDATABASE_URL comes from the repo-root .env, loaded by @crm/env — seedocs/environment.md. src/client.ts imports@crm/env/load before reading it, and prisma.config.ts does the same so the
CLI works without any app running.
Scripts
| Script | Purpose |
|---|---|
build |
prisma generate — cached by Turborepo, runs via ^build |
dev:prepare |
Apply pending local migrations, reject drift, and generate Prisma Client |
db:generate |
Regenerate Prisma Client |
db:migrate |
Create and apply a migration (development) |
db:deploy |
Apply pending migrations (CI / production) |
db:push |
Push the schema without a migration (prototyping only) |
db:reset |
Drop and recreate the database |
db:seed |
Run prisma/seed.ts |
db:studio |
Open Prisma Studio |
Each is also exposed at the repo root (bun run db:migrate) and routed throughturbo run.
The root bun run dev command runs dev:prepare before any service starts and
reruns it when the schema or migrations change. Services that hold a Prisma
client restart only after preparation finishes, so they cannot retain an older
generated model map.
Notes
- Prisma 7 + driver adapter. There is no query engine binary; the client
talks to PostgreSQL through@prisma/adapter-pg. Seesrc/client.ts. - Generated code is not committed.
prisma generatewrites tosrc/generated/, which is gitignored and declared as thebuildtask's
output so Turborepo caches it. - JIT package.
exportspoint at TypeScript sources; the consumer compiles
them. Turbopack transpiles workspace packages automatically, so a Next.js app
needs notranspilePackagesentry. Non-bundler consumers need a TypeScript
runtime — the NestJS API runs on Bun for exactly this reason. - Auth models are generated.
User,Session,Account,Verification
andRateLimitcome from@better-auth/cli. Do not hand-edit them — change
the Better Auth config in@crm/authand re-runbun run auth:generate.
The generator is additive: it adds models and fields a plugin needs but never
removes the ones a dropped plugin left behind, so removing a plugin means
deleting its models from the schema by hand.
Overview (`.agents/skills/nuqs/README.md`)
nuqs Best Practices for Next.js
A comprehensive best practices skill for using nuqs - type-safe URL query state management - in Next.js applications.
Overview
This skill provides 42 rules across 8 categories to help AI agents and developers write correct, performant, and maintainable code when using nuqs for URL state management.
Getting Started
# Install dependencies (if any)
pnpm install
# Build AGENTS.md from references
pnpm build
# Validate the skill
pnpm validateCategories
| Priority | Category | Impact | Rules |
|---|---|---|---|
| 1 | Parser Configuration | CRITICAL | 8 |
| 2 | Adapter & Setup | CRITICAL | 5 |
| 3 | State Management | HIGH | 7 |
| 4 | Server Integration | HIGH | 6 |
| 5 | Performance Optimization | MEDIUM | 5 |
| 6 | History & Navigation | MEDIUM | 4 |
| 7 | Debugging & Testing | LOW-MEDIUM | 3 |
| 8 | Advanced Patterns | LOW | 4 |
Creating a New Rule
- Create a new file in
references/with the pattern{prefix}-{slug}.md - Use the template from
assets/templates/_template.md - Include YAML frontmatter with
title,impact,impactDescription,tags - Add Incorrect and Correct code examples
- Run
pnpm buildto regenerate AGENTS.md - Run
pnpm validateto check for errors
Rule File Structure
---
title: Rule Title
impact: CRITICAL|HIGH|MEDIUM|LOW
impactDescription: Quantified impact (e.g., "2-10× improvement")
tags: prefix, keyword1, keyword2
---
## Rule Title
Explanation of WHY this matters.
**Incorrect (annotation):**
\`\`\`tsx
// Bad code example
\`\`\`
**Correct (annotation):**
\`\`\`tsx
// Good code example
\`\`\`
Reference: [Link](url)File Naming Convention
- Rule files:
{prefix}-{descriptive-slug}.md(e.g.,parser-use-typed-parsers.md) - Prefix must match a section defined in
references/_sections.md - Use lowercase with hyphens
Impact Levels
| Level | Description |
|---|---|
| CRITICAL | Causes build failures, runtime errors, or major performance issues |
| HIGH | Significant bugs, performance degradation, or maintenance problems |
| MEDIUM | Noticeable issues or suboptimal patterns |
| LOW-MEDIUM | Minor improvements or edge case handling |
| LOW | Nice-to-have optimizations or advanced patterns |
Scripts
pnpm build- Regenerate AGENTS.md from referencespnpm validate- Check skill against quality guidelines
Contributing
- Check existing rules to avoid duplication
- Follow the rule template structure
- Include production-realistic code examples
- Quantify impact where possible
- Run validation before submitting
References
Overview (`.agents/skills/vercel-composition-patterns/README.md`)
React Composition Patterns
A structured repository for React composition patterns that scale. These
patterns help avoid boolean prop proliferation by using compound components,
lifting state, and composing internals.
Structure
rules/- Individual rule files (one per rule)_sections.md- Section metadata (titles, impacts, descriptions)_template.md- Template for creating new rulesarea-description.md- Individual rule files
metadata.json- Document metadata (version, organization, abstract)AGENTS.md- Compiled output (generated)
Rules
Component Architecture (CRITICAL)
architecture-avoid-boolean-props.md- Don't add boolean props to customize
behaviorarchitecture-compound-components.md- Structure as compound components with
shared context
State Management (HIGH)
state-lift-state.md- Lift state into provider componentsstate-context-interface.md- Define clear context interfaces
(state/actions/meta)state-decouple-implementation.md- Decouple state management from UI
Implementation Patterns (MEDIUM)
patterns-children-over-render-props.md- Prefer children over renderX propspatterns-explicit-variants.md- Create explicit component variants
Core Principles
- Composition over configuration — Instead of adding props, let consumers
compose - Lift your state — State in providers, not trapped in components
- Compose your internals — Subcomponents access context, not props
- Explicit variants — Create ThreadComposer, EditComposer, not Composer
with isThread
Creating a New Rule
- Copy
rules/_template.mdtorules/area-description.md - Choose the appropriate area prefix:
architecture-for Component Architecturestate-for State Managementpatterns-for Implementation Patterns
- Fill in the frontmatter and content
- Ensure you have clear examples with explanations
Impact Levels
CRITICAL- Foundational patterns, prevents unmaintainable codeHIGH- Significant maintainability improvementsMEDIUM- Good practices for cleaner code
Overview (`.agents/skills/vercel-react-best-practices/README.md`)
React Best Practices
A structured repository for creating and maintaining React Best Practices optimized for agents and LLMs.
Structure
rules/- Individual rule files (one per rule)_sections.md- Section metadata (titles, impacts, descriptions)_template.md- Template for creating new rulesarea-description.md- Individual rule files
src/- Build scripts and utilitiesmetadata.json- Document metadata (version, organization, abstract)AGENTS.md- Compiled output (generated)test-cases.json- Test cases for LLM evaluation (generated)
Getting Started
Install dependencies:
bashpnpm installBuild AGENTS.md from rules:
bashpnpm buildValidate rule files:
bashpnpm validateExtract test cases:
bashpnpm extract-tests
Creating a New Rule
- Copy
rules/_template.mdtorules/area-description.md - Choose the appropriate area prefix:
async-for Eliminating Waterfalls (Section 1)bundle-for Bundle Size Optimization (Section 2)server-for Server-Side Performance (Section 3)client-for Client-Side Data Fetching (Section 4)rerender-for Re-render Optimization (Section 5)rendering-for Rendering Performance (Section 6)js-for JavaScript Performance (Section 7)advanced-for Advanced Patterns (Section 8)
- Fill in the frontmatter and content
- Ensure you have clear examples with explanations
- Run
pnpm buildto regenerate AGENTS.md and test-cases.json
Rule File Structure
Each rule file should follow this structure:
---
title: Rule Title Here
impact: MEDIUM
impactDescription: Optional description
tags: tag1, tag2, tag3
---
## Rule Title Here
Brief explanation of the rule and why it matters.
**Incorrect (description of what's wrong):**
```typescript
// Bad code exampleCorrect (description of what's right):
// Good code exampleOptional explanatory text after examples.
Reference: Link
File Naming Convention
- Files starting with
_are special (excluded from build) - Rule files:
area-description.md(e.g.,async-parallel.md) - Section is automatically inferred from filename prefix
- Rules are sorted alphabetically by title within each section
- IDs (e.g., 1.1, 1.2) are auto-generated during build
Impact Levels
CRITICAL- Highest priority, major performance gainsHIGH- Significant performance improvementsMEDIUM-HIGH- Moderate-high gainsMEDIUM- Moderate performance improvementsLOW-MEDIUM- Low-medium gainsLOW- Incremental improvements
Scripts
pnpm build- Compile rules into AGENTS.mdpnpm validate- Validate all rule filespnpm extract-tests- Extract test cases for LLM evaluationpnpm dev- Build and validate
Contributing
When adding or modifying rules:
- Use the correct filename prefix for your section
- Follow the
_template.mdstructure - Include clear bad/good examples with explanations
- Add appropriate tags
- Run
pnpm buildto regenerate AGENTS.md and test-cases.json - Rules are automatically sorted by title - no need to manage numbers!
Acknowledgments
Originally created by @shuding at Vercel.
Overview (`apps/agent/agent/sandbox/workspace/README.md`)
/workspace
Scratch space for research in progress. Nothing in here is the record — the CRM
is the record, and it is reached through tools, not through this filesystem.
Useful for:
- Dossiers.
dossiers/<contactId>.json— dump a profile you fetched, thengrep/jqit instead of re-reading it into context. - Diffs. Keeping last month's profile next to this month's is how a job
change becomes visible without a second lookup. - Working notes while you assemble a brief across several sources.
Two things this filesystem must never hold:
- Anything out of a mailbox. Email bodies and meeting notes stay in the
turn, in the app runtime.read_crm_historygives you the whole message
because it is our own data; writing it here moves it somewhere with a
different lifetime and a different set of eyes on it. - Credentials. There are none here, and there is no reason to create any.
There is no network from this sandbox. web_fetch and web_search still work
— they run outside it.
Agent Panel (`docs/agent-panel.md`)
The Agent panel — read when touching the record sheet's Agent tab
lib/agent-record.ts maps a record kind to everything downstream: the header sent, the
claim minted, the field a conversation is filed under, the empty-thread questions.AgentConversation holds only the handle (session id + cursor); the transcript is inAgentEvent from the audit hook.
- Load with
session.snapshot(), never by hand — one call returns the event
prefix, the cursor, and a continuation token iff eve will accept another turn.
Hand-rolling the stream produced every panel bug this thing has had. - The token is the authority on whether a message can be sent, not our reading of
the events. streamIndex: 0on resume — the saved index is where the last reader stopped.- The snapshot is cached for a minute, not refetched on every mount. A settled
thread cannot change under us —readyis waiting on the rep,endedis over — and
the snapshot is the whole session from index 0, tens of megabytes on a real thread.
A live thread stays live throughrefetchInterval, which ignoresstaleTime. loadThreadrethrows an abort. Swallowing it caches a falseofflinefor the
life of thestaleTime; only a genuinely unreachable agent may return one.- The archive is fetched only when the snapshot fails. It is the offline fallback
and nothing else, so loading it up front bought a serial round trip on every open.
Nothing renders until it lands —useEveAgentreadsinitialEventsonce, at mount. - It ages with the snapshot, and is never
staleTime: Infinity. A session's
archive is not immutable: the agent appends to it every turn. Held forever, an
agent that goes down, comes back, and goes down again renders the first outage's
transcript and silently drops what happened in between. Both reads mirror the same
session, so both expire atSETTLED_TTL_MS. - Never key the transcript on the archive. A refetch that lands while it is
mounted is ignored on purpose; remounting to pick it up would abort a live answer,
which is the same failurekeepMountedexists to prevent. Correcting a stale
fallback is the next mount's job. - The audit hook does not archive
reasoning.appended. Every delta carriesreasoningSoFar, the whole reasoning to that point, so the archive grew as the
square of the tokens — one insert per token, 90% ofAgentEvent, for events the
transcript never renders.reasoning.completedstill lands. eve's durable stream
keeps its own copy either way; this is only what we file. - Quiet for 90 seconds is over, not working — restarted agents leave sessions with
no closing boundary, which would lock the thread forever. - An unreachable agent is
offline, notworking— fall back to theAgentEvent
archive and keep the composer usable. - An ended thread gets a Start-a-new-conversation button, not a locked box;
composerState()keeps ended and working apart. - Nothing mounts until the list has loaded, or a new session starts and remounts.
- The landed thread is captured once (
resolveThread), or the first save swaps the
open conversation out from under a live answer. It lives in?thread=. keepMountedon the tab descriptor (detail-sheet.tsx) — Radix drops an inactive
tab, aborting the stream mid-answer.autoScrolland nothing else;scrollAnchorstops it following the bottom.- One
MessageScrollerItemper message, not per part; ids prefertoolCallId. - Scoped to the rep — a session id in a body decides which row, never whose.
This lives in the API and is not a breach of rule one: listing history decides nothing.
Agent (`docs/agent.md`)
Agent — `apps/agent`
An eve app, its own deployment, owning every piece of
intelligence in this repo. Read with api.md, whose first rule is that none of this
may move into the API. Local dev, the bridge env vars and the manual dispatch command
are in docs/setup.md.
Read the eve guide before writing eve code —apps/agent/node_modules/eve/docs/README.md matches the installed version;.agents/skills/eve is the skill. Guessing typechecks, builds, then misbehaves.
Model
Default zai/glm-5.2-fast; DEFAULT_AGENT_MODEL in @crm/db/settings because the
agent and the API both need it.
- A row (
AppSetting), not an env var, viadefineDynamiconsession.started.
Open conversations keep their model — prompt caches are per model. lib/model.tsalways sendsmodelContextWindowTokens; eve never inherits it.- A failed read logs and keeps the compiled fallback. Never throws.
- The chooser offers only
tool-usemodels (ModelCatalogService). - Not a frontier model, deliberately — refusing wrong answers is enforced by the
tools and evidence model, not model strength.
Pictures are copied, never linked
mirror() copies bytes to Vercel Blob; the record points at our copy. Lives in@crm/db/blob — writers are lib/brand-images.ts, lib/portrait.ts,FaviconService, ImageMirrorService, prisma/seed.ts.
- The key hashes the bytes — idempotent, and a redesigned mark gets a new URL.
COMPANY_IMAGE_FIELDS(@crm/db/images) is the one list of picture columns.- Fetch through
@crm/db/safe-fetch— vendor URLs are SSRF vectors. - No
BLOB_READ_WRITE_TOKENmeans no photographs; logos keep the origin URL. isOptimizable(@crm/db/images) is the whole rule:next.config.ts
allow-lists only our Blob host (a wildcard makes us an open image proxy), and a
mirrored SVG is still refused.- Faces are not optimized —
AvatarImageskips<Image>because Radix probes the
URL itself, doubling fetches. - A photograph only comes from a source already tied to this person —
lib/portrait-sources.ts: their LinkedIn, their GitHub, their employer's team page,
each keyed on an identifier already on the record. - There is no image search by name, and there must never be. Nobody audits a face.
Guess where to look, never what you will find.
Two lanes
schedules/dispatch.ts, split by DIRECT_KINDS in @crm/db/agent-tasks.
| Kinds | How | Per tick | |
|---|---|---|---|
| Visible | brand, portrait |
Directly — no receive, no model |
60, six at a time |
| Research | everything else | One eve session per row | 12 |
Neither visible kind has anything to decide, and through a session they queued
behind sixty LLM runs for 25 minutes (test/lanes.integration.spec.ts). The row says
what the work is; the lane only says whether it needs a conversation.
Priority: brand 900 · portrait 800 · workspace 500 · requested 300 ·meeting 200 · identify 100 · sweep 50 · companyProfile 40 · recheck 0. The
top two are what a rep reads before deciding what to open.
claimDue sorts what it claims — Postgres does not order UPDATE … RETURNING by
its sub-select's ORDER BY.
Dispatch on demand
POST /internal/crm/dispatch drains both lanes; AgentTriggerService.poke() calls
it after writing any AgentTask.
- Fire-and-forget, never awaited — the row is still the message.
- Both lanes. Visible-only made them diverge under
eve dev, where there is no
cron: logos resolved instantly whileidentifysat atattempts = 0forever. - Calls the channel's own
send, notreceive— it is already on the crm channel.
Principal fromAPP_AUTH(lib/app-auth.ts);taskAuth()keeps schedule and route
from drifting. drainAllcollapses viacollapsing()(lib/pool.ts) — forty new contacts poke
forty times, andclaimDuehands each a disjoint batch. Per-process; cross-process
overlap is leases andFOR UPDATE SKIP LOCKED.- An abandoned sweep is still in flight, and
dispatchHealth()says so. The
timeout aborts the lanes, which stop between items, but the sweep only leavesunsettledSweepswhen it truly settles. Until then health reportsrunning: true
and the endpoint answers503, and a new sweep is refused rather than stacked on
top of stuck work. - A slow
start(task)is reconciled, never failed. Pastsweep.startTimeoutMsthe lane stops waiting, the record stays Researching, and
the late session id is attached bynoteSessionwhen the send lands. Only a send
that actually rejects settles the recordFAILED; a send that never lands is
retired byretireExhaustedafterMAX_ATTEMPTS. A session id that never
reaches the row afterresearch.link.attemptsraisesunlinkedSessionsin
health, so a task running under a session nobody recorded is visible. AGENT_BRIDGE_SECRETunset refuses rather than opens.
`POST /internal/crm/verify-key`
Probes a candidate Context key → valid/invalid/unknown. No session, no model, no
task row; exists because the API may not call Context.
- The probe is free and chosen to be — a free-provider address gets a
422before
billable resolution. Do not point it at a real domain: ten credits per typo. classifyKeyrejects on401and nothing else.- The candidate key, never the stored one.
Blank fields are filled on the dispatch tick
sweepBlankFacts (lib/blank-facts.ts) applies every pending suggestion whose field is
still empty and clears the ones that have stopped saying anything. It runs at the top ofschedules/dispatch.ts, every minute, over every contact in the workspace — it is a
database pass with no session, no model, no task row and no credits, so there is nothing
to ration and nobody to scope it to.
- Scans 2000 suggestions and fills at most 500 a pass, and reports what it did not
reach (unscanned) rather than a clean sweep it did not make. - Idempotent — a second pass fills nothing, because those fields are no longer blank.
- The suggestions left are conflicts, every one against a value already on the
record. That number should stay small and reads as work for a rep. - It does not belong on sign-in, and that is not a preference. It lived there for one
release and never once ran. Two reasons, either fatal:onSignedInfires onsession.create, so a rep already signed in never triggers it; andBackfillService
does its work in a detachedvoid (async () => …)()after the response, on a Nest API
that is a Vercel serverless function (apps/api/api/index.ts) — the tail of that
chain is not guaranteed to run at all. A cron in the agent is the only trigger here
that is a fact rather than a hope.
Stale rows are closed on the dispatch tick
reconcileStaleTasks (lib/stale-tasks.ts) runs before drainAll, every minute. Like
the blank-field pass it is a database pass with no session, no model and no credits. It
closes rows that are provably done or provably dead, so the queue a rep reads is the
work that is actually happening.
- It only looks at work that is due. A
recheckbooked ninety days out is not
stale, it is scheduled, and closing it would delete the next look at that contact. - A live lease is never touched. That is work in flight, whatever the record says.
- A record that completed after the task started closes the task with a plain
outcome. That is the row the settle path failed to close: research landed, facts were
written, and the task sat open holding the contact on Researching. - A dead lease with attempts left goes back to waiting, and the lease is cleared so
the row reads as waiting rather than as running. Exhausted rows are retired byretireExhausted, which is the one rule for giving up. - Idempotent — a second pass in the same minute closes nothing, because the rows it
closed have afinishedAtand the rows it released have no lease. - Scans 200 rows a pass and reports
unscannedrather than a clean sweep it did not
make. The counts are indispatchHealth().staleTasks, so a growing number of stale
rows is visible atGET /internal/crm/dispatch-health.
It is a safety net, not the fix. The settle path still fails to close some rows;
this pass keeps that from reaching a rep, and the count it reports is how you see the
underlying bug getting worse.
Backfills
Sign-in sweep covers records never looked up (10 credits/company);ImageMirrorService in the same sweep re-hosts off-site pictures (free);backfill:images fixes enriched records missing only pictures (free);backfill:facts is the blank-field sweep above run by hand, with --dry to read it
first — the cron covers it, so this is for a machine pointed at another database.
- The image sweep keeps "every picture is ours" true, not true-since-Tuesday.
25 rows/table/sweep. - Nobody clicks a suggestion into a blank field. The rule is enforced at the write
path, so no queue forms; the sweep is for rows written before it existed and for the
field a rep clears while a suggestion is pending. An unreachable agent leaves the
suggestions where they are — the API cannot apply one itself. - A finished
portraittask stands that contact down for thirty days — that third
source costs credits and usually finds nothing. - No button, deliberately — a rep cannot know which records predate a resolver.
- The trigger is signing in, via
databaseHooks.session.create.afterinpackages/auth;BackfillServicesubscribes withonSignedInand has no router
(@crm/authmust not import a Nest provider). Five-minute stand-down, andauto()
returns before work starts. - The 500-row cap is on the pass, not each query in it. Deduplicate the union, cut
to 500, countremainingagainst the union.
Evidence, not confidence
No tool accepts a confidence, a score, or a sourceUrl offered as proof. Tools
report what they observed (crm.signature-block, github.account-identity) andlib/evidence.ts prices it. A model asked to grade its own certainty will, and will be
wrong in the direction that looks useful.
lib/facts.tsis the only write path to a contact's fields. Applies atVERIFIED, proposes below it, and enforces three things a prompt cannot: never
overwrite a human, never re-offer a dismissal, never write without a primary source.- A band decides only when there is something to lose. An empty field is filled by
whatever cleared the floor for keeping, whatever the band — approving a sourced guess
into a blank is a click that can only say yes, and a rep with four hundred contacts
reads none of them.fillsBlankis the whole rule: no human value in the way and
nothing already found.PROBABLEstill means a rep decides when the field is
already filled, which is the case where a wrong answer costs something. The dispatch
tick applies the same rule to rows that predate it —sweepBlankFacts, above. - Applying settles the field's other suggestions. They were all offers to fill the
same blank, and the sheet shows one at a time — so left alone, accepting one reveals
the next, forever. The same rule holds when a rep accepts one (decideFact). - The same value is never offered twice. A second
PROPOSEDrow with a value
already waiting is refused at the write path, not deduplicated on read. - "The same value" is
sameValue, and a URL is compared as an address, not a
string.canonicalValuelowercases, collapses whitespace, and forhttp(s)drops
the scheme,www., a trailing slash and the query, and readstwitter.comasx.com. Compared byte for byte,…/in/pogrebs/is not…/in/pogrebs, so the record
showed a suggestion offering back the URL already in the field — the exact click this
whole rule exists to remove. The stored value is still what the source said; only
the comparison normalises. The refusal
is for offers only — the check runs afterappliesis decided, so evidence that has
reachedVERIFIEDsince the offer was made still lands, settles the suggestion it
matches, and replaces the older value. Refusing it there left a weaker value on the
record with the answer sitting unread beneath it. - A new fact field goes in
FIELDS(lib/facts.ts) andFACT_COLUMNS
(apps/api/src/contacts/contacts.service.ts).
Optional by default
lib/capabilities.ts is the single place that knows what is set: prints it at boot,
states it in the session instructions, and gives tools a shared "not configured,
retrying will not help" result — checked before the research budget is charged. A
missing key removes a place to look. Never an error, never throws.
capabilities() is async because the Context key is a row;capabilitiesFrom()/markdownFor() are the pure halves. contextDevKey() is the only
resolver, and lib/context-dev.ts memoises its client on the key string.
Budget and scheduling
lib/focus.ts— per-session budget indefineState; running out is a normal ending.
A unit is one metered vendor call, not one credit.spend(2)is what a
billable lookup costs: a brand lookup is 10 Context credits, a person enrich is- Both charge 2, because the budget rations calls per contact and a session
with a budget of 4 must still be able to make two of them.
- Both charge 2, because the budget rations calls per contact and a session
lib/tasks.ts—claimDueleases withFOR UPDATE SKIP LOCKED.schedules/dispatch.tsis the only schedule and decides nothing. "Every N
minutes, the oldest ten contacts" belongs in adueAt.tools/schedule_recheck.ts— itsreasonis shown to the rep.
Three records, no dead ends
Every read hands back the ids of neighbouring records. Breaking this made the agent
ask a rep who had a company open, contacts on screen, to paste an email.
| Read (all free) | Hands back |
|---|---|
read_crm_history |
the contact's company id, their deals, their colleagues |
read_company_history |
every contact with id, deals, threads, meetings, notes |
read_deal_history |
stage clock and history, people with ids, last reply |
search_crm |
contacts, companies and deals matching typed text |
A preamble or tool result naming a record without its id is a bug — the only
recovery is asking the human. Ambiguity is fine: four Marchettis is four rows with
titles. search_crm does no fuzzy matching — "Marchetti" reaching "Marchetta" is a
wrong record about a real person.
Preambles
lib/preamble.ts varies on which record (each names its neighbours with ids and
points at the read to start from) and who opened it — a dispatched task is a
research pass with a budget, a rep in the sheet is a conversation. Told neither, the
agent answered a question with a work plan. taskKind is the tell.
task.ts is the resolver and owns one side effect: seeding lib/focus.ts, without
which the audit hook files events against nothing.
A fourth record kind = sessionPreamble entry + a read + a TOOL_VERBS line
(apps/app/lib/agent-transcript.ts) + a COPY entry (lib/agent-record.ts).
Every session knows who *we* are
composeClosing() puts a Who we are block before the capabilities in every
preamble; lib/workspace.ts is the only renderer.
- Tiny, enforced by the write path —
MAX_NARRATIVE(320) andMAX_LINEin@crm/db/workspace. It is prompt-cached and precedes every question. - It says what the context is for — fit, competitor, partner, or nothing — and
never a pitch, or the model sells our own product back to us. - No profile still gets the name line, plus do not guess at what we sell.
- The profile dies with its website —
readWorkspaceIdentityreturns it only whilewebsitematches. - Not a
Companyrow — that needs excluding from every list, facet and join. OneWorkspaceProfilekeyed onWORKSPACE_ID.
The pass is a workspace-profile task using web_fetch (no credits), filed only viawrite_workspace_profile, queued by WorkspaceService.update on a website change. A
finished attempt stands the sweep down for seven days.
What may be read, and what may leave
It may read everything, including full email bodies — internal single-tenant tool,
and a signature block is the best source of a job title there is. The boundary is
egress:
- No customer text in a third-party query. Derived questions only.
- Nothing from a mailbox into
/workspace— different lifetime. - Nothing sensitive logged. Reading is not logging.
skills/data-boundaries.md is the agent's copy. Keep them in step.
Sandbox
agent/sandbox/sandbox.ts: bash, file tools, /workspace, deny-all egress on
the backend factory so it cannot be forgotten per session. Costs nothing —web_fetch runs in the app runtime, web_search at the provider.
Never give the sandbox DATABASE_URL. CRM access is authored tools. A shell with
credentials and network is exfiltration-shaped; with neither it is a text processor.
Team-agent builder and runner
agent_builder and agent_runner are declared subagents with independent
instructions, tools and deny-all sandboxes. They inherit nothing from the root. The
root built-in agent copy tool is disabled; these two named specialists are the only
delegation paths for custom agents.
- Creation requires the current
CREATE_AGENTturn. Every builder tool checks the
purpose and command type in session auth. A normal builder chat cannot create a
draft by prompt alone. - Builder clarification is durable HITL. The specialist calls eve's built-in
ask_questiondirectly; descendant input requests are proxied to the root channel,
and the same child turn resumes when the user answers. The authoredtools/ask_question.tsdisable override must stay absent. Builder task output is
typed asdraft_readyand carries the immutable version ids only after save. - Empty never means all. A version chooses
SELECTEDorWORKSPACErecord scope.
Selected scope requires at least one record tagged in that private conversation;
workspace scope is an explicit grant and cannot also list selected records. - Connections are executable permissions. Only
google:gmailandgoogle:calendarare accepted, only when connected, and the runner does not query
their synced tables unless the deployed manifest includes that source. - Actions are structured permissions.
crm.activity.createseparately namesNOTE,TASK, or both. Runtime enforcement never infers a grant from the action's
prose summary. Every action is ledgered before execution and keyed by eve's call id
for replay safety. - CRM events are shared domain contracts.
@crm/db/crm-eventsowns the event
vocabulary, record kind, and builder-facing description used by the API, builder,
and worker. API writes enqueue a durableagent-eventtask; the agent worker alone
matches live triggers and creates runs. Do not duplicate event lists in prompts or
feature code. - Versions can have multiple triggers. The manifest stores a
triggersarray and
each entry becomes its ownAgentTriggerrow. Deployment enables every trigger on
the approved version, so one agent can react to several independent lifecycle
moments without polling or duplicate agents. - Deployment is the human approval boundary. Saving produces a private READY
version and never deploys it. The review screen shows its triggers, scope, actions,
access and exact files. A user's Deploy action pins that immutable version for the
team. Scheduled runner sessions use task mode and therefore cannot pause for a
per-action approval; the deployed permission and idempotent runtime checks are the
boundary. - Approved instructions are system context. The runner resolves the pinned
version instructions atsession.started, then callsinspect_runfor the manifest
and current run state. Every runner tool also checks theteam-agentpurpose and
revalidates scope and action permission. - Stopping a run is a row, not a signal.
agents.cancelRunsettles theAgentRuntoCANCELLEDinside one transaction — terminal event, outstandingAgentActionrows, audit entry — and then pokesPOST /internal/crm/cancel-run, which calls eve'scancel({ continuationToken })
forrun:<id>. The row is what stops the work: every run tool refuses a run
that is notRUNNING, so a cancel that never reaches the agent still means
nothing further is written. The poke only stops it spending tokens, and an unsetAGENT_BRIDGE_SECRETcosts exactly that and nothing more. - Cancelling is not undoing. eve keeps side effects that already completed, so
a note, task or Slack message the run already made stays. The dialog says so. - Whoever started it can stop it, plus the creator and workspace admins.
canCancelis computed server-side per run and is what the button reads, so the
control and the 403 cannot disagree. turn.cancelledsettles the run too, keyed offrun:in the continuation
token, so a cancel from any other path still reachesfinishedAtrather than
leaving the run reading Running forever.- No generic execution surface. Both specialists disable shell, file, arbitrary
web and todo built-ins. The runner also disables direct questions; the builder keeps
onlyask_questionfor durable clarification. CRM access exists only through their
small authored tool sets. Tool code runs in the trusted app runtime; the sandbox
remains isolated and deny-all.
Runner manifests fail closed when either the explicit record-scope mode or an
activity type grant is missing. Versions created before these typed permissions were
introduced must be revised and deployed again before they can run.
bun run --filter=agent eval runs the eve end-to-end builder eval against the real
HTTP channel. It creates an isolated private conversation, dispatches the builder,
asserts the declared subagent path, verifies a READY side-effect-free manifest, and
cleans up its rows. It skips visibly when the database, bridge secret or model
credential is unavailable.
The bridge
browser → /eve/v1/* (same origin, session cookie, x-crm-contact header)
→ apps/app/app/eve/v1/[...path]/route.ts
checks the Better Auth session, strips the cookie,
mints a 2-minute HS256 token naming the rep + record
→ AGENT_URL/eve/v1/* → channels/eve.ts repFromCrm()
→ instructions/task.ts reads attributes.contactId- The record travels in the token, never in the message.
- Mounted at
/eve/v1/*because that is whereuseEveAgent()looks — nohost,
no CORS, no cross-site cookie. - The proxy is an enforcement point, not a passthrough — the agent never sees the
session cookie, so if that route did not check, nothing would. - eve's
jwtHmac()resolves toprincipalType: "service"— wrong for a person,
andlib/approval.tsreads exactly those fields to decide whether to pause for a
human.repFromCrmmaps the subject to a real user principal
(test/channel-auth.spec.ts). AGENT_BRIDGE_SECRETunset skips the auth entry rather than opening it.
The panel
lib/agent-record.ts maps a record kind to everything downstream. The panel's own
rules — snapshot loading, composer state, thread capture, scrolling — are indocs/agent-panel.md. It lives in the API and is not a breach of rule one:
listing history decides nothing.
Continuation tokens are namespaced
eve prefixes them with the channel name. channels/crm.ts mints task:<id>;session.waiting returns crm:task:<id>. Minting the prefix ourselves meant matching
against crm:crm:task:<id> and returning before completeTask — research ran, facts
were written, sessions looked clean, but no task reached finishedAt, so contacts
sat on "Researching" forever. Hidden because the archived event'sdata.continuationToken is un-namespaced while channel.continuationToken is not.
Conversations are kept
A record accumulates conversations, and they survive a reload. AgentConversation
holds the handle — the durable eve session id plus its cursor — while the
transcript itself is already in AgentEvent, written by the audit hook. Nothing
is stored twice.
Resuming. The panel passes the saved cursor as
initialSession, so
reopening a contact continues last week's thread rather than starting another.
eve keeps sessions for 30 days.Replay from the start.
streamIndex: 0on resume, deliberately — the
saved index is where the last reader stopped, and a reopened thread should
show what was said in it, not only what has happened since.Which thread is open lives in the URL (
?thread=), like every other view
state in the sheet, so a refresh keeps your place and a conversation is a link.
It is cleared when the record or the tab changes, by the same rule that drops
a half-typed quick-add form.Nothing mounts until the list has loaded. Rendering a thread while the
history is still in flight starts a new eve session and then remounts onto
the real one — which presents as "the history only appears if I refresh".The thread the panel landed on is captured once. Re-deriving "the latest"
as the list changes would swap the open conversation out from under a live
answer the moment the first save adds a row.resolveThreadinlib/agent-transcript.tsholds the rule, and it is tested.The panel is not unmounted when you switch tabs. It holds a live stream,
and Radix drops an inactive tab by default — which aborts the stream
mid-answer, so the reply landed in the durable session with nothing attached
to receive it. That is the "I went to another tab and the answer never came
back" bug, and no amount of re-reading state on the way in could fix it,
because the events had been dropped.keepMountedon the tab descriptor
(detail-sheet.tsx) keeps it alive; it renders nothing until the tab is
opened once, so flicking through records costs nothing.A thread is loaded with
session.snapshot(), not by hand. One call
returns the complete event prefix, the cursor that continues from it, and a
continuation token if and only if eve will accept another turn — about 30ms
against a hundred-event thread.lib/agent-session.tsis the whole of it.What it replaced is worth remembering, because every panel bug of the last
day came out of it: a rawfetchof…/stream?startIndex=-1, parsing the
last line into a state machine. The endpoint follows, so awaiting the body
never returned. The stream opens with a bare newline, so "the first line" was
empty — which failed closed to "busy" and locked every reopened
conversation with "still working on the last question", including ones parked
with a perfectly good token. And a read that failed reported the session as
working rather than reporting itself as broken, so it could never recover.
The framework had a documented answer to the exact question that code was
asking. Read the guide before hand-rolling the protocol.The token is the authority on whether a message can be sent, not our
reading of the events. eve returns one only when the captured prefix ends
parked, which is precisely the condition under which the next send lands.A turn that has gone quiet for 90 seconds is over, not working. A
restarted agent leaves sessions with no closing boundary; they never park, and
treating them as in-flight locks that thread forever.An unreachable agent is
offline, notworking. One is a fact about us
and the other a claim about the session; stated as the latter it is both
untrue and unrecoverable, since the read fails identically next time. The
transcript then comes from our ownAgentEventarchive — which is also what
makes a thread older than eve's 30-day retention still readable — and the
composer stays usable.An ended thread gets a button, not a locked box. Ended and working both
disable the composer and mean completely different things: one is a wait of
seconds, the other is permanent.composerState()keeps them apart, and an
ended thread offers Start a new conversation, which moves the picker to a
new thread. The transcript stays on screen throughout, and the save hook
treats the fresh session as a new conversation by comparing session ids
rather than by whether the panel started empty.autoScrolland nothing else. The scroller is a state machine
(following-bottom,free-scrolling,anchored-to-message) andscrollAnchorselects the third, which stops it following the bottom — the
answer then streams below the fold while the modes fight over each new row.
Left alone,autoScrollfollows the tail while the reader is at the bottom
and releases the moment they scroll away, which lights the jump-to-end button.One
MessageScrollerItemper message, not per part. The row is what the
scroller measures; a row per tool call adds a boundary every few hundred
milliseconds during an answer. Part ids prefertoolCallId, which is stable
across a call's streaming states.A thread nobody has spoken in is loaded from nothing. The snapshot query
is disabled without a conversation; a brand-new thread mounts with no session
and no events, and its first message creates both.Scoped to the rep. Two people asking about the same contact are having two
conversations.ConversationsServicefilters on the caller, and a session id
in a request body decides which row, never whose.Cached the way
api.mdprescribes: read throughcache-manager(Redis whenREDIS_URLis set), write on miss, explicit
invalidation on every save. The list is read on every sheet open and changes
only when somebody sends a message, which is the shape a cache is for.
This lives in the API rather than the agent, and that is not a breach of rule
one: listing a record's history researches nothing, scores nothing and decides
nothing. The agent owns judgement; the data surface owns filing.
Turning it on
Same value in both processes, from the one root .env:
AGENT_URL="http://127.0.0.1:2000" # the default
AGENT_BRIDGE_SECRET="$(openssl rand -base64 32)"Then bun run dev (the agent serves on :2000) and open any contact.
If the Agent tab errors:
| Symptom | Cause |
|---|---|
503, "not configured for this install" |
AGENT_BRIDGE_SECRET is unset in the app's process |
401 |
The two processes hold different secrets |
502, "not reachable" |
The agent is not running, or AGENT_URL is wrong |
eve dev takes about five seconds to bind, and it listens on IPv4 only.
That is why AGENT_URL defaults to http://127.0.0.1:2000 rather thanhttp://localhost:2000: Node resolves localhost to ::1 first, so thelocalhost form fails to connect on a machine where the agent is plainly
running — and reports itself as "not reachable", which sends you looking in the
wrong place.
A variable in .env is not enough on its own: Turbo runs in strict env mode, soapps/app/turbo.json and apps/agent/turbo.json both declare the pair inpassThroughEnv. Adding a variable and not declaring it produces exactly the401 above.
Checking it without a browser
localDev() accepts anything on loopback, so a bare curl to 127.0.0.1
proves nothing about the bridge. Send a non-loopback Host to make that entry
skip:
curl -s -o /dev/null -w '%{http_code}\n' \
-H 'Host: agent.example.com' \
http://127.0.0.1:2000/eve/v1/info # 401
curl -s -H 'Host: agent.example.com' \
-H "authorization: Bearer $TOKEN" \
http://127.0.0.1:2000/eve/v1/info | jq '.tools.available | length'GET /eve/v1/info is the whole inventory — tools, skills, schedules, channels,
sandbox, and a diagnostics count that is the fastest way to find a file eve
silently ignored.
Watching it work
eve dev shows every tool call, every result and every token in its interactive
TUI, and it is the agent package's default dev command. The package's Turbo
task is explicitly interactive, so select the agent pane and press Enter to hand
stdin to eve. Type /traces to open the live trace viewer; Ctrl+Z returns input
to Turbo.
dev:headless keeps eve dev --no-ui available when a terminal cannot render
the nested TUI. It starts the same server on the same port with the same routes
and watcher. In that mode hooks/activity.ts narrates the session instead: a
line per tool call with its arguments, a line per result with how long it took,
the finish reason and token spend of each step, and any failure with its code.
- The lines go to stderr, not stdout. The TUI's default log mode is
stderr
and it keeps stdout buffered and hidden, so aconsole.loghere would be
invisible in the mode it exists to serve. Written to stderr the same lines show
under--no-ui, under the TUI, and ineve logs. - Contents print outside production; the shape prints everywhere. Which tool
ran, whether it worked, what it cost — none of that is anybody's data, so it
logs wherever the agent runs. Arguments and replies carry names, addresses and
whatever a rep typed, which is the "nothing sensitive logged" rule above, so
they are gated onNODE_ENV. In production the durable record is anAgentEventrow, not a log drain. - It is not the audit trail.
hooks/audit.tswrites every event toAgentEventwhatever this prints, and the panel's transcript is read back from
there. A change to one is not a change to the other. - A call is timed by remembering it, because the result event does not carry
the tool name or a duration. The map of in-flight calls is bounded rather
than trusted: a turn that dies between request and result would otherwise leak
an entry per call, forever, in a process that stays up for days.
eve logs reads the full record back, but only for an interactive eve dev
— that is the process that writes .eve/logs/. Under --no-ui the pane is the
record, so keep the turbo scrollback rather than going looking for a file that
was never written.
Two consequences of opting into dev:headless, both worth recognising rather
than debugging:
- A headless dev process cannot reconnect. An interactive
eve dev
reconnects to a local server that is already up;dev:headlessrejects it and
exits non-zero.A dev server is already running for this eve agentmeans
exactly what it says: use the terminal it is in, or stop it before starting
another. - An orphaned agent holds the port. If turbo dies without reaping its child,
nothing on screen says so and every laterdev:headlessfails the same way.lsof -nP -iTCP:2000 -sTCP:LISTENnames the process to kill.
Nothing is researching, and the queue only grows
eve dev never fires schedules on their cron cadence. It is one line in
eve's own schedules guide,
and it used to be the single most confusing thing about working on this agent,
because every visible part of the loop worked: the Research button wrote itsAgentTask row, the sheet said Queued, the toast promised the page would
update — and schedules/dispatch.ts, the only thing that turns a row into a
session, was never called. Twenty rows sat with attempts = 0 and AgentEvent
was empty. Nothing was broken and nothing reported a problem, because nothing
ran.
The poke is what makes dev behave like production now, which is most of why
it was widened to both lanes — see dispatch on demand.
A row written by the API is dispatched immediately whatever the clock is doing,
so the schedule is a backstop rather than the only door.
It only does that when AGENT_BRIDGE_SECRET is set, and that variable is
optional: poke() reads it first and returns without sending anything when it is
unset. So an install that has not set it is back at the paragraph above with no
cron behind it either — rows queue, nothing dispatches, and the queue looks
exactly like a slow agent. Set it, or run the dispatch below by hand.
That leaves two cases where the clock's absence bites even with the poke
working, and both look identical to the above: a task the API did not
write — schedule_recheck,
which books its own dueAt weeks out — and anything queued while the agent was
down, since a missed poke is not retried. For those, the dev server mounts a
one-shot route that runs the exact dispatch path production cron uses:
bun run --filter=agent dispatch
# {"scheduleId":"dispatch","sessionIds":["wrun_01KZ…", …]}It drains both lanes, exactly as the cron does: up to VISIBLE_BATCH (60)brand and portrait rows six at a time, handled in the process with no session
at all, and RESEARCH_BATCH (12) research rows, one session each. So thesessionIds it prints are the research rows only — a run that resolved forty
logos prints an empty list and was not idle. Either way it spends real credits, a
vendor call per visible row and a model session per research one; that is the
point of it, and the reason it is a command you run rather than a ticker somebody
leaves on. Watch the agent pane; the session ids it returns are also streamable atGET /eve/v1/session/:id/stream.
eve start on a built app does run the schedule, and so does Vercel, where
each defineSchedule becomes a Cron Job. Dev is the only place the clock is
missing.
The continuation token you write is not the one you read
eve namespaces a continuation token with the channel's name. channels/crm.ts
mints task:<id>; by the time session.waiting hands it back on the channel
context it is crm:task:<id>. The eve channel's own sessions read back aseve:<uuid> for the same reason.
This is worth stating because of how it failed, which was silently and
completely. taskToken() used to mint crm:task:<id> itself, so the handler was
matching startsWith("crm:task:") against crm:crm:task:<id>, getting null,
and returning before completeTask. Nothing errored. The research ran, facts
were written, briefs were saved, and the agent pane showed a clean session — but
no task ever reached finishedAt, so every contact sat on "Researching"
forever and the sweep re-queued work that had already been done. Twenty-eight
tasks, zero finished.
Two things made it survive a reading:
- The event data and the channel accessor disagree.
session.waiting'sdata.continuationTokencarries the token as stored — un-namespaced — whilechannel.continuationTokencarries it namespaced. Debugging from the archived
event says the token is fine, because from that angle it is. - Nothing downstream depends on settling. The record's status is the only
thing that notices, and "still researching" is indistinguishable from
"researching slowly" until you look atattemptsin the table.
So taskFromToken keys on the task: marker rather than a fixed prefix, which
reads correctly whoever namespaced it and still settles sessions parked before
the fix. test/crm-token.spec.ts pins all three forms.
The general rule: a channel handler must not assume the token it receives is
byte-identical to the one it sent. Parse for your own marker.
Tests
bun run --filter=agent test. The integration specs need DATABASE_URL and run
against a real Postgres, which is the point — "never overwrite a human" is only
true if the transaction says so.
The test/e2e scripts are separate and you run them by hand. Three of them cost
money or change something outside the database, so each one is off unless you
switch it on:
| Variable | What it does |
|---|---|
E2E_LIVE_MODEL=1 |
live-run.e2e.ts calls the real model. It spends credits. |
E2E_SLACK_JOIN=1 |
slack-join.e2e.ts joins real Slack channels. It does not leave them. |
E2E_LOAD_COUNT |
How many tasks load.e2e.ts queues. The default is 300. |
Stop the local agent before you run an e2e script against a dev database. A
running agent leases the rows the script seeds and retires exhausted tasks the
script did not create.taskFromToken keys on the task: marker, not a fixed prefix
(test/crm-token.spec.ts). A channel handler must not assume the token it receives
is byte-identical to the one it sent.
Api (`docs/api.md`)
API Rules
Logging
apps/api/src/logging. new Logger(Thing.name) picks up ContextLogger. Neverconsole.log. Format follows NODE_ENV and is not configurable.
- One object, not extra arguments —
logger.log({ message: "Saved", userId });
Nest prints a line per argument. - Errors pass the stack second —
logger.error({ message }, err.stack). Passing
the error object drops the trace. - Never log headers, query strings, or bodies — cookies and personal data.
LoggingModulestays first inAppModule's imports, and Better Auth routes log
via its ownmiddlewareoption — it mounts beforeMiddlewareConsumer, so/api/auth/*never reaches ours.requestIdfromRequestLoggerMiddlewareviaAsyncLocalStorage;UserContextInterceptoraddsuserId. Prisma statements are opt-in
(PRISMA_LOG_QUERIES).
Intelligence never lives in the API
The API serves HTTP, auth, tRPC and the mailbox sync. It does not research, enrich,
score, summarise, match identities or decide anything about a person or company — not
as a fallback, not behind a flag. That is the eve agent in apps/agent, which owns
the vendor clients, the confidence model and the writes.
Nest's half is to report that something happened: AgentTriggerService writes anAgentTask row. A row, not an HTTP call — the agent already leases from that table,
so the row survives the agent being down.
About to add a vendor client to apps/api? You want apps/agent/agent/lib. One
documented exception, for timing: the exchange-rate fetcher, below.
One organization, and it is not a tenancy boundary
Single tenant. No org header, no org interceptor, no org-scoped cache keys, noorganizationId on any CRM record.
A singleton workspace exists — Better Auth's organization plugin, one row with
id WORKSPACE_ID (the literal workspace, in @crm/db, re-exported by @crm/auth
so the agent needn't depend on it). It answers only: what are we called, who works
here, what do we sell.
- The id is a constant, never a parameter. A function taking an
organizationId
has turned the plugin into tenancy plumbing. - Signing in is the join; no invite flow.
ensureWorkspaceMembershipruns indatabaseHooks.session.create.beforeand degrades, never throws — a throw fails
the session create and locks everyone out. The plugin'sinvitationtable is unused. - First account is owner, and the hook enrols pre-existing users, oldest first.
- Permissions come from
@crm/auth—canRenameWorkspace,canChangeRole,canConfigureSso,canManageCurrency— enforced by the service and used to
disable the UI control, so the button and the 403 cannot disagree.WorkspaceServiceadds one invariant: the last owner cannot be demoted, withFOR UPDATEon the owner rows before counting. - Reads and writes go through tRPC, not
authClient.organization.*. - Name and website are required at onboarding and cannot be skipped, in the form
and inupdateWorkspaceInput, posting the sameworkspace.updateas settings. - Onboarded state is
onboardedAtinside the plugin'smetadatablob, not a
column;isOnboarded/markOnboarded(@crm/db/workspace) are the only accessors,
andmarkOnboardedpreserves every other key. - The name starts as
DEFAULT_WORKSPACE_NAME(CRM), a placeholder not an
answer. The header renders<name> CRM, soworkspaceLabeltests the name rather
than comparing to the default. - The website queues the agent's
workspace-profiletask and goes throughnormalizeDomain, rejecting null. Stored canonical, so re-saving uncanonically
counts as a change and re-queues research.
Gates in `proxy.ts`
Onboarding, then /onboarding/research for the Context key. Asked server-side every
request.
getSessionCookie()decides signed-in; pages still resolve the real session viarequireMailboxAccess().- Nothing is cached in a cookie — both facts revert on a database reset while a
year-long marker insists the gate passed. Cache in the API if cost ever matters. - Both reads run concurrently, but order decides which is asked — the research
read is never made while onboarding is open. - An unreachable API fails open (
unknownlets the request through). /sign-in,/grant-access,/eveare ungated./sign-inis the only path a
stranger may read;/joins it only whenIS_MARKETINGis set.- There is no way past the key gate but to answer — Skip stranded installs, every
later company sittingPENDINGwith nothing saying so.
The name is also the URL
Served under the workspace slug (/comp-ai/companies). Cosmetic, not tenancy —
every query still resolves through WORKSPACE_ID.
- The slug is the plugin's column, written by
workspaceSlug(name)
(@crm/db/workspace) on rename and create. Never derive it on read. ensureWorkspaceMembershipreconciles it;RESERVED_SLUGSprevents collision with
a real route (a collision gets-crm).- The proxy is the only thing that puts the slug on. Missing or stale slugs are
redirected with the query string intact, not 404'd;[slug]/layout.tsxis the
backstop. appPathinproxy.tsis the one place/resolves for a signed-in rep, which
keeps everycallbackURLcorrect without knowing about slugs.- Renaming moves the URL, so
workspace-form.tsxreplaces the location onto the
slugworkspace.updatereturns.
SSO is a row, not a deployment
An ssoProvider row via Better Auth's sso plugin, on Settings → SSO, because a
self-hoster's admin cannot redeploy.
- OpenID Connect only — issuer, client id, secret; endpoints from discovery. No
SAML UI: it needs an X.509 cert and SP signing key we have nowhere to keep. SsoServicepassesWORKSPACE_ID, never an input.- Management is tRPC (
sso.*); signing in isauthClient.signIn.sso(). sso.signInOptionsis the one public procedure in the app. Every othersso.*
takesAuthMiddlewareat the method, which is what leaves it open. A client
secret is never read back out.- It is the API's answer, not the app's — the API serves
/api/auth/*. - An install with no Google, no Microsoft and no provider says so, naming the
variables; a read that fails falls back to offering Google. - A provider hides the social buttons, it does not disable them —
/sign-in?method=googleand?method=microsoftstill work, so a mistyped issuer
cannot lock an admin out. - Signing in with an IdP does not cost you Gmail.
needsMailboxGrant(@crm/auth)
walls only an account whose sign-in rows are all mailbox providers — Google,
Microsoft, or both — and none of them granted.mailboxGrantsNeededreturns which,
so/grant-accessoffers the button they can actually use. ALLOWED_SIGN_INstill decides who gets an account, indatabaseHooks.user.create.before, for SSO sign-ups too.organizationProvisioning: { disabled: true }—ensureWorkspaceMembershipalready
does the join.
tRPC is the data surface; REST is auth and health only
- One router per module,
*.router.ts(the codegen glob), with@Router({ alias })and@UseMiddlewares(AuthMiddleware). NoAuthMiddleware
means public — there is no other guard. - Routers are thin: zod in, service call out; Prisma lives in
*.service.ts. - Services throw Nest's
HttpExceptionfamily;DomainErrorMiddlewaremaps them. - Filter, sort and paginate in Prisma. List procedures take
listInputand return{ rows, total, facetCounts }. Never filter a whole table in the browser; never
interpolatesortinto a field name — useresolveOrderBy. src/generated/server.tsis generated and committed, andbuildmust never
regenerate it — the generator needs GLIBC 2.39, newer than Vercel's build image.
Onlycheck-typesanddevrun it. If the app cannot see a new procedure, it has
not run.
The OpenAPI document is built at runtime, not committed
GET /openapi.json serves one document: Nest's own controllers plus a REST bridge
under /rest generated from every tRPC procedure. Swagger UI renders it at /.createApp builds both halves and merges them, so nothing is generated at build
time and no file is checked in — the document is whatever the routers are.
SwaggerModule.setup runs before app.init(), because it registers its Express
routes synchronously and Nest's own routing would otherwise shadow them. The factory
form defers building the document to the first request, which is what lets it read
the tRPC router that only exists after init.
Two rules follow for the serverless build:
@nestjs/swaggerstays inEXTERNALSinapps/api/scripts/build-func.mjs, becauseswagger-ui-distresolves its assets from disk at runtime and cannot be bundled.- Anything external must be vendored, and vendoring follows non-optional
peerDependencies, not onlydependencies. Nest packages declare their runtime
needs as peers, so followingdependenciesalone ships a function that throwsMODULE_NOT_FOUNDon the first request. Adding a name toEXTERNALSwithout
checking it lands in.vercel/outputbreaks production, and the build stays green.
Two mail providers, one pipeline
apps/api/src/mailbox is everything neither Google nor Microsoft owns:MailboxApiClient (bearer GET, and the one place a status code becomes an outcome),SyncStateService (the MailboxSync row), MailboxTokenService,MailboxMatchService, participants.ts, message-text.ts, andThreadWriterService.
ThreadWriterService.storeis the only writer ofEmailThread,EmailMessage
and theEMAILactivity. Gmail and Outlook each parse their own wire format down
to oneIncomingMessageand hand it over; matching, threading, counting and
stamping happen once. A second copy of that is how a rule like reply before you
create a company comes to be true in one inbox and not the other.- A thread is keyed by RFC message id, not by the provider's thread id. Root comes
fromReferences→In-Reply-To→ ownMessage-ID, so a rep on Gmail and a rep on
Outlook land on the sameEmailThreadfor the same conversation. Graph only returnsinternetMessageHeaderswhen$selected and not for every message, so Outlook falls
back tooutlook-conversation:<conversationId>— threading that still holds inside
Outlook, just not across to Gmail. MailboxSync.sourceis the discriminator —calendar,gmail,outlook. Each
provider's module only ever sees its own, andsync/mailbox-sync.service.tsis the
one place that dispatches. One cron, one budget:POST /internal/sync/mailboxes(/googleis kept as an alias so an existing
deployment's cron keeps working).- Gmail is forward-only from a
historyId, Outlook from a timestamp. Graph has no
mailbox-wide delta, so the Outlook cursor is the lastreceivedDateTimeseen,
re-read with a one-second overlap;rfcMessageIdis unique, so the overlap costs a
duplicate fetch and never a duplicate row. - Microsoft has no token-revocation endpoint.
revokeclears the columns and the
UI says the consent itself is removed in the user's Microsoft account. Google's still
posts tooauth2.googleapis.com/revokeand refuses to clear if that fails.
Not every address on a thread is a person
externalParticipants (mailbox/participants.ts) is the one gate, discarding us
(allow-list domains, User table), rep decisions (SuppressedContact,SuppressedDomain), and addresses no human reads.
isMachineDomain(companies/domain.ts) sits besideFREE_EMAIL_DOMAINS;domainFromEmailreturns null for both, andcompanyForEmailis the only path from
address to company — so a caller ignorant of the rule still cannot create one..calendar.google.comcovers shared calendars, rooms and ICS feeds.- Matches the host, never a substring —
calendar.acme.comis a real company. isMachineAddressalso catches opaque local parts (24 hex chars, UUIDs),
deliberately narrow: a false positive is a real customer never filed.- It leaves no row — a rep may still type these into quick-add; only the inbox
is barred from deciding.syncAttendeesfilters the same addresses besideattendee.resource. isAutomatedAddressis a separate list about the local part (sales@,noreply@), which is why[email protected]never becomes a lead.
People on a deal
DealContact is the join, and deals.attachContact / detachContact /setContactRole are the only ways to write it. deals.contactOptions is what the
picker reads.
- A contact on a deal works at that deal's company, enforced in the service and
not merely by the picker — the same rule ascompanies.setPrimaryContact. - Attaching is an upsert and re-attaching keeps the role already there, so a
double click cannot blank what somebody typed. - Detaching removes the row, never the contact. They stay in the CRM, on the
company, with their history. roleis blanked to null, never stored as""—blankToNull, as everywhere
else.
Deleting a record is archive first, purge later
contacts.archive, companies.archive, deals.archive set archivedAt. Nothing
else changes — no suppression, no cascade cleanup, no stamp recompute — because the
row is still there, just filtered out of every list.
archivedFilter(input.archived)is in everybuildWhereand everyfacetCounts
where. A list call defaults toarchived: false; the Archived view is the same
procedure witharchived: true. There is no third "everything" mode — mixing
active and archived rows in one table is exactly what this feature removes.archive/restoreare a bareupdate({ archivedAt }), nothing more. They do
not touchAgentTask,SuppressedContact, orlastActivityAt— the record is
unchanged, only hidden.purgeis the olddelete. Same transaction, same suppression, sameAgentTask/AgentEventcleanup, sameActivityStampService.recomputeAfterDelete.
Read the rest of this section aspurge's contract, notarchive's.- A rep can
purgestraight from the Archived view — retention is a ceiling, not a
wait.bulkPurge/the per-record "Delete forever" action call it directly, no
different from the cron.
Pruning: ArchiveRetentionController (internal/archive/prune, CRON_SECRET-gated,apps/api/vercel.json) reads AppSetting.archiveRetentionDays
(readArchiveRetentionDays, @crm/db/settings, default 180) and calls each service'spurgeExpired(before) — findMany({ archivedAt: { lte: before } }) capped atARCHIVE.prune.maxBatch (archive/archive-config.ts), then purge per row through
the ordinary runBulk. Settings → General has the day count
(settings.archiveRetention / setArchiveRetention).
Below is purge's contract — everything that used to be delete's:
- A purged contact is suppressed by address, or the sync recreates them from the
next thread.ContactsService.purgewritesSuppressedContact, andexternalParticipantsdrops it like aSuppressedDomain— one filter covering
contact creation, company auto-creation and attribution. - Keyed lower case.
normalizeEmail(crm/values.ts) is the one canonicaliser,
oncontacts.create,.updateand the suppression; conflict checks andallowAgain
match case-insensitively. - The address comes from the delete itself
(tx.contact.delete({ select: { email: true } })), not a read before it — and the
404 is that statement's ownP2025throughtranslate. - Adding them back lifts the suppression via
allowAgaininside the write's
transaction. Never automatic. - Purging a company does not suppress its domain — its people survive with no
company, and domain suppression stays the explicit Settings → Connections control. - Clear
AgentTaskandAgentEventyourself — they carrycontactId/companyId
with no foreign key, so nothing cascades. - Recompute
lastActivityAton exactly the records the purge reached.ActivityStampService.targetsOf(where)collects them inside the transaction (the
evidence is what gets deleted);recomputeManyrestamps. A company'swheremust
follow its deals:{ OR: [{ companyId }, { deal: { companyId } }] }.recomputeAll()is for a purge only. - Recompute after commit, logging rather than throwing — the row is already gone,
and a raised error makes the browser skip invalidation and retry into a 404.
Money
A deal is sold in one currency and reported in another, and only baseAmount may
ever be summed. The rules — baseCurrency, countedWhere/pendingWhere, frozen
rates, the supported currencies, the keyless feed, and why the fetcher is the one
documented exception to no intelligence in the API — are in docs/currency.md.
Read it before touching any amount, total, chart or rate.
Freshness: invalidate the query, don't disable the cache
- Invalidate in
onSuccessthroughuseCrmCache()(lib/trpc/cache.ts), never by
listing keys at the call site. Say what changed —cache.deal(id),cache.company(id),cache.contact(id),cache.activity(). A new mutation adds a
call there, not a new list of keys. - A deletion is
cache.removed(ref)— one wide fan-out, and the only placerefetchType: "none"is right: the deleted record'sbyIdquery is still mounted
while the sheet animates shut, so refetching reads a 404 into the closing sheet,
while leaving it alone serves 30s of a dead record from cache. { settle: "record" }for inline editors, so the field's spinner clears without
waiting for the table.- Infinite queries need
pathKey(), notqueryKey()— the latter stamps{ type: "query" }and silently cannot match{ type: "infinite" }.activities.timelineis read both ways. cache-manageris per-value and opt-in, not an interceptor;AuthService.getProfileis the model.- Background writes need polling, not invalidation —
refetchIntervalwhilePENDING/RUNNING, viaisEnriching()andENRICHMENT_POLL_MS. Lists poll too,
not just the sheet.
Connections (`docs/connections.md`)
Connections — rules for AI agents
Covers /settings/connections and everything under it: the index, per-connection
pages, the intake endpoint, the add-connection picker, and identity matching.
Read docs/design.md first. Read docs/api.md before touching apps/api, anddocs/agent.md before touching apps/agent. The Paper file isapp.paper.design/file/01KZ72S23CM00WXN6S1MP68M03/1-0, page crm - grim.
The one rule that governs everything here
A connection is a capability. An automation is an agent.
A connection page shows what a connection can do, who it is wired to, and which
agents use it. It never owns, authors, or edits an automation. Automations are
built in the agent builder chat by tagging the integration, and they live at/agents/:agentId.
This is already true in code: builderResource.kind inapps/api/src/conversations/conversations.contracts.ts is["integration", "company", "contact", "deal"], and the composer picker inapps/app/components/agent-builder/agent-composer.tsx is labelled "Tag CRM
records and integrations" with ids shaped google:calendar, google:gmail.
A new integration becomes taggable by extending that enum's id space, not by
building a second authoring surface.
If you find yourself adding a toggle, a trigger builder, or an "Edit automation"
form to a settings page, stop — that work belongs in the builder.
Removing a connection is an owner or admin decision
A connection is shared. One person disconnecting Slack stops every agent that
posts to it and clears the cached channel list for everybody, soSlackConnectionService.disconnect asks canManageConnections (@crm/auth)
after AgentAccessService.assertMember, and slack.status returns canManage
so the button is disabled for the same people the service refuses. Being signed
in is not authorisation: AuthMiddleware only proves a session exists.
Everything additive — joining a channel, creating one, refreshing people — stays
open to any workspace member, because the agent builder needs it and Slack can
undo it.
Connecting Slack is refused at the OAuth endpoints, not in the UI
Connecting is the same decision as disconnecting, because replaceSlackConnection
deletes every other Slack account row: a second person connecting replaces the
workspace's Slack, and every deployed agent then reads from and posts to whichever
Slack they installed. Hiding the button is not enough — authClient.oauth2.link
is one POST.
slackConnectGuard (packages/auth/src/slack-connect.ts) is Better Auth'shooks.before, and it asks the same canManageConnections the API does. It
covers all three doors: /oauth2/link, /sign-in/oauth2 (Slack is a connection,
never a sign-in method, and that endpoint needs no session) and/oauth2/callback/slack. The callback is the one that matters — refusing there
happens before the code is exchanged, so a refused attempt writes noSlackWorkspaceGrant user token and deletes no bot token. Google and Microsoft
sign in on different paths and never reach the guard.
A workspace with no owner and no admin lets any member connect. There is nobody
left to ask, and a fresh install must not be locked out of its first connection.
Direction is the organising idea
Every connection declares what it brings in and what it sends. Use those
two words. Not arrows, not "inbound/outbound", not scope tokens.
Sends earns its place by being reassuring when it is empty. HubSpot reads
"Nothing, so nothing here can change HubSpot" — that is the guarantee a migration
needs, stated where someone will look for it.
Destinations: derived or chosen
When an agent sends something, its destination is one of two kinds. Getting this
distinction wrong is what makes an automation feel like it works by magic.
Derived — follows from the record, so never ask. The deal's owner. The
channel that belongs to this deal. Say so explicitly in the UI ("The person who
booked, at the email they booked with. Nothing to choose.") so the absence of a
control reads as deliberate.
Chosen — cannot be inferred, so must be asked exactly once. A standing
channel like #sales. A naming pattern for channels the agent creates
(#deal-{company}). A billing contact.
Chosen destinations are collected in the builder conversation with the
Questionnaire, not in a settings form. See "The Questionnaire" below.
Slack needs two grants, not one
A bot token cannot add itself to a private channel. No Slack scope grants that.
The workspace must also hand over a user token (xoxp-), which the app then
uses to act as the person who connected Slack.
- Public channel — the bot self-joins with
conversations.join. Bot token only. - Private channel — only a user token can invite the bot. Both tokens.
The bot token lives on Account. The user token lives on SlackWorkspaceGrant,
keyed by Slack team id, because the grant belongs to the workspace and not to the
person who clicked Connect. packages/auth/src/slack-grant.ts writes it, andapps/agent/agent/lib/slack-connection.ts is the only place that reads either.
A missing user grant is a capability that is off, not an error. The connection
page names it, and the private-channel row falls back to asking a human.
The cached row does not choose the token. joinSlackChannel
(apps/agent/agent/lib/slack-membership.ts) reads the channel from Slack withconversations.info before it picks a path. slackChannel.isPrivate andisMember are a cache, and a cache is wrong the moment somebody adds the bot in
Slack — or the moment a new column arrives with a default, which classifies every
existing row as public and non-member until the inventory next runs. A bot that
cannot see the channel at all is read as private and not a member, which is the
only safe reading. When Slack and the row disagree the row is corrected and aslack-people-match task is queued, so the connection page stops showing the
stale classification too.
Nothing else calls Slack to read the channel list. The agent builder reads
the cached rows and, when they are older than SLACK.inventory.staleMs, queues
the same background task rather than waiting on a round trip
(apps/agent/agent/lib/slack-people.ts). A builder chat that blocks on Slack is
a builder chat that is as slow as Slack is.
Permissions are shown in groups, not one line each
Sixteen scopes read as noise. Group them by what they touch — people, channels it
can read, messages it can send, channels it can change — and give each group a
count of how many reach the whole workspace. SLACK_SCOPE_GROUPS inpackages/auth/src/slack-scopes.ts owns both the grouping and the plain wording.
The catalogue is the single source. auth.ts builds its scope request from it and
the page renders from it, so the screen cannot promise something the app never
asked for.
Identity matching is connection-level
"Message whoever owns the deal" needs a CRM user and a Slack member. Those are
two identities. Match on email, show the result, and let a human fix it.
An unmatched person must be allowed. The automation stops and says so rather than
guessing at a similar name — and the connection page surfaces the count so the
gap is visible before it bites.
The Questionnaire
The strongest pattern in this work. When the builder cannot derive something, it
asks in chat rather than failing or guessing.
Reuse packages/ui/src/components/questionnaire.tsx — Questionnaire,QuestionnaireItem, QuestionnaireTitle, QuestionnaireDescription,QuestionnaireChoices, QuestionnaireChoice, QuestionnaireActions,QuestionnaireSubmit. It is wired through agent-clarification-composer.tsx andbuilderQuestionResponseInput.
Rules for a question:
- Ask only what cannot be derived. Say which parts you already worked out.
- Title is the question in plain words. Description says why it is being asked
("This one is a standing channel, so it is the same every time"). - Choices carry a fact that helps decide — member counts, last activity — not
decoration. - Always offer an escape ("Search for another channel").
- Footnote the constraint that shapes the list ("Only channels Slack has been
added to"). - The submit label restates the answer: "Use #sales", not "Continue".
Failure is on the surface
Nobody owns these automations day to day, and a broken one currently sits for a
week or two. So every connection surface leads with liveness, not configuration:
"Last call arrived 31 minutes ago", "1 call needs a look", "3 of 4 matched".
An automation you cannot see rot in is one nobody will own.
Irreversible things are bounded in writing
Anything that messages a customer states its own limits next to the switch:
"It stops the moment Stripe reports the payment, and never messages the same deal
twice in a day." "It runs once per deal." An agent that DMs people about money
needs its guardrails legible to whoever turns it on.
Layout
Page metrics are tokens. Never a literal.
| Token | Value | Use |
|---|---|---|
--spacing-page-top |
40px | Top bar to first line of content |
--spacing-page-bottom |
40px | Below the last block |
--spacing-page-inline |
24px | Viewport gutters |
--spacing-page-gap |
24px | Between top-level blocks |
--spacing-block-inline |
20px | Inset on every block, including the page title |
--container-page |
820px | Content column |
--container-page-wide |
1120px | Two-column or table pages |
--container-narrow |
560px | Empty states, dialogs |
--container-sheet |
640px | Edit sheet over a page |
--spacing-block-inline is the one doing real work: a card sits flush at the
column edge and carries 20px of its own padding, so card text lands in the same
lane as the title on a flat page. The two page types stay aligned without
matching each other's chrome.
Short single-decision pages (empty state, pre-connect, match people) centre
vertically. List pages top-align so nothing jumps when a row is added.
Cards versus flat
Cards are for discrete, independently-actionable objects — an agent you can
pause on its own, a connection row with its own Manage button, a choice in a
picker, a Questionnaire.
Flat sections separated by a 1px --color-dark-border rule are for a page
describing one thing — the intake page, the pre-connect page.
The split is by what the block is, not by page. Do not card a section; do not
flatten a list of separately-actionable objects.
Brand marks
packages/ui/src/components/brand-logos/ has claude, eve, github, google,
microsoft, nextjs, slack, stripe, vercel. Stripe is there — use it.
Missing from the repo and living only in Paper: hubspot (hubspot iconL5E-0), docusign (docusign logo L5Z-0), ergo (ergo logo KJJ-0).
Extract their path data into brand-logos/*.tsx shaped like stripe.tsx when
building these screens.
Until a mark exists, EntityLogo falls back to an initials monogram onbg-muted — that is correct behaviour, not a placeholder to design around. Only
icon marks are used at these sizes; the wordmarks stay unused.
Docusign is spelled with a lowercase s since its 2024 rebrand. The note-taker is
Ergo.
Crm Plan (`docs/crm-plan.md`)
Plan — Agentic CRM (HubSpot replacement)
A lightweight, opinionated CRM for our sales team. Companies are the top-level
object; Contacts and Deals hang off them; everything that happens is an
Activity on a timeline. Company knowledge is filled in by an agent using
Context.dev rather than typed by a human.
This is the build plan: decisions, data model, contracts, conventions and
phased delivery. Read it with AGENTS.md,design.md and api.md.
1. The decisions that stop this becoming a nightmare
Five choices carry the whole design. Everything else follows from them.
- Core fields are real columns. Custom fields are one JSONB column. Not
EAV. A table showing eight custom columns must not become eight joins. §5. - One property registry drives columns, filters, forms and the detail
sheet. System fields and custom fields are described by the same
metadata, so adding a column to the CRM is a data change, not four code
changes. §5. - Permissions are enforced in the tRPC middleware and again in the service.
The UI hides buttons; it never guards anything. Row-level ownership is a
service concern because no statement grammar can express it. §6. - Sheets, not inner pages. A row click opens a sheet keyed by a URL param.
Back button closes it, links are shareable, and we never build a/companies/[id]/contacts/[id]/editroute tree. §10. - Everything that lists data is the same
DataTable. Ported verbatim frommvp, driven by nuqs, paginated server-side. §10.
Choice table
| Decision | Choice | Why |
|---|---|---|
| Tenancy | Single tenant, no organizations | Repo is already built this way (20260731160000_remove_organizations); internal tool, Google-only sign-in. |
| Hosting | Both apps on Vercel | §2 — with the serverless consequences handled explicitly, not discovered later. |
| Data fetching | tRPC end to end, prefetched in RSC | Type-safe Prisma→cell; first paint is already filtered. |
| Table state | nuqs, server-side page/sort/filter | Shareable URLs; 100k rows stay fast. |
| Data table | mvp's DataTable into packages/ui |
design.md makes /packages/ui the source of truth; simple-table and card-table already live there. |
| Custom fields | JSONB + PropertyDefinition registry |
§5. |
| Associations | Many-to-many, three typed join tables, labels + primary flags | HubSpot parity; a contact really can sit on two accounts. §4. |
| Lifecycle / lead status | First-class enum columns on Company and Contact | They drive filters and reporting; HubSpot parity. §4. |
| Last activity date | Denormalised lastActivityAt column, written with the activity |
It's a sortable column on every list; a subquery per row would be the slowest thing here. §4. |
| Saved views | SavedView row storing a serialised query string |
Falls out of URL-as-state for almost nothing. §11. |
| RBAC | Better Auth admin plugin + createAccessControl |
§6. No orgs, so the org plugin's role model doesn't apply. |
| Attachments | Vercel Blob, client uploads, presigned reads | §7. |
| Speed | Redis for sessions, property defs and aggregates + RSC prefetch | §8. |
| Forms | react-hook-form + zodResolver + @crm/ui Field primitives |
§11 — matches mvp exactly. |
| Effects | Banned, lint-enforced, useMountEffect escape hatch |
§12. |
| Comments | None in new code | §12. |
Judgment calls worth your veto
UNQUALIFIED_TO_BUYis terminal, not linear. You listed it between two
active stages, but it's a disqualification. Treated likeCLOSED_LOST:
excluded from pipeline value and forecast, shown under a Closed tab. Say the
word if you want it mid-funnel.- Reps read everything, write only what they own. Sales teams need
visibility; letting anyone edit anyone's deal is how data rots. Activities are
the exception — anyone can log an activity against any record. Company.domainandContact.emailare unique. Duplicates are the #1
way a CRM rots, and it makes the HubSpot import idempotent. Cost: two people
behind oneinfo@need separate records.- Not everything is a custom property. The sales-critical fields are real
columns with real constraints. Custom properties exist for the team's own
additions. Making everything dynamic is the Salesforce trap. - Many-to-many associations everywhere, with labels and primary flags. Your
call, over my earlier suggestion to flatten them. It costs a join on every
company column and turns the company timeline into a union (§4) — both
handled, neither free. In exchange the model matches how you actually sell,
and we never hit the wall where a contact genuinely sits on two accounts. - No Tickets, Quotes, Subscriptions, Payments, Orders or Payment links. The
record sidebars in your screenshots are mostly empty(0)cards. Rebuilding
those is rebuilding HubSpot. §15.
Repo issues found while surveying
docs/review.mddoes not exist butAGENTS.mdsays "Always review" it.
Every agent session currently starts by failing to read a required file.docs/api.mddescribes a multi-tenant world this repo doesn't have. The
org-context and per-org cache-generation sections came from the MVP; there are
no organizations here. The logging rules apply verbatim and must stay. Trim
the rest in Phase 0.apps/app/make-session.tmp.tsis a stray scratch file.- The existing codebase is heavily commented. The no-comments rule applies to
new code; existing comments are left alone rather than churned.
2. Hosting on Vercel, and what it costs us
Vercel is a serverless platform. Three things about this stack break there if
we don't design for them, and all three are cheap to handle now and expensive
to retrofit.
The API runtime changes from Bun to Node. apps/api currently builds withbun build --target=bun and runs bun src/main.ts. Vercel has no Bun runtime.
NestJS deploys as a single Node serverless function via @vercel/node, with a
cached bootstrap so warm invocations reuse the Nest app:
let cached: Promise | null = null;
export default async function handler(req: IncomingMessage, res: ServerResponse) {
cached ??= bootstrap();
const app = await cached;
app.getHttpAdapter().getInstance()(req, res);
}Keep the Bun scripts for local dev; add Node build/start scripts for Vercel.bun test stays as-is for local test runs.
Fire-and-forget dies on serverless. The function freezes once the response
is sent, so void this.enrichment.enqueue(id) silently never finishes. UsewaitUntil from @vercel/functions — the same primitive mvp already relies on
per docs/api.md. Enrichment is a ~7–18s Context.dev call, comfortably inside
the function budget. When volume outgrows it, move to a durable queue; theEnrichmentService interface stays the same either way, which is the point of
putting it behind a service in the first place.
Postgres connections exhaust. Every concurrent invocation is a new client.
Use a pooled connection string for the app and a direct one for migrations:
DATABASE_URL="postgres://...pooler...?pgbouncer=true&connection_limit=1"
DIRECT_URL="postgres://...direct..."with directUrl in the Prisma datasource. Neon or Supabase both give you this;
so does Prisma Accelerate. Getting this wrong produces "too many connections"
under load and nothing before it.
Redis must be serverless-friendly. Provision Upstash through the Vercel
Marketplace. @keyv/redis (already a dependency) speaks the Redis protocol,
which Upstash supports over TLS; Fluid Compute keeps the connection alive
across invocations.
Deployment shape
Two Vercel projects from one repo: crm-app (root apps/app) and crm-api
(root apps/api). Turborepo remote caching on. apps/app rewrites /api/* to
the API deployment, so the browser stays same-origin and the session cookie
just works.
3. Architecture
browser
│ same-origin /api/trpc
▼
apps/app (Next.js 16, RSC) Vercel project crm-app
├─ app/api/[...path]/route.ts ──proxy──▶ crm-api
├─ server components: getServerTrpc() → prefetch → HydrationBoundary
└─ client: useTRPC() + TanStack Query, nuqs for all table + sheet state
│
apps/api (NestJS, Node function) ▼ Vercel project crm-api
├─ /api/trpc routers: companies contacts deals activities
│ properties attachments users dashboard
├─ /api/auth/* Better Auth (Google) + admin plugin (RBAC)
├─ /api/blob/upload client-upload token handshake
├─ services Prisma, property validation, ownership guards
└─ enrichment Context.dev via waitUntil
│ │ │
Postgres (pooled) Redis (Upstash) Vercel Blob
packages/db sessions, defs, attachments
aggregatesDependencies to add
| Package | Where | Version |
|---|---|---|
@trpc/server, zod |
apps/api |
^11.18.0, ^4.4.3 |
@trpc/client, @trpc/server, @trpc/tanstack-react-query |
apps/app |
^11.18.0 |
@tanstack/react-query, @tanstack/react-query-devtools |
apps/app |
^5.101.2 |
nuqs |
apps/app, packages/ui |
^2.8.9 |
zod, @hookform/resolvers |
apps/app |
^4.4.3, latest |
@vercel/blob, @vercel/functions |
apps/api, apps/app |
latest |
keyv, @keyv/redis |
packages/auth |
matching apps/api |
context.dev |
apps/api |
latest |
nestjs-trpc@^2.13.0, @keyv/redis, cache-manager, react-hook-form andbetter-auth@^1.6.25 are already present.
4. Core data model
Auth models are owned by Better Auth and regenerated by bun run auth:generate
— never hand-edited. User gains only back-relations (CompanyOwner,ContactOwner, DealOwner, ActivityAuthor, AttachmentUploader,SavedViewAuthor) plus the admin plugin's fields (§6).
SavedView below references PropertyEntity, which is declared in §5 — Prisma
schemas are order-independent, so the split across sections is presentational
only. It is all one schema.prisma.
/* Detailed source-code truncated for AI context efficiency. */Activity.companyId is denormalised, and many-to-many complicates it. It is
stamped with the primary company of whatever the activity hangs off, so the
common case — one company, one timeline — stays a single indexed range scan.
But under M2M an activity on a deal shared by two companies belongs on both
timelines, and one stamped column cannot say that. The company timeline is
therefore a union:
WHERE "companyId" = $1
OR "dealId" IN (SELECT "dealId" FROM "companyDeal" WHERE "companyId" = $1)
OR "contactId" IN (SELECT "contactId" FROM "companyContact" WHERE "companyId" = $1)Every branch is indexed, and a company with a few hundred associated records
resolves in single-digit milliseconds. The denormalised column still earns its
place: it keeps the overwhelmingly common single-company read on one index, and
it is what lastActivityAt propagation walks.
If a company ever accumulates thousands of associated records, the fix is anActivityCompany fan-out table written alongside the activity. Do not build it
now — measure first. Noting it here so the upgrade path is deliberate rather
than a rewrite.
lastActivityAt is denormalised too, and this one is not optional. Your
HubSpot screenshots show Last activity date as a visible, sortable column on
every list and in every record's Data highlights. Computed as a correlatedMAX(activity.createdAt) subquery it would run once per row per page — the
single easiest way to make this CRM feel slow. It is a real indexed column,
written by the same service method that writes the activity, and backfilled by
the seed. Same argument for the createdAt indexes: Create date is the
default sort on the companies list.
Lifecycle stage and lead status are first-class, not custom. Both objects
carry lifecycleStage; contacts and companies carry leadStatus. They drive
filters, pipeline reporting and the Data highlights strip, so they get enum
columns with indexes rather than JSONB. Changing either writes aLIFECYCLE_CHANGE activity and stamps lifecycleStageChangedAt, which is what
lets the record header say "Customer for 21 hours" and offer history — the
timeline is the history, so there is no second audit table to keep in sync.
Associations are many-to-many by default, matching HubSpot — a contact can
sit on several companies, a deal can span several, and a deal has as many
contacts as it has humans in the room. This was your call over my earlier
recommendation, and it is the right one for a company selling to groups and
subsidiaries; the rest of this section is what it takes to do it without
paying for it later.
Three typed join tables, not one polymorphic edge table. A genericAssociation { fromType, fromId, toType, toId } looks tempting and is the same
mistake as EAV, one level up: no foreign keys, no cascade deletes, no Prisma
relations, and every query hand-written. There are exactly three pairings, that
number is bounded, so CompanyContact, CompanyDeal and DealContact each get
real columns and real integrity.
There are two independent notions of "primary", and conflating them is a
bug. HubSpot only models the first:
isPrimaryCompany— this contact's (or this deal's) main company. Drives
the Primary badge and the "Company" column on list views.isPrimaryContact— this company's main point of contact, which you asked
for in the original brief. Same for a deal's lead contact.
They point in opposite directions across the same row, so CompanyContact
carries both flags.
Postgres enforces one primary per side; the application does not. Prisma
cannot express partial unique indexes, so these go in a hand-written migration
and are not optional — "two primary companies" is exactly the corruption that
makes a CRM untrustworthy:
CREATE UNIQUE INDEX company_contact_one_primary_company
ON "companyContact" ("contactId") WHERE "isPrimaryCompany";
CREATE UNIQUE INDEX company_contact_one_primary_contact
ON "companyContact" ("companyId") WHERE "isPrimaryContact";
CREATE UNIQUE INDEX company_deal_one_primary_company
ON "companyDeal" ("dealId") WHERE "isPrimaryCompany";
CREATE UNIQUE INDEX deal_contact_one_primary_contact
ON "dealContact" ("dealId") WHERE "isPrimaryContact";Promoting a new primary is a two-statement transaction: demote the old, promote
the new. The index turns a race into a failed write rather than silent
duplication.
Deal.companyId was required and is now gone, so the "every deal belongs to
a company" invariant moves into the service: creating a deal requires at least
one company association, and removing the last one is rejected. A schema-levelNOT NULL was doing real work here; losing it means the service has to.
Association labels are a managed vocabulary, not free text. AssociationLabel
holds the options per pairing — Decision maker, Champion, Billing contact,
Parent company — editable in Settings next to custom properties, validated
server-side on write. Free text would give us four spellings of "champion"
inside a month.
What this costs, honestly. Every list that shows a company column now reads
through a join filtered to isPrimaryCompany, and every per-company count is a
join count rather than a foreign-key scan. Both are indexed and fine at this
scale. The real cost is the timeline, below.
Timeline semantics. Past = occurredAt set, or completedAt set, or aNOTE/STAGE_CHANGE/ENRICHMENT row using createdAt. Upcoming =dueAt > now() and completedAt IS NULL. Overdue = dueAt < now() andcompletedAt IS NULL.
Pipeline math. Open stages are DEMO_BOOKED, QUALIFIED_TO_BUY,DECISION_MAKER_BOUGHT_IN, CONTRACT_SENT. Closed are CLOSED_WON,CLOSED_LOST, UNQUALIFIED_TO_BUY. Pipeline value sums amount over open
stages only. Moving to a closed stage stamps closedAt and requiresclosedReason on the two losing outcomes.
5. Custom properties
The part that decides whether this is maintainable in two years.
Why JSONB and not EAV
An entity-attribute-value table is the textbook answer and the wrong one here.
Rendering a table with eight custom columns means eight joins or a pivot, on
every page of every list. Values live in one customFields JSONB column on the
row we already selected: zero extra queries, atomic writes, and a GIN index
that makes containment filters fast.
The trade — no referential integrity on values — is bought back by validating
every write against the definition registry server-side.
The registry
enum PropertyEntity { COMPANY CONTACT DEAL }
enum PropertySource { SYSTEM CUSTOM }
enum PropertyType {
TEXT
LONG_TEXT
NUMBER
CURRENCY
DATE
BOOLEAN
SELECT
MULTI_SELECT
URL
EMAIL
PHONE
USER
}
model PropertyDefinition {
id String @id @default(cuid())
entity PropertyEntity
source PropertySource @default(CUSTOM)
key String
label String
type PropertyType
description String?
options Json?
required Boolean @default(false)
showInTable Boolean @default(false)
filterable Boolean @default(true)
sortable Boolean @default(false)
order Int @default(0)
archivedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([entity, key])
@@index([entity, archivedAt])
@@map("propertyDefinition")
}SYSTEM rows describe the real columns from §4 — name, domain, stage,amount, owner. They are seeded from a versioned TypeScript constant and
upserted on boot, never hand-edited, and their key/type are immutable. Onlylabel, showInTable, order are editable on a system row.
CUSTOM rows describe keys inside customFields.
Your existing HubSpot custom fields validate the design
The deal record in your screenshots carries Deal Type, Term, Deal
Segment, Forecast Context, Forecast Context (manual), Competitors
Mentioned, Compliance Frameworks and Compliance Timeline; the company
carries VAT ID, Record source and Frameworks; the contact carries
Buying Role, Assessment, assessment-source and the Original Traffic
Source drill-downs. Every one of these is a CUSTOM definition in the
registry — no schema migration, no code change.
Two are worth calling out. Compliance Frameworks renders as removable tag
chips, which is the MULTI_SELECT type. And assesment-source is misspelled
in HubSpot today: the import maps it to a correctly-spelled key with the old
name preserved as an alias, so we carry the data across without carrying the
typo forward forever.
Seed these as part of the migration so day one in the new CRM has the same
fields the team already uses.
One registry, four consumers
properties.list({ entity }) returns both kinds in one array. From it:
| Consumer | Function | Produces |
|---|---|---|
| Table | buildColumns(defs, renderers) |
DataTableColumn<Row>[] |
| Filters | buildFacets(defs) |
DataTableFacet[] for every SELECT with filterable |
| Forms | buildFormSchema(defs) |
the Zod object for the custom section |
| Detail sheet | buildProperties(defs, row) |
DetailSheetProperty rows |
Adding a property becomes a row in one table. No code change touches four
files, which is the entire point.
Rules that keep it safe
keyis immutable after creation. Renaming the label is free; renaming
the key orphans every stored value. The API rejects key changes outright.- Delete is archive.
archivedAthides the property everywhere and leaves
the JSONB untouched, so an accidental delete is a one-click restore. Hard
purge is a separate, explicit, owner-only action that strips the key from
every row in a transaction. - Every write is validated server-side against a Zod schema compiled from
the definitions and cached in Redis, invalidated when a definition changes.
Unknown keys are rejected, not silently stored — silent acceptance is how you
end up with four spellings of the same field. - Type changes are restricted to safe widenings (
TEXT→LONG_TEXT,NUMBER→CURRENCY). Anything else requires archive-and-recreate, so no
write can land against a type that no longer matches the stored data. USERtype values store a user id and are resolved through the same
owner-picker component as system owner fields.
Filtering and sorting
Filtering uses Prisma's JSON path filters against the GIN index:
where: { customFields: { path: [def.key], equals: value } }Sorting is the sharp edge: Prisma cannot orderBy into a JSON column. The list
service takes a raw ORDER BY "customFields" #>> ARRAY[$1] escape hatch, with
the key validated against the definition registry before it reaches SQL — an
allowlist derived from data we control, never the raw query string. Only
properties flagged sortable are offered in the sort menu.
Free-text q searches system text columns in v1. Extending it across custom
text properties wants pg_trgm; that is a later, measured change.
6. Role-based access control
Better Auth's admin plugin with createAccessControl, imported from the
dedicated paths for tree-shaking. The org plugin's role model does not apply —
there are no organizations. Roles live on user.role.
// packages/auth/src/permissions.ts
import { createAccessControl } from "better-auth/plugins/access";
import { defaultStatements, adminAc } from "better-auth/plugins/admin/access";
export const statement = {
...defaultStatements,
company: ["read", "create", "update", "delete"],
contact: ["read", "create", "update", "delete"],
deal: ["read", "create", "update", "delete", "reassign"],
activity: ["read", "create", "update", "delete"],
attachment: ["read", "create", "delete"],
property: ["read", "manage"],
member: ["read", "manage"],
} as const;
export const ac = createAccessControl(statement);| Role | Intent |
|---|---|
owner |
Everything, including user management and hard-purging properties. |
manager |
All records, reassign deals, manage the property schema. No user management. |
rep |
Read everything; create anything; update and delete only records they own. |
readonly |
Read everything. No writes except nothing at all. |
Three layers, and why each exists
- tRPC middleware.
PermissionsMiddlewarereadsmeta: { permissions: { deal: ["update"] } }off the procedure and checks it
against the role — mvp's pattern, minus the org lookup. Coarse-grained, and
it catches an entire router that someone forgot to guard. - Service-level ownership. No statement grammar expresses "only rows where
ownerId = me". A sharedassertCanMutate(record, user)runs inside the
service, before the write. This is the layer that actually protects a rep's
deal from another rep. - UI.
roleCan()hides buttons a user cannot use. Presentation only.
Anything enforced solely here is not enforced.
Two operational details that bite later
- Role changes lag the cookie cache.
session.cookieCacheis on with a
5-minutemaxAge, and the cached user object carries the role. Revoke the
user's sessions when their role changes so the next request re-reads from the
database. Otherwise a demotion takes up to five minutes to bite. - Re-run
bun run auth:generateafter adding the plugin. It addsrole,banned,banReason,banExpirestoUserandimpersonatedBytoSession. Skipping this produces runtime adapter errors, not type errors.
First Google sign-in bootstraps as owner if no users exist; everyone after
defaults to rep, promoted from Settings → Members.
7. Attachments on Vercel Blob
vercel blob create-store crm-attachments writes BLOB_READ_WRITE_TOKEN into
the project. Not yet created — see §14.
model Attachment {
id String @id @default(cuid())
pathname String @unique
filename String
contentType String
size Int
companyId String?
company Company? @relation(fields: [companyId], references: [id], onDelete: Cascade)
contactId String?
contact Contact? @relation(fields: [contactId], references: [id], onDelete: Cascade)
dealId String?
deal Deal? @relation(fields: [dealId], references: [id], onDelete: Cascade)
activityId String?
activity Activity? @relation(fields: [activityId], references: [id], onDelete: Cascade)
uploadedById String
uploadedBy User @relation("AttachmentUploader", fields: [uploadedById], references: [id])
createdAt DateTime @default(now())
@@index([companyId])
@@index([dealId])
@@map("attachment")
}Client uploads, not server uploads. Vercel caps a serverless request body
at 4.5 MB; a signed contract PDF blows straight through that. Useupload() from @vercel/blob/client against a handleUpload route that
authenticates the session, checks attachment: ["create"], and only then issues
a token. Server-side put() is reserved for small files we generate ourselves.
Store the pathname, not the URL. Blob URLs carry an unguessable random
suffix, which is obscurity, not authorisation — and a CRM holds customer
contracts. Downloads go through an API route that checks RBAC and then issues a
presigned URL (the CLI exposes blob presign and blob signed-token; confirm
the SDK flag names when implementing). Row payloads never contain a directly
fetchable URL.
Deletes are two-phase. Delete the row, then delete the blob in waitUntil.
A dangling blob costs pennies; a dangling row is a broken download.
The @crm/ui attachment.tsx component already models the
idle/uploading/processing/error/done states the upload flow needs.
8. Speed: Redis and rendering
Three distinct uses of Redis. Conflating them is how caches go stale.
- Better Auth secondary storage. Port mvp's
packages/auth/src/secondary-storage.tsverbatim. Every RSC render validates
a session; on Vercel that is a Postgres round trip per render unless sessions
live in Redis. Biggest single win available. - Application cache via the existing
AppCacheModule. Cache the things
that are hot and rarely change: property definitions, the user list, and
dashboard aggregates. Keys are namespacedcrm:v{gen}:{entity}:{hash}; a
write bumps that entity's generation counter rather than deleting keys. - Enrichment dedupe. A short-lived
enrich:lock:{domain}key stops two
concurrent creates of the same domain from both burning 10 credits.
Do not cache per-record reads behind Redis in v1. TanStack Query already
dedupes and caches client-side, and record freshness after a write matters more
than shaving a query. Cache aggregates and registries; leave records live.
Rendering. Every list page prefetches on the server throughgetServerTrpc() and hydrates:
const queryClient = getServerQueryClient();
await queryClient.prefetchQuery(trpc.companies.list.queryOptions(params));
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<CompaniesPanel />
</HydrationBoundary>
);Parsed nuqs values feed the prefetch, so the first paint is already filtered and
sorted — no loading flash on a shared link. Subsequent filter changes stay
shallow and are served by TanStack Query.
9. The agentic layer
apps/api/src/enrichment/ — a typed ContextDevClient overhttps://api.context.dev/v1 plus an EnrichmentService invoked throughwaitUntil.
| Trigger | Call | Cost |
|---|---|---|
Company created or given a new domain |
POST /brand/retrieve {type:"by_domain"} |
10 credits |
| Manual re-enrich | same, maxAgeMs: 0 |
10 credits |
| Contact created with a work email, no company | {type:"by_email"} → find-or-create Company, link as primary |
10 credits |
| CSV import | POST /utility/prefetch per row, then retrieve |
prefetch free on paid plan |
| Research this company | POST /web/extract with our schema → ENRICHMENT activity |
10 credits |
Field mapping. title→name (only when the record was created domain-first
and unnamed); description/slogan→description; logos[] picked bymode+type, never logos[0] — light+logo→logoUrl, dark+logo→logoDarkUrl, icon→iconUrl; colors[0].hex→brandColor (key off hex,
the name is generated); industries.eic[0]→industry/subIndustry;address→city/stateCode/country/countryCode; socials[]→ the social
URLs by type; links→pricingUrl/careersUrl. Whole payload →CompanyEnrichment.raw, so re-deriving a field never costs credits again.
Error handling. Every field is nullable — never overwrite a human-entered
value with null. 400 NOT_FOUND and WEBSITE_ACCESS_ERROR are SKIPPED, notFAILED, and are not billed. 422 on an email lookup (free or disposable) isSKIPPED with no company created. 403 on prefetch logs once and falls through
to a direct retrieve. 408/429 get one backoff retry, then FAILED with the
reason in enrichmentError. Cold lookups run p50 ≈ 7s, p90 ≈ 18s, sotimeoutMS: 60000 and never in the request path.
Progress in the UI is a TanStack Query refetchInterval that runs whileenrichmentStatus is PENDING or RUNNING and returns false once it settles
— not a setInterval in an effect.
CONTEXT_DEV_API_KEY lives in the API's environment, declared @IsOptional()
in env.validation.ts. No key means enrichment is disabled, logged once at
boot, and companies stay PENDING. The key is server-side only.
Deal intelligence (Phase 11)
Your deal record shows a Deal Score card and an AI-written Forecast Context
that reads like a summary of the timeline. That is the second agent, and it is
the one that makes this "agentic" rather than "enriched":
- Deal score — a 0–100 health number with a one-paragraph rationale,
derived from stage age, activity recency and cadence, contact coverage
(do we have a champion and an economic buyer?), and what the notes
actually say. AddsdealScore,dealScoreSummary,dealScoredAt,forecastContextandforecastContextManualtoDealin a Phase 11
migration — deliberately not in the §4 schema, so the core model ships
without waiting on the model work. Recomputed on stage change and on a
nightly cron, never in the request path. - Forecast context — a rolling summary of the deal's timeline, regenerated
when the timeline changes materially. Their record keeps Forecast Context
and Forecast Context (manual) side by side; we do the same, andforecastContextManualalways wins when set. - Stalled-deal detection — an open deal with no activity in N days raises a
task for its owner. This is the cheapest high-value automation in the whole
product and needs no model at all.
Use Claude via the Anthropic API for these; the scoring prompt reads the
timeline the CRM already stores, so there is nothing new to integrate.
10. API contract
Every list endpoint speaks one shape, so one table component drives everything.
export const listInput = z.object({
q: z.string().default(""),
sort: z.string().default(""),
dir: z.enum(["asc", "desc"]).default("asc"),
page: z.number().int().min(1).default(1),
pageSize: z.number().int().min(1).max(100).default(25),
filters: z.record(z.string(), z.string()).default({}),
});Returning { rows, total, facetCounts }, where facetCounts feeds the counts
in the filter dropdowns.
| Router | Procedures |
|---|---|
users |
me, list, setRole, ban |
companies |
list, byId, create, update, remove, enrich |
contacts |
list, byId, create, update, remove |
deals |
list, board, byId, create, update, setStage, remove |
activities |
timeline, create, update, complete, remove, myTasks |
properties |
list, create, update, archive, restore, purge, reorder |
associations |
list, link, unlink, setLabel, setPrimary, labels |
savedViews |
list, create, update, remove, reorder |
attachments |
list, createUploadToken, download, remove |
dashboard |
summary |
One associations router serves all three pairings, taking{ pairing, fromId, toId } rather than one endpoint per direction — the
alternative is nine near-identical procedures. setPrimary runs the
demote-then-promote transaction from §4, and unlink refuses to remove a
deal's last company.
listInput.filters carries facet values as strings. Date-range facets encode
as either a named window (last_7_days, this_month, last_quarter) or an
explicit from..to pair of ISO dates; the service resolves both to a Prismagte/lte on the mapped column. Named windows are resolved server-side so a
shared URL means the same thing tomorrow as it does today.
Every router carries @UseMiddlewares(AuthMiddleware, PermissionsMiddleware)
with per-procedure meta.permissions. deals.setStage writes theSTAGE_CHANGE activity and stamps stageChangedAt/closedAt in one
transaction — a stage change that doesn't appear on the timeline is a bug, so
it cannot be two calls.
11. Frontend
Routes — flat, because sheets carry the depth
apps/app/app/(app)/
page.tsx dashboard
companies/page.tsx
contacts/page.tsx
deals/page.tsx
settings/properties/page.tsx
settings/members/page.tsxThere are no [id] routes. A record opens in a sheet driven by a URL param:?company=cmp_123, ?deal=deal_456, ?new=company. The back button closes it,
the URL is shareable, and we never grow a nested route tree. Deep links render
the list server-side and open the sheet on top.
Sheets
Port responsive-sheet.tsx and detail-sheet.tsx from mvp — Sheet on
desktop, Drawer on mobile, behind one API. DetailSheet supplies Header / Stats / Properties / Section / Body /Footer, which maps onto the HubSpot record layout almost one to one — their
three-column page becomes one vertical sheet:
Header — logo, name, domain link, owner, enrichment chip, then the
composer row your screenshots put under the record title: Note · Email ·
Call · Task · Meeting · More. Same five, same order.Stats —
DetailSheetStatsis their Data highlights strip verbatim:
Create date · Lifecycle stage · Last activity date. On a deal, stage
replaces lifecycle.Properties — their About this company / Details panel. System
properties first in registry order, then custom, all from §5. Inline-editable
in place; no separate edit mode.Sections — the right rail, flattened: Companies, Contacts, Deals,
Attachments, Timeline. Each shows a count and an Add action, and each
renders aSimpleTablerather than the fullDataTable— these are short
embedded lists, not paginated views. The associated-deal rows carry an inline
stage dropdown, matching the contact record in your screenshot.Because associations are many-to-many, each row also carries a Primary
badge and an association label chip, both editable in place, exactly as
your screenshots show. Sections cap at five rows with a View all associated
… link that opens the full list filtered to that record — a company with
forty contacts must not render forty rows inside a sheet. Adding an
association is acmdksearch over existing records with a create-new
fallback, so linking never means leaving the sheet.Footer — save, delete, re-enrich.
Timeline. Grouped by month with day sub-headers, newest first, matching
their Recent activities. It gets its own search box, an activity-type filter,
a date-window filter and a collapse-all toggle. Upcoming and overdue items pin
above the history with a completion checkbox.
Create buttons live in the page shell header
Every list page puts its primary action in PageShellActions, which the shell
already positions top-right:
Companies
Every account we sell to.
<CreateCompanyButton />The button sets ?new=company; the same sheet component handles create and
edit, distinguished by whether an id is present. One component, one form
schema, two entry points.
The data table, and its rules
Ported from /Users/lewiscarhart/mvp/apps/app/components/data-table/, rewriting@trycompai/ui → @crm/ui:
| Source | Destination |
|---|---|
data-table.tsx |
packages/ui/src/components/data-table.tsx |
table-pagination.tsx, numbered-pagination.tsx |
packages/ui/src/components/ |
use-table-query.ts, build-search.ts |
apps/app/components/data-table/ |
Everything it needs already exists in @crm/ui: table, dropdown-menu,input-group, button, spinner, lib/row-accent. The hook stays app-side
because it is routing state, not UI. nuqs becomes a packages/ui dependency
since the table owns the expand and hide params.
Behaviour carried over unchanged:
table-fixedlayout,overflow-hiddencells, so long values truncate instead
of blowing out the table- sticky header with
bg-background - search in an
InputGroupwith a leadingSearchicon - one dropdown per facet, radio-group single-select,
allas the reset option - a tab dropdown for the primary partition, with counts from
facetCounts - a Sort dropdown listing sortable columns plus asc/desc, and clickable sortable
headers with an arrow indicator andaria-sort - a Columns dropdown toggling
hideablecolumns, persisted in?hide= - the whole filter row collapses behind a Filters button under
sm, with an
active-filter count useDeferredValueon rows so typing stays smoothROW_ACCENTon clickable rows; expandable rows useROW_ACCENT_EXPANDABLE- pagination footer showing range and total
Five CRM-specific additions, four of them driven by the HubSpot screenshots:
- Columns come from the property registry, not a literal array (§5).
- A row click sets the record's URL param instead of navigating.
- Date-range facets. mvp's
DataTableonly has single-select radio
facets, but every HubSpot list filters on Create date, Last activity
date and Close date. Add atypediscriminator toDataTableFacet:selectkeeps today's behaviour,dateRangerenders a preset list (Today,
Last 7 days, This month, Last quarter, Custom) over@crm/ui's existingcalendarandpopover. This is the one real extension to the ported
component; everything else is reuse. - Saved views as tabs above the table. HubSpot's "Active Customers",
"Intent", "Closed Won Deals". Because the entire table state already lives
in the URL, a saved view is a stored query string —savedViews.create
snapshotsquery.search, and clicking a tab replaces the search params.
A+tab saves the current filter set. This is nearly free given the
architecture and it is the feature the team will notice missing on day one. - Standard cell renderers, so every list looks like one product:
OwnerCell(avatar + name,No ownerwhen null),RelativeDateCell
("Today at 10:07", "Yesterday at 3:20 PM", then absolute),CurrencyCell
(right-aligned, tabular numerals),StageBadge,LifecycleBadge, and@crm/ui's existingempty-cellrendering--for every null — matching
HubSpot exactly.
Default columns
Mirroring your screenshots, so the team's muscle memory survives the move.
| List | Columns |
|---|---|
| Companies | Name (icon + link), Owner, Industry, Lifecycle stage, Create date, Phone, Last activity, City |
| Contacts | Name, Email, Company, Title, Lead status, Lifecycle stage, Owner, Last activity |
| Deals | Name, Stage, Close date, Owner, Amount, Forecast category, Last activity |
Companies default-sort by Create date descending; Deals by Close date
ascending. Everything beyond the first four columns is hideable, so the
Columns dropdown is genuinely useful rather than decorative.
The Company column renders the primary association, with a +N chip when a
record has more — one line per row, no wrapping, and the full set is one click
away in the sheet. AssociationCell owns this so all three lists agree.
Forms
react-hook-form + zodResolver + @crm/ui Field primitives
(Field, FieldGroup, FieldLabel, FieldContent, FieldDescription,FieldError), matching mvp's person-sheet.tsx.
- One schema per form, shared with the tRPC input where the shapes match.
Controllerfor non-native controls (Select, Switch, Combobox);registerfor plain inputs.""means clear,undefinedmeans leave alone. The submit handler maps
empty strings to explicitnull. Collapsing the two makes "clear this field"
silently a no-op — mvp learned this the hard way.- Submit through
useMutationwith an optimistic row patch,toast.error
on failure, invalidate on settle. - The custom-property section is generated from
buildFormSchema(defs)and
rendered by aPropertyFieldswitch ontype. - No effect resets the form when the selected record changes. The sheet body
is keyed:<RecordForm key={recordId} />.
12. Conventions
No `useEffect`
Banned in app and feature code. The five replacements:
| Instead of an effect for | Use |
|---|---|
| Deriving state from state or props | compute inline during render |
| Fetching | TanStack Query / tRPC |
| Reacting to a user action | the event handler |
| Resetting state when a prop changes | key on the component |
| One-time external sync on mount | useMountEffect |
packages/ui/src/hooks/use-mount-effect.ts already exists and is the only
sanctioned wrapper. Concretely in this codebase: sheet open state is derived
from nuqs, never synced; search debounce is nuqs' limitUrlUpdates, not a
timer; enrichment polling is refetchInterval, not setInterval; form reset is
a key, not a dependency array.
Enforce it with Biome — noRestrictedImports banning the useEffect named
import from react, scoped to apps/app/** and packages/ui/src/hooks/**.packages/ui/src/components is already Biome-excluded, so vendored shadcn files
(calendar, sidebar, carousel) are unaffected. Verify the importNames
syntax against Biome 2.5 when wiring it.
No code comments
New code carries no comments. Names, types and small functions do the
explaining; anything that genuinely needs prose goes in docs/. Existing
comments stay — churning them adds diff noise and no value. biome-ignore
directives and the auto-generated Prisma header are not comments in this sense.
Add both rules to AGENTS.md so every agent session picks them up.
13. Phases
Each phase ends green on bun run check-types and bun run lint.
Phase 0 — Plumbing. Dependencies; apps/api/src/trpc/ (module, context,
auth + permissions + logging middleware, error handler) from the mvp reference;nestjs-trpc generate + exports["./app-router"]; the /api/[...path] proxy;lib/trpc/*; NuqsAdapter + TRPCReactProvider; the data table intopackages/ui; Biome no-useEffect rule; housekeeping on docs/review.md,docs/api.md and the stray temp file. Verify: the dashboard rendersusers.me through a server-prefetched tRPC query.
Phase 1 — Auth, RBAC and hosting. admin plugin + access control inpackages/auth; bun run auth:generate; Redis secondary storage; owner
bootstrap; PermissionsMiddleware; two Vercel projects; pooled DATABASE_URL +DIRECT_URL; Upstash; blob store. Verify: a readonly user gets 403 from a
mutation, and the button is hidden.
Phase 2 — Schema, registry and seed. Models from §4 and §5; one migration;
system property definitions seeded from the versioned constant; seed ~15
companies with real domains, ~40 contacts, ~25 deals across stages, ~150
activities including overdue and upcoming. Verify: db:migrate, db:seed,
inspect in Studio.
Phase 3 — Companies. companies + properties routers; the standard cell
renderers; the dateRange facet type; list page with the registry-driven
DataTable and the default columns from §11; company sheet with header,
composer row, Data highlights, properties, contacts, deals; lifecycle stage and
lead status editable inline; create button in PageShellActions. Verify:
filter by owner and a date window, sort, page; copy the URL into a new tab and
get the same view with the sheet open.
Phase 3b — Associations. associations router; the three join tables with
their partial unique indexes; AssociationLabel vocabulary and its settings
screen; AssociationCell; the add/link/label/set-primary UI in the sheet
sections. Lands here because Companies is the first surface that needs it and
every later phase assumes it. Verify: a contact on two companies shows Primary
on exactly one, and a concurrent double-promote fails the write instead of
producing two.
Phase 4 — Contacts. contacts router; list; sheet; multi-company
associations with labels; lifecycle stage, lead status and buying role.
Replaces the placeholder page.
Phase 5 — Deals. deals router; list with stage tab, owner facet and close
date range; inline stage change writing STAGE_CHANGE; forecast category; deal
sheet with stage stepper, amount, associated companies and contacts with
labels; the at-least-one-company service invariant. Verify: a stage change
appears on the deal and on the timelines of every associated company, andlastActivityAt moves on all affected records.
Phase 5b — Saved views. savedViews router; the tab strip above every
table; save-current-filters, rename, reorder, share. Verify: a saved view
survives a reload and a different browser.
Phase 6 — Activity, tasks, dashboard. Timeline component and composer;activities router; complete and snooze; dashboard with pipeline by stage,
closing this month, overdue tasks, recent activity.
Phase 7 — Attachments. Blob client uploads; handleUpload token route;
RBAC-checked presigned downloads; attachment sections on all three sheets.
Phase 8 — Custom properties UI. Settings → Properties: create, edit,
reorder, archive, restore; live preview against the table.
Phase 9 — The agent. ContextDevClient, EnrichmentService, waitUntil,
field mapping, status polling, contact-email → company, research brief.
Verify: create a company with domain stripe.com and watch logo, description,
industry, socials and address populate within ~20s without a reload.
Phase 10 — Migration and polish. HubSpot CSV import for companies,
contacts, deals, their custom fields and their associations with labels and
primary flags — HubSpot exports associations as separate files, so the
importer runs records first, associations second, and reconciles primaries in a
third pass. Dedupe on domain/email, lifecycle and lead-status value
mapping, assesment-source typo fix, lastActivityAt backfill, prefetch
warming, dry-run report first. Then deal board view (?view=board) and thecmdk quick switcher.
Phase 11 — Deal intelligence. Deal score, forecast context, stalled-deal
tasks (§9). Last because it is worth nothing until the timeline has real data
in it.
14. Open items
- Vercel Blob store — done.
crm-attachments
(store_pXY7wYOD7ccPGUNP), regioniad1, private access, in thecomp-ai-testteam. Not yet attached to a project, because the repo has no
Vercel projects yet; attaching it in Phase 1 is what populatesBLOB_READ_WRITE_TOKEN. Private access confirms §7's design — the CLI
requires--access public|privateand exposesblob presign, so
RBAC-checked presigned downloads are the supported path, not a workaround. - Postgres provider for pooled connections — Neon and Supabase both work;
pick one and I'll wireDATABASE_URL/DIRECT_URL. docs/review.md— write it or drop theAGENTS.mdreference.
15. Deliberately out of scope
Email sync, calendar sync, sequences and cadences, workflow automation, a
reporting builder, and territory rules. Plus every object sitting at (0) in
your record sidebars: Tickets, Quotes, Subscriptions, Payments, Orders, Payment
links and Products. Multiple pipelines are out too — your deals list shows an
"All Pipelines" selector over a single "Deals pipeline", so one opinionated
pipeline is the honest version of what you actually run.
Each of these is a HubSpot feature we are choosing not to rebuild. If sales
asks for one, it is a new plan, not a phase.
Currency (`docs/currency.md`)
Currency — read when touching deal amounts, totals, or rates
Two amounts, and only one is ever summed. (_sum: { amount: true } once added euros
to dollars and printed $2.0M, silently.)
amount+currencyis what the customer pays. Never converted in place.baseAmountis the only column any total, chart, average or sort may touch;fxRate/fxRateAtrecord how it got there.baseCurrencysays whatbaseAmountis denominated in — without it a figure
converted against a stale base is indistinguishable from a correct one.countedWhere(base)filters every money aggregate. Counts are deliberately
not filtered, so stage groups read counts and sums from separate queries.pendingWhere(base)= nullbaseAmountor wrongbaseCurrency, and it
matches null explicitly —{ not: base }isNULL, not true, for a null
column, so such rows were invisible everywhere.- Compose with
AND, never spread — it contains anOR, and so does the deals
list's ownwhere. - Every writer of
baseAmountwritesbaseCurrencyin the same statement:ConversionServiceandprisma/seed.ts.
- The rate is resolved once and frozen.
create/updatecallConversionService.dealFieldswhenamountorcurrencychanges, reading the
unchanged one back in the same call. Converting on read makes a closed quarter change
value every morning. - A missing rate is a null, disclosed not zeroed — it falls out of
_sum
automatically, andunconvertedcounts those rows so the UI can say 3 deals in CHF
are not included. fillMissing()never touches a converted deal;rerateAll()is the only thing
that overwrites a frozen rate, and only on a reporting-currency change.
ExchangeRate.rate = units of baseCurrency per unit of quoteCurrency, sobaseAmount = amount × rate everywhere. The feed quotes the other way; RatesService
inverts on ingest.
MANUALbeatsFETCHED(unique on(base, quote, source)), which makes the
fetcher optional — Settings → Currencies is the manual path.resolveRaterefuses a
rate ≤ 0.- Re-rating deduplicates codes through a
Set—currencywas free text, sousdandUSDwere two groups each updating every variant. MAX_AMOUNT_CENTS(deals.contracts.ts) is whatDecimal(14, 2)holds;baseAmountisDecimal(24, 4)so amount × rate still fits.- Reporting currency is
AppSetting.reportingCurrency, read only throughreadReportingCurrency. - Codes are validated against
isCurrencyCode(@crm/db/currency), not a regex —z.string().length(3)acceptedZZZ.isWellFormedCurrencyis separate becauseIntlthrows on non-three-letter input. CURRENCIESis eleven currencies and that is all this CRM supports — USD, EUR,
JPY, GBP, CNY, AUD, CAD, CHF, HKD, SGD, ZAR, in array order.isCurrencyCodeis the
single gate for the picker, the feed filter and the stored setting. A refresh
prunesFETCHEDrows outside the list and leavesMANUALalone.applyRaterounds to the reporting currency'sminorUnitsOf, not to two. The
×100 cents transport cannot represent a three-decimal minor unit; nor canDecimal(14, 2).
Feed: open.er-api.com, keyless, two attempts at 6s. Check result, not just
the status — an unsupported base returns HTTP 200 with {"result":"error"}. An
unreachable provider warns and returns { ok: false }; only the interactive refresh
throws.
The fetcher is in the API deliberately: a daily rate decides nothing, andDealsService needs it synchronously to write baseAmount in the same transaction.
Any judgement about it belongs in apps/agent.
POST /internal/sync/rates is guarded by CRON_SECRET (timingSafeEquals) and
fails closed when unset. A route is not a schedule — add it toapps/api/vercel.json in the same change or nothing runs it.
Design (`docs/design.md`)
Design — Rules for AI Agents
- /packages/ui is the single source of truth for all UI.
- Always use shared shadcn components from /packages/ui.
- Do not override component styles with className.
- Do not introduce custom border radii, spacing, colours, shadows, or other visual deviations.
- Corners are rounded, from the scale only:
rounded-sm(4px) for the smallest
controls,rounded-md(5px) for buttons, inputs and segments,rounded-lg
(8px) for surfaces that contain controls — popovers, dialogs, menus, table
shells. Never a literal radius at the call site. rounded-noneis still correct in one case: an element that must join its
neighbour edge to edge. The input inside an input group, the middle cells of
a selected date range, and the drawer handle are the existing examples.- If a component needs a new variant or style, implement it in /packages/ui so the entire application stays consistent.
Colour
Flat white, neutral greys, and one brand green (#006B4F). The greys are
untinted on purpose: there is no scene to tint them toward, and a tinted grey
without a reason reads as indecision.
Only two things are filled: primary for the action you want, destructive
for the one you cannot undo. Everything else — secondary, outline, ghost — is a
white chip in light and a dark chip in dark. That is what keeps a rep's eye
landing on go or stop and skimming past the rest.
--primary and --destructive hold the same value in both themes. A brand
colour that changes per theme is not one colour, it is two, and both then need
maintaining. The single exception is --ring, which lightens in dark: a fill
carries the brand, but a ring only has to be seen, and #006B4F is too close to
the dark background to register.
Environment (`docs/environment.md`)
Environment
Setup, DB commands, Google Cloud and the vercel env pull hazard: docs/setup.md.
One `.env`, at the repo root
.env.example is the documentation — every variable the repo reads, with a note,
and nothing that is not read. packages/env walks up to the workspace root and reads.env, then .env.local on top.
- Real environment variables always win — the loader never overwrites
process.env, so Vercel/Docker/CI takes precedence. - Never add a per-package
.env. Four once existed with duplicateDATABASE_URL/BETTER_AUTH_SECRET; when they drifted the API minted a cookie the
app could not verify and the browser bounced between/sign-inand/forever. - The root marker is a
package.jsondeclaringworkspaces— stopping at the
firstturbo.jsonresolves the API's root toapps/api.
A new variable has three homes, not two
.env.example and — if the API reads it — env.validation.ts are the two people
remember. The third is globalPassThroughEnv in the root turbo.json, and it is
the one that bites: Turborepo hides an undeclared variable from every task it runs, so
a deployment that sets the variable perfectly still hands the code undefined, and
nothing anywhere says so. That is how MICROSOFT_CLIENT_ID shipped with the sign-in
button quietly missing. passThroughEnv, never env — a secret in env is a
cache key, which means a cache miss on every rotation and the secret in the cache
metadata. The root file's comment has the whole account.
Required
DATABASE_URL, BETTER_AUTH_SECRET, ALLOWED_SIGN_IN. Everything else has a
localhost default or is genuinely optional.
GOOGLE_CLIENT_ID + GOOGLE_CLIENT_SECRET are the sign-in button and the
Gmail/Calendar sync — optional, so an SSO-only install needn't create a Google project,
but set together or not at all (packages/auth/src/env.ts throws on one).
MICROSOFT_CLIENT_ID + MICROSOFT_CLIENT_SECRET are the same bargain for Entra
ID: the other sign-in button and the Outlook mail sync, one app registration, the
same pair rule. MICROSOFT_TENANT_ID defaults to common and is the only one of
the three that is genuinely optional on its own — set it to your tenant's GUID to
refuse other tenants at Microsoft instead of at ALLOWED_SIGN_IN. There is no
Microsoft equivalent of hd: tenantId is the whole of it.
Neither pair is required, but an install wants one of them or an SSO provider —
with none, the sign-in page says so by name rather than rendering nothing.
ALLOWED_SIGN_IN — comma-separated whole domains or single addresses (bare
addresses exist for a solo self-hoster, where gmail.com would be an open door). One
list, read by the sign-in guard and the sync's "which side is external" decision —
if they drifted a colleague would be refused at the door or filed as a lead. An empty
list fails closed. Parsed on demand. packages/auth/src/workspace.ts.
Where things are
API_URL(:3001) mints session cookies and serves/api/auth/*;next.config.tsrepublishes it asNEXT_PUBLIC_API_URL, so one variable does both
sides.BETTER_AUTH_URLis a legacy fallback.- Editing a file under
packages/does not restart the API. Restart it by hand.bun --watch src/main.tsrefuses to watch outside its project directory and
says so once at boot:File ... is not in the project directory and will not be watched. So a change topackages/authorpackages/dbleaves the API
serving the old module until someone kills it. This cost an hour once: the
Slack OAuth scope list was correct in source and stale in the process, and
every reconnect kept asking Slack for the old scopes.
Running the API from the repo root fixes the watch and breaks Nest, which
resolves its tsconfig paths from the current directory and then cannot build
its dependency graph. There is no fix in the dev script today. APP_URL(:3000) is also the trusted-origin andcallbackURLallow-list.- Every OAuth
redirect_uriis built fromAPI_URL, neverAPP_URL. Better
Auth serves/api/auth/*atbaseURL, andbaseURLisapiUrl. A redirect
built fromAPP_URLpoints at the web app, where/api/auth/callbackdoes not
exist, and the provider rejects it with "redirect_uri did not match". This is
invisible until someone setsAPP_URLto a tunnel or a LAN host, at which
point the redirect silently becomes that host.ssoCallbackBase()is the
pattern;slackRedirectUriinauth.tsonce was not. AUTH_COOKIE_DOMAINonly for API and app on different subdomains of one parent.AGENT_URLis the agent's deployment, server-side only, and must include the
scheme — validated at boot, or it throws when a task is queued instead.AUTH_COOKIE_PREFIXiscrm(@crm/auth/cookies), set on bothadvanced.cookiePrefixinauth.tsandgetSessionCookie(request, { cookiePrefix })
inproxy.ts— one alone redirects every signed-in request. Better Auth's default
collides with any neighbour on a shared parent domain, silently: sign-in completes,
the row is written, every reader resolvesnull. Changing it signs everybody out.
`IS_MARKETING` — landing page flag, off by default
"true" serves app/(landing) at /; anything else sends a signed-out visitor to/sign-in, because the page markets this product.
- Only the literal
true(same shape asPRISMA_LOG_QUERIES). - It decides one thing: what a stranger at
/sees. isMarketing()(apps/app/lib/env.ts) reads per request, so a config change
needs no rebuild. Declared inapps/app/turbo.jsonpassThroughEnv.
Typed, validated env
apps/api/src/config/env.validation.ts runs via ConfigModule.forRoot({ validate }),
and lists every variable the API reads and nothing else.
- Validation runs while
AppModuleis evaluated — a test must set variables before
importing it (see the dynamicimport()intest/auth.e2e.spec.ts). - The schema is the API's, not the repo's —
@crm/authand the agent read their own.
Optional: what the agent can do
Every outside source is optional and the agent runs with none. A missing key removes a
place to look; never an error, never throws. agent/lib/capabilities.ts is the
single place that knows what is set.
| Variable | What it adds |
|---|---|
PERPLEXITY_API_KEY |
Open-web research with citations; finds a LinkedIn slug |
GITHUB_TOKEN |
Raises the GitHub rate limit from 60/hour |
BLOB_READ_WRITE_TOKEN |
Mirrors logos and photos into Blob |
AI_GATEWAY_API_KEY |
The model. Not needed on Vercel (OIDC) |
AGENT_BRIDGE_SECRET |
The rep-facing Agent panel — see agent.md |
BLOB_READ_WRITE_TOKEN is also in env.validation.ts and apps/api/turbo.json
because the API and the seed write pictures too. The Next.js app is deliberately
excluded — recognising our URL for the image optimizer needs no token.
The Context key is asked for, not configured
CONTEXT_DEV_API_KEY is not a variable here and must not become one. The key lives
in AppSetting, is asked for at /onboarding/research, and changes on Settings →
General — an admin who cannot redeploy cannot set a variable.
- It buys two places to look, not one. Company brand data by domain, and a person
read back from a LinkedIn URL already on their record. Both capabilities inagent/lib/capabilities.tsturn on and off with this one key. - An install that had the variable is asked again: no migration, no fallback, and
the gate cannot be dismissed. - Nothing is lost while waiting. A keyless
brandtask settlesSKIPPEDbefore
anything marks the rowRUNNING, andsettleonly overwritesRUNNING— so the
company staysPENDING, which the sweep re-queues
(test/keyless-brand.integration.spec.ts). - Saving the key runs the company sweep immediately (fire-and-forget).
readContextDevKey(@crm/db/settings) is the only reader, read live with no
cache. An unreadable database is a capability that is off, not an exception.- The key is never read back — only whether one is set, and its last four.
- The agent checks it, not the API (a vendor client in the API is a bug):
settings.setResearchKeycallsPOST /internal/crm/verify-keyand writes unless the
answer is invalid.401is the only answer meaning the key is wrong, and a
check that cannot be made is not a failed check —unknownsaves anyway and logs it
unverified.
Mailbox sync
Always on, on whichever social provider is configured, so there is no extra redirect
URI beyond the sign-in one. Scopes are requested at sign-in and gated byrequireMailboxAccess(), because granular consent lets a user untick one and still
sign in.
An SSO rep is not gated — needsMailboxGrant (@crm/auth) walls only an account
whose sign-in rows are all mailbox providers. It cannot be "has the scopes": an SSO
rep has no Google or Microsoft account to grant on, and revoke() keeps the account
row, so trying the optional feature and revoking would lock them out. They connect from
Settings → Connections, posting the same linkSocial call.
One granted mailbox is enough. A rep with both providers linked who granted Google
is not asked for Outlook; mailboxGrantsNeeded names the ones still outstanding and/grant-access offers exactly those buttons.
Microsoft's granted scopes come back fully qualified —https://graph.microsoft.com/Mail.Read, not Mail.Read. parseScopes is the one
canonicaliser and strips that prefix, so the comparison is against the bare permission
everywhere.
Sync is forward-only — Gmail records the current historyId on its first pass and
imports nothing, Calendar reads from now, and Outlook records now as its cursor.
CRON_SECRET (min 16 chars) guards POST /internal/sync/mailboxes and/internal/sync/rates; both fail closed when unset. /internal/sync/google is
kept as an alias of the first, so an existing deployment's cron does not break on
deploy. Crons live in apps/api/vercel.json — mailboxes */5 * * * *, rates
daily. Minute-level schedules need a Pro plan; on Hobby it silently becomes daily.
Deliberate absences: no GOOGLE_SYNC_ENABLED (a switch that can disable a mandatory
feature is only ever wrong), no GOOGLE_WORKSPACE_DOMAIN (ALLOWED_SIGN_IN already
says who is internal — two sources is how a colleague becomes a lead), noGMAIL_BACKFILL_DAYS, no OUTLOOK_BACKFILL_DAYS, no rate provider variable.
Telemetry is on, and turning it off is one variable
CRM_TELEMETRY_DISABLED="1" — or DO_NOT_TRACK=1, honoured identically — and nothing
is sent. No client is constructed, so there is no queue waiting to flush later.
- Server side only,
posthog-nodein the API and the agent.posthog-js
appears once, on thetrycrm.ailanding page, and nowhere a record can be
reached: autocapture on a CRM would lift contact names and deal amounts out of
somebody else's database. That one import is gated onwindow.location.hostname, not onIS_MARKETING— turning the landing page on
for your own domain never loads it.docs/telemetry.md. - There is no variable for the destination. The project key and host are
constants inpackages/telemetry/src/project.ts. Aphc_key is write-only —
it can send events and read nothing back — so making it configurable would
only imply it were a secret. Edit the constants to point somewhere else. - The install ID is a row, not a file —
install, one row, UUID written by
the migration. Vercel's filesystem is ephemeral, so~/.crm/telemetry-id
would count containers. - Declared in
env.validation.tsas optional, like everything else here. Every
event and the never-sent list are indocs/telemetry.md.
Not env vars
- Cache TTL —
DEFAULT_TTL_MS(60s) incache.module.ts;CACHE_TTL_MSoverrides. - Redis — optional; without
REDIS_URLthe cache is per-instance in-memory, which
is wrong for multi-instance. - Sign-in method — Google and Microsoft are in code; an IdP is a row (SSO, in
api.md).
List Building Roadmap (`docs/list-building-roadmap.md`)
List-Building Roadmap
A plan to answer the Director of Sales Development's read on the CRM: filtering
looks thin for real BDR list-building. Integrations are out of scope, by his own
note — this is about fields, filters, and views inside the CRM itself.
1. What he saw
"As it stands it looks like a research/automation layer more than a system of
record — I'd be concerned about moving off HubSpot/Salesforce-caliber
filtering without seeing those gaps closed first."— Director of Sales Development, first-look feedback
He is right about the current state. Today's filtering is this small:
- 3 Company filters — owner, industry, enrichment
- 2 Contact filters — owner, company
- 1 value per filter — no "is any of" yet
- 0 saved or shared list views
2. Why this is cheaper than it looks
Two systems already in the CRM cover most of the fields on his list. Neither is
exposed as a filter yet — that is the actual gap, not missing intelligence.
Already built — admin fields. An admin-configurable field system already
backs every record sheet and table: FieldDefinition + FieldOption +FieldValue, with a SELECT type for picklists and a USER type for a second
owner. Account type, segment/tier, territory, lifecycle stage, and lead source
all fit this today — no database migration required.
Already built — agent inference. The agent already scores seniority andfunction (persona) per contact from evidence, the same pipeline that fills
title and LinkedIn today. It just isn't wired to a column, so it never reaches
the table or a filter.
3. The gap map
Every field from his note, what backs it today, and the plan to close it.
Companies view
All seven rows below are already seeded as FieldDefinition rows
(seedCompanyFields in packages/db/prisma/seed.ts) with real option sets. The
gap is not the field — it is that none of them show on the table or reach the
filter bar yet.
| Field he wants | Where it stands today | Plan | Effort |
|---|---|---|---|
| Account type (prospect / customer / partner / churned) | Seeded SELECT field — Prospect, Customer, Partner, Churned |
Turn on table + filter | Small |
| Inside Sales / BDR owner (separate from AE / account owner) | Seeded USER field, sits beside Owner |
Turn on table + filter | Small |
| Segment / tier | Seeded SELECT field — Enterprise, Mid-Market, SMB |
Turn on table + filter | Small |
| ICP fit score | Seeded NUMBER field; agent scoring is a natural next step, not a v1 requirement |
Turn on table + filter | Small |
| Territory | Seeded SELECT field — AMER, EMEA, APAC |
Turn on table + filter | Small |
| Lifecycle stage | Seeded SELECT field — Lead, MQL, SQL, Opportunity, Customer, … |
Turn on table + filter | Small |
| Lead source (IB / OB / Event) | Seeded SELECT field — Inbound, Outbound, Event, kept separate from the source enum that records how a row entered the CRM |
Turn on table + filter | Small |
Contacts view
| Field he wants | Where it stands today | Plan | Effort |
|---|---|---|---|
| Title filter | Column exists, shown on the table, no filter | Add it as a filter | Small |
| Seniority | The agent already infers and scores this per contact — never synced anywhere filterable | Sync into a real column the same way title already syncs; then filter on it | Medium |
| Persona | Agent infers this as "function" — same situation as seniority | Same fix as seniority, same pipeline | Medium |
| Engagement / activity recency | Last-activity date is tracked and shown, not filterable | Add an "active within 7 / 30 / 90 days" filter | Medium |
| Sequence status | No source of truth — this CRM does not run sequences | Needs an outreach-tool connection. Deferred, per your note to set integrations aside. | Deferred |
4. The part that actually blocks list-building
Adding every field above still will not give a BDR a HubSpot-style
list-builder. The filter bar itself is one value at a time. This is the real
foundation, and it comes before the fields above pay off.
- Filters are single-select (Medium) — a BDR cannot say "VP or Director"
or "SF Bay Area or NYC" in one filter today — each filter holds exactly one
value. - Admin fields are not filterable at all (Medium) — every custom
field — present or future — shows up as a column only. None of it reaches
the filter bar without new plumbing. - No saved or shared views (Large) — a working list a BDR builds today
lives only in the URL. There is no way to name it, save it, or hand it to a
teammate.
5. Sequence
Four phases, in order — each phase's fields are inert without the phase before
it.
Phase 1 — Filter engine foundation *(ships first)*
Multi-select ("is any of") on every filter. Any admin-defined SELECT orUSER field becomes a filter automatically. An activity-recency filter goes in
here too.
Resolves: unlocks every field below · engagement / activity recency
Phase 2 — Company segmentation fields
Turn on table and filter visibility for the seven Company fields from the gap
map — account type, BDR owner, segment/tier, territory, lifecycle stage, lead
source, ICP fit score. All seven are already seeded; nothing new to create.
Resolves: account type · BDR owner · segment/tier · territory · lifecycle
stage · lead source · ICP fit score
Phase 3 — Contact intelligence, surfaced
Sync the agent's existing seniority and persona facts into real, filterable
fields. Add the title filter.
Resolves: seniority · persona · title filter
Phase 4 — Saved & shared views *(later, on request)*
Name a filter combination, save it, share it with the team. Sequence status
waits here too, tied to an outreach-tool connection if that scope reopens.
Resolves: reusable BDR lists · sequence status (needs an integration)
6. Issues
Reported in full, including risks this plan does not remove.
- RISK — New fields alone do not fix list-building. Filters stay single-select
without phase 1.
Fix: build multi-select filtering first. Sequence, above, already orders
this first. - NOT DONE — Custom fields are not wired into any filter today. They render as
table columns only.
Fix: not done. Planned in phase 1 as automatic facet generation. - NOT DONE — Sequence status has no source of truth inside this CRM.
Fix: not done. Needs an outreach-tool integration — out of scope now, per
your note. - RISK — A BDR owner shipped as an admin field will not appear in
owner-based reports until someone decides it should.
Fix: ship as an admin field first. Promote to a first-class column only if
reporting needs it later. - UNKNOWN — No user role distinguishes an AE from a BDR today, so no field
can default itself by role.
Fix: not investigated. Confirm before phase 2 whether role-based defaults
matter to him.
Scoped to the CRM's own data model and filter UI. Integrations, per your note,
are out of scope for this plan.
Setup (`docs/setup.md`)
Setup and local development
Operational detail moved out of the rule docs. api.md, agent.md andenvironment.md are what agents read before changing code; this is what a person
reads once.
First run
cp .env.example .env # fill DATABASE_URL, BETTER_AUTH_SECRET, ALLOWED_SIGN_IN
docker compose up -d # Postgres, matching .env.example
bun run db:migrate && bun run db:seed
bun run dev # app :3000, api :3001, agent :2000Prisma from the repo root: db:generate, db:migrate, db:push, db:reset,db:seed, db:studio, db:deploy.
dev depends on ^dev:prepare, so every start applies pending migrations and
regenerates the Prisma client before a single server boots. That is why the first
run needs db:migrate only for the seed that follows it. When the database andschema.prisma have diverged past what migrate deploy can reconcile,dev:prepare stops the whole run rather than starting servers against a schema
they do not match — reconcile with db:migrate, or db:reset when the divergence
is an edited migration that has already been applied.
Google Cloud
- Enable the Gmail API and the Google Calendar API on the project.
- Set the consent screen to User type: Internal if you are on Workspace.
gmail.readonlyis a restricted scope, so an External app needs OAuth
verification plus an annual CASA assessment. Going External later means the full
review — a decision, not a checkbox.
The agent bridge
AGENT_URL="http://127.0.0.1:2000" # 127.0.0.1, not localhost: eve dev is IPv4-only
AGENT_BRIDGE_SECRET="$(openssl rand -base64 32)"| Agent tab error | Cause |
|---|---|
503 |
AGENT_BRIDGE_SECRET unset in the app's process |
401 |
The two processes hold different secrets, or passThroughEnv in apps/app/turbo.json / apps/agent/turbo.json is missing the pair (Turbo is strict-env) |
502 |
Agent not running, or AGENT_URL wrong |
localDev() accepts any loopback request, so curl 127.0.0.1 proves nothing about
the bridge — send -H 'Host: agent.example.com'. GET /eve/v1/info is the whole
inventory, including a diagnostics count that finds files eve silently ignored.
Running the agent
The agent package's default dev command is interactive eve dev. The root
Turbo task marks it interactive, so select the agent pane and press Enter before
using the eve TUI. Run turbo run dev:headless --filter=agent when a terminal
cannot render the TUI; that uses eve dev --no-ui, and the Turbo pane is the
record because only interactive development writes .eve/logs/ for eve logs.
Reach for the Turbo task rather than bun run --filter=agent dev:headless: the
package script alone skips dev:prepare, so the agent would start against
unmigrated tables.
hooks/activity.ts is the replacement narration, to stderr (the TUI hides
stdout), printing shape everywhere and argument contents outside production only. It
is not the audit trail — hooks/audit.ts writes AgentEvent regardless.
- A second
bun run devfails the whole turbo run. - An orphaned agent holds the port:
lsof -nP -iTCP:2000 -sTCP:LISTEN.
Nothing is researching, and the queue only grows
eve dev never fires schedules on their cron cadence, and everything visible
still works — the row is written, the sheet says Queued, and dispatch.ts is never
called. The poke covers this only when AGENT_BRIDGE_SECRET is set; unset,poke() returns silently and the queue looks exactly like a slow agent.
Tasks the API did not write (schedule_recheck) and anything queued while the agent
was down still need a manual run:
bun run --filter=agent dispatch # exact production path, both lanes, real creditsIts printed sessionIds are research rows only, so a run that resolved forty logos
prints an empty list and was not idle. eve start and Vercel do run the schedule.
`vercel env pull` writes `.env.local`, which wins
.env.local is the override the loader reads last, and vercel env pull writes
production credentials there by default. Pull once and every process silently
points at production — not as an error, but as bun run dev working perfectly against
the live database. On 2026-08-01 eleven migrations landed on Neon from a laptop.
- Pull somewhere inert:
vercel env pull .env.vercel. packages/db/scripts/require-local-db.tsguardsdb:migrate,db:push,db:reset,db:seedand takesALLOW_REMOTE_DB=1.db:deployis unguarded on
purpose. It reads the root files directly rather thanprocess.env, because Bun
auto-loads the working directory's.envwhile Prisma's CLI only sees@crm/env/load.
Migrations run on the production deploy, and nowhere else
apps/api/scripts/build-func.mjs runs prisma migrate deploy during the crm-api
build, gated on VERCEL_ENV === "production". The schema therefore moves when the
release pull request merges and release deploys — with the code that needs it,
and once rather than once per branch.
Preview deploys share the production database: DATABASE_URL is a single value
across production, preview and development. Until that changes, a preview of a
branch that adds a migration runs against a database without those tables — it
builds, and the pages that touch them fail. Test schema changes locally, wherebun run dev migrates for you. Before the gate existed the reverse was true and
worse: every preview applied its own migrations to the production database, so on
2026-08-07 the live schema ran six migrations ahead of the live code all day.
`migrate deploy` is not proof the schema is right
The build follows the deploy with prisma migrate diff --exit-code againstschema.prisma and shouts in the build log when they disagree. No pending migrations to apply only means _prisma_migrations has a row for every file —
it says nothing about what the tables actually look like.
They came apart once. A prisma db push shaped production from a laptop, the
migration rows were recorded as applied without their SQL ever running, andagentConversationAttachment went live without its position column. Every deploy
reported nothing pending, for days, while conversations.builderById returned 500.
The tell is an object in the database that no migration defines — there was anagentConversationAttachment_submissionId_createdAt_idx that appears in no
migration file, only in a db push of an older schema.
Reconciling is one command, and it is worth reading before running:
DATABASE_URL="…" bunx prisma migrate diff \
--from-config-datasource --to-schema prisma/schema.prisma --scriptSecrets hygiene
.gitignore ignores .env and .env.* with one negation for .env.example, so.env.bak is ignored too. .env.example ships no secret — placeholders are empty
strings, asserted by packages/env/test/root.spec.ts. Generate your own secret;
never reuse one from an example, a tutorial, or another environment.
Tests
bun run --filter=api test
bun run --filter=agent test # integration specs need DATABASE_URL + real PostgresThe test database rebuilds itself when it drifts
bun run db:test creates crm_test and runs migrate deploy on it. The database
name must end in _test; the suite deletes rows it expects to put back, so it
refuses anything else.
migrate deploy only applies migrations that are missing. It never removes a
table, a column or a constraint the database has and the schema does not. Acrm_test built on a branch that was later abandoned therefore keeps that branch's
objects forever, and db:test used to report already exists and move on. The
extra objects are invisible until one of them rejects a write, and then the failure
names a constraint that appears in no migration and in no schema — a straytrackedEvent_visitorId_fkey once failed seven tracking specs this way, on every
branch, for as long as the database survived.
So db:test now checks the database it found and rebuilds it when either is true:
- It holds a migration this branch does not have. The database came from
another branch. The name of the first one is printed. - It no longer matches
schema.prisma, byprisma migrate diff. Something
was pushed or altered by hand.
A rebuild drops the database and re-runs every migration, and it says which of the
two reasons fired. Force one with bun run db:test --reset. Nothing else in the
repo may drop a database, and this may only because the _test suffix is checked
first.
Telemetry (`docs/telemetry.md`)
Telemetry
What this install reports about itself, in full. The decisions behind it are inadrs/telemetry.md; this file is the list.
Turn it off with CRM_TELEMETRY_DISABLED="1" or DO_NOT_TRACK=1 in the root .env, then
restart. This file is the full account of what is sent; there is no settings page for it, so
this is the thing to read.
Where it goes is not configurable and deliberately so: the project key and host are constants
in packages/telemetry/src/project.ts. A phc_ key is write-only — it sends events and reads
nothing back — so a variable would only have made a public identifier look like a secret. Point
a fork somewhere else by editing that file. Nothing is sent while NODE_ENV istest, so a test run cannot emit.
The shape of it
- Server side only, in everything you run.
posthog-nodeinapps/apiandapps/agent.
No autocapture, no session replay and nothing client-side anywhere a record can be reached.
Autocapture on a CRM would lift contact names, email addresses, company domains and deal
amounts out of your database; the simplest way not to hold that is not to collect it. The
single exception is the marketing site attrycrm.ai, which is a web page and not an
install — The landing page, below, is the whole of it, and it cannot run on your
hostname. - One identifier, a UUID generated by the migration that created your database. It is
attached to nothing else.identify()is never called, there are no person properties, and no
user, email orALLOWED_SIGN_INvalue is ever sent. - One event a day, plus eight one-shot setup events and one event per failure.
- No IP address. Every event sets
$ip: nulland disables geoip, and the receiving project
hasanonymize_ipson so the address is dropped at ingestion. Both are needed: PostHog stamps$ipfrom the incoming connection whatever the SDK sends, and reads a null as "not provided"
rather than "do not keep". Point this at a different project and you must set it there too. - Never blocks and never throws. Every send is fire-and-forget behind a swallowed handler; a
telemetry failure logs at debug and is invisible everywhere else. - Off leaves no marks. With telemetry disabled the rollup route returns before the funnel
sweep runs: no milestone is recorded, no counter is drained, no rollup day is claimed. Turn it
back on and the funnel is still whole, because nothing was quietly consumed while it was off. - Nothing is sent from a test run.
NODE_ENV=testdisables it, andapps/apiandapps/agent
additionally setCRM_TELEMETRY_DISABLED=1in theirtestscripts — because a spec is allowed
to reassignprocess.env.NODE_ENV, and one that does was enough to let a real event out. - An allowlist, in code.
ALLOWED_PROPERTIESinpackages/telemetry/src/allowlist.tsnames
every property that may be sent. Anything else is dropped before the event is built — not by
review, not by convention.
Events
`install_daily`
Once per install per day, from the API itself. Counts and distributions from grouped queries.
Never a value from a row.
It runs in-process and needs no cron. TelemetryService rolls up on boot and then hourly,
and the day claim below makes all but the first of those a no-op. That covers both shapes an
install comes in: a serverless deployment rolls up on a cold start, a long-running container on
the timer. POST /internal/telemetry/rollup still exists for a platform cron that would rather
drive it, and still refuses without CRON_SECRET — it is an anonymous route, so an unauthenticated
one would let a stranger drive any install's aggregation. Nothing depends on it: an install that
never sets CRON_SECRET reports exactly the same.
The day is claimed under a row lock on install before anything is gathered, so two crons that
fire at once produce one event rather than two — and the second does not find the counters
already drained. A rollup that fails to send hands the day and the counters back, so tomorrow's
run is not suppressed by a send that never happened.
The event carries a deterministic uuid, stableUuid(install.uuid, "install_daily", day) — a
SHA-256 digest in a v8 UUID — and PostHog ingests a repeat of one it has already seen only once.
RFC 4122 defines v5 as SHA-1, and CodeQL rightly refuses to see a hash of the install identity
and a weak algorithm in the same expression. Nothing here needs v5, so it is v8, the slot RFC 9562
leaves for a derivation of one's own. The lock
stops two processes racing; the id stops the same process sending twice, which the release path
can otherwise cause. Counting installs survives a duplicate either way, because PostHog's unique
math is per install per day, but tool_calls_total and the other summed properties would be
counted twice, and now are not.
A send reports failure conservatively. posthog-node does not reject on a failed send — itssendImmediate catches the error and emits it on the client instead — so client.ts enqueues and
awaits flush(), which does throw, and additionally checks the client's error counter, which any
capture in flight can move. Either signal is treated as a failure. That errs toward re-sending a
rollup that actually landed, which the deterministic id makes free, rather than consuming a day
whose event never arrived, which cannot be recovered.
The install
| Property | What it is |
|---|---|
crm_version |
The root package.json version, re-read on every boot |
git_commit_sha |
VERCEL_GIT_COMMIT_SHA when there is one; omitted otherwise |
days_since_install |
Whole days since the first migration ran |
is_vercel |
Whether VERCEL is set |
node_version |
Major only, e.g. 22 |
postgres_version |
Major only, e.g. 17 |
members_bucket |
How many people work here, in bands |
agent_model_id |
The model chosen on Settings → General, e.g. zai/glm-5.2-fast |
agent_model_context_window |
Its context window in tokens |
seed_only |
True when every contact came from bun run db:seed |
Capabilities — booleans, never values
cap_perplexity, cap_context_dev, cap_blob, cap_github, cap_redis,cap_agent_bridge, cap_cron_secret, cap_ai_gateway, cap_google_oauth, cap_sso_provider,cap_tracking, is_marketing.
Each is only whether the key is set. cap_context_dev is whether an AppSetting row holds one,cap_sso_provider whether an ssoProvider row exists, cap_tracking whether a tracking site id
has been minted — never the id itself. No key, value or last-four is sent.
cap_context_dev covers both of the agent's Context capabilities: company brand data by domain,
and reading a person off a LinkedIn URL. They are one key, so they are one boolean.
The agent
| Property | What it is |
|---|---|
tool_calls |
Calls in the last 24h, keyed by tool name |
tool_calls_total |
Their sum |
tool_errors |
Of those, the ones that returned an error, keyed by tool name |
sandbox_used |
Whether any sandbox tool (bash, grep, glob, file reads and writes) ran |
sessions_started / sessions_completed / sessions_failed |
Distinct sessions in the window |
tools_per_session_mean |
Tool calls divided by sessions that made one |
tasks_claimed / tasks_completed / tasks_retired |
AgentTask counts keyed by kind |
task_attempts_mean / task_attempts_max |
Attempts per kind |
budget_exhausted |
Sessions that stopped because the research budget ran out. Once per session, not once per blocked tool call |
recheck_scheduled |
recheck tasks queued in the window |
recheck_interval_days |
Their intervals, in day bands |
agent_conversations |
How many AgentConversation rows exist |
Tool names are matched against the authored tools in apps/agent/agent/tools/ and eve's own
builtins. Anything else is counted as other. Task kinds are matched against TASK_KINDS.
Tool counts are read from the agentEvent rows the audit hook already writes — the event type
and the tool name only. AgentEvent.data itself is never sent.
The evidence ledger
| Property | What it is |
|---|---|
facts_by_status |
ContactFact counts by APPLIED / PROPOSED / DISMISSED / SUPERSEDED |
facts_by_band |
Counts by VERIFIED / PROBABLE / POSSIBLE |
facts_by_method |
Counts by the method label a tool recorded |
facts_by_evidence_kind |
Counts by evidence kind, from the WEIGHTS map in lib/evidence.ts |
fact_dismissal_rate |
The share of decided facts a human rejected |
fact_decision_median_hours |
Median hours from observedAt to decidedAt |
facts_superseded_within_7_days |
Facts the agent changed its mind about |
Evidence kinds are matched against the eleven in lib/evidence.ts; anything else is other.method is a label a tool writes, so it is sent only when it matches a strict lowercase
dotted-slug shape under 40 characters — a name, an address or a sentence cannot pass, and is
counted as other instead. The fact's value, field, evidence and sourceUrl are never
sent.
The CRM
| Property | What it is |
|---|---|
contacts_bucket, companies_bucket, deals_bucket, activities_bucket |
Sizes, in bands |
contacts_by_source, companies_by_source |
Counts by MANUAL / IMPORT / EMAIL / CALENDAR |
deals_by_stage |
Counts by DealStage. Stages, never amounts |
activities_by_type |
Counts by ActivityType |
mailbox_sync_configured |
Whether any MailboxSync row exists |
mailbox_sync_status |
Counts by GoogleSyncStatus |
threads_ingested, messages_ingested |
How many arrived in the window. Counts only |
enrichment_by_status |
Company counts by EnrichmentStatus |
suppressed_domains, suppressed_contacts |
Counts. Never the domains or addresses |
workspace_profile_written |
Whether a WorkspaceProfile row exists |
Website tracking
| Property | What it is |
|---|---|
tracking_domains |
How many domains are on the allow list, in bands. Never a host |
tracking_page_views |
Page views recorded in the window. A count, never a path |
tracking_forms |
Form submissions stored in the window. A count, never a field |
tracking_contacts_created |
Contacts created by tracking in the window — Contact.source = TRACKING, not submissions that attached to somebody already filed |
tracking_capped |
Submissions the hourly contact cap refused. Matched against CONTACT_CAP_REASON (@crm/db/tracking) exactly, so the wording is a constant two files share rather than prose one of them may reword |
tracking_paused |
Whether collection is paused |
The setup funnel
Eight events, each sent once per install, ever. Each carries the timestamp of the thing it
records rather than the moment it was noticed, so the funnel reads true even though a nightly
job is what sends it.
migrations_applied → first_sign_in → google_oauth_configured → first_mailbox_sync →first_non_seed_contact → first_agent_task_claimed → first_agent_task_completed →first_fact_applied
They carry no properties of their own beyond the common ones — version, days since install,
whether this is Vercel.
google_oauth_configured is the one step with no row to read a time off: two environment
variables leave no timestamp behind. It is stamped from the first Google account row when
one exists — configuration must have come before the account did — and otherwise from the
moment the sweep first saw the variables set. That sweep runs on every API boot, not only on
the cron, so an install that arrives configured is stamped on its first boot.
first_fact_applied is the earliest decidedAt of any fact a human accepted, or the earliestobservedAt of any fact the agent applied itself, whichever came first. Facts that were
applied and later superseded still count — being replaced later does not make the first
application not have happened.
The step is claimed before it is sent, and handed back if the send fails. The claim is the
insert itself — telemetryMilestone.step is the primary key, so of two processes sweeping at the
same instant exactly one is told it landed the row, and only that one sends. A failed send deletes
the row, so the next sweep tries again rather than losing the step forever.
This is the opposite of the ordering the funnel shipped with, which sent first and recorded after.
That was chosen on the grounds that a duplicate is a rounding error while a lost step cannot be
recovered — but it made a duplicate the likely outcome rather than a rare one, because both the
boot sweep and the rollup call sweep(). One install sent first_fact_applied six times.
Claiming first costs the case the old ordering was protecting against: a crash between the claim
and the send drops the step. Each event also carries a deterministic uuid,stableUuid(install.uuid, step), so a step that is genuinely sent twice is still ingested once.
Nothing is recorded at all while telemetry is off.
Errors
Error class and where it came from. Never a message, never a stack trace, never a payload:
our errors would carry contact fields.
| Event | Properties |
|---|---|
agent_error |
error_class, tool, task_kind, error_source (tool / turn / session) |
sync_error |
error_class, sync_source (gmail / calendar / outlook), error_source (google_sync / microsoft_sync / mailbox_sync) |
api_error |
error_class, route, status_code |
model_error |
error_class, model_id |
sync_error's error_source is derived from sync_source rather than passed in, so one
mailbox pipeline that dispatches to both providers cannot report an Outlook failure as a Google
one. A source that is not one of the three is sent as other, and its error_source asmailbox_sync.
route is the route pattern — /internal/sync/google, /trpc/contacts.list — never a URL
with parameters in it. Anything that does not match a strict path shape is sent as other, and
so is any error class that is not a plain identifier.
The landing page
trycrm.ai is the one place posthog-js runs: web analytics and session replay of strangers
reading a pitch. It is a website, not an install — there is no database behind that page and
no record within reach of it. Everything above still holds for the CRM itself.
It cannot run on your hostname. analyticsAllowed (apps/app/lib/analytics.ts) compareswindow.location.hostname against two literals — trycrm.ai and www.trycrm.ai — andposthog-js sits behind a dynamic import() on the far side of that check. TurnIS_MARKETING="true" on and serve the same page from a domain of your own and the SDK is
never fetched: not sent and then dropped, never downloaded. The gate is the hostname rather
than IS_MARKETING, because IS_MARKETING is precisely the flag a self-hoster is invited to
set.
PostHog refuses it from the other side too. recording_domains on the receiving project names
the same two origins, so replay and heatmaps are declined for any other page that presents the
key.
It shares the install's project, key and host, and the two do not mix: the landing page
sends $pageview, $pageleave, $autocapture, $web_vitals and the two CTA events below
about a browser, an install sends install_daily and its funnel about a database. Install
events set $process_person_profile: false, so they never become a person and never join one.
The two CTA events
| Event | When |
|---|---|
setup_prompt_copied |
The setup prompt reached the clipboard — after the write resolves, so a refused clipboard is not counted as a copy |
github_star_clicked |
The Star on GitHub button was clicked. A click on the way to GitHub, not a star: whether one was given happens on a page we cannot see |
Both carry one property, cta_location — hero or closing — because both buttons appear
twice on the page and which one a stranger reaches is the only interesting thing about them.
Nothing else is attached.
They go through captureLanding in components/landing/analytics.tsx, behind the same
hostname gate and the same dynamic import() as init. On any other hostname the SDK is never
fetched, so the click is not sent and then dropped — there is nothing there to send it. Both
sends are fire-and-forget behind a swallowed handler; neither delays the copy or the
navigation.
anonymize_ips stays on, and that costs something here: PostHog drops the address before
geoip runs, so the landing page has no country, region or city. That setting is what makes the
no-IP promise above true for every install, and an install's anonymity outranks a marketing
map. A separate project is the only way to have both.
ALLOWED_PROPERTIES does not reach these events — it guards what packages/telemetry builds,
and these are the browser SDK's own. What it sends is ordinary web analytics: URL, referrer,
UTM parameters, browser, device, screen size, a session ID. Replay masks every input by
default and the page has no field to type in.
What is never sent
- Any
ContactorUsername, email, title or photo - Any
Companyname, domain, industry or location ContactFact.value,.sourceUrl,.evidence,.fieldAgentTask.reasonandAgentTask.outcome— both free text the agent wrote about a named personContactBriefcontent,WorkspaceProfile.website,.narrative,.sectionsEmailThreadandEmailMessagesubjects or bodies,CalendarEventtitles,CalendarAttendeerowsDealnames and amounts. Stage distribution is fine; amounts are not.AgentEvent.data,AgentConversationcontent, prompts, completions, reasoning tracesALLOWED_SIGN_IN,AppSetting.contextDevApiKey, any key, secret, token or connection stringSuppressedDomainandSuppressedContactvalues — counts only- IP address. Set
$ip: nulland disable geoip. n8n collects IP and has to caveat their
anonymity claim because of it. We do not need it and we would rather the claim be unqualified.
On that last line: the two properties alone do not achieve it, because PostHog fills $ip in from
the connection during ingestion. anonymize_ips is enabled on the receiving project, which is what
actually drops it. See adrs/telemetry.md.
Where it lives
packages/telemetry |
The only place posthog-node is imported. Allowlist, install accessor, event builders |
install table |
The UUID, the version, and when the last rollup went. One row, made by the migration |
telemetryMilestone table |
Which funnel steps have fired |
telemetryCounter table |
The one runtime number no other row records — budget_exhausted — drained by each rollup and put back if the send fails |
packages/telemetry/src/project.ts |
The key, the ingest host and the UI host. Three constants, no imports, so the browser can read them too |
apps/api/src/telemetry |
The daily rollup, the funnel sweep, the boot-and-hourly timer, the optional cron route |
apps/agent/agent/hooks/telemetry.ts |
Tool, turn and session failures |
apps/app/lib/analytics.ts |
The two hostnames the landing page may report from |
apps/app/components/landing/analytics.tsx |
The only posthog-js import in the repo, behind that gate and a dynamic import(). init on mount, captureLanding for the two CTA events |
Nothing is instrumented inside apps/agent/agent/sandbox, which runs deny-all egress by
design. A capture in there would hang and then fail.
Adding a property
- Add its name to
ALLOWED_PROPERTIES. Until it is there, it is dropped. - Add a row to the table above.
- If it is an open set of keys — a tool, a method, a route — give it a
permitted*function so
the keys are constrained too, not just the property name.
Tracking (`docs/tracking.md`)
Website tracking
A first-party script on the customer's own marketing site, a collector in the API,
and one rule about what it is for: a form submission becomes a contact. Page
views exist to give that contact a story, not to be a web-analytics product.
Everything here is one install's own website. There is no second tenant, no shared
pixel, and no vendor: the script is served from the same origin as the app, the
cookie is first-party, and the only thing that ever leaves the browser is a POST to/api/t/e on the install's own API.
Two scripts, and why
apps/app/lib/tracking/loader.ts → /t/crm.js |
The tag a rep pastes. Reads the site id from data-site or from ?site=, checks the shape, injects the second script. Immutable and cached for a year at the edge |
apps/app/lib/tracking/tracker.ts → /t/<siteId>.js |
The tracker itself, with the config baked into the source rather than fetched. Cached for five minutes |
The split is the whole cache design. The tag never changes, so it is immutable
and free forever; the config does change, so the file that carries it is the one
with the short life. Baking the config in also means a page view costs one request,
not a request and then a config fetch before anything can be recorded.
- The site id has two carriers, and a tag manager is the reason. Google Tag
Manager's Custom HTML injector rebuilds the script element and keeps only the
URL —data-site,asyncanddeferare all dropped on the way in. An
attribute-only tag therefore loads/t/crm.js, finds no site, and returns:
the loader is in the network tab, the tracker never is, and the install looks
installed while recording nothing.?site=rides in thesrc, which no
injector can strip.data-sitestill wins when both are present, so a
pasted tag beats a stale URL. Either way the loader stays config-free —
baking the install's own id into it would put a year-immutable edge cache in
front of the rotate kill switch. - Five minutes is a promise. Pause tracking and every browser stops within
CONFIG_MAX_AGE_SECONDS. That is why/t/[site]carries nostale-while-revalidate— a revalidation window is exactly a licence to keep
executing the old config after the pause, and 24 hours of it once cost this
guarantee entirely. - Both routes are anonymous, listed in
proxy.tsasANONYMOUS = ["/t"]. A
stranger's browser on the customer's marketing site has no session and must not
be redirected to/sign-in. - The tracker has a size budget, asserted in
apps/app/test/tracking-bundle.spec.ts:
1 KB brotli for the loader, 4 KB for the tracker, because the settings page
promises a number. A change that busts it fails the test rather than the promise.
Writing tracker source
It is a string of ES5 in a template literal, minified by hand, and it runs on
somebody else's page. That imposes rules nothing else in this repo has:
- Never throw. An uncaught error on line 40 means no listeners are installed and
the page records nothing at all. EverydecodeURIComponent,JSON.parse,new URLandhistorycall is already wrapped; a malformed UTM parameter must read as
absent, never as fatal. - Constants come from
@crm/db/tracking, interpolated in —MAX_BODY_BYTES,MAX_EVENTS_PER_BATCH,LINKER_MAX_AGE_SECONDS,COOKIE_NAME. A literal at the
call site is a client and a server that disagree, and the disagreement is silent. - The client stays inside the body limit the collector enforces.
flush()
measures the packed batch and splits it, then trims a single oversized form's
fields, rather than posting a body the collector will destroy — a rejected POST is
not retried, so the whole batch would simply be lost.
The collector
POST /api/t/e, anonymous, 204, in TrackingController. It answers nothing: a
tracker that could read a response is a tracker whose failures a stranger can probe.
It is the one route that sets Cross-Origin-Resource-Policy: cross-origin, and
it must. helmet() puts same-origin on every response, which is right for an API
only its own app calls — but this one is called by a no-cors beacon on somebody
else's marketing site, so Chrome blocks the reply withERR_BLOCKED_BY_RESPONSE.NotSameOrigin and logs a failure under every page view.
The header is set on the response, not switched off in helmet, so the exception
stays with the route that needs it.
The gauntlet, in order, in TrackingIngestService.accept:
- User agent — the
BOTpattern. - Site id — must be a live
cmp_id, andforSiterefuses a rotated one. - Origin —
originAllowed. A missingOriginheader is refused even with
the allow list off, because the header is the only thing tying the POST to a
browser on a page. - Visitor id — 8–64 of
[a-zA-Z0-9_-], or the batch is dropped. scripted()— three or more events sharing one timestamp is a replay. It
only fires when every event carries a numericat; missing timestamps are
no signal, and treating them as proof discarded honest traffic.- Host — each event's own host against the allow list. The batch is filtered,
not refused, because one bad host in twenty is a bug in somebody's SPA. - Rate —
EVENTS_PER_MINUTEcharged per accepted event, atomically,
throughTrackingCounter.
The rate limit counts events, not requests, and it counts them in the
database. A per-request counter with a full batch behind it admits twenty times
the number on the constant, and a read-then-write incache-manageradmits
however many requests are in flight.TrackingCounterService.take(key, limit, amount)is one statement — anINSERT … ON CONFLICT DO UPDATEwhoseWHERE
carries the limit — so the check and the charge cannot be separated, and a
refused batch spends nothing. Increment-then-compare looks equivalent and is
not: the refused batch still burns its own size out of the window, so one
oversized batch locks out the honest small ones behind it for the rest of the
minute. It fails closed: a counter it cannot reach refuses the write.
MAX_BODY_BYTES is enforced by destroying the request as it streams, before any
parse. Keep it in step with what the tracker can produce — a form is forty fields
of five hundred characters, and a limit under that silently drops the submission
this whole feature exists to catch.
What is never stored
- No query strings.
normalizePathstrips?and#from every path, andstripQuerydoes the same to every referrer, on the event row and inside thefirstTouch/lastTouchblobs. A password-reset link in a referrer is a
password-reset link in the database. UTM parameters are captured as their own
fields, so nothing is lost by it. - No sensitive fields.
clean()drops any field whose name matchesSENSITIVE, anything shaped like a card number, and everypassword,hidden
andfileinput — the last three never leave the browser at all. - No IP address, anywhere, ever.
Attribution
packages/db/src/attribution.ts, and it is pure: no database, no config, one
function. classifyTouch turns UTM parameters and a referrer into a Touch.
- An explicit
utm_sourcebeats the referrer, because the marketer said so. - The referrer host is matched on DNS labels, never on a substring, and the
provider's labels must sit at the registrable name — one tail label, or two when
the first is a second-level suffix and the last is a two-letter country code
(co.uk,com.au,co.jp). Soimages.google.deandscholar.google.co.jpare
Google, whilenotgoogle.com,google.evil.com,google.com.phish.exampleandgoogle.com.exampleare all plain referrals. A substring match hands a phisher a
trusted source name in the rep's report, and so doescom.<anything>: the country
code is what stopsSECOND_LEVELfrom waving through everycom.lookalike.
It is still a heuristic, not the public suffix list — a provider under an
unusual two-label suffix reads as a referral, which is the safe way to be wrong. - Webmail is checked before search.
mail.google.comcontainsgoogle., so
order alone decides whether a campaign's own click-throughs read as email or as
organic search — and reading your newsletter as Google organic is how a
marketing report lies. Directis a source, not a null. Everything lands in one of the sevenMEDIUMS; an unrecognised medium isother.
Filing: from a submission to a contact
TrackingFilingService.file is the only path from a form to a Contact, and it
reuses the mailbox pipeline's judgement rather than inventing a second one —isMachineAddress, isAutomatedAddress, isMachineDomain, workspaceDomains,SuppressedContact, SuppressedDomain, companyForEmail, splitName. A rule
that holds for the inbox holds here. A second copy is how deleted contacts stay
deleted comes to be true of email and not of forms.
- Every submission is stored; filing is separate and may decline.
skipReason
says why, and the row stays for a rep to look at. - A refusal is a
skipReason, never a lost row, andCONTACT_CAP_REASONis a
shared constant becauserollup.service.tsmatches on it. Telemetry that
substring-matches prose breaks the first time somebody improves the wording. CONTACTS_PER_HOURbounds the blast radius of a scripted form. It is charged
only when a new contact would be created — an existing contact costs nothing,
because attaching to somebody already in the CRM cannot flood it — and a create
that loses the race hands its slot back, so duplicate delivery of one form
cannot eat the hour's quota for a contact that was only ever made once.- The email race is handled, not hoped away. Two submissions for one address
land at once, onecreateloses on the unique index, and the loser attaches to
the contact that won. Left as a rawP2002it becomes a stored submission that is
never filed and never retried. attachclaims the row before it writes anything. It is anupdateManyonfiledAt: null, and a caller that loses the claim returns without
writing — otherwise two concurrent deliveries of one submission each leave the
contact aSubmitted a form on …note.- Duplicate delivery retries an unfiled row.
dedupeKeycollapses a resend
inside one minute, but if the first attempt died before it filed — nofiledAt,
noskipReason— the resend is the retry. - The agent is told, never asked.
agent.contactCreatedwrites anAgentTask.
No enrichment, no scoring and no identity matching happens here; seedocs/api.md.
Retention
POST /internal/tracking/retention, nightly at 04:00 via apps/api/vercel.json,CRON_SECRET or nothing.
- The cutoff is a whole UTC day,
EVENT_RETENTION_DAYSback and then truncated.
A mid-day cutoff splits one calendar day across two nightly runs, andtrackedPageDailykeeps the larger half of a day it saw twice — so the boundary
day is quietly under-reported forever. Roll whole days or do not roll. - Roll before you delete.
TrackingRollupServiceaggregatespage_viewrows
only; a click is not a page view, and counting visitors over both produces a row
readingviews = 0, visitors = 3. - Deleting is batched and bounded —
SWEEP_BATCH×MAX_SWEEP_PASSES. A sweep
that hits the ceiling says so, in the log and in the response, because
silently leaving events behind reads exactly like having deleted them. - A visitor outlives their events only if a contact points at them. An
anonymous visitor with nothing left is removed.
Settings, and who may change them
Settings → Tracking & Analytics. canManageTracking (@crm/auth) gates every
mutation in the service, and the same flag disables the control — the button and
the 403 cannot disagree, as everywhere else.
tracking.sources is manager-only too, so the page must not prefetch it for
everybody: a member's render would fire a request that can only be refused.
- Verify reports on the page it actually fetched.
safeFetchfollows
redirects, so the host in the result comes fromfetched.url, not from what the
rep typed — otherwiseacme.comreports the allow-list status of a page that
lives onwww.acme.com. - Verify reads Tag Manager containers, not only the HTML. A tag a container
injects is not in the response, so an HTML-only check calls a working install
broken — and it did. When the HTML carries no tag,verifytakes theGTM-…
ids out of it and readsgtmContainerUrl(id), at mostMAX_CONTAINERSof
them. The URL is built from the id we matched, never from anything a rep
typed, so the one host this can ever reach is Google's. A container holding
the attribute form is reported as found and broken, with the fix, because
that is precisely what it is — andmissingnames the containers it read, so
a rep can tell not installed from not looked at. MAX_VERIFY_BYTESis measured against real marketing pages. The old
512 KB cut was smaller than one homepage this repo's own company ships, and a
tag past the cut reads exactly like a tag that is not there.- Rotating the site id is the kill switch for a stolen snippet. The old id stops
resolving atforSitewithin the cache TTL. - The compiled config is cached for five minutes and invalidated on every write.
Two things guard the cache, because one is not enough.invalidate()bumps a
per-process generation before it deletes, which stops this replica's own
in-flight read putting the pre-pause config back. That counter means nothing to a
second replica, so nothing is written to the cache unless its hash still equalsAppSetting.trackingConfigHash— the shared version, in the database, that
every replica can see. A read that raced a change computes the old hash, finds
it no longer current, and declines to cache it. Pausing sets the column tonull, so while tracking is paused no config can validate and none is cached
at all.
Where it lives
packages/db/src/tracking.ts |
Every constant and every pure helper both sides share. The single source of truth — a literal copied out of here is a future divergence |
packages/db/src/attribution.ts |
classifyTouch and the source tables. No imports, no database |
apps/app/lib/tracking/ |
The loader and the tracker source |
apps/app/app/t/ |
The two public routes |
apps/api/src/tracking/ |
Collector, config cache, ingest, filing, counters, rollup, retention, tRPC router |
apps/app/app/(app)/[slug]/settings/tracking/ |
The settings page |
apps/app/components/crm/website-activity.tsx |
The record-sheet section, which renders its own heading so it can render nothing at all |
Plan Contact Intelligence Agent (`docs/plan/contact-intelligence-agent.md`)
Plan — Contact intelligence agent (v2)
Turn the enrichment worker we have into an agent that decides: what to
research, how hard, when to look again, and how sure it is. Every claim it
writes carries its evidence; the contact sheet shows the person, the background,
and how much of it to believe — live, while the agent works.
Read it with AGENTS.md, api.md,design.md, crm-plan.md,gmail-calendar-plan.md andpeople-enrichment-agent.md, which is v1 and
whose §0 findings still hold. Conventions there are assumed, not repeated.
Two rules from the top, and everything below obeys them:
- Never at the API level. This is an agentic-first platform. NestJS serves
HTTP, auth, tRPC and the Google sync. It does not research, enrich, score,
summarise or decide anything about a person — not as a fallback, not "just
the cheap bit", not behind a feature flag. §2, §10. - The agent may read everything we have. This is a single-tenant internal
tool. Email bodies, meeting notes, attendee lists and every activity on a
timeline are legitimate evidence and the agent gets them in full. The one
boundary is egress, not access. §4.
This document is not static either. Each phase closes by appending what it
measured to §14, the way v1's §0 rewrote v1's architecture.
0. Where we actually are
Measured against the tree on 2026-08-01, not remembered.
Two enrichment stacks, one job
| Lines | Owns | |
|---|---|---|
apps/api/src/enrichment/ |
~1,720 | Context.dev company brand + research, LinkDAPI contact identity, avatar mirroring, an in-process queue |
apps/agent/agent/ |
~2,140 | LinkDAPI identity again, Perplexity research, summaries, socials |
Two LinkedIn clients (enrichment/linkdapi.client.ts, agent/lib/linkdapi.ts).
Two identity matchers. looksLikeSameCompany, nameMatchesLocalPart andsearchTerms exist verbatim in both trees — and have already drifted: the
agent's looksLikeSameCompany guards (!b && !c), the API's does not, so with
an empty company name the API copy evaluates a.includes("") and matches every
employer on earth. It is unreachable today because the only caller passes a
domain fallback. That is the point: nobody noticed, because nobody was looking
at the copy they were not working in.
Triggers are scattered to match: companies.service.ts:305,364,389 for
companies, google-match.service.ts:335 for contacts, two eve schedules for
everything else.
The "agent" is a worker with a numbered list
Both schedules are step lists — research-new-contacts steps 1–5,profile-contacts steps a–f. The model chooses nothing that matters. It does
not pick who to work on (a take: limit does), how deep to go (a boolean flag
does), or when to look again (a cron expression does). Ten tools, each one API
call. That is a script with a language model inside it.
It is also using almost none of the framework. No sandbox, so no bash, noweb_fetch, no web_search, no workspace. No defineState, no defineDynamic,
no hooks, no channels, no subagents, no evals. Ten hand-written tools, several of
which re-implement things eve ships.
Confidence is computed, then thrown away
get_linkedin_profile returns high | medium | low. update_contact refuses
anything below high and returns a sentence. Nothing is stored. A medium
match — the right person, one weak signal short — is discarded so completely
that the next run pays to rediscover it and discards it again. v1 §5 saidmedium becomes a suggestion. Never built.
There is no provenance store, and no way to see the agent think
v1 §6's ContactEnrichment model never landed. The only field-level source in
the schema is Contact.summarySourceUrl, shipped today. Contact has noenrichmentStatus, so the sheet cannot even show work in flight. Everything the
agent does happens in a cron job nobody can watch, explain or interrupt.
1. What "better than HubSpot and Salesforce" has to mean
Not more fields. They will always have more fields. Four structural advantages:
| They optimise for | We do instead |
|---|---|
| Enrichment as a purchase. Credits buy a record; the unit is the record and the vendor's confidence is not your business. | Enrichment as an argument. Every claim carries evidence, method and date, and the UI shows them. A rep can disagree with one field for one reason. |
| Per-seat, per-credit economics across millions of tenants, so re-reading every contact monthly is a line item they must charge for. | One tenant, our Postgres, our keys. Re-reading costs cents. That turns enrichment from a snapshot into a feed, which is the only way job-change detection exists at all. |
| Generic B2B data. The same firmographics every customer of the platform gets. | Our own conversation history as evidence — in full. We sync Gmail and Calendar; a signature block settles a job title outright, and a reply on a thread is proof of identity no data vendor can sell us (§4). |
| A chat panel bolted onto a record. Ask a question, get an answer, nothing persists. | A resident agent with durable sessions. It works the record on its own clock, and when a rep opens the sheet they can watch it, interrupt it and redirect it — same session, same memory, next week. |
The fourth is the one that is hard to copy, and eve is the reason it is cheap
for us: durable sessions, a streamable event log and a browser client are
framework features, not a project.
The test: a rep opens a contact five minutes before a call and knows who
they are, what they do, how long they have done it, what we have already said to
each other, and which parts a machine inferred. Then they type "is he still at
Fleetio?" into the same panel and watch it check.
2. Decisions that carry the design
- Never at the API level. Not a fallback, not a fast path. The API's
enrichment directory is deleted, not deprecated — §0 shows what a second
copy does even when nobody is calling it. Nest emits events; the agent
decides what they mean. §10. - The agent runs in a sandbox and uses the harness.
bash,web_fetch,web_search,glob,grep,todo,ask_questionand a/workspacecome
with eve. Authored tools are for the three things the harness cannot do:
vendor APIs that need keys and verification, CRM reads and writes, and
decisions. Every tool we write that wraps a public HTTP GET is a tool we are
maintaining for nothing. §4. - Confidence is data, not a gate. Every fact is stored with evidence,
score, method and source — including the ones too weak to apply. Discarding amediummatch throws away the most valuable thing the run produced: a
candidate a human could settle in three seconds. §5. - Background runs propose; people approve. A markdown schedule is task mode
and cannot park for a person — so an approval gate in a cron run is not a
pause, it is a failure. Background work below the bar writes a proposal.
Approval prompts andask_questionare for sessions a human started. §5, §7. - The record is the interface to the agent. The Next app mounts eve's
routes withwithEveand the contact sheet renders a live session withuseEveAgent. The rep watches the same event stream the runtime persists.
No second app, no chat silo. §6. - The agent may read the whole CRM, including bodies. Internal tool, single
tenant, our own data. The boundary is egress: raw customer text never
leaves as a third-party search query, and never reaches the sandbox, which
has network access. §4, §8. - Budget is an input the agent spends, held in
defineStateand enforced
in the tool — a VP on a live deal earns a deep pass, a newsletter signup
earns one lookup. §7. - Humans outrank the agent, permanently. A field a person typed is never
overwritten; a dismissed proposal is never re-proposed. Enforced in the fact
store, not in a prompt.
Judgment calls worth your veto
- Deleting the API path outright. The safe-sounding option is to keep it as
a fallback. That is how we got two of everything. Enrichment has never been on
a request path; if the agent is down, contacts wait. - Giving the agent a shell. A sandbox with
bashand web egress is a real
capability and eve's own docs tell you to review it before production. The
mitigation is not "no shell", it is no database credentials in the sandbox
(§8): CRM access is authored tools in the app runtime, neverpsql. - Numeric confidence, shown as words. Storing
0.82and rendering
"probable" invites "why not 0.79?". The honest answer is that the number is
calibrated by evals (§9), not a probability. Three opaque bands cannot be
calibrated at all. - A live agent panel on the record. More UI, and it puts model output in
front of reps constantly. That is the intent: an agent nobody watches is an
agent nobody corrects. - The agent schedules itself. Bounded by lease and budget, but it decides to
come back in fourteen days. Anything less and "not static" is a slogan.
3. Data model
Current values stay denormalised on Contact so lists stay one query. The new
tables are the reasoning, not the read path.
/* Detailed source-code truncated for AI context efficiency. */AgentEvent is close to free: a single defineHook on * writinginsert … on conflict (id) do nothing, exactly the pattern eve's streaming doc
recommends, and ULIDs keep it append-ordered. It gives us the agent's full
history — which tool ran, what it returned, what it decided — without inventing
an audit format.
Contact also gains enrichmentStatus/enrichedAt/enrichmentError matchingCompany, so the sheet reuses isEnriching() and ENRICHMENT_POLL_MS
(api.md) for the non-streaming case. ContactFact subsumessummarySourceUrl, summaryUpdatedAt and socialsCheckedAt shipped today;
those columns migrate into facts in Phase 1.
Job changes fall out for free: a new employer fact superseding an applied
one is the event. No diffing machinery, which is why v1 put it last and this
plan does not have to.
4. Capabilities: what eve gives us, what we write
From the harness (a sandbox is all it takes)
| Built-in | What it replaces |
|---|---|
web_search, web_fetch |
Half of lib/perplexity.ts. Perplexity stays for cited synthesis; plain retrieval is a built-in. |
bash, read_file, write_file, glob, grep |
A /workspace dossier per contact: dump the profile JSON, grep it, diff this month's against last month's. Job-change detection becomes a diff. |
todo |
Durable per-session task list. Multi-step research stops living in the prompt. |
ask_question |
The agent asks the rep — "two Marchettis at Fernhill, which one?" — and parks. Worth more than any heuristic we could write for that case. |
agent (root-only) |
Parallel research fan-out. Meeting prep with four attendees is four children, not four sequential passes. |
load_skill |
Procedures load on demand instead of bloating every turn. |
Enabled deliberately, per eve's own guidance to review the defaults: bash,
file tools and web_fetch stay on because a research agent without retrieval is
a chatbot; the sandbox gets no database credentials (§8).
Authored tools — only where the harness cannot go
| Group | Tools | Why authored |
|---|---|---|
| CRM read | read_contact, read_crm_history, read_company |
Database access belongs in the app runtime, not the sandbox. read_crm_history returns threads, meetings, attendance and message bodies — the strongest identity evidence we own. |
| Vendor | get_linkedin_profile, get_work_history, research_person |
Keys, rate limits, and LinkDAPI's 200 {success:false} envelope. |
| Judge | verify_identity, verify_social, corroborate_from_crm |
These fetch their own evidence and rule in code. verify_social exists and is the template: no confidence argument, no model-supplied sourceUrl. |
| Decide | plan_research, schedule_recheck, set_priority |
The tools that make it an agent. schedule_recheck takes a date and a reason, and the reason is shown to the rep. |
| Write | apply_fact, propose_fact, write_brief, record_job_change |
Approval policy lives here (§5). |
Two invariants: a tool never accepts the model's own confidence, and "looked,
found nothing" is recorded with a date — the generalisation of today'ssocialsCheckedAt, without which every run re-litigates the same absence.
Context control
instructions.md shrinks to identity and the one rule. Everything else becomes
a skill loaded on demand — identity-matching (exists), confidence-scoring,writing-a-brief, meeting-prep, job-change, data-boundaries. load_skill
adds instructions, never execution surface, so this is free discipline.
defineDynamic then makes the surface itself situational, resolved atsession.started: a contact with no company gets no company tools; a task on a
live deal loads the deal-brief skill; ambiguous identity work can resolve a
stronger model. (Model resolution stays at session.started — switching
mid-session re-ingests the conversation at uncached prices.)
The egress boundary
The agent reads everything. What leaves is controlled:
- Never send raw customer text to a third-party search. Perplexity and
web_searchget derived questions ("what did Acme announce in 2026?"), never
a pasted thread. - Nothing from a mailbox reaches
/workspace. The sandbox has network
access; email bodies stay in the app runtime, in the model's context. api.mdstill stands: never log headers, bodies or query strings. Read
is not log.
5. The confidence model
Evidence, weighted
Starting values, to be calibrated (§9), not truths:
| Evidence | Weight | Primary? |
|---|---|---|
| Exact email match on a profile | 0.95 | ✔ |
| LinkedIn: employer matches and name is consistent with the address | 0.85 | ✔ |
| They replied on a thread we synced | 0.85 | ✔ |
GitHub API: the account's own name or company matches |
0.8 | ✔ |
| A signature block in a thread states the title | 0.8 | ✔ |
| They attended a meeting on our calendar | 0.7 | ✔ |
| Perplexity states it with a citation | 0.4 | |
| Handle is a construction of their name | 0.35 | |
| A search engine cites the profile for their name + employer | 0.35 | |
| Employer matches, name does not | 0.2 |
The two bolded rows are ours alone, and they are near the top. That is the
argument in §1 turned into arithmetic.
Combined as 1 − Π(1 − wᵢ) over independent items, capped at 0.99, with two
rules a score cannot express:
- No auto-apply without a primary source. Three weak signals multiply to a
confident-looking number and remain three weak signals. - Contradiction floors the score. A profile saying one employer and a mail
header saying another is not 0.6, it is unresolved, and the fact is held.
Bands
| Band | Score | Behaviour | In the UI |
|---|---|---|---|
VERIFIED |
≥ 0.85 + primary | Applied automatically | The value, plain. Dotted underline reveals the source. |
PROBABLE |
0.55–0.85 | Stored as a proposal; field stays empty | A line under the field: "LinkedIn suggests Head of Security · accept · dismiss" |
POSSIBLE |
0.3–0.55 | Stored, never shown as a value | Only in the provenance panel, as what was considered |
| below | < 0.3 | Not stored | — |
Enforced by the approval policy, not the prompt
eve's docs are explicit that model behaviour alone must not guard sensitive
writes, and a rubric in a system prompt is model behaviour. The policy also
has to know who is driving, because a task-mode schedule cannot park for a
person:
approval: ({ session, toolInput }) => {
const auth = session.auth.current;
const automated =
auth?.authenticator === "app" &&
auth.principalId === "eve:app" &&
auth.principalType === "runtime";
if ((toolInput?.score ?? 0) >= 0.85 && toolInput?.hasPrimarySource) {
return "not-applicable"; // verified: just write it
}
// A cron turn cannot wait for a human, so asking would hang the run.
return automated
? { type: "denied", reason: "Below the bar — call propose_fact instead." }
: "user-approval"; // a rep is here; ask them
};Same tool, two behaviours, decided by the principal rather than by a flag the
model can set. Sensitive fields (email, companyId, anything that re-parents
a record) sit a band higher regardless of score.
6. The contact sheet
All of it inside design.md: components from /packages/ui, no new radii or
colours, no className overrides on shared components, new variants land in the
package.
The Background section
Above Details, because it is what the sheet is for:
BACKGROUND refreshed 2 days ago
Lewis Carhart is the CEO and co-founder of Comp AI. He previously led
growth at Fleetio and spent four years at Deloitte in risk advisory.
Current role CEO & Co-founder · Comp AI · 2 yrs 3 mos
Previously Head of Growth, Fleetio · Risk Advisory, Deloitte
Seniority Founder / C-level Function Executive
Based London, UK
WE KNOW THEM ← from our own data
12 emails · last reply 4 days ago · 3 meetings · next: Thursday 14:00
Also here Sarah Chen (CTO) · Tom Reyes (Security Lead)The second block is the one no incumbent renders, because it is built from the
Gmail and Calendar sync rather than a purchased record.
How confidence reads
status-indicator.tsx argues against pill chips in its own comment and it is
right: a sheet speckled with coloured badges is a sheet you read the badges of.
- Verified values render as plain text. No chrome. Decoration on everything
is noise, and noise is how the one uncertain field gets missed. - Agent-written values carry a dotted underline. Hover or focus reveals a
ProvenanceTooltip— source, method, date, the evidence that scored it. One
new component in/packages/ui. - Proposals are not values. The field stays empty; the suggestion sits
beneath with accept and dismiss. A guess is never formatted like a fact. StatusIndicatoralready covers in-flight and failed.
Accept and dismiss write ContactFact.status with decidedById — labelled
data for §9, produced by the people best placed to judge, when they care.
The live panel
withEve() in apps/app/next.config.ts mounts eve's routes on the app's own
origin — no CORS, no URL env var. A useEveAgent() panel in the sheet then
gives the record something neither incumbent has:
- Watch it work.
actions.requested/action.resultrender as a plain
list of steps: searched LinkedIn · read profile · checked GitHub API ·
rejectedgithub.com/lewis. The rejections are the most trust-building thing
on the page. - Interrupt and redirect. "Is he still at Fleetio?" continues the same
durable session, with the same history, on the same record. - Answer its questions.
ask_questionrenders inline: two Marchettis at
Fernhill, pick one. The answer is worth more than any tiebreak heuristic. - Replay. Sessions are durable for 30 days and the stream is rewindable, so
"why does it think that?" is a question with an answer.
Tables get none of this. The contacts list stays plain text; scanning is its
whole job. Confidence and agency belong where a decision gets made.
7. Not static: how the agent chooses
One dispatcher, not two cron prompts
A single defineSchedule({ cron: "* * * * *", run }) claims due AgentTask
rows under an atomic lease and calls receive(...) per row with appAuth,
following eve's documented dynamic-scheduling pattern. Prompts stop being
numbered lists; the row carries the objective, the budget and the reason.
The agent writes its own rows
schedule_recheck takes a date and a reason:
- champion on an open deal → 14 days ("job change here moves a live deal")
- named contact, no open deal → 90 days
- nothing found twice → 365 days, low priority
support@,no-reply@→ never
Today's cron: "20 * * * *" over take: 10 ORDER BY createdAt treats the CEO of
a live opportunity and a bounced alias identically.
Events jump the queue
A calendar event tomorrow with an unknown external attendee inserts a
high-priority meeting-prep task the moment the sync sees it. Delivery is a
custom channel (agent/channels/crm.ts) exposing routes the sync posts domain
events to — "thread ingested", "attendee unknown" — never "enrich this". The
agent decides what an event means; that is the whole point of rule 1.
Depth is a decision, budget is state
plan_research reads the contact, the deal context and the budget, then picks
the passes. The budget is a defineState slot decremented by the tools that
spend it — eve's own budget example, applied to vendor calls:
const research = defineState("crm.research-budget", () => ({ spent: 0, cap: 8 }));A $40k opportunity earns more calls than a newsletter signup, and the agent —
not a boolean — makes that call.
Fan-out for briefs
Meeting prep with four external attendees delegates four children through the
built-in agent tool with non-overlapping write scopes, then writes one brief.
If the orchestration gets genuinely complex, the opt-in Workflow tool lets the
model author that fan-out as one durable step; it stays off until a brief needs
it, because a capped subagent budget is easier to reason about than
model-authored JavaScript.
8. What stays deterministic
Widening discretion is only safe if the boundary is code:
- Identity verification.
verify_*fetch their own evidence and rule. - The approval policy. §5, keyed on principal, in the tool definition.
- Never overwrite a human; never re-propose a dismissal. In the fact store.
- No database credentials in the sandbox. The sandbox has
bashand
egress; CRM access is authored tools in the app runtime. This is the single
most important line in the section. - No raw customer text leaves as a search query. §4.
- Rate limits and budgets. A token bucket per vendor; a per-task budget the
agent spends but cannot raise. - Business context only. Name, title, employer, tenure, public profile. No
special categories regardless of what an endpoint returns (v1 §9 stands).
Dynamic in what to do. Fixed in what is allowed.
9. Evals, or the confidence number means nothing
evals/*.eval.ts with defineEval, driving real sessions over the same HTTP
surface the app uses; mockModel for deterministic fixtures where the point is
the plumbing rather than the judgement.
| Metric | Target | Why |
|---|---|---|
Precision at VERIFIED |
≥ 0.98 | An auto-applied wrong fact is the failure that costs a deal. |
Precision at PROBABLE |
≥ 0.75 | Below this, reps learn to dismiss without reading. |
| Recall | tracked, not gated | Coverage is a cost decision; precision is a trust one. |
| Cost per contact | tracked | Where "budget as an input" meets reality. |
The fixture set is real CRM addresses with known answers, including ones whose
correct answer is nothing — a matcher that never abstains scores well on a set
where everybody is findable. Accept/dismiss decisions from §6 feed the fixtures,
which is the flywheel: the more it is corrected, the better calibrated the number
the corrections are shown against.
Calibration is the deliverable: when a band says 0.85, roughly 85 of 100 should
be right. The weights in §5 move until that holds.
10. Retiring the API path
Order matters — no window with two writers:
- Facts first.
ContactFactand theAgentEventhook land; the agent
writes through them. - Invert the triggers.
companies.service.tsandgoogle-match.service.ts
stop calling enrichment and post a domain event to the agent's CRM channel.
Nest learns nothing about enrichment; it reports what happened. - Move company enrichment. Context.dev brand mapping becomes agent tools.
brand-mapping.tsmoves as-is — pure and well-tested. - Delete
apps/api/src/enrichment/, minusavatar.service.ts(Blob
mirroring belongs to whoever writes the URL). ~1,600 lines out of the API,
~200 of them moving rather than dying. One LinkedIn client in the repo, one
identity matcher, one place to fix a bug. - Move the tests.
enrichment.spec.ts,contact-enrichment.spec.tsand
the integration spec go toapps/agent/test/. They encode findings, not
implementation.
What Nest keeps: HTTP, auth, tRPC, the sync, and the ability to say "this
happened". What it stops doing: deciding anything about people.
11. Phases
| Phase | Scope | Done when |
|---|---|---|
| 1 — Provenance spine | ContactFact, ContactBrief, AgentEvent hook, Contact.enrichmentStatus; ProvenanceTooltip in /packages/ui |
Every agent-written value on the sheet traces to a source and a date without opening a database. |
| 2 — Harness | Sandbox on, built-ins reviewed and gated, instructions split into skills, defineState budget, defineDynamic surface |
The agent researches with web_search/web_fetch/bash; three authored tools are deleted as redundant. |
| 3 — Confidence | Evidence ledger, bands, principal-aware approval policy, proposals inline with accept/dismiss | A medium match becomes a suggestion a rep settles in three seconds instead of a discarded log line. |
| 4 — Agent-only | §10 steps 2–5 | apps/api/src/enrichment/ is gone. |
| 5 — Background panel | Structured sections, tenure, "we know them" from Gmail/Calendar, colleagues | The five-minutes-before-a-call test in §1 passes. |
| 6 — Live panel | Same-origin proxy, useEveAgent on the contact sheet, ask_question inline, step list |
A rep watches a rejection happen and trusts the accepted ones more. |
| 7 — Dynamic scheduling | AgentTask, dispatcher, plan_research, schedule_recheck, CRM channel, subagent fan-out |
Two contacts researched on different cadences, for reasons the agent wrote and the rep can read. |
| 8 — Meeting prep | Calendar-triggered briefs for external attendees | Thursday's 14:00 has a brief on it by Wednesday night. |
| 9 — Job changes | Superseding facts raise an activity and a task for the owner | A champion moves and their owner knows that week. |
Phases 1–4 make the data trustworthy; 5–9 are what a rep would miss if you took
it away. One ordering constraint is non-negotiable, as v1 also said: a brief
built on unverified identities is confidently wrong in front of a customer.
12. Risks
- eve is in preview. APIs may change before GA.
apps/agent/lib/is where
the vendor-shaped code lives; keep framework surface in the authored files
thin enough to move. - A sandbox with egress is a real capability. §8's no-credentials rule is
the mitigation, and it only holds if nobody "temporarily" addsDATABASE_URL
to the sandbox env. - Task-mode schedules cannot pause. Any design that wants a human mid-cron
is wrong by construction; that is why §5's policy denies rather than asks. - Live streams show reasoning. eve's docs flag the privacy implications of
rendering reasoning events. Internal tool, so acceptable — but it is a
decision, and it is recorded here as one. - Single-provider LinkedIn risk (v1 §9) is unchanged.
13. Open questions
- Score on the record, or only in the tooltip? Leaning: band in the
tooltip, raw number only in the provenance panel. - Do proposals expire? A suggestion nobody has judged in 90 days is noise.
Auto-dismiss, or keep and stop re-scoring? - Who owns the budget dial? A constant, or a per-account setting a rep can
turn up? Does the agent see message bodies?Decided: yes, in full. Internal
tool, single tenant. The boundary is egress (§4), not access.- Company facts too? The model generalises, but companies have one source
and no identity problem. Probably later, possibly never. - A rep edits a field by hand — what happens to the fact? Superseded and
kept as history is right, but the store must then accept writes that did not
come from the agent. - Does the live panel belong on companies and deals too? Same machinery,
three times the surface. Contacts first.
14. What each phase actually taught us
Appended as phases land, in v1 §0's spirit — measurements that changed the
design, not a changelog.
Phases 1–4 (2026-08-01)
The weights in §5 do not behave the way the table implies, and a test caught
it. crm.signature-block at 0.8 is below the VERIFIED floor, so a title
read out of a signature was going to become a suggestion rather than a fact —
not what §1 promises. The realistic case saves it: a signature is read on a
thread they replied to, so the evidence is crm.thread-reply +crm.signature-block → 0.97, and it writes. The lesson is that weights must be
tested against the combinations that actually occur, not one at a time.
Four weak signals do not reach 0.85 (they reach 0.797), so the "pile of weak
evidence" scenario the primary-source rule exists to stop was not reachable by
arithmetic anyway. The rule is still right — it has to hold under
re-calibration — but the test for it now asserts bandFor(0.99, false)
directly rather than pretending the weights get there.
Approval gates were wrong for record_fact. The plan had one on the write
tool. Building it showed the flaw: if approval denies the call, execute never
runs, so the proposal is never stored — the gate would have deleted the
feature it was protecting. Band logic inside the same function that scores the
evidence is strictly stronger, because there is no path around it. Approval
moved to where blast radius is bigger than one field: record_job_change,
which can re-parent a contact.
Verification functions should return evidence, not verdicts. verifyGithub
used to decide accepted: true on any of name-match, company-match, or
domain-link. That meant an account sharing only an employer — a colleague — was
written with the same authority as one naming the person. Returning evidence
kinds instead put both through the ledger, and the colleague case now scores
0.2 and is dropped. Same code, one fewer way to be confidently wrong.
The "delete three redundant tools" prediction was wrong in detail and right in
outcome. Nothing became redundant because of the harness — research_person
and the two candidate-finders still earn their keep. Six tools went anyway
(update_contact, write_contact_summary, write_briefing,list_contacts_to_research, list_contacts_to_profile, plus the API's two
enrichment services), because the fact store replaced the write path and onelist_outstanding_work replaced the "which queue am I" tools.
The sandbox needs no network, which was not obvious until the harness table
was read properly. web_fetch runs in the app runtime and web_search at the
provider — only bash and the file tools run in the sandbox. So deny-all
costs nothing and removes the only shell-shaped path out of the building for
the email bodies this agent now reads in full. It is set on the backend factory
rather than in onSession, so it cannot be forgotten per session.
packages/db and packages/auth imported @crm/env without declaring it.
Hoisting hid it until bun add in apps/agent created a local node_modules
that shadowed the root, and then every DB-touching test failed at once. Two
one-line package.json fixes; worth recording because the symptom
("Cannot find module" in a package nobody edited) looks nothing like the cause.
Phase 6 (2026-08-01)
withEve was the wrong tool, and the constraint made the design better.
The agent stays its own deployment, so mounting it inside the Next build was
never available. What replaced it is the pattern the app already uses for the
API: a same-origin proxy, at app/eve/v1/[...path]/route.ts, mounted at
exactly the path useEveAgent() defaults to. The hook needs no host, the
browser never learns the agent has its own origin, and there is no CORS and no
cross-site cookie anywhere in it.
The proxy is an enforcement point, not a passthrough, and that is the one
structural difference from the API proxy beside it. The agent never sees the
session cookie — it is stripped — so if the route did not check the session,
nothing downstream would. It checks, then mints a two-minute HS256 token naming
the rep.
eve's jwtHmac() helper labels the caller a service, and that would have been
a silent bug. Verified through the helper, a rep's token resolves toprincipalType: "service" with principalId: "crm-app:user_123" — sensible for
a machine credential, wrong for a person. lib/approval.ts decides whether to
pause for a human by reading exactly those fields, so a sensitive write would
have been refused with "not something to do unattended" while the rep sat
watching. repFromCrm wraps verifyJwtHmac and maps the subject to a real user
principal instead. Found by printing the verifier's output rather than trusting
the shape, and now pinned by channel-auth.spec.ts.
Both halves of the token are tested against each other: the app mints and
eve's own verifier accepts (apps/app/test/agent-bridge.spec.ts), and forged,
expired, wrong-audience, wrong-issuer and subject-less tokens are all rejected
by the real auth function (apps/agent/test/channel-auth.spec.ts). A shared
secret is the whole security of the panel, so neither side is asserted from
memory.
AGENT_BRIDGE_SECRET unset skips the entry rather than opening it. No
signer, no panel, everything else unchanged — the agent still runs its own
schedule. That is the correct failure for an optional capability whose absence
must never widen access.
One duplication survived on purpose. domainOf in the agent anddomainFromEmail/normalizeDomain in the API do the same job. Both apps
genuinely need it, and a shared package for two pure functions is more
machinery than the risk justifies — but it is the same shape as the drift in §0,
so it is written down here rather than left to be rediscovered.
Plan Crm Plan (`docs/plan/crm-plan.md`)
Plan — Agentic CRM (HubSpot replacement)
A lightweight, opinionated CRM for our sales team. Companies are the top-level
object; Contacts and Deals hang off them; everything that happens is an
Activity on a timeline. Company knowledge (logo, description, industry,
socials, address) is filled in by an agent using Context.dev
rather than typed by a human.
This document is the build plan: decisions, data model, contracts, and phased
delivery. Read it with AGENTS.md, design.md
and api.md.
1. Decisions up front
| Decision | Choice | Why |
|---|---|---|
| Tenancy | Single tenant, no organizations | The repo is already built this way (20260731160000_remove_organizations). It's an internal tool; Google sign-in is the only door. |
| Data fetching | tRPC (nestjs-trpc) end to end | Type-safe from Prisma to the table cell, and the pattern is proven in /Users/lewiscarhart/mvp. |
| Table state | nuqs, server-side pagination/sort/filter | URLs are shareable and survive reload; the API does the work so 10k rows stay fast. |
| Data table | Port mvp's DataTable into packages/ui |
design.md makes /packages/ui the single source of truth; simple-table and card-table already live there. Same code as mvp, different address. |
| Enrichment | Context.dev, fire-and-forget on write, cached in Postgres | 10 credits per company; we never pay twice for the same domain. |
| Activity model | One Activity table, denormalised companyId |
A company timeline must show its deals' and contacts' events. One indexed query beats three joins. |
| Deal owner | Deal.ownerId → User, required |
Every deal has a name against it. Companies and contacts have optional owners. |
Judgment calls worth your veto
UNQUALIFIED_TO_BUYis terminal, not linear. You listed it between
"qualified to buy" and "decision maker bought-in", but it's a disqualification,
not a step forward. Plan treats it likeCLOSED_LOST: excluded from pipeline
value and forecast, shown in a "Closed" tab rather than the open pipeline. Say
the word if you want it mid-funnel instead.Contact.emailis unique. Matches HubSpot's own constraint and makes the
CSV import idempotent. The cost: two people behind oneinfo@address need
separate records with one email each.Company.domainis the enrichment key and is unique. Two records for the
same domain is the #1 way a CRM rots, so we block it at the database.
Repo inconsistencies found while surveying
docs/review.mddoes not exist butAGENTS.mdsays "Always review" it.
Either write it or drop the reference — right now every agent starts by
failing to read a required file.docs/api.mddescribes a multi-tenant world this repo doesn't have. The
org-context (x-organization-slug,OrgContextInterceptor) and per-org cache
generation sections were carried over from the MVP; there are no
organizations here. The logging rules apply verbatim and should stay.
Recommend trimming api.md to logging + a short "no orgs, no org cache keys"
note in Phase 0.apps/app/make-session.tmp.tsis a stray scratch file, safe to delete.
2. Architecture
browser
│ same-origin /api/trpc (cookies just work)
▼
apps/app (Next.js 16, RSC)
├─ app/api/[...path]/route.ts ──proxy──▶ apps/api:3001
├─ server components: prefetch via getServerTrpc() → HydrationBoundary
└─ client components: useTRPC() + TanStack Query, nuqs for table state
│
apps/api (NestJS on Bun, port 3001) ▼
├─ /api/trpc nestjs-trpc routers (companies, contacts, deals, activities, users)
├─ /api/auth/* Better Auth (Google only)
├─ services Prisma queries, timeline writes
└─ enrichment Context.dev client + queue ──▶ api.context.dev
│
packages/db (Prisma) ◀────────────────────────┘Two things the CRM adds to the existing skeleton:
apps/app/app/api/[...path]/route.ts— a catch-all proxy (ported frommvp) so the browser calls/api/trpcsame-origin. Today the app sends the
browser straight to:3001; that works for auth redirects but makes every
tRPC call a cross-origin, credentialed request. The proxy removes that whole
class of problem and streams SSE through untouched.apps/apiexports its router type:"exports": { "./app-router": "./src/generated/server.ts" },
generated bynestjs-trpc generate. The app importstype { AppRouter } from "api/app-router".
Dependencies to add
| Package | Where | Version (matching mvp) |
|---|---|---|
@trpc/server, zod |
apps/api |
^11.18.0, ^4.4.3 |
@trpc/client, @trpc/server, @trpc/tanstack-react-query |
apps/app |
^11.18.0 |
@tanstack/react-query, @tanstack/react-query-devtools |
apps/app |
^5.101.2 |
nuqs |
apps/app + packages/ui |
^2.8.9 |
zod |
apps/app |
^4.4.3 |
context.dev |
apps/api |
latest |
nestjs-trpc (^2.13.0) and @keyv/redis/cache-manager are already inapps/api. Note nestjs-trpc ships a binary that may need thepostinstall chmod shim mvp uses — check on first install.
3. Data model
New models in packages/db/prisma/schema.prisma. Auth models are untouched.
/* Detailed source-code truncated for AI context efficiency. */User gains the back-relations (CompanyOwner, ContactOwner, DealOwner,ActivityAuthor). It is otherwise untouched — Better Auth owns that model andbun run auth:generate regenerates it.
Timeline semantics
- Past =
occurredAtset, orcompletedAtset, or aNOTE/STAGE_CHANGE/ENRICHMENTrow (usecreatedAt). - Upcoming =
dueAt > now()andcompletedAt IS NULL. - Overdue =
dueAt < now()andcompletedAt IS NULL. - A company's timeline is
WHERE companyId = ?, which picks up deal and contact
activity for free because we stampcompanyIdon write.
Pipeline math
- Open stages:
DEMO_BOOKED,QUALIFIED_TO_BUY,DECISION_MAKER_BOUGHT_IN,CONTRACT_SENT. - Closed stages:
CLOSED_WON,CLOSED_LOST,UNQUALIFIED_TO_BUY. - Pipeline value =
SUM(amount)over open stages. Setting a closed stage stampsclosedAtand prompts forclosedReasonon the two losing outcomes.
4. The agentic layer (Context.dev)
apps/api/src/enrichment/ — a ContextDevClient (thin typed wrapper overhttps://api.context.dev/v1) plus an EnrichmentService that owns a small
in-process queue (concurrency 2, one retry with backoff, per-domain dedupe).
Trigger points
| Event | Call | Cost |
|---|---|---|
Company created/updated with a new domain |
POST /brand/retrieve {type:"by_domain"} |
10 credits |
| Manual "Re-enrich" button | same, maxAgeMs: 0 |
10 credits |
| Contact created with a work email and no company | POST /brand/retrieve {type:"by_email"} → find-or-create Company |
10 credits |
| CSV import (bulk) | POST /utility/prefetch per row, then retrieve |
prefetch free (paid plan) |
| "Research this company" action | POST /web/extract with our schema → ENRICHMENT activity |
10 credits |
Field mapping from the brand object:
title→name(only when the record was created domain-first and unnamed)description,slogan→descriptionlogos[]→ pick bymode/type, neverlogos[0]:logoUrl= light+logo,logoDarkUrl= dark+logo,iconUrl=iconcolors[0].hex→brandColor(key offhex; thenameis generated)industries.eic[0]→industry/subIndustryaddress→city,stateCode,country,countryCodesocials[]→linkedinUrl/twitterUrl/githubUrlbytypelinks→pricingUrl,careersUrlemail,phone→ same fields- Whole payload →
CompanyEnrichment.raw
Handling, per the Context.dev skill:
- Every field is nullable — never overwrite a human-entered value with
null. 400 NOT_FOUND/WEBSITE_ACCESS_ERROR→SKIPPED, notFAILED(and not billed).422on email lookup (free/disposable) →SKIPPED, no company created.403 FORBIDDENon prefetch → log once, fall through to direct retrieve.408/429→ one backoff retry, thenFAILEDwithenrichmentError.- Cold lookups are p50 ≈ 7s, p90 ≈ 18s. Always background; never block the
create mutation. SettimeoutMS: 60000.
Research brief (POST /web/extract) uses a fixed JSON Schema —{ positioning, pricingModel, targetCustomer, notableCustomers[], recentNews[] }
— and posts the result as an ENRICHMENT activity on the company timeline so a
rep sees it in context instead of in a side panel.
Config: CONTEXT_DEV_API_KEY in apps/api/.env, declared @IsOptional()
in env.validation.ts. Absent key ⇒ enrichment disabled, logged once at boot,
companies stay PENDING. The key never reaches the browser; all calls are
server-side.
Caching note: docs/api.md's org-scoped generation keys don't apply (no
orgs). tRPC responses aren't served by the HTTP response cache; freshness comes
from TanStack Query invalidation on mutation. Enrichment finishing is a
background write the client can't see, so the company detail page polls
(refetchInterval while enrichmentStatus is PENDING/RUNNING) and stops
when it settles.
5. tRPC contract
Every list endpoint speaks the same shape so one DataTable drives all three
modules.
// apps/api/src/trpc/list-input.ts
export const listInput = z.object({
q: z.string().default(""),
sort: z.string().default(""),
dir: z.enum(["asc", "desc"]).default("asc"),
page: z.number().int().min(1).default(1),
pageSize: z.number().int().min(1).max(100).default(25),
});
// → { rows: T[]; total: number; facetCounts: Record<string, Record<string, number>> }| Router | Procedures |
|---|---|
users |
me, list (owner pickers) |
companies |
list (+ facets owner, industry, enrichment), byId, create, update, remove, setPrimaryContact, enrich |
contacts |
list (+ facets owner, company), byId, create, update, remove |
deals |
list (+ tab stage, facets owner, closing), board, byId, create, update, setStage, setContacts, remove |
activities |
timeline ({ companyId? contactId? dealId?, filter, cursor }), create, update, complete, remove, myTasks |
dashboard |
summary (pipeline by stage, closing this month, overdue tasks, recent activity) |
All routers carry @UseMiddlewares(AuthMiddleware) — ported from mvp minus the
org and permissions middleware, which have no meaning here. deals.setStage
writes the STAGE_CHANGE activity and stamps stageChangedAt/closedAt in the
same transaction.
Filtering, sorting and pagination happen in Prisma, not in the browser.q searches name/domain/email with a case-insensitive contains; add apg_trgm index if the tables ever get big enough to notice.
6. Frontend
Routes
apps/app/app/(app)/
page.tsx dashboard
companies/page.tsx DataTable
companies/[companyId]/page.tsx detail + timeline
contacts/page.tsx DataTable (placeholder exists today)
contacts/[contactId]/page.tsx detail + timeline
deals/page.tsx DataTable (+ ?view=board)
deals/[dealId]/page.tsx detail + timelineAppIconRail gains Companies and Deals entries alongside the existing Overview
(Dashboard) and Contacts (UserMultiple) — Carbon glyphs imported one at a
time through the Icon wrapper, per the README. Pick the glyph names off the
Carbon icon library and
confirm the file exists in @carbon/icons-react/es/ before importing; the
package isn't installed in this checkout yet, so the names are unverified.
The data table
Port these from /Users/lewiscarhart/mvp/apps/app/components/data-table/,
rewriting @trycompai/ui → @crm/ui:
| Source | Destination |
|---|---|
data-table.tsx (631 lines) |
packages/ui/src/components/data-table.tsx |
table-pagination.tsx, numbered-pagination.tsx |
packages/ui/src/components/ |
use-table-query.ts, build-search.ts |
apps/app/components/data-table/ |
Everything it depends on already exists in @crm/ui: table, dropdown-menu,input-group, button, spinner, and lib/row-accent. The hook stays in the
app because it's routing state, not UI. nuqs becomes a dependency ofpackages/ui (the table owns the expand and hide params).
Behaviour we're keeping verbatim: sticky header, table-fixed with truncating
cells, sort/columns/facet dropdowns, mobile filter collapse, expandable rows,useDeferredValue so typing stays smooth, and the pagination footer.
nuqs conventions
NuqsAdapterfromnuqs/adapters/next/appwraps the root layout, alongsideTRPCReactProvider.- Shared parsers live in a
*-search-params.tsper module, importing fromnuqs/serverso the page and the client component agree. - Server components read them with
createLoader, and pass the parsed values
into the tRPC prefetch so the first paint is already filtered. - The search box uses
limitUrlUpdates(nuqs' built-in debounce) rather than a
hand-rolled timer. clearOnDefaulteverywhere, so a default view has a clean URL.- Table state stays shallow (default): TanStack Query refetches; we don't
round-trip the RSC on every keystroke. Detail sheets (?company=,?deal=)
are shallow too.
Server rendering
Each list page prefetches through getServerTrpc() + getServerQueryClient()
and wraps the client panel in <HydrationBoundary state={dehydrate(queryClient)}>
— the exact mvp pattern, minus the org slug.
Company detail page
The page that has to be good, because it's where reps live:
- Header — logo (
iconUrl, falling back to initials), name, domain link,
industry badge, owner avatar, and an enrichment state chip that shows a
spinner while the agent works. - Left column — properties card (inline-editable: name, domain, website,
phone, address, owner), primary contact card, socials row. - Right column — the timeline: composer at the top (Note · Call · Email ·
Meeting · Task), then Upcoming/Overdue pinned, then history grouped by day.
Filter tabs via nuqs (?tab=all|notes|upcoming|done). - Below — Contacts table and Deals table, both
SimpleTable(not the fullDataTable; these are short embedded lists).
Per design.md: shared components only, no bespoke radii/spacing/colours, no
rounded corners. brandColor is used as a small accent swatch, never as a
surface — brand colours would fight the design tokens.
7. Phases
Each phase ends green on bun run check-types and bun run lint.
Phase 0 — Plumbing (no product surface)
- Add the dependencies in §2; wire
apps/api/src/trpc/(module, context,AuthMiddleware, error handler, logging middleware) from the mvp reference. nestjs-trpc generatescript +exports["./app-router"]; add the watch task
todev.apps/app/app/api/[...path]/route.tsproxy;lib/trpc/{server.ts,client.tsx,query-client.ts}.NuqsAdapter+TRPCReactProviderin the root layout.- Port the data table into
packages/ui. - Housekeeping: resolve
docs/review.md, trim the org sections fromdocs/api.md, deletemake-session.tmp.ts. - Verify: dashboard renders
users.methrough tRPC, server-prefetched.
Phase 1 — Schema and seed
- The models in §3, one migration,
Userback-relations. - Extend
packages/db/prisma/seed.ts: ~15 companies (real domains so enrichment
has something to chew on), ~40 contacts, ~25 deals spread across stages, ~150
activities including overdue and upcoming tasks. - Verify:
bun run db:migrate && bun run db:seed, inspect in Studio.
Phase 2 — Companies
companiesrouter + service; list page with DataTable — columns: logo+name,
domain, industry, owner, contacts, open deals, last activity; facets owner /
industry / enrichment status; search on name+domain.- Company detail page per §6, minus the timeline (Phase 5 stub).
- Create sheet: name + domain + owner, nothing else. The agent fills the rest.
- Verify: filter + sort + page, copy the URL into a new tab, same view.
Phase 3 — Contacts
contactsrouter; list DataTable (name, title, email, company, owner, last
activity); detail page; company link; "make primary contact" action.- Replace the existing
contacts/page.tsxplaceholder.
Phase 4 — Deals
dealsrouter; list DataTable (name, company, stage badge, amount, owner,
expected close, last activity); tab = open/closed/all, facets owner + stage.- Inline stage change from the row (dropdown) writing
STAGE_CHANGE. - Deal detail: stage stepper, amount/currency/close date, associated contacts,
timeline slot. - Verify: stage change appears on the deal and the company timeline.
Phase 5 — Activity, tasks and dashboard
- Timeline component + composer;
activitiesrouter; complete/snooze on tasks. - Dashboard: pipeline value by stage, deals closing this month, my overdue
tasks, recent activity feed. - Verify: a note on a deal shows on the company timeline; an overdue task
shows on the dashboard.
Phase 6 — The agent
ContextDevClient,EnrichmentService, queue, field mapping, status polling.- Auto-enrich on company create; manual re-enrich; contact-email → company.
- Research brief action writing an
ENRICHMENTactivity. - Verify: create a company with domain
stripe.com→ logo, description,
industry, socials and address populate within ~20s without a reload.
Phase 7 — Migration and polish
- HubSpot CSV import for companies/contacts/deals, dedupe on
domain/email,utility/prefetchwarming before the retrieve pass, dry-run report first. - Deal board view (
?view=board, stage columns, drag to change stage). cmdkquick switcher over companies/contacts/deals.
8. What this plan deliberately leaves out
Email sync, calendar sync, sequences/cadences, quotes, custom properties,
multiple pipelines, reporting builder, and permissions/roles. Every one of them
is a HubSpot feature we're choosing not to rebuild. If the sales team asks for
one, it's a new plan, not a phase.
Plan Dynamic Fields Build (`docs/plan/dynamic-fields-build.md`)
Plan — Dynamic fields (build)
Build instructions for the agent implementing dynamic fields on companies,
contacts and deals.
dynamic-fields.md is the design record — it owns the
schema and the reasoning behind every decision. This document is the build
order: what to type, where, and in what sequence. Where they disagree, the
design doc wins on why and this one wins on how.
Two constraints govern everything below.
- The Paper design is fixed. Match it exactly. Do not improve it, round it,
or reword it. - shadcn components only. No hand-rolled buttons, inputs, switches, chips or
menus. If a component does not exist, add it topackages/uiwith the shadcn
CLI or add a variant there — never at the call site.
0. Read before you type, and say what you read
| Before touching | Read |
|---|---|
| Anything | AGENTS.md, design.md, dynamic-fields.md |
apps/api |
api.md, .agents/skills/nestjs-trpc/SKILL.md |
packages/db |
.agents/skills/prisma-database-setup/SKILL.md |
apps/app |
apps/app/AGENTS.md → then the relevant guide in apps/app/node_modules/next/dist/docs/01-app/ |
| URL state | .agents/skills/nuqs/SKILL.md |
| Any UI | .agents/skills/shadcn/SKILL.md and rules/styling.md, rules/composition.md, rules/forms.md |
apps/agent |
.agents/skills/eve/SKILL.md → then apps/agent/node_modules/eve/docs/README.md |
| A telemetry event | telemetry.md |
1. The design
Paper file CRM, page crm - lewis:https://app.paper.design/file/01KZ72S23CM00WXN6S1MP68M03/5-1
| Artboard | What it specifies |
|---|---|
/companies?record=company:abcd |
Cog in the DETAILS header; custom fields inline; a pending agent suggestion |
/contacts?record=contact:abcd |
Cog placement only |
/deals?record=deal:abcd |
Cog placement only |
…&manageFields=company |
The fields sheet — list state |
…&manageFields=company (first run) |
Empty state |
…&manageFields=company&manageField=new |
Create a field |
…&manageFields=company&manageField=runs_on |
Edit a field, with coverage |
…&manageFields=company&manageField=runs_on (archive) |
Archive confirmation |
Take values from the file, not from screenshots. get_jsx,get_computed_styles, get_node_info. A screenshot will not tell you whether a
gap is 8 or 10.
Every user-visible string is in §7. They are final.
2. Schema
Copy the Prisma models from dynamic-fields.md §3
verbatim — FieldEntity, FieldType, FieldDefinition, FieldOption,FieldValue. That section also explains why values are typed columns rather than
JSON, why one value table carries three nullable FKs, and why key is immutable.
Do not redesign it; if you think it is wrong, say so before writing the
migration.
On top of that:
- Migration name:
dynamic_fields. Run throughbun run db:migrateinpackages/db— neverdb:pushfor a schema that ships. - Add the back-relations on
Company,ContactandDeal
(fieldValues FieldValue[]). agentFilled Boolean @default(true)— the default is the feature. Seedynamic-fields.md§5.- No
organizationId. Single tenant.
3. `@crm/db/fields` — the shared core
Everything else in this plan is a caller of this module. It is what makes the
feature API-driven and agent-driven without two implementations drifting apart.
Create packages/db/src/fields.ts and add "./fields": "./src/fields.ts" to theexports map in packages/db/package.json — the same shape as@crm/db/currency and @crm/db/workspace, which are the precedents.
It owns:
FIELD_TYPESand the type→storage-column map (TEXT→text,NUMBER→number,DATE→date,CHECKBOX→bool,SELECT→optionId,USER→userId, …).fieldKeyFromLabel(label)— slug, collision-checked by the caller against@@unique([entity, key]).valueSchemaFor(definition)— returns the Zod schema for one field's value.readValues(entity, recordId)/writeValues(tx, entity, recordId, values)—
the only two functions that touchFieldValue.serializeField(definition)— the wire shape used by tRPC and by agent
tools, so both see identical field metadata.
Rule: no field logic anywhere else. Not in a router, not in a service, not in
a component, not in an eve tool. A validator written twice is a bug with a
delay on it.
4. API — `apps/api/src/fields/`
Four files, mirroring apps/api/src/companies/:fields.contracts.ts, fields.router.ts, fields.service.ts, fields.module.ts.
Register FieldsModule in app.module.ts imports; FieldsRouter must be in
the module's providers or it silently does not exist at runtime.
@Router({ alias: "fields" }) + @UseMiddlewares(AuthMiddleware) on the class.
No middleware means public — there is no other guard.
| Procedure | Input | Notes |
|---|---|---|
fields.list |
{ entity, includeArchived? } |
Definitions with options, position order |
fields.byKey |
{ entity, key } |
For the editor screen |
fields.create |
label, type, options, agentFilled, agentBrief, placement |
Key derived from label |
fields.update |
id + same, minus key | Reject a key change with BAD_REQUEST |
fields.reorder |
{ entity, ids } |
One transaction, positions rewritten from the array |
fields.archive / fields.restore |
{ id } |
Archive is the normal path |
fields.delete |
{ id } |
Loud, and separate from archive |
fields.backfill |
{ id } |
Writes one AgentTask, kind: "field-backfill" — never one per record |
Rules that are not optional:
- Routers are thin. Zod in, service call out. Prisma lives in the service.
- Values do not get their own router. They ride the record procedures:
companies.byIdgainsfields,companies.updateacceptsfields: Record<key, value>. Same for contacts and deals. One write, one
invalidation, and the existing inline editors keep their mutation. - Services throw Nest's
HttpExceptionfamily;DomainErrorMiddlewaremaps them. - Regenerate and commit
src/generated/server.ts(bun run trpc:generate).
If the app cannot seetrpc.fields.*, this is why. - Tests:
apps/api/test/fields.spec.ts,bun:test, modelled onapps/api/test/bulk.spec.ts(real database,TEST_RUN_ID-suffixed data,
cleaned up inafterAll). Cover: key collision, key immutability, value
coercion per type, archive keeps values, delete cascades, reorder is atomic.
5. Agents — `apps/agent`
The API is one caller of @crm/db/fields; the agent is the other. Addapps/agent/agent/lib/fields.ts as a thin wrapper, in the shape oflib/facts.ts, then these tools in apps/agent/agent/tools/:
| Tool | Does |
|---|---|
list_fields |
Every field for an entity with its key, type, options and brief — so the agent can discover the schema instead of being told it |
set_field_value |
Write a value the agent is certain of |
propose_field_value |
Offer a value with evidence — routes through the fact/proposal path, not a direct write |
create_field / update_field |
Manage definitions, so "add a field for X" works in chat |
archive_field |
Behind lib/approval.ts. Destructive schema changes get a human |
Constraints:
- The
agentBriefis the instruction the agent works from.list_fieldsmust
return it. - Tool descriptions carry the same voice as the existing tools — read
record_fact.tsandsearch_crm.tsfirst. - Nest still writes rows, not intelligence.
fields.backfillwrites anAgentTask; the agent decides fan-out, batching and priority. - Missing capability ⇒ missing capability, never a throw.
MCP later, for free. Every capability is a tRPC procedure over@crm/db/fields, and every tool is a thin call to the same module. An MCP server
is then a mechanical wrapper over fields.* with no new logic. That only stays
true if you keep logic out of the tools. Do not build MCP now.
6. App — `apps/app`
URL state (nuqs)
Two params, added to the existing shared parser object inapps/app/components/crm/record-sheet/record-stack.ts — do not start a second
parser file:
fields: parseAsStringLiteral(RECORD_KINDS),
field: parseAsString,fieldsis the entity whose fields are open;fieldis either the create
sentinelnewor a field key. Add nothing else to the pasted lines — AGENTS.md
forbids code comments, and these go straight intorecord-stack.ts.newis a reserved key, held back byRESERVED_KEYSinpackages/db/src/fields-shape.ts, so a field labelled New slugs tonew_fieldand its editor can still be opened. Never let the sentinel and a
derivable key collide.- Both cleared by the existing
write()alongsidetab,add,thread. useQueryStatesfor the pair;nullto clear;history: "replace"for sheet
state, matching whatrecord-stack.tsalready does.- The cog sets
fieldsto the record's own kind, so the sheet opens on the
entity you came from.
Components — use these, build nothing
| Element in Paper | Use |
|---|---|
| Fields sheet shell | DetailSheet (components/detail-sheet.tsx) at a new md size |
| Sheet header, back + title + close | DetailSheetHeader — it already does back-at-left, close-at-right |
| Entity switcher | Tabs / TabsList / TabsTrigger, default variant |
| Standard / Archived disclosure rows | Collapsible — add it: bunx --bun shadcn@latest add collapsible in packages/ui |
Type chip (Select, Date, Number, Checkbox) |
Badge — add it, then add a mono variant in packages/ui |
| Row overflow menu | Button variant="ghost" size="icon-xs" + DropdownMenu |
| Drag handle | Button variant="ghost" size="icon-xs" carrying the dnd listeners, Carbon Draggable icon |
| Form labels and helper text | Field, FieldLabel, FieldDescription, FieldGroup (packages/ui/src/components/field.tsx) |
| Label / brief inputs | Input, Textarea |
| Type picker | Select |
| Option rows | InputGroup with a leading handle addon and a trailing remove Button |
| "Add option" | Button variant="ghost" size="sm" |
| Agent toggle | Switch |
| Placement toggles | Checkbox + FieldLabel |
| Footer actions | Button (primary) and Button variant="outline" |
| Coverage block | Mirror DetailSheetPending's muted panel; dot is StatusIndicator |
| Archive confirm | AlertDialog, action button destructive |
| Empty state | Empty / EmptyHeader / EmptyMedia / EmptyTitle / EmptyDescription / EmptyContent |
| The cog itself | Button variant="ghost" size="icon-sm", Carbon Settings, in DetailSheetSection's existing action slot |
Three additions to packages/ui, because that is where they belong:
mdsheet size.sheetContentVariantshassm | lg | xl | 2xland
nothing near 460px. Addmd: "data-[side=left]:sm:max-w-[460px] data-[side=right]:sm:max-w-[460px]".
The width comes from the artboards, and 1:1 outranks landing on a Tailwind step.Badge+ amonovariant for the type chip.SortableListover@dnd-kit/core+@dnd-kit/sortable. The repo has no
drag-and-drop library today. The artboards show drag handles on both field
rows and option rows, so dragging is in scope and a Move up / Move down menu
is not an acceptable substitute here.
EmptyDescription also has no wrap class — add text-balance there so the empty
state does not orphan its last word. Fix it in the component, not at the call
site.
Rendering fields on a record
In company-sheet.tsx (then contacts, then deals), custom fields render inline
inside the existing Details section, after the standard properties, inposition order. No "Custom fields" heading, no separate section.
One map from FieldType to the editor that already exists:
| Type | Component |
|---|---|
TEXT, URL, EMAIL, PHONE |
InlineField (with type) |
LONG_TEXT |
InlineTextArea |
NUMBER |
InlineField |
DATE |
InlineDateField |
SELECT, USER |
InlineSelectField |
CHECKBOX |
Checkbox in a DetailSheetProperty |
Agent-sourced values get SOURCED_VALUE (dotted underline) with SourcedValue
for the hover source. Pending proposals render with the existing Suggestion
component. Both are built — use them, do not restyle them.
Table columns
showOnTable is opt-in per field. A workspace with thirty fields must not get a
thirty-column table. Wire it into the existing column definitions incompanies-table.tsx and friends.
Cache
Add fields(entity?) to CrmCache in apps/app/lib/trpc/cache.ts. A definition
change invalidates fields.list plus every list and record query for that
entity. Value edits keep { settle: "record" } so the inline spinner clears
without waiting for the table. A new mutation adds a call there, not a new list
of keys at the call site.
7. Copy — verbatim
Fields sheet
- Title
Fields - Subtitle
This shapes every company in your CRM.(company / contact / deal) - Tabs
CompaniesContactsDeals Standard fields·8 · reorder and hide onlyCustom fields·Drag to orderArchived·2 · values kept, hidden everywhere- Footer
New field·Order here is the order on the sheet
Empty state
No custom fields yetCreate dynamic fields that your agents can research and pre-fill.New field
Editor
LabelKey·What the API and your agents call it. Set from the label, fixed once saved — renaming the label later never breaks a caller.Let your agents fill this·They propose a value with a source, and never overwrite yours.What counts as an answer·Leave it empty and your agents work from the label and type alone.Type·Select — one of a fixed listOptions·Add optionShow on the company sheet·Offer as a column on the Companies tableCreate field·Cancel·Save changes·Archive
Coverage
Filled on 41 of 64 companiesLast looked 2 days ago · you accepted 38 of 41Fill the rest
Archive dialog
Archive Runs on?Hidden everywhere. Its 41 values are kept.Cancel·Archive field
Never "eve" in the product. It is your agents.
8. Order of work
Each phase ends green — typecheck, lint, tests — before the next begins.
- Schema. Models, migration, back-relations.
bun run db:migrate. @crm/db/fields. Types, key derivation, value schemas, read/write. Unit
tests here; it is pure and cheap to test.- API. Contracts, service, router, module, registration. Regenerate and
commitserver.ts.fields.spec.tsgreen. - Values on records.
byIdreturnsfields,updateaccepts them, all
three entities. packages/uiadditions. Sheetmd,Badge+ mono,Collapsible,SortableList,EmptyDescriptionbalance.- The sheet. List → empty → create → edit → archive, in that order,
checking each against its artboard before moving on. - The cog and the record rendering. Companies first, then contacts and
deals. - Table columns.
- Agent lib and tools.
9. Do not
- Do not deviate from the artboards, including the copy.
- Do not write a custom button, input, chip, switch or menu.
- Do not override component colours or typography with
className. Layout only. - Do not put field logic in a router, a component or a tool — it goes in
@crm/db/fields. - Do not put a vendor client, scoring or enrichment in
apps/api. - Do not add code comments.
- Do not add a
Co-Authored-Bytrailer. - Do not add a per-package
.env. - Do not hand-edit
apps/api/src/generated/server.ts— regenerate it. - Do not build the MCP server. Keep the seam clean so it stays a wrapper.
- Do not make the agent toggle default off.
10. Decisions already made — do not relitigate
- Agent-first:
agentFilleddefaults true, the brief sits above the type in the
editor, and only manual fields are badged.dynamic-fields.md§5. - Archive over delete for definitions, with delete kept as a separate loud action.
- Standard fields appear in the list, reorderable and hideable, never deletable.
- Custom fields render inline in DETAILS with no grouping of their own.
11. Open — decide with Lewis, do not guess
Generalising ContactFact to RecordFact (three nullable FKs plus an
optional fieldId) so agent proposals work for custom fields on all three
entities, instead of a parallel proposal table. ContactFact is contact-only and
keys on field String. Agent-first makes this load-bearing rather than optional,
and it is the one decision that gets more expensive after launch. Raise it before
phase 9 — earlier if phase 1 would touch it.
Plan Dynamic Fields (`docs/plan/dynamic-fields.md`)
Plan — Dynamic fields
Fields a workspace defines for itself, on companies, contacts and deals, edited
from one sheet that opens from any record.
The visual design is in Paper, file CRM, page crm - lewis, artboardsDynamic fields — cog placement, /companies?record=company:abcd&manageFields=company,Dynamic fields — new field & empty and Dynamic fields — on the record. This
document is the half Paper cannot hold: the model, the API and the agent.
This document is the design record — what we decided and why. The build order —
files, procedures, component mapping, phases — isdynamic-fields-build.md, which points back here
for the schema in §3 rather than copying it.
Read it with AGENTS.md, api.md anddesign.md.
1. One sheet, one cog
The cog goes in the DETAILS section header, on all three sheets. That is theaction slot DetailSheetSection already has, so no new primitive and no
per-sheet variation — the same element in the same place whether DETAILS is the
full width of the contact sheet or the 320px rail of the company sheet.
Not the header bar beside Re-enrich and Close: that bar is about this record,
and the cog is not. It changes every company at once.
Which is the one thing the sheet has to say out loud. It opens with "This
shapes every company in your CRM", because a control reached from Northwind
Labs' sheet will otherwise be read as belonging to Northwind Labs.
The sheet stacks over the record sheet at 460px, dimming it, and carries an
entity switcher — Companies / Contacts / Deals — so the same sheet reached from a
deal can still fix a contact field without closing anything.
State lives in the URL beside record, in record-stack.ts:
/companies?record=company:abcd&manageFields=companyso it is shareable, Escape and Back close it in the right order, and the record
underneath survives a refresh.
2. Standard fields are in the list
The sheet shows two groups: Standard (Name, Domain, Website, Phone, Email,
City, Country, Owner) and Custom fields. Standard fields are real rows — they
can be reordered and hidden, never deleted or retyped. A rep who opens the sheet
looking for "Domain" finds it and learns the rule in the same second, which is
cheaper than any explanation of why it is missing.
Custom fields then render inline in DETAILS, in the order set here. No
"Custom fields" box. A field is a field; the grouping was an implementation
detail and does not belong on the record.
3. The model
/* Detailed source-code truncated for AI context efficiency. */User carries the other half of that relation:
fieldValues FieldValue[] @relation("FieldValueUser")Six decisions worth defending:
- Typed columns, not a
Jsonvalue.api.mdsays filter, sort and paginate
in Prisma. A JSON blob cannot be indexed or ordered honestly, and the moment
someone wants the pipeline sorted by Renewal date the answer would be to pull
the table into the browser. One column per storage class keeps that promise;
the type on the definition says which one is live. - One value table with three nullable FKs, not three tables. Every FK
cascades, so deleting a company takes its values with it.AgentTaskcarriescontactId/companyIdwith no FK andapi.mddocuments the chore that
creates — "clearAgentTaskandAgentEventyourself". Not repeating it. - Options are rows. Renaming "Google Cloud" must not orphan every record
pointing at it, and a JSON array of strings makes rename indistinguishable from
delete-plus-create. keyis immutable,labelis not. The key is what the API and the agents
say; the label is what the rep reads. Renaming the label of a field an agent
has filled for a month cannot break the brief that taught it.- The record columns are indexed on their own, not only inside the uniques.
A record read iswhere: { companyId }with nofieldId—FieldsService.valuesForopens every sheet that way — and a unique startingfieldIdcannot serve it. The composite uniques stay for the one-value-per-
field-per-record rule; the standalone indexes are what the reads use. userIdis a foreign key, like every other id on the row. AUSERfield
holds a workspace user, so a deleted rep must not leave an id on the record
that resolves to nobody.SetNullmatchesCompany.ownerandContact.owner: the value empties, and it never blocks the delete.
Archive, not delete. A record delete is hard in this CRM by design, but a
definition delete destroys a column of typed data across every record at once.
Archiving keeps the values and hides the field everywhere; a true delete stays
available behind a separate destructive confirm that names the count.
No organizationId, on any of the three. Single tenant.
4. The API
A new module, apps/api/src/fields/, one router as the codegen glob expects:
| Procedure | Notes |
|---|---|
fields.list({ entity, includeArchived }) |
Definitions with options, in position order. |
fields.create / fields.update |
Key derived from label on create, rejected on update. |
fields.reorder({ entity, ids }) |
One transaction, positions rewritten from the array. |
fields.archive / fields.restore / fields.delete |
Delete is the loud one. |
Values do not get their own router. They ride the record procedures that
already exist: companies.byId returns fields, companies.update acceptsfields: Record<key, value>. One write per record, one invalidation, and the
inline editors keep using the mutation they already use.
The value validator is derived from the definition at runtime — a sharedcrm/fields.ts that both the router's zod schema and the service use, so aNUMBER field cannot be written a string by either path.
useCrmCache() gains cache.fields(entity). A definition change is a schema
change: it fans out to every list and record query for that entity. Rare enough
that the width does not matter, and { settle: "record" } still covers the
inline value edits.
src/generated/server.ts is regenerated by dev/check-types and committed, or
the app cannot see any of this.
5. Agent-first, and what that costs
This was the open question, and the answer is agent-first: agentFilled
defaults to true. A new field is something your agents keep up to date unless
you say otherwise, and "Manual only" is the exception a rep opts into.
That is a real difference, not a default flipped for effect. It changes three
things:
- Order in the editor. The brief — What counts as an answer — sits directly
under the label, above the type. The second thing you write is the
instruction, not the shape. - What the list shows. Each row's subtitle is its brief, truncated. Agent
upkeep is the unmarked normal case; only the exceptions read "Manual only".
Nothing is badged for doing the expected thing. - What an empty brief means. Not "never touched" — that was the old,
opt-in reading. Agents work from the label and the type alone; the brief
sharpens what would count. Opting out isagentFilled: false, one switch.
Never "eve" in the product. UI copy says your agents. eve is the runtime
in apps/agent, an implementation detail a rep should never have to learn, and
a self-hoster may well point this at their own. The word appears in this
document and in the code; it does not appear on screen.
Nest still writes rows and fills nothing. Creating or editing a definition withagentFilled writes one AgentTask (kind: "field-backfill", reason =
the key) — one row per field, not one per record. The agent leases it and decides
the fan-out, batching and priority itself, which is the point of the rule inapi.md and the reason a workspace with 5,000 companies does not get 5,000 rows
from one click.
agentBrief is the whole instruction, in the rep's words:
Which cloud they run production on. Their docs, status page or engineering job
ads say it outright — infer nothing from a logo on a customer wall.
The self-hoster's answer is unchanged and still needs no special case: with no
agent running, nothing leases the task, nothing proposes, and every field is one
you type into. A missing capability removes a capability. It does not throw.
Proposed, not written — and this is what makes agent-first safe to default
on. A value an agent finds arrives the way a ContactFact arrives: PROPOSED,
with a score, a band and evidence, rendered by the Suggestion component that
already exists, accepted or dismissed by a human. A dotted underline
(SOURCED_VALUE) then marks what an agent put there, and hover gives the source.
Nothing a rep typed is ever overwritten.
Turning this on by default would be reckless if agents wrote straight into the
field. They do not, so the cost of a wrong default is a suggestion someone
dismisses — against a rep never discovering the feature at all, which is the cost
of the opt-in version.
That behaviour is already built; the fork is that ContactFact is contact-only
and keys on field String.
Recommendation: generalise ContactFact to RecordFact with the same three
nullable FKs and an optional fieldId, rather than growing a parallel proposal
table for custom fields. It is the larger migration and the smaller system. This
is the one decision in this plan that should be made before any of it is built,
because it is the only one that gets more expensive later — and agent-first makes
it load-bearing rather than optional.
6. What the UI needs that does not exist
Everything in the design is already in packages/ui — Sheet, Input, Select,
Switch, Checkbox, Button, Suggestion, the Inline* editors — with one exception.
There is no drag-and-drop library in the repo. The design shows a drag handle
on every row. Either:
- add
@dnd-kit/core+@dnd-kit/sortabletopackages/uiand build aSortableListthere, which is where a shared interaction belongs; or - ship reordering as Move up / Move down in the row's overflow menu, and add
dragging later.
Option 2 is a smaller first cut and the ordering it produces is identical. Option
1 is what the artboards show. Worth deciding explicitly rather than by accident.
7. Deliberately out of scope
- Filtering and sorting the tables by a custom field. The columns exist
(showOnTable), the indexes support it,resolveOrderBywould need to learn
about field keys. Phase two. - Quick-add. New fields do not appear in quick-add unless
required. - Per-record fields. A field belongs to an entity, never to one company.
- Formula, rollup and relation types. Not in the first cut.
Plan Gmail Calendar Plan (`docs/plan/gmail-calendar-plan.md`)
Plan — Gmail & Calendar
Put the conversation back in the CRM. Three features, in the order they should
be built:
- Meetings on the record. A calendar event with someone from Acme shows on
Acme's timeline — before it happens, and after. - Email threads in-line. A thread with someone from Acme shows on Acme's
timeline, collapsed to a subject and a snippet, expandable to the messages. - Auto-created companies and contacts. Booking a meeting or holding a
two-way thread with a new domain creates the company and the contact, so
nobody types what Google already knows.
Read it with AGENTS.md, api.md,design.md and crm-plan.md. The conventions
there are assumed, not repeated: single tenant, tRPC as the data surface,
services throw HttpException, freshness via useCrmCache(), all UI from/packages/ui.
1. The decisions that carry the design
Six choices. Everything else follows.
- Gmail and Calendar are granted at sign-in, and using the CRM is
conditional on them. This is an internal, single-tenant tool where seeing
the conversation on a record is the product, so a rep with an account and no
mailbox is a half-configured account nobody notices is broken. The scopes go
on the provider config; because Google's granular consent lets someone untick
one and still finish signing in, the app also gates on what was actually
granted. §3. - Google's per-mailbox ids are not identities. Gmail
threadIdis scoped
to one mailbox, so two reps on one thread would create two CRM threads.
Identity is the RFC 822Message-IDfor email andiCalUIDfor calendar,
both of which are global. §4.2. - Email and calendar get real tables; the timeline gets a projection. One
Activityrow per thread and per event, linked by FK. The timeline query,
its indexes, its filters and its cursor paging are untouched. §4. - Nothing is stored unless it is relevant. A message is persisted only if a
participant resolves to a company we track, or qualifies to create one.
A rep's inbox contains their doctor. §5. - Auto-creation needs two-way engagement. A thread the rep replied to, or a
meeting on the calendar. Not a received newsletter. §6. - Sync is cron-driven and its cursor lives in Postgres. The API is a
serverless function; there is no worker to hold state, and the existing
in-processEnrichmentQueuedeliberately drops work on shutdown. §7.
Choice table
| Decision | Choice | Why |
|---|---|---|
| Connect flow | Scopes on the provider config, requested at sign-in | §3.1. Internal tool — mailbox access is a condition of having an account. |
| Enforcement | requireGoogleAccess() gate in the app shell |
§3.1. Granular consent means "signed in" never implies "granted". |
| Repair path | authClient.linkSocial({ provider: "google", scopes }) |
§3.1. Re-consent for a partial grant or a pre-existing account. |
| Connected? | Account.scope contains both scopes |
§3.1. The grant itself is the state — nothing to keep in sync. |
| Token refresh | auth.api.getAccessToken({ providerId: "google", userId }) |
Better Auth refreshes on demand. No hand-rolled refresh. |
| Disconnect | There isn't one — only purge-and-re-import, or revoke | §3.4. Syncing is mandatory, and a local disconnect would be undone by the next reconcile anyway. |
| Scopes | gmail.readonly, calendar.readonly, read-only |
Nothing writes to Google in v1. Sending mail is a separate decision. |
| Google app type | Internal (Workspace-only) | Skips OAuth verification entirely. §3.3. |
| Email identity | Message-ID header, unique |
Gmail threadId is per-mailbox. §4.2. |
| Thread identity | Root of References / In-Reply-To, unique |
Same conversation, one CRM thread, whoever synced it. |
| Event identity | (iCalUID, originalStartTime), unique |
iCalUID is shared across attendees and recurrence instances. §4.2. |
| Timeline | One projected Activity per thread/event, FK-linked |
Keeps the one-index-scan timeline in activities.service.ts. |
| Relevance | Participant domain must resolve to a Company, or qualify to create one |
§5. Privacy and junk prevention are the same rule. |
| Bodies | Stored, plain text, quoted history stripped | "In-line" means readable. Never logged, never in list payloads. |
| Sync trigger | Vercel Cron → authenticated internal route | §7. Pub/Sub push is a later upgrade behind the same handler. |
| Sync state | MailboxSync row per account per source |
Serverless has no memory between invocations. |
| Backfill | None. Forward-only from first sight of a mailbox | §7.3. Nobody asked for three months of old mail in their CRM, and it deletes a whole class of machinery. |
| Feature flag | None. Sync is simply on | §13. It gates nothing that can genuinely be absent, and defaults to off — so it is only ever wrong. |
| Freshness | Poll while syncing, mirroring enrichment-status |
A background write the browser did not cause. api.md. |
| New UI | Added to /packages/ui |
design.md — thread disclosure and attendee list are new primitives. |
Judgment calls worth your veto
- Calendar ships before Gmail. Smaller API, cleaner dedupe key, no body
parsing, no privacy surface worth arguing about, and it proves the whole
connect → cron → project → render pipeline on the easy case. Gmail then adds
only Gmail's problems. If you want threads first, say so — but Phase 2 is
about two weeks cheaper than Phase 3 for most of the perceived value. - Read-only, both APIs. No sending, no event creation, no "log this email"
button writing back. It halves the scope review and removes every way this
feature can damage a customer relationship. Writing is a v2 conversation. - Auto-create fires without an approval queue. The guardrails in §6 are
tight enough that a review queue would mostly be a list of correct rows
waiting for someone to click Accept — and approval queues rot. Instead every
auto-created record is tagged with its provenance and is filterable, so a bad
rule is visible and reversible in bulk. Say the word if you want the queue. - We store message bodies. The alternative — metadata plus a deep link to
Gmail — is more private and much less useful, because the point is reading the
thread without leaving the record. §10 covers what that obliges us to do. - Personal mail is dropped at ingest, not stored-then-hidden. Slightly more
work at sync time, and the only version of this that survives someone reading
the database.
2. What exists that we build on
| Thing | Where | Use |
|---|---|---|
| Google OAuth, Better Auth | packages/auth/src/auth.ts |
Add scope and accessType: "offline" here. Scope strings live in packages/auth/src/scopes.ts, imported by the API and the app. |
Account.accessToken / refreshToken / scope |
schema.prisma |
Better Auth already persists per-provider tokens. |
domainFromEmail() + free-domain blocklist |
apps/api/src/companies/domain.ts |
The matching backbone. Already rejects gmail.com et al. |
EnrichmentService.companyForEmail() |
apps/api/src/enrichment/enrichment.service.ts:197 |
Find-or-create company by domain, then enqueue enrichment. Auto-creation reuses this verbatim. |
ActivityType.EMAIL / MEETING |
schema.prisma |
The enum values already exist and are already in NOTE_TYPES. |
| Timeline, cursor-paged, filter tabs | apps/api/src/activities/activities.service.ts |
Extended, not replaced. |
| Enrichment polling pattern | apps/app/components/crm/enrichment-status.tsx |
Copy the shape for sync status. |
| Cache fan-out | apps/app/lib/trpc/cache.ts |
Add cache.google(); do not list keys at call sites. |
3. Connecting an account
3.1 Scopes at sign-in, and the gate behind them
The scopes go on the provider config in packages/auth/src/auth.ts, so every
sign-in asks for them:
socialProviders.google = {
...env.google,
scope: [...SYNC_SCOPES], // gmail.readonly, calendar.readonly
accessType: "offline", // so Google issues a refresh token
};Four behaviours make this work, all read out of the installed[email protected] rather than assumed:
- Scopes merge, they don't replace. The Google provider builds
["email", "profile", "openid"], then appendsoptions.scope, then appends
any per-callscopes. - Incremental authorisation is already on. The provider hardcodes
additionalParams: { include_granted_scopes: "true" }, so a token issued for
a widened grant covers the union rather than only the new scopes. - Re-consenting updates the account in place. The OAuth callback finds the
existing account by(providerId, accountId)and updates the tokens, both
expiries andscope. That makes the repair path idempotent. Account.scopeis the connection state. The granted scopes are persisted
as a string, so "did they actually grant it?" is a column read.
Requesting is not the same as granting. Google's granular consent lets
someone untick a scope and finish signing in anyway, and any account created
before these scopes were required still carries the old grant. Either way the
person lands inside a CRM that looks signed-in and silently syncs nothing — so
asking at sign-in is necessary but not sufficient, and the app gates on the
result:
requireGoogleAccess()(apps/app/lib/session.ts) readsAccount.scope,cache()d so it costs one query per request, and redirects to/grant-accesswhen either scope is missing.- The
(app)layout calls it instead ofrequireSession(), so the gate covers
every page in the shell at once. /grant-accesslives outside the(app)group deliberately — a page inside
it would be redirected by the very layout doing the gating. It explains what
is read and why, and offerslinkSocialto re-consent, plus a sign-out for
anyone who would rather not.
prompt: "consent" is deliberately not set: it is a provider-level option,
so it would show the consent screen on every sign-in forever. It is not needed,
because a first sign-in — and any sign-in asking for a scope not yet granted —
prompts anyway, and it is that prompt which mints the refresh token. The
missing-refresh-token case is detected (hasRefreshToken) and repaired rather
than pre-empted.
3.2 Reading and refreshing tokens
Never touch Account.accessToken directly. A GoogleTokenService wraps:
const { accessToken } = await auth.api.getAccessToken({
body: { providerId: "google", userId },
});which refreshes if expired (within five seconds of expiry, per the
implementation) and persists the new token. Confirmed present in 1.6.25 as/get-access-token. If it throws, the connection is dead — mark theMailboxSync row NEEDS_RECONNECT and surface a reconnect prompt; do not retry
in a loop.
3.3 Google Cloud setup (Phase 0, and it gates everything)
One OAuth client, existing GCP project, add the two scopes.
Set the consent screen User type: Internal. This is the whole ball game.
gmail.readonlyis a restricted scope (Google's own classification, not a
judgement call), and an External app using it needs OAuth verification plus
a CASA security assessment, renewed annually — months of work. For an Internal
app Google's documentation is explicit: "scopes aren't listed on the consent
screen and use of restricted or sensitive scopes doesn't require further
review by Google." This CRM is already internal-only behind Google sign-in,
so the exemption costs us nothing.Two caveats worth writing down now, because both are expensive later: the
exemption is tied to the Internal user type specifically — an External app
that merely happens to target Workspace users does not get it — and going
External at any point in the future means the full restricted-scope review.
If this CRM will ever be sold or opened to another domain, that decision
belongs here, not in a year.Enable the Gmail API and the Google Calendar API on the project.
The callback is the existing
…/api/auth/callback/google. Extending the
grant adds no new redirect URI, which is one fewer thing to get wrong across
preview environments.
3.4 There is no disconnect
Making mailbox access a condition of having an account settles this section:
there is no supported state where a signed-in rep is not syncing, so there is no
disconnect button.
That is not only a policy choice, it is what the mechanics allow. A local
disconnect would delete the MailboxSync rows, and the next reconcile — which
derives those rows from the grant — would rebuild them within five minutes. A
button that appears to work and silently undoes itself is worse than no button.
Better Auth also refuses to unlink the last account
(FAILED_TO_UNLINK_LAST_ACCOUNT), and Google is our only sign-in, so unlinking
was never available either.
What the settings page offers instead:
- Delete and re-import (
google.purgeSyncedData) — deletes every thread and
event this mailbox contributed, cascading to the projected activities, then
resets the cursors so the window re-imports from scratch. This is the answer to
"get my mail out of the CRM", and it should not need a migration. - Revoke Google access — calls Google's revoke endpoint. One OAuth client
means one grant per user, so this ends CRM access itself, not just syncing:
the scopes disappear, the gate catches the next request, and the person lands
on/grant-access. Worded as such and behind a confirm, never as a default.
The only design with independent revocation — mailbox access off, sign-in intact
— is a separate GCP OAuth client for sync. Under a mandatory-sync policy
that state is not wanted, so the second client stays unbuilt. §15.
The missing-refresh-token edge case still applies: if Google decides not to
re-prompt, no refresh token is issued and getAccessToken fails an hour later.hasRefreshToken detects it and the settings page shows a reconnect banner
rather than the sync dying quietly.
4. Data model
4.1 New models
/* Detailed source-code truncated for AI context efficiency. */4.2 Why those keys, specifically
Both were checked against Google's docs rather than assumed, because getting
either wrong produces duplicates that are painful to unpick later.
- Gmail
threadIdis mailbox-scoped. If two of our reps are on one thread,
each mailbox reports a differentthreadIdfor it. Deduping onthreadId
would put the same conversation on Acme's timeline twice. The RFC 822Message-IDheader is globally unique per message, soEmailMessage
deduplicates on that, and the thread is keyed by the root message id — the
first entry ofReferences, elseIn-Reply-To, else the message's ownMessage-ID. iCalUIDis shared across attendees' copies — that is its purpose, per
RFC 5545 and Google's own reference — and it is also shared by every
instance of a recurring series, where only theiddiffers. So the key is(iCalUid, originalStartTime):originalStartTimeidentifies the instance
even if that instance has been moved.
4.3 Changes to existing models
enum RecordSource {
MANUAL
IMPORT
EMAIL
CALENDAR
}
model Company {
// …
source RecordSource @default(MANUAL)
emailThreads EmailThread[]
calendarEvents CalendarEvent[]
}
model Contact {
// …
source RecordSource @default(MANUAL)
emailThreads EmailThread[]
calendarEvents CalendarEvent[]
eventAttendance CalendarAttendee[]
}
model Activity {
// …
/// The projection link. Unique: one timeline entry per thread and per event.
emailThreadId String? @unique
emailThread EmailThread? @relation(fields: [emailThreadId], references: [id], onDelete: Cascade)
calendarEventId String? @unique
calendarEvent CalendarEvent? @relation(fields: [calendarEventId], references: [id], onDelete: Cascade)
}
model User {
// …
mailboxSyncs MailboxSync[]
}Activity.createdById stays required and is set to the user whose mailbox
ingested the item — but the UI renders synced entries as "via Gmail" rather than
as something that person wrote, because they didn't.
Migration: bun run --filter=@crm/db db:migrate, namedadd_google_sync. Nothing backfills; existing rows default to MANUAL.
4.4 Why project into `Activity` rather than union three tables
The timeline is one indexed range scan over activity(companyId, createdAt),
with cursor paging and four filter counts. A union across three tables loses the
index, makes the cursor a compound mess, and rewrites code that works. Instead
sync writes one Activity per thread and per event and keeps it current:
- New message in a known thread → update the projected activity's
occurredAt
and snippet. One row per thread, not per message. - Event moved or cancelled → update the projected activity, or delete it when
the event is cancelled.
The row is a summary. Expanding it fetches the messages or the attendees through
a separate procedure (§8), so the timeline payload does not carry email bodies.
5. Matching: from a Google payload to a CRM record
One resolver, GoogleMatchService, used by both syncs. Given the participant
addresses of a thread or an event:
- Drop our own people. Any address on our own Workspace domain, and any
address belonging to aUser. What is left is the external side. - Drop noise.
domainFromEmail()already returnsnullfor free hosts.
Also dropSuppressedDomainhits and no-reply local parts
(noreply,no-reply,notifications,donotreply,mailer-daemon,bounce,via .*senders). - Contact first, then company. An exact
Contact.emailmatch wins —Contact.emailis already unique. Otherwise resolve the domain againstCompany.domain, which is already unique and normalised. - If nothing matches, hand off to §6. If §6 declines, the item is not
stored at all. - Stamp both ids. Per the existing timeline convention, an activity carries
companyIdeven when it is really about a contact, so the company timeline
stays a single scan.
Multiple external domains on one thread (a customer plus their lawyer) attach to
the domain with the most participants, tie-broken by whichever already exists as
a Company. It is a heuristic; it is also visibly wrong when it is wrong, and
re-attaching is a single field edit.
6. Auto-creating companies and contacts
The rule is two-way engagement, evaluated per source:
| Source | Creates when | Rationale |
|---|---|---|
| Calendar | An event with ≥1 external attendee, status not cancelled, and our user is organiser or has not declined |
Somebody put time in a diary. That is as strong a signal as this CRM gets. |
| Gmail | The connected user has sent ≥1 message in the thread | A reply proves a relationship. Inbound-only is a newsletter, a recruiter, or spam. |
Then, in both cases, all of:
- The external domain is non-free (
domainFromEmail()returns non-null), not
our own, and not inSuppressedDomain. - The sender is not a no-reply address.
- The thread has ≥1 message, or the event ≥1 confirmed external attendee.
Creation itself reuses what exists: EnrichmentService.companyForEmail(email)
already does exactly this job — normalise the domain, find or upsert theCompany, and enqueue enrichment so the logo, industry and description fill in
behind the scenes. Contacts are created alongside, with the display name from
the From header or the attendee's displayName split into first/last, falling
back to the local part.
Everything created this way is stamped source = EMAIL | CALENDAR, which gives
a filter on the companies and contacts tables, a bulk undo, and an honest answer
to "where did all these come from".
A per-user "Auto-create records" toggle, default on for calendar, off for
Gmail on first connect. Meetings are unambiguous; the first week of mailbox
auto-creation is where a bad rule shows up, and it should be a deliberate opt-in
after the rep has seen what the sync matches.
7. Sync mechanics
7.1 Shape
The API is a bundled Vercel function (apps/api/api/index.ts) — no long-lived
process, so no in-memory scheduler. A Vercel Cron hits an internal route
every 5 minutes; the route iterates connected accounts and runs one incremental
pass each.
// apps/api/vercel.ts (vercel.ts is the current recommendation over vercel.json)
crons: [{ path: "/internal/sync/google", schedule: "*/5 * * * *" }],The route sits outside tRPC — it is not a user action — and is guarded by aCRON_SECRET bearer check plus Vercel's cron header, alongside /health as one
of the few non-tRPC surfaces api.md permits. It returns immediately after a
bounded amount of work.
Plan check: minute-level cron needs a Pro plan; Hobby is once a day. Worth
confirming before Phase 2.
7.2 Incremental sync
Both APIs are cursor-based, and both invalidate cursors. Verified behaviour:
- Gmail —
users.history.list(startHistoryId)returns changes since ahistoryId. History records are retained "typically at least one week"; an
out-of-rangestartHistoryIdreturns HTTP 404, and the client must fall
back to a full sync. We do not: forward-only means the cursor is re-pointed at
now and the gap is dropped, because refetching it would be importing old mail. - Calendar —
events.listreturnsnextSyncTokenon the final page. An
expired or ACL-invalidated token returns HTTP 410 Gone, requiring a full
resync. Incremental results always include deleted entries, so cancellations
arrive as events to remove — delete the projected activity when one does.
7.3 No backfill — forward-only
Nothing from before a mailbox was first seen is ever imported.
- Gmail — the first pass calls
users.getProfile, stores the currenthistoryIdas the cursor, and imports nothing. Every later tick ishistory.listfrom that point, so only mail that arrives after connection is
read. When the cursor falls out of Gmail's retention window (a 404, which
takes roughly a week of the sync not running), the gap is not fetched: the
cursor is re-pointed at now and the miss is logged. - Calendar —
timeMinisnow, never earlier. Events that start from now
onwards are imported, including ones booked before sync was switched on,
because a meeting in next Tuesday's diary is the entire point of the feature
and putting it on a timeline is not back-dating anything. Past meetings are
never read.
What this deletes, which is most of the reason to do it: the chunk loop, thebackfilledTo resume column, the BACKFILLING status, GMAIL_BACKFILL_DAYS,
the cursor-invalidation path that re-entered a backfill, and the progress UI
that had to explain all of it. A tick is now one shape — "what changed since the
cursor?" — rather than two.
The cost is honest and small: a company added today shows conversations from
today. The history is in Gmail, where it always was.
7.4 Idempotency and ordering
Every write is an upsert on the natural key from §4.2, so re-running a window is
free. Two reps' syncs racing on the same thread is settled by the unique index
on rfcMessageId, exactly as the Company.domain index already settles two
contacts arriving at a new company.
7.5 Later: near-real-time
Gmail users.watch + Cloud Pub/Sub push turns 5 minutes into seconds. It also
adds a Pub/Sub topic, a push endpoint, and a watch registration that expires
every 7 days and must be renewed on a schedule. Build it behind the same
handler the cron calls, once the polling version has proven the matching rules.
Not Phase 2 work.
8. API surface
One module, apps/api/src/google, one router per api.md
(google.router.ts, @Router({ alias: "google" }), @UseMiddlewares(AuthMiddleware)),
thin over services.
| Procedure | Kind | Notes |
|---|---|---|
google.status |
query | Per-source: connected, status, lastSyncedAt, lastError, auto-create toggles. Drives the settings page and the poll. |
google.purgeSyncedData |
mutation | Deletes every thread and event this mailbox contributed. Syncing continues forward from the current cursor. Not a disconnect — see §3.4. |
google.revokeAccess |
mutation | The explicit hard revoke. Calls Google's revoke endpoint; the rep re-consents at next sign-in. Confirmed in the UI, never the default. |
google.syncNow |
mutation | Enqueues a pass for the calling user. Rate-limited; it is a nicety, not the mechanism. |
google.setAutoCreate |
mutation | { source, enabled }. |
google.suppressDomain |
mutation | "Not a customer" — adds to SuppressedDomain and optionally deletes what it matched. |
email.thread |
query | { threadId } → messages with bodies. Only called on expand. |
calendar.event |
query | { eventId } → attendees, conference link, description. |
Connecting needs no procedure — the client calls authClient.linkSocial()
directly, and google.status reads the result off Account.scope.
Extensions to existing surfaces:
activities.contracts.ts— add"email"and"meetings"toTimelineFilter;activities.service.ts— the matching arms infilterClause()and the extra
counts intimelineCounts().ENTRY_SELECT— include a summary of the linked thread/event
(messageCount,lastMessageAt,startsAt, attendee count). Not bodies.companies.service.ts/contacts.service.ts—nextMeetingAtandlastEmailAton the detail payload, andsourceas a filterable column.
Regenerate and commit apps/api/src/generated/server.ts with the router
change, per api.md.
9. UI
9.1 New primitives — in `/packages/ui`, per `design.md`
Nothing bespoke in apps/app, no className overrides, no new radii:
accordion.tsx— viabun run --filter=@crm/ui ui:add accordion. The thread
disclosure. Not currently in the package.thread-message.tsx— one message: sender, time, direction, body. Composesavatar,separator.attendee-list.tsx— stacked avatars with response status, overavatarandtooltip.connection-card.tsx— a connected-service row with status and actions, overcard,status-indicator,button. Reused by any future integration.
9.2 Timeline
timeline-entry.tsx gets two branches beside the existing STAGE_CHANGE one:
- EMAIL — subject,
"3 messages · latest 2h ago", direction arrow, snippet.
Expands into an accordion ofThreadMessages, fetchingemail.threadon
first open. Deep-links to Gmail via the storedgmailMessageId. - MEETING — title, time range,
AttendeeList, conference link. An upcoming
meeting sorts bystartsAtand reads "in 2 days";relativeTimeFromIso()
already exists in@crm/ui/lib/format.
activity-icon.tsx already maps both types. Filter tabs gain Email and
Meetings (timeline-search-params.ts, nuqs, per the existing pattern).
9.3 Settings
A new apps/app/app/(app)/settings/page.tsx — there is no settings route yet.
One card, not one per source. Both scopes come from a single grant at
sign-in, so "Gmail connected but Calendar not" is not a state that exists, and
rendering it as two identical cards invented a distinction the system does not
have. The card carries connection health, when it last checked, and the two
auto-create toggles — the only decision on the page with a consequence. The
destructive actions from §3.4 sit underneath as quiet text links, because
neither is anybody's daily business. No connect button: the gate guarantees the
scopes before anyone reaches this page.
9.4 Freshness
Sync is a background write no client action caused, so per api.md it is
polled, not invalidated. Mirror the enrichment pattern exactly — acomponents/crm/sync-status.tsx exporting isSyncing() and SYNC_POLL_MS, so
the rule has one definition. Poll on the settings page and on the record sheet
while a sync is running, remembering the lesson already recorded inapi.md: the company sheet polled and the companies table did not, and a
newly-added company stayed blank in the table until a reload.
User-initiated actions still invalidate through useCrmCache(). Add acache.google() that fans out to the activity keys plus companies and contacts
lists — and note that activities.timeline must use pathKey(), notqueryKey(), because it is read both as an infinite query and as a plain one.
10. Privacy, security, logging
This is the part that makes the feature acceptable rather than merely working.
- Relevance filtering at ingest, not at read. Non-matching mail is never
written. §5. - Never log content.
api.mdalready bans headers, query strings and
bodies. Extend it in spirit: no subjects, no snippets, no addresses in log
lines. Log counts, ids, and domains.logger.log({ message: "Gmail sync", userId, threadsWritten, messagesWritten })— one object, per the rules. - Bodies never appear in list payloads.
ENTRY_SELECTcarries a snippet;
bodies come only fromemail.thread, one thread at a time. - Read scopes only, and the reconnect path revokes and re-consents rather
than silently widening. - Disconnect means it. Deleting the account row stops sync;
{ purge: true }
removes the messages, threads, events and their projected activities. Someone
will ask for this on their last day, and the answer should not be a migration. - Every rep sees every synced thread — that follows from "signed in is the
entire authorisation model" inapi.md, and it is a real consequence worth
saying out loud before the first sync runs, not after. If that is not
acceptable, it is a row-level permissions decision that belongs in this plan
now rather than as a retrofit.
11. Failure modes to design for
| Failure | Response |
|---|---|
| Refresh token revoked | NEEDS_RECONNECT on the sync row, banner in settings, stop trying. |
historyId too old (404) |
Clear cursor, resume from now, log the gap. §7.3. |
syncToken gone (410) |
Clear cursor, re-read from now forward. §7.2. |
| Google rate limits (429/403) | Exponential backoff persisted on the sync row; skip that account this tick. |
| Cron tick overruns | Chunked work with a wall-clock budget; the cursor makes the next tick resume, not restart. |
| Two reps, one thread | Unique rfcMessageId. §4.2. |
| Auto-create makes junk | source filter → bulk delete → SuppressedDomain. §6. |
| Deploy mid-tick | The cursor only advances on a settled pass, so at worst a window is re-read — every write is an upsert. |
12. Phases
| Phase | Scope | Done when |
|---|---|---|
| 0 — Google Cloud | Internal consent screen, both APIs enabled, redirect URIs for all envs | Consent screen shows the two scopes; no verification required. |
| 1 — Connect | Scopes on the provider, accessType: "offline", requireGoogleAccess() gate, /grant-access, GoogleTokenService, MailboxSync, settings page, google.status. No sync. |
A fresh sign-in grants both scopes and lands in the app; a user who unticks one is bounced to /grant-access and can re-consent; an account predating the requirement is caught by the same gate. |
| 2 — Calendar | Calendar sync, CalendarEvent/CalendarAttendee, matching, projection, MEETING timeline entry, Meetings filter |
A meeting with an existing company appears on its timeline within 5 minutes, and a cancellation removes it. |
| 3 — Gmail | Gmail sync, EmailThread/EmailMessage, thread accordion, Email filter |
A thread with an existing company appears collapsed and expands to readable messages. |
| 4 — Auto-create | §6 rules, RecordSource, SuppressedDomain, filters, bulk undo, toggles |
A meeting with an unknown work domain creates an enriched company and a contact; a newsletter creates nothing. |
| 5 — Real-time (optional) | users.watch + Pub/Sub push, 7-day renewal |
Threads land in seconds; the cron stays as the safety net. |
Phases 1–3 are the user's stated ask. Phase 4 is the "would be cool", and it
should not ship before Phase 3 has run on real mailboxes for a week — the
matching rules need to be observed against real mail before they are allowed to
write rows.
13. Environment
| Variable | Where | Notes |
|---|---|---|
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET |
exists | Unchanged. Same client, same callback — the grant is just widened at sign-in. |
CRON_SECRET |
new | Bearer guard on /internal/sync/google. The route fails closed without it, so a local checkout simply never runs the cron. |
Nothing else. There is deliberately no feature flag: a switch earns its keep
only when it gates something that can genuinely be absent, and sync has
everything it needs the moment the app boots — the OAuth client is mandatory
because Google is the only sign-in, and the scopes are a condition of having an
account. A switch that can disable a mandatory feature, defaulting to off, is a
switch that is only ever wrong. Our own domains are derived from the User
table rather than configured, so they cannot go stale when the team adds a
second one.
All added to EnvironmentVariables in apps/api/src/config/env.validation.ts
with class-validator decorators, and to docs/environment.md.
14. Testing
- Unit, no network. Matching (§5) and the auto-create rules (§6) are pure
functions over parsed payloads — table-drivenbun testwith fixtures for the
awkward cases: two external domains, a no-reply sender, a free-host address, a
thread the rep never replied to, a declined meeting, a moved recurrence
instance. - Idempotency. Run a fixture window twice; assert row counts are identical.
- Cross-mailbox dedupe. Ingest the same conversation as two different
mailboxes with differentthreadIds; assert oneEmailThread, one projectedActivity. - Cursor invalidation. Stub 404 and 410 and assert the cursor is cleared and
the next pass resumes from now, rather than a crash or a silent stall.
15. Open questions
- Whose mail syncs? Every signed-in user who connects, or a defined sales
group? Affects volume and the §10 visibility question. - Visibility. §10 — every rep seeing every synced thread follows from the
current auth model. Confirm that is intended. - Retention. Do synced messages age out (say, 24 months), or live as long
as the company record? - Attachments. Out of scope here.
crm-plan.md§7 already picks Vercel Blob
for CRM attachments; email attachments would reuse it, but v1 stores names
and sizes only, not bytes. - Cron granularity. Confirm the Vercel plan supports
*/5. - Does hard revocation need to be per-feature? §3.4 — with one OAuth client
there is one Google grant, so revoking mailbox access also revokes sign-in.
Under the mandatory-sync policy that is arguably the correct behaviour, since
an account that will not grant the scopes cannot use the CRM anyway. Revisit
only if someone needs "keep my login, stop reading my mail", which the policy
currently says is not a state we support.
Plan People Enrichment Agent (`docs/plan/people-enrichment-agent.md`)
Plan — People enrichment agent (eve + LinkedIn)
An eve agent that works out who the people in the CRM
actually are, using real profile data rather than inference.
Historical. The agent this planned is built and agent.md
describes what it does now. One decision below did not survive: LinkedIn no
longer comes from a third-party LinkedIn API of its own. Context's people enrich
endpoint reads a person back from a URL, under the same key that already buys
company brand data, so there is one vendor and one key rather than two.
Read it with AGENTS.md, api.md,design.md andgmail-calendar-plan.md, whose sync is what puts
these contacts in the CRM in the first place.
0. Phase 0 is done — what the API actually does
Run against the real key on 2026-07-31, using addresses from our own CRM. This
section exists because it changed the architecture, and because half of it
contradicts the vendor's own marketing page.
People search is broken. Do not build on it.
GET /api/v1/search/people?keywords=... returns HTTP 200 with a well-formed
payload of entirely unrelated people. keywords=marchetti fernhill returned a
list of Seattle-area engineers; keywords=Paula Marchetti returned Italian
executives — Brightwater's CEO, an HR lead at Reply. Different random set each time.
The parameter appears to be ignored.
This matters more than a broken endpoint usually would: search was the entry
point of the original design. Email → search → candidates → match was the whole
identity-resolution flow, and it does not exist.
What does work, and works well
| Endpoint | Status | Notes |
|---|---|---|
GET /api/v1/profile/overview?username=<slug> |
Excellent | Full name, headline, current positions with company URNs and logos, follower count, connection count, join date. |
GET /api/v1/companies/company/info?name=<slug> |
Excellent | Id, universal name, LinkedIn URL, tagline, full description. Takes name, not username. |
GET /api/v1/profile/contact-info?username=<slug> |
Works | — |
GET /api/v1/profile/skills |
Exists, different params | Rejects username; wants a URN or profile id. |
profile/experience, profile/posts, companies/company/employees |
Do not exist | 404 under the documented names, despite being on the marketing page. The real names need finding before anything depends on them. |
The architecture this forces — and it is better
LinkDAPI is an enricher, not a finder. Given a slug it is superb; given a
name it is useless. So the resolver stage moves to a real search engine:
email + company → Context.dev web search (site:linkedin.com/in) → slug
slug → LinkDAPI profile/overview → deep structured profileProven end to end on a real CRM contact:
[email protected]
→ site:linkedin.com/in tokonkwo "Northwind"
→ tomi-okonkwo
→ Tomi Okonkwo — VP, Fund Finance & Venture Lending — Northwind Banky + okonkwo. That is the "Tokonkwo" row from the contacts list, resolved to a
real person with a real title at the right employer.
It also means the two vendors are used for what each is actually good at.
Context.dev is a search engine and cannot read LinkedIn (LinkedIn blocks
scrapers); LinkDAPI can read LinkedIn and cannot search. Neither alone solves
this.
Hit rate is partial, and that is the honest headline
[email protected] resolved to nothing — no candidate slug at all. An
unusual local part at an 8,000-person company is the hard case, and it is the
one that prompted this work. Expect a meaningful miss rate on exactly the
contacts that look worst in the UI.
Two consequences:
- The fallback matters. When no slug resolves, the existing Context.dev
extract path still runs and may find a team page. - A miss must stay a miss. With search returning plausible strangers, the
temptation to accept a near-match is exactly how Dario Fontana ends up
filed as Paula Marchetti. §5's confidence bands are not ceremony.
A bug this spike caught in already-shipped code
Context.dev's web.search rejects numResults < 10. ContextDevClient.search
defaulted to 5, so every call would have failed validation — the contact
enrichment shipped last turn could never have worked. Fixed; worth recording as
the reason to spike against a real key rather than a type definition.
1. What this is actually for
The prompt for this was a contacts list reading Pmarchetti, Tokonkwo, Chris —
names derived from an email address because Google Calendar returned nodisplayName. Fixing those is the entry case, and on its own it would not
justify an agent.
The reason to do this properly is what a profile carries beyond a name:
| Signal | Why a CRM wants it |
|---|---|
| Full name, headline, title | The contacts list stops looking broken. |
| Work history and tenure | "Started 3 months ago" is a different conversation from "eight years in seat". |
| Job changes | A champion moving to a new company is the single highest-intent event in B2B sales, and nobody sees it today. |
| Seniority and function | Who actually signs, versus who you have been emailing. |
| Company headcount and growth | Qualification, without asking. |
| Mutual connections | A warm path in. |
The agent does both: it fills the fields and it produces the output. The
fields are the substrate — you cannot write "you are meeting the VP who joined
four months ago" without knowing the title and the start date — and the output
is the reason anyone opens the CRM. Neither half is optional, and shipping only
the fields is what makes enrichment feel like admin.
Job-change detection is the prize. It is also impossible without a people data
source, which is why the existing Context.dev path cannot grow into it alone.
What exists today, and what happens to it
ContactEnrichmentService (apps/api/src/enrichment/contact-enrichment.service.ts)
already does a sourced web lookup: search, read a page, write only what the page
states. It is deliberately timid — it refuses to guess, so it returns nothing
for most people.
It is not deleted by this plan. It becomes the fallback the agent reaches
for when LinkedIn has no match, which is common for junior people, non-US
markets, and anyone who does not keep a profile. §5.
2. The decisions that carry the design
- Curated tools, not a raw OpenAPI connection. Not to save money — one won
deal at $10–15k ARR funds more enrichment than this will ever run. Because
the surface is not trustworthy: several advertised endpoints 404 and the
search endpoint returns confident nonsense (§0). Tools pin the agent to what
is verified to work. §4. - The agent proposes; the CRM decides. Writes go through one tool that
stamps provenance, and a low-confidence identity match is approval-gated
rather than merged. Getting a person wrong is worse than not knowing them. §6. - Identity matching is the hard part, not data fetching.
[email protected]
→ which of the four Marchettis at a 8,000-person company? This gets a skill, a
confidence rubric and evals. §5. - A separate Vercel app, sharing
@crm/db. eve is its own runtime with its
own build; it does not embed in NestJS. It reads and writes the CRM through
the workspace database package, not through a new internal HTTP API. §3. - Rate limits are a correctness concern, not a billing one. A 429 mid-run
is a half-enriched contact. The token bucket exists so the agent never
discovers the tier cap. §7. - Provenance on every field. Which endpoint, which profile URL, when,
how confident. Without it nobody can tell an enriched field from a typed one,
and nobody will trust either. §6.
Choice table
| Decision | Choice | Why |
|---|---|---|
| Framework | eve, apps/agent, own Vercel project |
Durable sessions, approval gating, schedules and evals are all things we would otherwise hand-roll. |
| Model | anthropic/claude-sonnet-5 via AI Gateway |
The default. Routes over project OIDC, so no provider key to manage. Matching is not a frontier-model problem; revisit if eval scores say otherwise. |
| LinkedIn access | LinkDAPI, profile + company endpoints only | Its people search is broken — §0. Profile-by-slug and company-by-name are excellent. Superseded: Context's people enrich reads the same profile under the key we already hold. |
| Finding the person | Context.dev web search resolves the LinkedIn slug | §0. The one thing LinkDAPI cannot do, and the thing a real search engine is good at. |
| Tool surface | Curated tools, not an OpenAPI connection | Not cost — predictability. Half the advertised endpoints do not exist under the documented names (§0), so the surface has to be pinned to what is verified. |
| CRM access | @crm/db directly |
Already a workspace package. A second internal API would be plumbing with no reader. |
| Trigger | eve schedule (Vercel Cron) over a bounded queue | Same shape as the Google sync — cursor in Postgres, work bounded per tick. |
| Write path | One update_contact tool, provenance stamped |
§6. |
| Low confidence | eve approval: always() on merges below the bar |
§6. Cheap to add now, impossible to retrofit trust later. |
| Fallback | Existing Context.dev search | Covers the long tail LinkedIn misses. |
| Retention | Raw payload in ContactEnrichment, mirroring CompanyEnrichment |
Re-deriving a field later costs nothing — the pattern already paid off once, for iconTone. |
| Coverage | Every contact with a work domain, re-checked on a cadence | Billing is not a constraint: one won deal at $10–15k ARR pays for far more enrichment than this will ever run. |
Judgment calls worth your veto
- Curated tools rather than the full API. You lose the agent's ability to
improvise across endpoints we did not anticipate. You gain a predictable bill.
If a use case turns up that the four tools cannot serve, add a fifth — that is
cheaper than discovering an agent spent 4,000 credits exploring. - Job-change detection is Phase 4, not Phase 1. It needs a baseline to
diff against, so it cannot exist until enrichment has been running a while.
Worth saying out loud because it is the most valuable part and it is last. - The agent never emails anyone. It reads, matches and writes CRM fields.
Outbound stays a human action. eve makes it easy to give an agent a channel
and a send tool, and that is a different product decision with a different
risk profile. - Contacts only, initially. LinkDAPI's company endpoints are tempting, but
Context.dev already fills company firmographics and two sources writing the
same columns is a merge conflict nobody owns. §8.
3. Where it lives
eve is a filesystem-first framework: an agent is a directory, and the slot a
file lands in determines how it loads. It builds and runs as its own app, so it
becomes a third workspace app beside api and app.
apps/agent/
├── package.json # name = "agent"; the root agent takes its name from here
├── agent/
│ ├── agent.ts # model + runtime config
│ ├── instructions.md # what the agent is for and what it must not do
│ ├── lib/ # shared helpers — import-only, never reaches the sandbox
│ │ ├── linkdapi.ts # typed client: auth, retry, rate limit, credit accounting
│ │ └── crm.ts # @crm/db queries the tools call
│ ├── tools/ # one file per tool; filename IS the model-facing name
│ │ ├── find_person.ts
│ │ ├── get_profile.ts
│ │ ├── find_colleagues.ts
│ │ └── update_contact.ts
│ ├── skills/
│ │ └── identity-matching.md
│ └── schedules/
│ └── enrich.ts # becomes a Vercel Cron Job on deploy
└── evals/ # sibling of agent/, not inside itNaming is path-derived — agent/tools/find_person.ts is the tool find_person,
and you never write a name field.
Deployment. eve link creates/links a Vercel project and pulls its env.
Schedules become Vercel Cron Jobs, evaluated in UTC. A string model id routes
through the AI Gateway authenticated by project OIDC, so there is no Anthropic
key to rotate. Note that eve dev never fires schedules on their cadence —
there is a dispatch route for triggering one while iterating.
Monorepo wiring. apps/agent joins the bun workspace and gets aturbo.json declaring passThroughEnv for DATABASE_URL and the vendor keys,
matching how apps/api does it. check-types and lint join the root
pipeline; build is eve build.
4. The tools
Four, deliberately. Each one runs in the app runtime with access toprocess.env and lib/, and each is a place to put a rate limiter rather than
hope the model paces itself.
| Tool | Wraps | Notes |
|---|---|---|
resolve_profile |
Context.dev web.search, site:linkedin.com/in |
Turns an email + company into candidate slugs. Never returns a match — returns candidates for §5 to judge. This replaces the LinkDAPI search that does not work. |
get_profile |
LinkDAPI profile/overview (+ contact-info) |
One slug, in full. The deep data nothing else can reach. |
get_company |
LinkDAPI companies/company/info?name= |
Tagline, description, LinkedIn URL — company context for a brief. |
research_web |
Context.dev web.extract |
The fallback when no slug resolves, and the source of non-LinkedIn context (funding, launches) for briefs. |
update_contact |
@crm/db |
The only write. §6. |
Two things go in lib/linkdapi.ts rather than in each tool:
- A token bucket sized to the tier. Testing is 7 requests/minute, Hobby 30,
Developer 70. Not a cost control — a 429 in the middle of a run leaves a
half-enriched contact, and the agent must not be the thing that discovers the
cap. - Call accounting. Endpoint plus the contact it was for. Not for the bill,
for the debugging: when a profile comes back wrong you want to know exactly
what was asked.
A note from eve's own docs that applies here: a step interrupted mid-execution
re-runs, so anything non-idempotent must be made idempotent or gated. Our
writes are upserts keyed on contact id, so a replay is free; the credit spend
is not, which is another reason the counter is on the client and not the tool.
5. Identity matching
The actual problem. [email protected] is one address at a company with
thousands of employees, and picking the wrong Marchetti writes a real person's
career history onto the wrong CRM record.
This gets a skill — an on-demand procedure the agent loads when it needs it —
rather than being buried in the system prompt:
agent/skills/identity-matching.md sets out the evidence hierarchy:
- Exact email on the profile — decisive. LinkDAPI exposes contact info on
some profiles; when the address matches, the match is certain. - Local-part decomposition against a real name.
pmarchettiis consistent
witha+marchettiand withabi+gham. Checked against candidates,
never used to generate a name — the direction matters, and it is the whole
difference between this and guessing. - Company match, current employer equals the contact's company.
- Corroboration — the meeting we already synced, the thread they replied
on, the title they signed off with in an email footer.
And the confidence bands the write path keys on:
| Confidence | Evidence | Behaviour |
|---|---|---|
high |
Email match, or unique local-part decomposition at the right company | Merged automatically. |
medium |
Consistent decomposition but more than one plausible candidate | Written as a suggestion; a rep accepts or rejects. |
low |
Company match only | Discarded. Not stored, not shown. |
When LinkDAPI returns nothing, the agent falls back to the existing Context.dev
search path, which is already sourced and already refuses to guess.
Evals live and die on this. evals/ gets a fixture set of real addresses
with known answers — including the ones that should resolve to nothing — and
the metric is precision first: a wrong merge costs more than a missing one.
6. Writing back, and provenance
One tool, update_contact, and it never writes a bare field:
model ContactEnrichment {
contactId String @id
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
source String // "linkdapi" | "context.dev"
sourceUrl String? // the profile the claim came from
confidence String // high | medium | low
raw Json // the payload, so re-deriving costs nothing
fetchedAt DateTime @default(now())
@@map("contactEnrichment")
}Mirrors CompanyEnrichment deliberately: keeping the raw payload has already
paid for itself once — iconTone and iconDarkUrl were backfilled for every
company from stored payloads without spending a credit.
Contact gains enrichmentStatus, enrichedAt and enrichmentError matchingCompany, so the UI reuses isEnriching() / the existing poll rather than
inventing a second freshness mechanism (api.md: background writes the browser
cannot see are polled, not invalidated).
Approval. eve's approval: always() pauses a tool call durably until a
person signs off. update_contact takes a policy rather than a flag: automatic
at high, approval-gated at medium. eve's own responsible-use guidance is
that agents run permissively unless you configure otherwise and that model
behaviour alone should not be relied on to prevent sensitive writes — a
confidence rubric in a prompt is model behaviour; the approval policy is not.
Never overwrite a human. Same rule the rest of the codebase already
follows: brandToUpdate fills only empty fields, and isDerivedName() is
already the test for "this name is a placeholder nobody has improved on".
7. Scheduling and coverage
agent/schedules/enrich.ts — a defineSchedule with a run handler rather
than a markdown prompt, because the batch has to be selected in code:
export default defineSchedule({
cron: "*/10 * * * *",
async run({ receive }) { /* claim a bounded batch, hand each to the agent */ },
});Sizing is set by the rate limit, not the budget:
- Batch size is the per-minute cap times the tick, with headroom. At
Developer (70/min) and a 10-minute tick that is hundreds of contacts, which is
more than the whole database. - Every contact with a work domain, not just the ones with ugly names and
not just the ones on open deals. A correct title on a contact nobody is
actively selling to is what makes the next deal's brief good. - Re-check on a cadence, not once. A profile read once is a fact that decays;
a profile read every 30 days is a job-change feed (Phase 4). This is the
single biggest behavioural difference the "billing is irrelevant" call buys,
and it is worth taking deliberately rather than by omission.
Pick the tier from the measured request rate, then stop thinking about it.
8. What this does *not* touch
- Company firmographics. Context.dev owns those columns. LinkDAPI's company
endpoints are for people context — headcount, who works there — and land in
new fields or not at all. - The Google sync's matching.
GoogleMatchServicedecides which company a
thread belongs to. That is a different question from who a person is, and
merging them would couple a cheap local decision to a paid network call. - Outbound anything.
9. The part to decide before building
LinkDAPI is an unofficial LinkedIn API. Using it is a business decision, not
a technical one, and it belongs to you rather than to this plan — but it should
be made deliberately rather than discovered:
- LinkedIn's terms prohibit automated collection, and they enforce against
it. The exposure is to the data provider and to LinkedIn, not to a court's
view of the CFAA, whichhiQ v. LinkedInlargely settled in favour of public
data. Practically: this is what Apollo, Clearbit, ZoomInfo and most of the
sales stack do, and it is priced accordingly. - This is personal data under UK/EU GDPR even though it is professional and
public. What that actually requires of an internal B2B CRM: a lawful basis
(legitimate interest is the usual one, and wants a balancing test on file), a
retention period, and the ability to answer an access or erasure request.ContactEnrichmentwithsourceUrlandfetchedAtis most of the answer —
it makes "where did this come from and when" a query rather than an
investigation. - Keep it to business context. Name, title, employer, tenure, public
profile. Nothing about a person outside their work, and none of the special
categories, regardless of what an endpoint returns. - Single-provider risk. An unofficial API breaks when LinkedIn changes.
lib/linkdapi.tsbeing the only file that knows the vendor is what makes
swapping it a day rather than a rewrite.
None of this blocks the build. It does mean §6's provenance is not a nicety —
it is the thing that makes the rest defensible.
10. Phases
| Phase | Scope | Done when |
|---|---|---|
| 0 — Spike | Done — §0. Search is unusable; profile and company are excellent; the hybrid resolver works end to end on a real contact; hit rate is partial. | |
| 1 — Skeleton | apps/agent, eve link, update_contact writing to @crm/db, schedule claiming a batch, no LinkedIn |
A contact's title changes because the agent changed it, on a cron, in a deployed environment. The loop is proven before the vendor is in it. |
| 2 — Enrichment | Five tools, identity-matching skill, confidence bands, ContactEnrichment |
Tokonkwo becomes Tomi Okonkwo, VP Fund Finance at Northwind Bank, with a sourceUrl against it — and an ambiguous address becomes a suggestion rather than a wrong answer. |
| 3 — Trust | Approval on medium, evals with a labelled fixture set, provenance in the contact sheet |
Precision measured, not asserted. A rep can see where a field came from. |
| 4 — Meeting prep briefs | On a calendar event with external attendees, research everyone and write a brief to the timeline before the call | Opening a company the morning of a call shows who is attending, what they do, how long they have been there, what the company just announced, and where the deal stands. This is the feature people will describe to other people. It is only possible because the Calendar sync already knows the meeting is coming. |
| 5 — Job changes | Re-read on a 30-day cadence, diff against the stored payload, activity + task on a move | A champion changing employer creates an activity on their old company's timeline and a task for their owner. The highest-intent signal in B2B, and nobody else in the stack sees it. |
Phase 0 already earned its keep: it deleted the original resolver design, found
a shipped bug, and replaced a marketing-page assumption with a measured one.
The ordering after that is deliberate. Phases 1–3 make the data trustworthy;
4 and 5 are what a rep would actually miss if you took it away. Resist doing 4
first — a brief built on unverified identities is confidently wrong in front of
a customer, which is the one failure mode that costs a deal rather than a
click.
11. Open questions
- Whose credits? One shared app key (
principalType: "app", which is
eve's default whengetTokenis the only auth), or per-rep? Shared is
simpler and almost certainly right for an internal tool. - Does a
mediumsuggestion surface in the UI, or a queue? The Gmail plan
argued against approval queues because they rot. A suggestion inline on the
contact — "LinkedIn thinks this is Paula Marchetti · accept" — probably beats a
separate inbox, but it is more UI work. - Retention. How long does
ContactEnrichment.rawlive? It is the thing
that makes re-derivation free and the thing an erasure request is about. - Do we enrich every contact, or only ones on open deals? Every contact is
simpler; deal-scoped is far cheaper and probably where the value is. - Is
find_colleaguesallowed to create contacts? Listing a company's
employees is a lead-generation capability, not an enrichment one. It should
not create records in v1, and that line is worth holding deliberately.
--- METRICS ---
- Files Extracted: 28
- Estimated Token Budget: ~94154 tokens
- Canonical Reference: https://codewiki.google/github.com/trycompai/crm