Setup, DB commands, Google Cloud and the vercel env pull hazard: docs/setup.md.
One .env, at the repo root
.env.example is the documentation: every variable the repo reads, with a note, and nothing that is not read. packages/env walks up to the workspace root and reads .env, then .env.local on top.
- Real environment variables always win: the loader never overwrites
process.env, so Vercel/Docker/CI takes precedence. - Never add a per-package
.env. Four once existed with duplicateDATABASE_URL/BETTER_AUTH_SECRET; when they drifted the API minted a cookie the app could not verify and the browser bounced between/sign-inand/forever. - The root marker is a
package.jsondeclaringworkspaces: stopping at the firstturbo.jsonresolves the API's root toapps/api.
NEXT_DIST_DIR, unset by default
The folder next build writes into. Unset it and Next.js writes .next, which is what next start and both Docker images read.
Set it only for a deploy that must answer requests while the new build runs: build into .next-build, rename the folder afterwards, then restart the app. Turbo treats the value as part of the build hash, so a relocated build never reuses the cache of a normal one.
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 both values enable the provider. An incomplete pair disables that provider and logs a warning.
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. Password sign-in, an available OAuth provider, or an SSO provider supplies account access. Incomplete optional credentials disable that provider. They do not prevent API startup.
ALLOWED_SIGN_IN: comma-separated whole domains or single addresses (bare addresses exist for a solo self-hoster, where gmail.com would be an open door). One list, read by the sign-in guard and the sync's "which side is external" decision: if they drifted a colleague would be refused at the door or filed as a lead. An empty list fails closed. Parsed on demand. packages/auth/src/workspace.ts.
Where things are
API_URL(:3001) mints session cookies and serves/api/auth/*;next.config.tsrepublishes it asNEXT_PUBLIC_API_URL, so one variable does both sides.BETTER_AUTH_URLis a legacy fallback.- The API dev command compiles before each start.
apps/api/scripts/dev.tswatches API source, tsconfig, and shared packages. A successful build restartsdist/main.js. A failed build leaves the previous process running. The next successful build replaces that process. APP_URL(:3000) is also the trusted-origin andcallbackURLallow-list.- Every OAuth
redirect_uriis built fromAPI_URL, neverAPP_URL. Better Auth serves/api/auth/*atbaseURL, andbaseURLisapiUrl. A redirect built fromAPP_URLpoints at the web app, where/api/auth/callbackdoes not exist, and the provider rejects it with "redirect_uri did not match". This is invisible until someone setsAPP_URLto a tunnel or a LAN host, at which point the redirect silently becomes that host.ssoCallbackBase()is the pattern;slackRedirectUriinauth.tsonce was not. AUTH_COOKIE_DOMAINonly for API and app on different subdomains of one parent.AGENT_URLis the agent's deployment, server-side only, and must include the scheme: validated at boot, or it throws when a task is queued instead.
IS_MARKETING, landing page flag, off by default
"true" serves app/(landing) at /; anything else sends a signed-out visitor to /sign-in, because the page markets this product.
- Only the literal
true(same shape asPRISMA_LOG_QUERIES). - It decides one thing: what a stranger at
/sees. isMarketing()(apps/app/lib/env.ts) reads per request, so a config change needs no rebuild. Declared inapps/app/turbo.jsonpassThroughEnv.
GOOGLE_SITE_VERIFICATION, unset by default
Google Search Console proves that the site belongs to you. The DNS method needs a TXT record at your domain provider. The HTML tag method needs this variable: paste the token Search Console shows for "HTML tag", without the surrounding meta tag. The app then writes the tag into every page of the public site.
RELOOP_PLANS, off by default
The Plan card in Settings names the limits of a hosted plan. A self-hosted install has none, so the card only renders when RELOOP_PLANS is the literal "true". The operator of a hosted install sets it; nobody else does.
RELOOP_DEMO, off by default
A floating Play demo button drives a scripted tour of the real app with a fake cursor, for recording a product video. It only renders when RELOOP_DEMO is the literal "true". demoOffered() (apps/app/lib/operator.ts) reads it on the server. The steps and timings live in apps/app/components/demo/demo-tour-config.ts. A normal install leaves it unset.
RELOOP_GERMAN, off by default
English is the only language unless RELOOP_GERMAN is the literal "true". Then Settings > General offers German and the agent writes German. The app reads it on the server (apps/app/lib/i18n/server.ts), the agent in agent/lib/language.ts. A missing German key falls back to English, so a new text needs no German translation.
RELOOP_UPDATE_CHECK, on by default
system.version (apps/api/src/system) reports the version from the root package.json and asks api.github.com for the newest release, no token, a short timeout, one call per six hours per process. A failed call gives latest: null, retries after SYSTEM.updateCheck.retryMs, and keeps the last good answer. The literal "false" turns the call off; the procedure then answers checkDisabled: true and never reaches GitHub. Declared in env.validation.ts and the root turbo.json.
Typed, validated env
apps/api/src/config/env.validation.ts runs via ConfigModule.forRoot({ validate }), and lists every variable the API reads and nothing else.
- Validation runs while
AppModuleis evaluated: a test must set variables before importing it (see the dynamicimport()intest/auth.e2e.spec.ts). - The schema is the API's, not the repo's:
@crm/authand the agent read their own.
Optional: what the agent can do
Every outside source is optional and the agent runs with none. A missing key removes a place to look; never an error, never throws. agent/lib/capabilities.ts is the single place that knows what is set.
| Variable | What it adds |
|---|---|
PERPLEXITY_API_KEY | Open-web research with citations; finds a LinkedIn slug |
GITHUB_TOKEN | Raises the GitHub rate limit from 60/hour |
BLOB_READ_WRITE_TOKEN | Mirrors logos and photos into Blob |
OPENROUTER_API_KEY | The model through OpenRouter, when no key was pasted on Settings → General. A pasted key wins |
AGENT_BRIDGE_SECRET | The rep-facing Agent panel, see agent.md |
CODEX_HOME | Where the Codex login lives and where the agent downloads codex on the first ChatGPT sign-in. Defaults to ~/.codex |
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.tsturn on and off with this one key. - An install that had the variable is asked again: no migration, no fallback, and the gate cannot be dismissed.
- Nothing is lost while waiting. A keyless
brandtask settlesSKIPPEDbefore anything marks the rowRUNNING, andsettleonly overwritesRUNNING, so the company staysPENDING, which the sweep re-queues (test/keyless-brand.integration.spec.ts). - Saving the key runs the company sweep immediately (fire-and-forget).
readContextDevKey(@crm/db/settings) is the only reader, read live with no cache. An unreadable database is a capability that is off, not an exception.- The key is never read back: only whether one is set, and its last four.
- The agent checks it, not the API (a vendor client in the API is a bug):
settings.setResearchKeycallsPOST /internal/crm/verify-keyand writes unless the answer is invalid.401is the only answer meaning the key is wrong, and a check that cannot be made is not a failed check:unknownsaves anyway and logs it unverified.
Mailbox sync
Always on, on whichever social provider is configured, so there is no extra redirect URI beyond the sign-in one. Scopes are requested at sign-in and gated 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-nodein the API and the agent. No browser SDK exists anywhere, the landing page included.docs/telemetry.md. - The destination comes from
POSTHOG_KEY,POSTHOG_HOSTandPOSTHOG_UI_HOST(packages/telemetry/src/project.ts). WithoutPOSTHOG_KEYnothing is sent. - 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-idwould count containers. - Declared in
env.validation.tsas optional, like everything else here. Every event and the never-sent list are indocs/telemetry.md.
Not env vars
- Cache TTL:
DEFAULT_TTL_MS(60s) incache.module.ts;CACHE_TTL_MSoverrides. - Redis: optional; without
REDIS_URLthe cache is per-instance in-memory, which is wrong for multi-instance. - Sign-in method: Google and Microsoft are in code; an IdP is a row (SSO, in
api.md).