Configuration
One .env, at the root of the repo
There is one .env, read by all three processes. .env.example is its documentation — every variable the repo reads is in it, with a note, and nothing that isn't read is in it. Real environment variables always win, so on a hosting platform you configure it there and the file is a local convenience.
cp .env.example .envNever add a per-package .env. Files that must agree are files that can disagree — an older layout had four copies of DATABASE_URL, and the failure mode was a session cookie the app couldn't verify and a browser bouncing between /sign-in and / forever.
Required
The API refuses to start without these three:
| Variable | Why it has no default |
|---|---|
DATABASE_URL | docker compose up -d starts a Postgres matching .env.example exactly |
BETTER_AUTH_SECRET | Signs session cookies — openssl rand -base64 32 |
ALLOWED_SIGN_IN | The entire authorization model |
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET are the effective fourth value — set together or not at all — but the API boots without them for an SSO-only install.
Where things are served
| Variable | Purpose |
|---|---|
API_URL | Origin that mints session cookies and serves /api/auth/*. Republished to the browser as NEXT_PUBLIC_API_URL. |
APP_URL | Where the browser is — also the trusted-origin allow-list for credentialed calls and post-sign-in redirects. |
AUTH_COOKIE_DOMAIN | Only when the API and app are on different subdomains of one parent — then .example.com. |
AGENT_URL | The research agent's own origin. Read server-side only, by the app (to proxy the bridge) and the API (to poke the dispatcher). Must include the scheme. |
The session cookie is prefixed crm (__Secure-crm.session_token), not the auth library's default — a shared parent domain with another app would otherwise collide two identically named cookies and every reader would resolve null.
Optional: what the agent can do
Every outside source is optional, and the agent is designed to run with none of them. A missing key removes a place to look — it is never an error, and it never throws. The agent prints the list at boot and states it in the session instructions so it plans around what it actually has.
| Variable | What it adds |
|---|---|
PERPLEXITY_API_KEY | Open-web research with citations, the search that finds a LinkedIn slug, and the company real name / industry / location / socials lookup |
RAPIDAPI_KEY | LinkedIn profiles — name, title, employer, tenure |
GITHUB_TOKEN | Raises the GitHub rate limit above 60/hour when matching profiles |
BLOB_READ_WRITE_TOKEN | Mirrors every logo and profile picture into blob storage rather than linking them |
AGENT_BRIDGE_SECRET | Lets a rep talk to the agent from a record's Agent tab |
REDIS_URL | A shared cache. Without it, per-instance and in-memory. |
CRON_SECRET | Bearer guard on the Gmail / Calendar sync route — required to use it, fails closed if unset |
The agent's model key is not an environment variable either — it's a row, set on Settings → General. See Agent model & providers.
The landing page is a flag, and it's off
IS_MARKETING="true" serves the marketing landing page at /. Anything else — unset, empty, false, 1 — sends a signed-out visitor to /sign-in. It's off by default because the page markets this product; a clone serving it is advertising a CRM to someone already running it. Only the literal true turns it on.
Deploying
The three services are independent; the only things they must agree on are DATABASE_URL and BETTER_AUTH_SECRET. Set API_URL and APP_URL to the real origins, add the API's callback to the OAuth client's redirect URIs, set CRON_SECRET and point a scheduler at POST /internal/sync/google.
vercel env pull writes to .env.local, which wins.env.local is read after .env and overrides it, and vercel env pull writes production credentials there by default. Pull once and every process in the repo silently points at production — with bun run dev and prisma migrate dev running against the live database. Pull somewhere inert instead (vercel env pull .env.vercel). The destructive db:* scripts refuse a non-local host unless you pass ALLOW_REMOTE_DB=1.
The tRPC data surface
Everything the app reads or writes goes through tRPC routers under /api/trpc. The only REST endpoints are /api/auth/* (auth) and /health. Filtering, sorting and pagination happen in the database, never in the browser. The router type is generated and committed — bun run --filter=api trpc:generate — and build must never regenerate it.