crm Documentation

Comp AI CRM is an open source, CRM designed for AI agents. Agentic-first CRM.

11,591 stars TypeScript TypeScript 28 files · ~94,231 tokens
RAW Doc

Docker & Self-Hosted Deployment Verified OCI

Deploy crm on any server, homelab, or cloud container platform.

1-Click Terminal Command
bash (docker run)
docker run -d --name crm -p 5432:5432 --restart always postgres:17-alpine
Configured Services & Ports
⚙️ postgres
image: postgres:17-alpine
🔌 Ports: 5432:5432
docker-compose.yml
yaml (docker-compose.yml)
services:
  postgres:
    image: postgres:17-alpine
    container_name: crm-postgres
    restart: unless-stopped
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: crm
    ports:
      - "5432:5432"
    volumes:
      - crm-postgres:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres -d crm"]
      interval: 3s
      timeout: 5s
      retries: 20

volumes:
  crm-postgres:

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 with
FOR 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:

text
[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/ui is 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.

sh
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 dev

The 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

  1. Google Cloud console → Credentials → Create credentials → OAuth client ID → Web application.
  2. Under Authorised redirect URIs, add http://localhost:3001/api/auth/callback/google.
  3. Enable the Gmail API and the Calendar API for the project.
  4. 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

  1. Azure portal → Microsoft Entra ID → App
    registrations
    → New registration. Microsoft's own walkthrough is
    Register an application.
  2. 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_IN still decides who gets an account — but the narrow choice turns
    outsiders away at Microsoft rather than at our door.
  3. Set the Redirect URI to Web and
    http://localhost:3001/api/auth/callback/microsoft. In production this is
    https://<your-api-host>/api/auth/callback/microsoft — the API's origin, not the
    app's, because the API is what serves /api/auth/*.
  4. API permissions → Add a permission → Microsoft Graph → Delegated
    permissions
    → tick User.Read and Mail.Read. offline_access is 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.
  5. Certificates & secrets → New client secret. Copy the Value, not the
    Secret ID — the value is shown once and never again.
  6. Overview → copy the Application (client) ID.
  7. Put the two into .env as MICROSOFT_CLIENT_ID and MICROSOFT_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, set
MICROSOFT_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:

sh
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 install

Configuration

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 and
BETTER_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 at
POST /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

sh
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:prod

Three values are required and the process refuses to boot without them, naming
the one it is missing: DATABASE_URL, BETTER_AUTH_SECRET and
ALLOWED_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, register
http://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 with
credentials: "include".

AuthModule.forRoot({ auth }) mounts the Better Auth handler and registers a
global AuthGuard, so every route is protected unless it opts out:

ts
@Get('public')
@AllowAnonymous()          // no session required
@OptionalAuth()            // session optional; @Session() may be undefined

It 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 to
    secondaryStorage backed 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:

bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev

Open 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:

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

ts
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):

ts
import { auth } from "@crm/auth";

export const GET = auth.handler;
export const POST = auth.handler;

Client

ts
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

bash
cp .env.example .env      # at the repo root — there is no per-package env file
openssl rand -base64 32   # -> BETTER_AUTH_SECRET

This 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. See
docs/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, so
src/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:

bash
bun run auth:generate   # rewrites packages/db/prisma/schema.prisma
bun run db:migrate      # create the migration

Notes

  • JIT package. exports point 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
    no transpilePackages entry.
  • Next.js server actions need the nextCookies() plugin (from
    better-auth/next-js) as the last entry in the plugin array. It is omitted
    here so the package stays framework-agnostic; add it — along with next as 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 need
    AUTH_COOKIE_DOMAIN set to the shared parent (.example.com).

Overview (`packages/db/README.md`)

`@crm/db`

PostgreSQL access for the monorepo: the Prisma schema, migrations, and a shared
PrismaClient instance.

Usage

ts
import { db } from "@crm/db";

const users = await db.user.findMany({ take: 10 });

Types and query helpers come from the same entrypoint:

ts
import { Prisma, type User } from "@crm/db";

Setup

bash
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 migrations

DATABASE_URL comes from the repo-root .env, loaded by @crm/env — see
docs/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 through
turbo 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. See src/client.ts.
  • Generated code is not committed. prisma generate writes to
    src/generated/, which is gitignored and declared as the build task's
    output so Turborepo caches it.
  • JIT package. exports point at TypeScript sources; the consumer compiles
    them. Turbopack transpiles workspace packages automatically, so a Next.js app
    needs no transpilePackages entry. 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
    and RateLimit come from @better-auth/cli. Do not hand-edit them — change
    the Better Auth config in @crm/auth and re-run bun 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

bash
# Install dependencies (if any)
pnpm install

# Build AGENTS.md from references
pnpm build

# Validate the skill
pnpm validate

Categories

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

  1. Create a new file in references/ with the pattern {prefix}-{slug}.md
  2. Use the template from assets/templates/_template.md
  3. Include YAML frontmatter with title, impact, impactDescription, tags
  4. Add Incorrect and Correct code examples
  5. Run pnpm build to regenerate AGENTS.md
  6. Run pnpm validate to check for errors

Rule File Structure

markdown
---
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 references
  • pnpm validate - Check skill against quality guidelines

Contributing

  1. Check existing rules to avoid duplication
  2. Follow the rule template structure
  3. Include production-realistic code examples
  4. Quantify impact where possible
  5. 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 rules
    • area-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
    behavior
  • architecture-compound-components.md - Structure as compound components with
    shared context

State Management (HIGH)

  • state-lift-state.md - Lift state into provider components
  • state-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 props
  • patterns-explicit-variants.md - Create explicit component variants

Core Principles

  1. Composition over configuration — Instead of adding props, let consumers
    compose
  2. Lift your state — State in providers, not trapped in components
  3. Compose your internals — Subcomponents access context, not props
  4. Explicit variants — Create ThreadComposer, EditComposer, not Composer
    with isThread

Creating a New Rule

  1. Copy rules/_template.md to rules/area-description.md
  2. Choose the appropriate area prefix:
    • architecture- for Component Architecture
    • state- for State Management
    • patterns- for Implementation Patterns
  3. Fill in the frontmatter and content
  4. Ensure you have clear examples with explanations

Impact Levels

  • CRITICAL - Foundational patterns, prevents unmaintainable code
  • HIGH - Significant maintainability improvements
  • MEDIUM - 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 rules
    • area-description.md - Individual rule files
  • src/ - Build scripts and utilities
  • metadata.json - Document metadata (version, organization, abstract)
  • AGENTS.md - Compiled output (generated)
  • test-cases.json - Test cases for LLM evaluation (generated)

Getting Started

  1. Install dependencies:

    bash
    pnpm install
  2. Build AGENTS.md from rules:

    bash
    pnpm build
  3. Validate rule files:

    bash
    pnpm validate
  4. Extract test cases:

    bash
    pnpm extract-tests

Creating a New Rule

  1. Copy rules/_template.md to rules/area-description.md
  2. 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)
  3. Fill in the frontmatter and content
  4. Ensure you have clear examples with explanations
  5. Run pnpm build to regenerate AGENTS.md and test-cases.json

Rule File Structure

Each rule file should follow this structure:

markdown
---
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 example

Correct (description of what's right):

typescript
// Good code example

Optional 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 gains
  • HIGH - Significant performance improvements
  • MEDIUM-HIGH - Moderate-high gains
  • MEDIUM - Moderate performance improvements
  • LOW-MEDIUM - Low-medium gains
  • LOW - Incremental improvements

Scripts

  • pnpm build - Compile rules into AGENTS.md
  • pnpm validate - Validate all rule files
  • pnpm extract-tests - Extract test cases for LLM evaluation
  • pnpm dev - Build and validate

Contributing

When adding or modifying rules:

  1. Use the correct filename prefix for your section
  2. Follow the _template.md structure
  3. Include clear bad/good examples with explanations
  4. Add appropriate tags
  5. Run pnpm build to regenerate AGENTS.md and test-cases.json
  6. 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, then
    grep/jq it 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:

  1. Anything out of a mailbox. Email bodies and meeting notes stay in the
    turn, in the app runtime. read_crm_history gives 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.
  2. 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 in
AgentEvent 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: 0 on 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 — ready is waiting on the rep, ended is over — and
    the snapshot is the whole session from index 0, tens of megabytes on a real thread.
    A live thread stays live through refetchInterval, which ignores staleTime.
  • loadThread rethrows an abort. Swallowing it caches a false offline for the
    life of the staleTime; 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 — useEveAgent reads initialEvents once, 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 at SETTLED_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 failure keepMounted exists to prevent. Correcting a stale
    fallback is the next mount's job.
  • The audit hook does not archive reasoning.appended. Every delta carries
    reasoningSoFar, the whole reasoning to that point, so the archive grew as the
    square of the tokens — one insert per token, 90% of AgentEvent, for events the
    transcript never renders. reasoning.completed still 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, not working — fall back to the AgentEvent
    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=.
  • keepMounted on the tab descriptor (detail-sheet.tsx) — Radix drops an inactive
    tab, aborting the stream mid-answer.
  • autoScroll and nothing else; scrollAnchor stops it following the bottom.
  • One MessageScrollerItem per message, not per part; ids prefer toolCallId.
  • 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, via defineDynamic on session.started.
    Open conversations keep their model — prompt caches are per model.
  • lib/model.ts always sends modelContextWindowTokens; eve never inherits it.
  • A failed read logs and keeps the compiled fallback. Never throws.
  • The chooser offers only tool-use models (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_TOKEN means 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 — AvatarImage skips <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 while identify sat at attempts = 0 forever.
  • Calls the channel's own send, not receive — it is already on the crm channel.
    Principal from APP_AUTH (lib/app-auth.ts); taskAuth() keeps schedule and route
    from drifting.
  • drainAll collapses via collapsing() (lib/pool.ts) — forty new contacts poke
    forty times, and claimDue hands each a disjoint batch. Per-process; cross-process
    overlap is leases and FOR 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 leaves
    unsettledSweeps when it truly settles. Until then health reports running: true
    and the endpoint answers 503, and a new sweep is refused rather than stacked on
    top of stuck work.
  • A slow start(task) is reconciled, never failed. Past
    sweep.startTimeoutMs the lane stops waiting, the record stays Researching, and
    the late session id is attached by noteSession when the send lands. Only a send
    that actually rejects settles the record FAILED; a send that never lands is
    retired by retireExhausted after MAX_ATTEMPTS. A session id that never
    reaches the row after research.link.attempts raises unlinkedSessions in
    health, so a task running under a session nobody recorded is visible.
  • AGENT_BRIDGE_SECRET unset 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 422 before
    billable resolution. Do not point it at a real domain: ten credits per typo.
  • classifyKey rejects on 401 and 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 of
schedules/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: onSignedIn fires on
    session.create, so a rep already signed in never triggers it; and BackfillService
    does its work in a detached void (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 recheck booked 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 by
    retireExhausted, which is the one rule for giving up.
  • Idempotent — a second pass in the same minute closes nothing, because the rows it
    closed have a finishedAt and the rows it released have no lease.
  • Scans 200 rows a pass and reports unscanned rather than a clean sweep it did not
    make. The counts are in dispatchHealth().staleTasks, so a growing number of stale
    rows is visible at GET /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 portrait task 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.after in
    packages/auth; BackfillService subscribes with onSignedIn and has no router
    (@crm/auth must not import a Nest provider). Five-minute stand-down, and auto()
    returns before work starts.
  • The 500-row cap is on the pass, not each query in it. Deduplicate the union, cut
    to 500, count remaining against 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) and
lib/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.ts is the only write path to a contact's fields. Applies at
    VERIFIED, 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. fillsBlank is the whole rule: no human value in the way and
    nothing already found. PROBABLE still 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 PROPOSED row 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.
    canonicalValue lowercases, collapses whitespace, and for http(s) drops
    the scheme, www., a trailing slash and the query, and reads twitter.com as
    x.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 after applies is decided, so evidence that has
    reached VERIFIED since 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) and FACT_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 in defineState; 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
    1. 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.
  • lib/tasks.ts — claimDue leases with FOR UPDATE SKIP LOCKED.
  • schedules/dispatch.ts is the only schedule and decides nothing. "Every N
    minutes, the oldest ten contacts" belongs in a dueAt.
  • tools/schedule_recheck.ts — its reason is 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) and MAX_LINE in
    @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 — readWorkspaceIdentity returns it only while
    website matches.
  • Not a Company row — that needs excluding from every list, facet and join. One
    WorkspaceProfile keyed on WORKSPACE_ID.

The pass is a workspace-profile task using web_fetch (no credits), filed only via
write_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:

  1. No customer text in a third-party query. Derived questions only.
  2. Nothing from a mailbox into /workspace — different lifetime.
  3. 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_AGENT turn. 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_question directly; descendant input requests are proxied to the root channel,
    and the same child turn resumes when the user answers. The authored
    tools/ask_question.ts disable override must stay absent. Builder task output is
    typed as draft_ready and carries the immutable version ids only after save.
  • Empty never means all. A version chooses SELECTED or WORKSPACE record 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:gmail and
    google:calendar are 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.create separately names
    NOTE, 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-events owns the event
    vocabulary, record kind, and builder-facing description used by the API, builder,
    and worker. API writes enqueue a durable agent-event task; 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 triggers array and
    each entry becomes its own AgentTrigger row. 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 at session.started, then calls inspect_run for the manifest
    and current run state. Every runner tool also checks the team-agent purpose and
    revalidates scope and action permission.
  • Stopping a run is a row, not a signal. agents.cancelRun settles the
    AgentRun to CANCELLED inside one transaction — terminal event, outstanding
    AgentAction rows, audit entry — and then pokes
    POST /internal/crm/cancel-run, which calls eve's cancel({ continuationToken })
    for run:<id>. The row is what stops the work: every run tool refuses a run
    that is not RUNNING, so a cancel that never reaches the agent still means
    nothing further is written. The poke only stops it spending tokens, and an unset
    AGENT_BRIDGE_SECRET costs 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.
    canCancel is computed server-side per run and is what the button reads, so the
    control and the 403 cannot disagree.
  • turn.cancelled settles the run too, keyed off run: in the continuation
    token, so a cancel from any other path still reaches finishedAt rather 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
    only ask_question for 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

text
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 where useEveAgent() looks — no host,
    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 to principalType: "service" — wrong for a person,
    and lib/approval.ts reads exactly those fields to decide whether to pause for a
    human. repFromCrm maps the subject to a real user principal
    (test/channel-auth.spec.ts).
  • AGENT_BRIDGE_SECRET unset 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 in
docs/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's
data.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: 0 on 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. resolveThread in
    lib/agent-transcript.ts holds 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. keepMounted on 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.ts is the whole of it.

    What it replaced is worth remembering, because every panel bug of the last
    day came out of it: a raw fetch of …/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, not working. 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 own AgentEvent archive — 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.

  • autoScroll and nothing else. The scroller is a state machine
    (following-bottom, free-scrolling, anchored-to-message) and
    scrollAnchor selects 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, autoScroll follows the tail while the reader is at the bottom
    and releases the moment they scroll away, which lights the jump-to-end button.

  • One MessageScrollerItem per 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 prefer toolCallId, 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. ConversationsService filters on the caller, and a session id
    in a request body decides which row, never whose.

  • Cached the way api.md prescribes: read through
    cache-manager (Redis when REDIS_URL is 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:

sh
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 than
http://localhost:2000: Node resolves localhost to ::1 first, so the
localhost 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, so
apps/app/turbo.json and apps/agent/turbo.json both declare the pair in
passThroughEnv. Adding a variable and not declaring it produces exactly the
401 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:

sh
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 a console.log here would be
    invisible in the mode it exists to serve. Written to stderr the same lines show
    under --no-ui, under the TUI, and in eve 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 on NODE_ENV. In production the durable record is an
    AgentEvent row, not a log drain.
  • It is not the audit trail. hooks/audit.ts writes every event to
    AgentEvent whatever 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:headless rejects it and
    exits non-zero. A dev server is already running for this eve agent means
    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 later dev:headless fails the same way.
    lsof -nP -iTCP:2000 -sTCP:LISTEN names 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 its
AgentTask 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:

sh
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 the
sessionIds 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 at
GET /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 as
eve:<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's
    data.continuationToken carries the token as stored — un-namespaced — while
    channel.continuationToken carries 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 at attempts in 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. Never
console.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.
  • LoggingModule stays first in AppModule's imports, and Better Auth routes log
    via its own middleware option — it mounts before MiddlewareConsumer, so
    /api/auth/* never reaches ours.
  • requestId from RequestLoggerMiddleware via AsyncLocalStorage;
    UserContextInterceptor adds userId. 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 an
AgentTask 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, no
organizationId 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. ensureWorkspaceMembership runs in
    databaseHooks.session.create.before and degrades, never throws — a throw fails
    the session create and locks everyone out. The plugin's invitation table 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.
    WorkspaceService adds one invariant: the last owner cannot be demoted, with
    FOR UPDATE on 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 in updateWorkspaceInput, posting the same workspace.update as settings.
  • Onboarded state is onboardedAt inside the plugin's metadata blob, not a
    column; isOnboarded/markOnboarded (@crm/db/workspace) are the only accessors,
    and markOnboarded preserves every other key.
  • The name starts as DEFAULT_WORKSPACE_NAME (CRM), a placeholder not an
    answer.
    The header renders <name> CRM, so workspaceLabel tests the name rather
    than comparing to the default.
  • The website queues the agent's workspace-profile task and goes through
    normalizeDomain, 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 via
    requireMailboxAccess().
  • 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 (unknown lets the request through).
  • /sign-in, /grant-access, /eve are ungated. /sign-in is the only path a
    stranger may read; / joins it only when IS_MARKETING is set.
  • There is no way past the key gate but to answer — Skip stranded installs, every
    later company sitting PENDING with 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.
  • ensureWorkspaceMembership reconciles it; RESERVED_SLUGS prevents 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.tsx is the
    backstop.
  • appPath in proxy.ts is the one place / resolves for a signed-in rep, which
    keeps every callbackURL correct without knowing about slugs.
  • Renaming moves the URL, so workspace-form.tsx replaces the location onto the
    slug workspace.update returns.

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.
  • SsoService passes WORKSPACE_ID, never an input.
  • Management is tRPC (sso.*); signing in is authClient.signIn.sso().
  • sso.signInOptions is the one public procedure in the app. Every other sso.*
    takes AuthMiddleware at 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=google and ?method=microsoft still 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. mailboxGrantsNeeded returns which,
    so /grant-access offers the button they can actually use.
  • ALLOWED_SIGN_IN still decides who gets an account, in
    databaseHooks.user.create.before, for SSO sign-ups too.
  • organizationProvisioning: { disabled: true } — ensureWorkspaceMembership already
    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). No AuthMiddleware
    means public — there is no other guard.
  • Routers are thin: zod in, service call out; Prisma lives in *.service.ts.
  • Services throw Nest's HttpException family; DomainErrorMiddleware maps them.
  • Filter, sort and paginate in Prisma. List procedures take listInput and return
    { rows, total, facetCounts }. Never filter a whole table in the browser; never
    interpolate sort into a field name — use resolveOrderBy.
  • src/generated/server.ts is generated and committed, and build must never
    regenerate it
    — the generator needs GLIBC 2.39, newer than Vercel's build image.
    Only check-types and dev run 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/swagger stays in EXTERNALS in apps/api/scripts/build-func.mjs, because
    swagger-ui-dist resolves its assets from disk at runtime and cannot be bundled.
  • Anything external must be vendored, and vendoring follows non-optional
    peerDependencies, not only dependencies. Nest packages declare their runtime
    needs as peers, so following dependencies alone ships a function that throws
    MODULE_NOT_FOUND on the first request. Adding a name to EXTERNALS without
    checking it lands in .vercel/output breaks 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, and
ThreadWriterService.

  • ThreadWriterService.store is the only writer of EmailThread, EmailMessage
    and the EMAIL activity.
    Gmail and Outlook each parse their own wire format down
    to one IncomingMessage and 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
    from References → In-Reply-To → own Message-ID, so a rep on Gmail and a rep on
    Outlook land on the same EmailThread for the same conversation. Graph only returns
    internetMessageHeaders when $selected and not for every message, so Outlook falls
    back to outlook-conversation:<conversationId> — threading that still holds inside
    Outlook, just not across to Gmail.
  • MailboxSync.source is the discriminator — calendar, gmail, outlook. Each
    provider's module only ever sees its own, and sync/mailbox-sync.service.ts is the
    one place that dispatches. One cron, one budget:
    POST /internal/sync/mailboxes (/google is 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 last receivedDateTime seen,
    re-read with a one-second overlap; rfcMessageId is unique, so the overlap costs a
    duplicate fetch and never a duplicate row.
  • Microsoft has no token-revocation endpoint. revoke clears the columns and the
    UI says the consent itself is removed in the user's Microsoft account. Google's still
    posts to oauth2.googleapis.com/revoke and 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 beside FREE_EMAIL_DOMAINS;
    domainFromEmail returns null for both, and companyForEmail is the only path from
    address to company — so a caller ignorant of the rule still cannot create one.
    .calendar.google.com covers shared calendars, rooms and ICS feeds.
  • Matches the host, never a substring — calendar.acme.com is a real company.
  • isMachineAddress also 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. syncAttendees filters the same addresses beside
    attendee.resource.
  • isAutomatedAddress is 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 as companies.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.
  • role is 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 every buildWhere and every facetCounts
    where.
    A list call defaults to archived: false; the Archived view is the same
    procedure with archived: true. There is no third "everything" mode — mixing
    active and archived rows in one table is exactly what this feature removes.
  • archive/restore are a bare update({ archivedAt }), nothing more. They do
    not touch AgentTask, SuppressedContact, or lastActivityAt — the record is
    unchanged, only hidden.
  • purge is the old delete. Same transaction, same suppression, same
    AgentTask/AgentEvent cleanup, same ActivityStampService.recomputeAfterDelete.
    Read the rest of this section as purge's contract, not archive's.
  • A rep can purge straight 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's
purgeExpired(before) — findMany({ archivedAt: { lte: before } }) capped at
ARCHIVE.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.purge writes SuppressedContact, and
    externalParticipants drops it like a SuppressedDomain — one filter covering
    contact creation, company auto-creation and attribution.
  • Keyed lower case. normalizeEmail (crm/values.ts) is the one canonicaliser,
    on contacts.create, .update and the suppression; conflict checks and allowAgain
    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 own P2025 through translate.
  • Adding them back lifts the suppression via allowAgain inside 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 AgentTask and AgentEvent yourself — they carry contactId/companyId
    with no foreign key, so nothing cascades.
  • Recompute lastActivityAt on exactly the records the purge reached.
    ActivityStampService.targetsOf(where) collects them inside the transaction (the
    evidence is what gets deleted); recomputeMany restamps. A company's where must
    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 onSuccess through useCrmCache() (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 place
    refetchType: "none" is right: the deleted record's byId query 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(), not queryKey() — the latter stamps
    { type: "query" } and silently cannot match { type: "infinite" }.
    activities.timeline is read both ways.
  • cache-manager is per-value and opt-in, not an interceptor;
    AuthService.getProfile is the model.
  • Background writes need polling, not invalidation — refetchInterval while
    PENDING/RUNNING, via isEnriching() and ENRICHMENT_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, and
docs/agent.md before touching apps/agent. The Paper file is
app.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 in
apps/api/src/conversations/conversations.contracts.ts is
["integration", "company", "contact", "deal"], and the composer picker in
apps/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, so
SlackConnectionService.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's
hooks.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 no
SlackWorkspaceGrant 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, and
apps/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 with
conversations.info before it picks a path. slackChannel.isPrivate and
isMember 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 a
slack-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 in
packages/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 and
builderQuestionResponseInput.

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 icon
L5E-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 on
bg-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.

  1. 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.
  2. 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.
  3. 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.
  4. 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]/edit route tree. §10.
  5. Everything that lists data is the same DataTable. Ported verbatim from
    mvp, 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_BUY is terminal, not linear. You listed it between two
    active stages, but it's a disqualification. Treated like CLOSED_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.domain and Contact.email are unique. Duplicates are the #1
    way a CRM rots, and it makes the HubSpot import idempotent. Cost: two people
    behind one info@ 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.md does not exist but AGENTS.md says "Always review" it.
    Every agent session currently starts by failing to read a required file.
  • docs/api.md describes 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.ts is 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 with
bun 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:

ts
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. Use
waitUntil 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; the
EnrichmentService 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:

text
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

text
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
                            aggregates

Dependencies 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 and
better-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.

text
/* 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:

sql
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 an
ActivityCompany 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 correlated
MAX(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 a
LIFECYCLE_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 generic
Association { 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:

sql
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-level
NOT 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 a
NOTE/STAGE_CHANGE/ENRICHMENT row using createdAt. Upcoming =
dueAt > now() and completedAt IS NULL. Overdue = dueAt < now() and
completedAt 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 requires
closedReason 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

prisma
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. Only
label, 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

  • key is immutable after creation. Renaming the label is free; renaming
    the key orphans every stored value. The API rejects key changes outright.
  • Delete is archive. archivedAt hides 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.
  • USER type 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:

ts
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.

ts
// 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

  1. tRPC middleware. PermissionsMiddleware reads
    meta: { 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.
  2. Service-level ownership. No statement grammar expresses "only rows where
    ownerId = me". A shared assertCanMutate(record, user) runs inside the
    service, before the write. This is the layer that actually protects a rep's
    deal from another rep.
  3. 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.cookieCache is on with a
    5-minute maxAge, 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:generate after adding the plugin. It adds role,
    banned, banReason, banExpires to User and impersonatedBy to
    Session. 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.

prisma
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. Use
upload() 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.

  1. Better Auth secondary storage. Port mvp's
    packages/auth/src/secondary-storage.ts verbatim. 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.
  2. 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 namespaced crm:v{gen}:{entity}:{hash}; a
    write bumps that entity's generation counter rather than deleting keys.
  3. 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 through
getServerTrpc() and hydrates:

tsx
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 over
https://api.context.dev/v1 plus an EnrichmentService invoked through
waitUntil.

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 by
mode+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, not
FAILED, and are not billed. 422 on an email lookup (free or disposable) is
SKIPPED 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, so
timeoutMS: 60000 and never in the request path.

Progress in the UI is a TanStack Query refetchInterval that runs while
enrichmentStatus 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. Adds dealScore, dealScoreSummary, dealScoredAt,
    forecastContext and forecastContextManual to Deal in 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, and
    forecastContextManual always 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.

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),
  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 Prisma
gte/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 the
STAGE_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

text
apps/app/app/(app)/
  page.tsx           dashboard
  companies/page.tsx
  contacts/page.tsx
  deals/page.tsx
  settings/properties/page.tsx
  settings/members/page.tsx

There 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 — DetailSheetStats is 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 a SimpleTable rather than the full DataTable — 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 a cmdk search 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:

tsx
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-fixed layout, overflow-hidden cells, so long values truncate instead
    of blowing out the table
  • sticky header with bg-background
  • search in an InputGroup with a leading Search icon
  • one dropdown per facet, radio-group single-select, all as 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 and aria-sort
  • a Columns dropdown toggling hideable columns, persisted in ?hide=
  • the whole filter row collapses behind a Filters button under sm, with an
    active-filter count
  • useDeferredValue on rows so typing stays smooth
  • ROW_ACCENT on clickable rows; expandable rows use ROW_ACCENT_EXPANDABLE
  • pagination footer showing range and total

Five CRM-specific additions, four of them driven by the HubSpot screenshots:

  1. Columns come from the property registry, not a literal array (§5).
  2. A row click sets the record's URL param instead of navigating.
  3. Date-range facets. mvp's DataTable only has single-select radio
    facets, but every HubSpot list filters on Create date, Last activity
    date
    and Close date. Add a type discriminator to DataTableFacet:
    select keeps today's behaviour, dateRange renders a preset list (Today,
    Last 7 days, This month, Last quarter, Custom) over @crm/ui's existing
    calendar and popover. This is the one real extension to the ported
    component; everything else is reuse.
  4. 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
    snapshots query.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.
  5. Standard cell renderers, so every list looks like one product:
    OwnerCell (avatar + name, No owner when null), RelativeDateCell
    ("Today at 10:07", "Yesterday at 3:20 PM", then absolute), CurrencyCell
    (right-aligned, tabular numerals), StageBadge, LifecycleBadge, and
    @crm/ui's existing empty-cell rendering -- 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.
  • Controller for non-native controls (Select, Switch, Combobox);
    register for plain inputs.
  • "" means clear, undefined means leave alone. The submit handler maps
    empty strings to explicit null. Collapsing the two makes "clear this field"
    silently a no-op — mvp learned this the hard way.
  • Submit through useMutation with an optimistic row patch, toast.error
    on failure, invalidate on settle.
  • The custom-property section is generated from buildFormSchema(defs) and
    rendered by a PropertyField switch on type.
  • 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 into
packages/ui; Biome no-useEffect rule; housekeeping on docs/review.md,
docs/api.md and the stray temp file. Verify: the dashboard renders
users.me through a server-prefetched tRPC query.

Phase 1 — Auth, RBAC and hosting. admin plugin + access control in
packages/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, and
lastActivityAt 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 the
cmdk 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), region iad1, private access, in the
    comp-ai-test team. Not yet attached to a project, because the repo has no
    Vercel projects yet; attaching it in Phase 1 is what populates
    BLOB_READ_WRITE_TOKEN. Private access confirms §7's design — the CLI
    requires --access public|private and exposes blob 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 wire DATABASE_URL/DIRECT_URL.
  • docs/review.md — write it or drop the AGENTS.md reference.

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 + currency is what the customer pays. Never converted in place.
  • baseAmount is the only column any total, chart, average or sort may touch;
    fxRate/fxRateAt record how it got there.
  • baseCurrency says what baseAmount is 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) = null baseAmount or wrong baseCurrency, and it
      matches null explicitly — { not: base } is NULL, not true, for a null
      column, so such rows were invisible everywhere.
    • Compose with AND, never spread — it contains an OR, and so does the deals
      list's own where.
    • Every writer of baseAmount writes baseCurrency in the same statement:
      ConversionService and prisma/seed.ts.
  • The rate is resolved once and frozen. create/update call
    ConversionService.dealFields when amount or currency changes, 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, and unconverted counts 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, so
baseAmount = amount × rate everywhere. The feed quotes the other way; RatesService
inverts on ingest.

  • MANUAL beats FETCHED (unique on (base, quote, source)), which makes the
    fetcher optional — Settings → Currencies is the manual path. resolveRate refuses a
    rate ≤ 0.
  • Re-rating deduplicates codes through a Set — currency was free text, so
    usd and USD were two groups each updating every variant.
  • MAX_AMOUNT_CENTS (deals.contracts.ts) is what Decimal(14, 2) holds;
    baseAmount is Decimal(24, 4) so amount × rate still fits.
  • Reporting currency is AppSetting.reportingCurrency, read only through
    readReportingCurrency.
  • Codes are validated against isCurrencyCode (@crm/db/currency), not a regex —
    z.string().length(3) accepted ZZZ. isWellFormedCurrency is separate because
    Intl throws on non-three-letter input.
  • CURRENCIES is eleven currencies and that is all this CRM supports — USD, EUR,
    JPY, GBP, CNY, AUD, CAD, CHF, HKD, SGD, ZAR, in array order. isCurrencyCode is the
    single gate for the picker, the feed filter and the stored setting. A refresh
    prunes FETCHED rows outside the list and leaves MANUAL alone.
  • applyRate rounds to the reporting currency's minorUnitsOf, not to two. The
    ×100 cents transport cannot represent a three-decimal minor unit; nor can
    Decimal(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, and
DealsService 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 to
apps/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-none is 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 duplicate
    DATABASE_URL/BETTER_AUTH_SECRET; when they drifted the API minted a cookie the
    app could not verify and the browser bounced between /sign-in and / forever.
  • The root marker is a package.json declaring workspaces — stopping at the
    first turbo.json resolves the API's root to apps/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.ts republishes it as NEXT_PUBLIC_API_URL, so one variable does both
    sides. BETTER_AUTH_URL is a legacy fallback.
  • Editing a file under packages/ does not restart the API. Restart it by hand.
    bun --watch src/main.ts refuses 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 to packages/auth or packages/db leaves 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 and callbackURL allow-list.
  • Every OAuth redirect_uri is built from API_URL, never APP_URL. Better
    Auth serves /api/auth/* at baseURL, and baseURL is apiUrl. A redirect
    built from APP_URL points at the web app, where /api/auth/callback does not
    exist, and the provider rejects it with "redirect_uri did not match". This is
    invisible until someone sets APP_URL to a tunnel or a LAN host, at which
    point the redirect silently becomes that host. ssoCallbackBase() is the
    pattern; slackRedirectUri in auth.ts once was not.
  • AUTH_COOKIE_DOMAIN only for API and app on different subdomains of one parent.
  • AGENT_URL is 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_PREFIX is crm (@crm/auth/cookies), set on both
    advanced.cookiePrefix in auth.ts and getSessionCookie(request, { cookiePrefix })
    in proxy.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 resolves null. 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 as PRISMA_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 in apps/app/turbo.json passThroughEnv.

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 AppModule is evaluated — a test must set variables before
    importing it (see the dynamic import() in test/auth.e2e.spec.ts).
  • The schema is the API's, not the repo's — @crm/auth and 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 in
    agent/lib/capabilities.ts turn 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 brand task settles SKIPPED before
    anything marks the row RUNNING, and settle only overwrites RUNNING — so the
    company stays PENDING, 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.setResearchKey calls POST /internal/crm/verify-key and writes unless the
    answer is invalid. 401 is the only answer meaning the key is wrong, and a
    check that cannot be made is not a failed check
    — unknown saves 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 by
requireMailboxAccess(), 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), no
GMAIL_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-node in the API and the agent. posthog-js
    appears once, on the trycrm.ai landing 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 on
    window.location.hostname, not on IS_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 in packages/telemetry/src/project.ts. A phc_ 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.ts as optional, like everything else here. Every
    event and the never-sent list are in docs/telemetry.md.

Not env vars

  • Cache TTL — DEFAULT_TTL_MS (60s) in cache.module.ts; CACHE_TTL_MS overrides.
  • Redis — optional; without REDIS_URL the 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 and
function (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.

  1. 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.
  2. 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.
  3. 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 or
USER 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.

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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 and
environment.md are what agents read before changing code; this is what a person
reads once.

First run

sh
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 :2000

Prisma 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 and
schema.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.readonly is 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

sh
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 dev fails 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:

sh
bun run --filter=agent dispatch    # exact production path, both lanes, real credits

Its 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.

  1. Pull somewhere inert: vercel env pull .env.vercel.
  2. packages/db/scripts/require-local-db.ts guards db:migrate, db:push,
    db:reset, db:seed
    and takes ALLOW_REMOTE_DB=1. db:deploy is unguarded on
    purpose. It reads the root files directly rather than process.env, because Bun
    auto-loads the working directory's .env while 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, where
bun 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 against
schema.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, and
agentConversationAttachment 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 an
agentConversationAttachment_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:

sh
DATABASE_URL="…" bunx prisma migrate diff \
  --from-config-datasource --to-schema prisma/schema.prisma --script

Secrets 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

sh
bun run --filter=api test
bun run --filter=agent test    # integration specs need DATABASE_URL + real Postgres

The 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.
A
crm_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 stray
trackedEvent_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, by prisma 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 in
adrs/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 is
test, so a test run cannot emit.

The shape of it

  • Server side only, in everything you run. posthog-node in apps/api and apps/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 at trycrm.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 or ALLOWED_SIGN_IN value 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: null and disables geoip, and the receiving project
    has anonymize_ips on so the address is dropped at ingestion. Both are needed: PostHog stamps
    $ip from 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=test disables it, and apps/api and apps/agent
    additionally set CRM_TELEMETRY_DISABLED=1 in their test scripts — because a spec is allowed
    to reassign process.env.NODE_ENV, and one that does was enough to let a real event out.
  • An allowlist, in code. ALLOWED_PROPERTIES in packages/telemetry/src/allowlist.ts names
    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 — its
sendImmediate 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 earliest
observedAt 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 as
mailbox_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) compares
window.location.hostname against two literals — trycrm.ai and www.trycrm.ai — and
posthog-js sits behind a dynamic import() on the far side of that check. Turn
IS_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 Contact or User name, email, title or photo
  • Any Company name, domain, industry or location
  • ContactFact.value, .sourceUrl, .evidence, .field
  • AgentTask.reason and AgentTask.outcome — both free text the agent wrote about a named person
  • ContactBrief content, WorkspaceProfile.website, .narrative, .sections
  • EmailThread and EmailMessage subjects or bodies, CalendarEvent titles, CalendarAttendee rows
  • Deal names and amounts. Stage distribution is fine; amounts are not.
  • AgentEvent.data, AgentConversation content, prompts, completions, reasoning traces
  • ALLOWED_SIGN_IN, AppSetting.contextDevApiKey, any key, secret, token or connection string
  • SuppressedDomain and SuppressedContact values — counts only
  • IP address. Set $ip: null and 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

  1. Add its name to ALLOWED_PROPERTIES. Until it is there, it is dropped.
  2. Add a row to the table above.
  3. 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, async and defer are 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 the src, which no
    injector can strip. data-site still 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 no
    stale-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.ts as ANONYMOUS = ["/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. Every decodeURIComponent, JSON.parse, new URL and history call 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 with
ERR_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:

  1. User agent — the BOT pattern.
  2. Site id — must be a live cmp_ id, and forSite refuses a rotated one.
  3. Origin — originAllowed. A missing Origin header is refused even with
    the allow list off
    , because the header is the only thing tying the POST to a
    browser on a page.
  4. Visitor id — 8–64 of [a-zA-Z0-9_-], or the batch is dropped.
  5. scripted() — three or more events sharing one timestamp is a replay. It
    only fires when every event carries a numeric at; missing timestamps are
    no signal, and treating them as proof discarded honest traffic.
  6. 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.
  7. Rate — EVENTS_PER_MINUTE charged per accepted event, atomically,
    through TrackingCounter.

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 in cache-manager admits
however many requests are in flight. TrackingCounterService.take(key, limit, amount) is one statement — an INSERT … ON CONFLICT DO UPDATE whose WHERE
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. normalizePath strips ? and # from every path, and
    stripQuery does the same to every referrer, on the event row and inside the
    firstTouch / lastTouch blobs. 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 matches
    SENSITIVE, anything shaped like a card number, and every password, hidden
    and file input — 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_source beats 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). So images.google.de and scholar.google.co.jp are
    Google, while notgoogle.com, google.evil.com, google.com.phish.example and
    google.com.example are all plain referrals. A substring match hands a phisher a
    trusted source name in the rep's report, and so does com.<anything>: the country
    code is what stops SECOND_LEVEL from waving through every com. 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.com contains google., 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.
  • Direct is a source, not a null. Everything lands in one of the seven
    MEDIUMS; an unrecognised medium is other.

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, and CONTACT_CAP_REASON is a
    shared constant because rollup.service.ts matches on it. Telemetry that
    substring-matches prose breaks the first time somebody improves the wording.
  • CONTACTS_PER_HOUR bounds 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, one create loses on the unique index, and the loser attaches to
    the contact that won. Left as a raw P2002 it becomes a stored submission that is
    never filed and never retried.
  • attach claims the row before it writes anything. It is an
    updateMany on filedAt: null, and a caller that loses the claim returns without
    writing — otherwise two concurrent deliveries of one submission each leave the
    contact a Submitted a form on … note.
  • Duplicate delivery retries an unfiled row. dedupeKey collapses a resend
    inside one minute, but if the first attempt died before it filed — no filedAt,
    no skipReason — the resend is the retry.
  • The agent is told, never asked. agent.contactCreated writes an AgentTask.
    No enrichment, no scoring and no identity matching happens here; see docs/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_DAYS back and then truncated.
    A mid-day cutoff splits one calendar day across two nightly runs, and
    trackedPageDaily keeps 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. TrackingRollupService aggregates page_view rows
    only; a click is not a page view, and counting visitors over both produces a row
    reading views = 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. safeFetch follows
    redirects, so the host in the result comes from fetched.url, not from what the
    rep typed — otherwise acme.com reports the allow-list status of a page that
    lives on www.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, verify takes the GTM-…
    ids out of it and reads gtmContainerUrl(id), at most MAX_CONTAINERS of
    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 — and missing names the containers it read, so
    a rep can tell not installed from not looked at.
  • MAX_VERIFY_BYTES is 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 at forSite within 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 equals
    AppSetting.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 to
    null, 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 and
people-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:

  1. 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.
  2. 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 and
searchTerms 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, no
web_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 said
medium 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 no
enrichmentStatus, 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

  1. 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.
  2. The agent runs in a sandbox and uses the harness. bash, web_fetch,
    web_search, glob, grep, todo, ask_question and a /workspace come
    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.
  3. Confidence is data, not a gate. Every fact is stored with evidence,
    score, method and source — including the ones too weak to apply. Discarding a
    medium match throws away the most valuable thing the run produced: a
    candidate a human could settle in three seconds. §5.
  4. 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 and ask_question are for sessions a human started. §5, §7.
  5. The record is the interface to the agent. The Next app mounts eve's
    routes with withEve and the contact sheet renders a live session with
    useEveAgent. The rep watches the same event stream the runtime persists.
    No second app, no chat silo. §6.
  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.
  7. Budget is an input the agent spends, held in defineState and enforced
    in the tool — a VP on a live deal earns a deep pass, a newsletter signup
    earns one lookup. §7.
  8. 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 bash and 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, never psql.
  • Numeric confidence, shown as words. Storing 0.82 and 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.

text
/* Detailed source-code truncated for AI context efficiency. */

AgentEvent is close to free: a single defineHook on * writing
insert … 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 matching
Company, so the sheet reuses isEnriching() and ENRICHMENT_POLL_MS
(api.md) for the non-streaming case. ContactFact subsumes
summarySourceUrl, 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's
socialsCheckedAt, 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 at
session.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_search get 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.md still 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:

  1. No auto-apply without a primary source. Three weak signals multiply to a
    confident-looking number and remain three weak signals.
  2. 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:

ts title="agent/tools/apply_fact.ts (policy sketch)"
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:

text
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.
  • StatusIndicator already 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.result render as a plain
    list of steps: searched LinkedIn · read profile · checked GitHub API ·
    rejected github.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_question renders 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:

ts
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 bash and
    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:

  1. Facts first. ContactFact and the AgentEvent hook land; the agent
    writes through them.
  2. Invert the triggers. companies.service.ts and google-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.
  3. Move company enrichment. Context.dev brand mapping becomes agent tools.
    brand-mapping.ts moves as-is — pure and well-tested.
  4. Delete apps/api/src/enrichment/, minus avatar.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.
  5. Move the tests. enrichment.spec.ts, contact-enrichment.spec.ts and
    the integration spec go to apps/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" adds DATABASE_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

  1. Score on the record, or only in the tooltip? Leaning: band in the
    tooltip, raw number only in the provenance panel.
  2. Do proposals expire? A suggestion nobody has judged in 90 days is noise.
    Auto-dismiss, or keep and stop re-scoring?
  3. Who owns the budget dial? A constant, or a per-account setting a rep can
    turn up?
  4. Does the agent see message bodies? Decided: yes, in full. Internal
    tool, single tenant. The boundary is egress (§4), not access.
  5. Company facts too? The model generalises, but companies have one source
    and no identity problem. Probably later, possibly never.
  6. 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.
  7. 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 one
list_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 to
principalType: "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 and
domainFromEmail/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_BUY is 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 like CLOSED_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.email is unique. Matches HubSpot's own constraint and makes the
    CSV import idempotent. The cost: two people behind one info@ address need
    separate records with one email each.
  • Company.domain is 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.md does not exist but AGENTS.md says "Always review" it.
    Either write it or drop the reference — right now every agent starts by
    failing to read a required file.
  • docs/api.md describes 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.ts is a stray scratch file, safe to delete.

2. Architecture

text
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:

  1. apps/app/app/api/[...path]/route.ts — a catch-all proxy (ported from
    mvp) so the browser calls /api/trpc same-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.
  2. apps/api exports its router type: "exports": { "./app-router": "./src/generated/server.ts" },
    generated by nestjs-trpc generate. The app imports type { 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 in
apps/api. Note nestjs-trpc ships a binary that may need the
postinstall chmod shim mvp uses — check on first install.


3. Data model

New models in packages/db/prisma/schema.prisma. Auth models are untouched.

text
/* 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 and
bun run auth:generate regenerates it.

Timeline semantics

  • Past = occurredAt set, or completedAt set, or a NOTE/STAGE_CHANGE/ENRICHMENT row (use createdAt).
  • Upcoming = dueAt > now() and completedAt IS NULL.
  • Overdue = dueAt < now() and completedAt IS NULL.
  • A company's timeline is WHERE companyId = ?, which picks up deal and contact
    activity for free because we stamp companyId on 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 stamps
    closedAt and prompts for closedReason on the two losing outcomes.

4. The agentic layer (Context.dev)

apps/api/src/enrichment/ — a ContextDevClient (thin typed wrapper over
https://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 → description
  • logos[] → pick by mode/type, never logos[0]:
    logoUrl = light+logo, logoDarkUrl = dark+logo, iconUrl = icon
  • colors[0].hex → brandColor (key off hex; the name is generated)
  • industries.eic[0] → industry / subIndustry
  • address → city, stateCode, country, countryCode
  • socials[] → linkedinUrl / twitterUrl / githubUrl by type
  • links → pricingUrl, careersUrl
  • email, 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, not FAILED (and not billed).
  • 422 on email lookup (free/disposable) → SKIPPED, no company created.
  • 403 FORBIDDEN on prefetch → log once, fall through to direct retrieve.
  • 408/429 → one backoff retry, then FAILED with enrichmentError.
  • Cold lookups are p50 ≈ 7s, p90 ≈ 18s. Always background; never block the
    create mutation. Set timeoutMS: 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.

ts
// 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 a
pg_trgm index if the tables ever get big enough to notice.


6. Frontend

Routes

text
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 + timeline

AppIconRail 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 of
packages/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

  • NuqsAdapter from nuqs/adapters/next/app wraps the root layout, alongside
    TRPCReactProvider.
  • Shared parsers live in a *-search-params.ts per module, importing from
    nuqs/server so 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.
  • clearOnDefault everywhere, 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 full
    DataTable; 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 generate script + exports["./app-router"]; add the watch task
    to dev.
  • apps/app/app/api/[...path]/route.ts proxy; lib/trpc/{server.ts,client.tsx,query-client.ts}.
  • NuqsAdapter + TRPCReactProvider in the root layout.
  • Port the data table into packages/ui.
  • Housekeeping: resolve docs/review.md, trim the org sections from
    docs/api.md, delete make-session.tmp.ts.
  • Verify: dashboard renders users.me through tRPC, server-prefetched.

Phase 1 — Schema and seed

  • The models in §3, one migration, User back-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

  • companies router + 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

  • contacts router; list DataTable (name, title, email, company, owner, last
    activity); detail page; company link; "make primary contact" action.
  • Replace the existing contacts/page.tsx placeholder.

Phase 4 — Deals

  • deals router; 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; activities router; 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 ENRICHMENT activity.
  • 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/prefetch warming before the retrieve pass, dry-run report first.
  • Deal board view (?view=board, stage columns, drag to change stage).
  • cmdk quick 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.

  1. The Paper design is fixed. Match it exactly. Do not improve it, round it,
    or reword it.
  2. shadcn components only. No hand-rolled buttons, inputs, switches, chips or
    menus. If a component does not exist, add it to packages/ui with 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 through bun run db:migrate in
    packages/db — never db:push for a schema that ships.
  • Add the back-relations on Company, Contact and Deal
    (fieldValues FieldValue[]).
  • agentFilled Boolean @default(true) — the default is the feature. See
    dynamic-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 the
exports map in packages/db/package.json — the same shape as
@crm/db/currency and @crm/db/workspace, which are the precedents.

It owns:

  • FIELD_TYPES and 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 touch FieldValue.
  • 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.byId gains fields, companies.update accepts
    fields: Record<key, value>. Same for contacts and deals. One write, one
    invalidation, and the existing inline editors keep their mutation.
  • Services throw Nest's HttpException family; DomainErrorMiddleware maps them.
  • Regenerate and commit src/generated/server.ts (bun run trpc:generate).
    If the app cannot see trpc.fields.*, this is why.
  • Tests: apps/api/test/fields.spec.ts, bun:test, modelled on
    apps/api/test/bulk.spec.ts (real database, TEST_RUN_ID-suffixed data,
    cleaned up in afterAll). 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. Add
apps/agent/agent/lib/fields.ts as a thin wrapper, in the shape of
lib/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 agentBrief is the instruction the agent works from. list_fields must
    return it.
  • Tool descriptions carry the same voice as the existing tools — read
    record_fact.ts and search_crm.ts first.
  • Nest still writes rows, not intelligence. fields.backfill writes an
    AgentTask; 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 in
apps/app/components/crm/record-sheet/record-stack.ts — do not start a second
parser file:

ts
fields: parseAsStringLiteral(RECORD_KINDS),
field: parseAsString,
  • fields is the entity whose fields are open; field is either the create
    sentinel new or a field key. Add nothing else to the pasted lines — AGENTS.md
    forbids code comments, and these go straight into record-stack.ts.
  • new is a reserved key, held back by RESERVED_KEYS in
    packages/db/src/fields-shape.ts, so a field labelled New slugs to
    new_field and its editor can still be opened. Never let the sentinel and a
    derivable key collide.
  • Both cleared by the existing write() alongside tab, add, thread.
  • useQueryStates for the pair; null to clear; history: "replace" for sheet
    state, matching what record-stack.ts already does.
  • The cog sets fields to 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:

  1. md sheet size. sheetContentVariants has sm | lg | xl | 2xl and
    nothing near 460px. Add md: "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.
  2. Badge + a mono variant for the type chip.
  3. SortableList over @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, in
position 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 in
companies-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 Companies Contacts Deals
  • Standard fields · 8 · reorder and hide only
  • Custom fields · Drag to order
  • Archived · 2 · values kept, hidden everywhere
  • Footer New field · Order here is the order on the sheet

Empty state

  • No custom fields yet
  • Create dynamic fields that your agents can research and pre-fill.
  • New field

Editor

  • Label
  • Key · 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 list
  • Options · Add option
  • Show on the company sheet · Offer as a column on the Companies table
  • Create field · Cancel · Save changes · Archive

Coverage

  • Filled on 41 of 64 companies
  • Last looked 2 days ago · you accepted 38 of 41
  • Fill 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.

  1. Schema. Models, migration, back-relations. bun run db:migrate.
  2. @crm/db/fields. Types, key derivation, value schemas, read/write. Unit
    tests here; it is pure and cheap to test.
  3. API. Contracts, service, router, module, registration. Regenerate and
    commit server.ts. fields.spec.ts green.
  4. Values on records. byId returns fields, update accepts them, all
    three entities.
  5. packages/ui additions. Sheet md, Badge + mono, Collapsible,
    SortableList, EmptyDescription balance.
  6. The sheet. List → empty → create → edit → archive, in that order,
    checking each against its artboard before moving on.
  7. The cog and the record rendering. Companies first, then contacts and
    deals.
  8. Table columns.
  9. 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-By trailer.
  • 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: agentFilled defaults 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, artboards
Dynamic 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 — is
dynamic-fields-build.md, which points back here
for the schema in §3 rather than copying it.

Read it with AGENTS.md, api.md and
design.md.


1. One sheet, one cog

The cog goes in the DETAILS section header, on all three sheets. That is the
action 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:

text
/companies?record=company:abcd&manageFields=company

so 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

text
/* Detailed source-code truncated for AI context efficiency. */

User carries the other half of that relation:

prisma
fieldValues FieldValue[] @relation("FieldValueUser")

Six decisions worth defending:

  • Typed columns, not a Json value. api.md says 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. AgentTask carries
    contactId/companyId with no FK and api.md documents the chore that
    creates — "clear AgentTask and AgentEvent yourself". 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.
  • key is immutable, label is 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 is where: { companyId } with no fieldId —
    FieldsService.valuesFor opens every sheet that way — and a unique starting
    fieldId cannot serve it. The composite uniques stay for the one-value-per-
    field-per-record rule; the standalone indexes are what the reads use.
  • userId is a foreign key, like every other id on the row. A USER field
    holds a workspace user, so a deleted rep must not leave an id on the record
    that resolves to nobody. SetNull matches Company.owner and
    Contact.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 accepts
fields: 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 shared
crm/fields.ts that both the router's zod schema and the service use, so a
NUMBER 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 is agentFilled: 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 with
agentFilled 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 in
api.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:

  1. add @dnd-kit/core + @dnd-kit/sortable to packages/ui and build a
    SortableList there, which is where a shared interaction belongs; or
  2. 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, resolveOrderBy would 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:

  1. Meetings on the record. A calendar event with someone from Acme shows on
    Acme's timeline — before it happens, and after.
  2. 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.
  3. 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.

  1. 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.
  2. Google's per-mailbox ids are not identities. Gmail threadId is scoped
    to one mailbox, so two reps on one thread would create two CRM threads.
    Identity is the RFC 822 Message-ID for email and iCalUID for calendar,
    both of which are global. §4.2.
  3. Email and calendar get real tables; the timeline gets a projection. One
    Activity row per thread and per event, linked by FK. The timeline query,
    its indexes, its filters and its cursor paging are untouched. §4.
  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.
  5. Auto-creation needs two-way engagement. A thread the rep replied to, or a
    meeting on the calendar. Not a received newsletter. §6.
  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-process EnrichmentQueue deliberately 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:

ts
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 appends options.scope, then appends
    any per-call scopes.
  • 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 and scope. That makes the repair path idempotent.
  • Account.scope is 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) reads Account.scope,
    cache()d so it costs one query per request, and redirects to
    /grant-access when either scope is missing.
  • The (app) layout calls it instead of requireSession(), so the gate covers
    every page in the shell at once.
  • /grant-access lives 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 offers linkSocial to 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:

ts
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 the
MailboxSync 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.readonly is 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

text
/* 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 threadId is mailbox-scoped. If two of our reps are on one thread,
    each mailbox reports a different threadId for it. Deduping on threadId
    would put the same conversation on Acme's timeline twice. The RFC 822
    Message-ID header is globally unique per message, so EmailMessage
    deduplicates on that, and the thread is keyed by the root message id — the
    first entry of References, else In-Reply-To, else the message's own
    Message-ID.
  • iCalUID is 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 the id differs. So the key is
    (iCalUid, originalStartTime): originalStartTime identifies the instance
    even if that instance has been moved.

4.3 Changes to existing models

prisma
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, named
add_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:

  1. Drop our own people. Any address on our own Workspace domain, and any
    address belonging to a User. What is left is the external side.
  2. Drop noise. domainFromEmail() already returns null for free hosts.
    Also drop SuppressedDomain hits and no-reply local parts
    (noreply, no-reply, notifications, donotreply, mailer-daemon,
    bounce, via .* senders).
  3. Contact first, then company. An exact Contact.email match wins —
    Contact.email is already unique. Otherwise resolve the domain against
    Company.domain, which is already unique and normalised.
  4. If nothing matches, hand off to §6. If §6 declines, the item is not
    stored at all.
  5. Stamp both ids. Per the existing timeline convention, an activity carries
    companyId even 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 in SuppressedDomain.
  • 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 the
Company, 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.

ts
// 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 a
CRON_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 a
    historyId. History records are retained "typically at least one week"; an
    out-of-range startHistoryId returns 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.list returns nextSyncToken on 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 current
    historyId as the cursor, and imports nothing. Every later tick is
    history.list from 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 — timeMin is now, 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, the
backfilledTo 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" to TimelineFilter;
    activities.service.ts — the matching arms in filterClause() and the extra
    counts in timelineCounts().
  • ENTRY_SELECT — include a summary of the linked thread/event
    (messageCount, lastMessageAt, startsAt, attendee count). Not bodies.
  • companies.service.ts / contacts.service.ts — nextMeetingAt and
    lastEmailAt on the detail payload, and source as 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 — via bun run --filter=@crm/ui ui:add accordion. The thread
    disclosure. Not currently in the package.
  • thread-message.tsx — one message: sender, time, direction, body. Composes
    avatar, separator.
  • attendee-list.tsx — stacked avatars with response status, over avatar and
    tooltip.
  • connection-card.tsx — a connected-service row with status and actions, over
    card, 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 of ThreadMessages, fetching email.thread on
    first open. Deep-links to Gmail via the stored gmailMessageId.
  • MEETING — title, time range, AttendeeList, conference link. An upcoming
    meeting sorts by startsAt and 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 — a
components/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 in
api.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 a
cache.google() that fans out to the activity keys plus companies and contacts
lists — and note that activities.timeline must use pathKey(), not
queryKey(), 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.md already 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_SELECT carries a snippet;
    bodies come only from email.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" in api.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-driven bun test with 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 different threadIds; assert one EmailThread, one projected
    Activity.
  • 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

  1. Whose mail syncs? Every signed-in user who connects, or a defined sales
    group? Affects volume and the §10 visibility question.
  2. Visibility. §10 — every rep seeing every synced thread follows from the
    current auth model. Confirm that is intended.
  3. Retention. Do synced messages age out (say, 24 months), or live as long
    as the company record?
  4. 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.
  5. Cron granularity. Confirm the Vercel plan supports */5.
  6. 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 and
gmail-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:

text
email + company  →  Context.dev web search (site:linkedin.com/in)  →  slug
slug             →  LinkDAPI profile/overview                      →  deep structured profile

Proven end to end on a real CRM contact:

text
[email protected]
  → site:linkedin.com/in tokonkwo "Northwind"
  → tomi-okonkwo
  → Tomi Okonkwo — VP, Fund Finance & Venture Lending — Northwind Bank

y + 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 no
displayName. 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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.

text
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 it

Naming 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 a
turbo.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 to
process.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:

  1. Exact email on the profile — decisive. LinkDAPI exposes contact info on
    some profiles; when the address matches, the match is certain.
  2. Local-part decomposition against a real name. pmarchetti is consistent
    with a + marchetti and with abi + gham. Checked against candidates,
    never used to generate a name — the direction matters, and it is the whole
    difference between this and guessing.
  3. Company match, current employer equals the contact's company.
  4. 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:

prisma
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 matching
Company, 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:

ts
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. GoogleMatchService decides 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, which hiQ v. LinkedIn largely 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.
    ContactEnrichment with sourceUrl and fetchedAt is 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.ts being 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 Prove the API resolves real CRM addresses 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

  1. Whose credits? One shared app key (principalType: "app", which is
    eve's default when getToken is the only auth), or per-rep? Shared is
    simpler and almost certainly right for an internal tool.
  2. Does a medium suggestion 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.
  3. Retention. How long does ContactEnrichment.raw live? It is the thing
    that makes re-derivation free and the thing an erasure request is about.
  4. 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.
  5. Is find_colleagues allowed 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 ---