Comp AI CRM is an open source, CRM designed for AI agents.
Agentic-first CRM.
The agent · Stack · Quick start · Configuration · Deploying · Contributing
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.
|
|
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 enrichment source is optional. The agent model itself needs an
OpenRouter key. With no research-vendor keys it still works: read_crm_history reads your own threads,
meetings and signature blocks, which is free and is the best evidence there is — no
data vendor can sell you a reply from the person's own address. Each key opens one
more place to look. It is told at the start of every session which ones this install
has, so it plans around what it actually has rather than discovering the gaps one
failed call at a time, and it prints the list at startup:
[agent] on LinkedIn (RAPIDAPI_KEY)
[agent] off Web research (OPENROUTER_API_KEY)
[agent] off Company brand data (Settings → General)
Company brand data is Context — 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. 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.
A Turborepo monorepo on Bun, deployed on Vercel.
| Agent | eve — durable sessions, tools, skills, schedules, sandboxes |
| Model | OpenRouter — one key for model access and Perplexity Sonar research |
| 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 — local email/password or your own IdP; one allow-list |
| Files | Self-hosted MinIO — 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.
| 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 |
Written up where the work happens, not in a style guide:
- Intelligence never lives in the API (docs/api.md). Nest reports that something happened; the agent decides what it means. Two copies of an identity matcher once drifted until one matched every employer on earth.
packages/uiis the only source of UI (docs/design.md). No overriding styles at the call site.- There are no organizations. Single tenant, deliberately. An
organizationIdthat 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.
You need Bun and Docker.
git clone https://github.com/northgraindata/crm.git && cd crm
cp .env.example .env # then fill in the values below
bun install
docker compose -f docker-compose.dev.yml up -d # Postgres on :5432
bun run db:deploy # apply migrations
bun run db:seed # optional: a believable pipeline to look at
bun run devThe app is on localhost:3000, the API on localhost:3001.
That clone gives you release, the default branch and the last tagged release — what you
want if you are running this. main is where unreleased work lands: green, but not cut
yet. If you are here to send a pull request, git switch main first and read
CONTRIBUTING.
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. you@gmail.com. |
OPENROUTER_API_KEY |
An API key from OpenRouter. |
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. |
ZOHO_CLIENT_ID / ZOHO_CLIENT_SECRET |
A Zoho server OAuth client — below. Both or neither. |
Create an account with email/password, then connect Google, Microsoft and Zoho from Settings → Connections. Provider OAuth is not used for login. Each user may add multiple provider accounts; one Google grant exposes all calendars on that account and one Zoho grant may expose multiple mailboxes.
DATABASE_URL already matches the docker compose Postgres, so leave it alone unless
you brought your own.
Getting the Google OAuth client
- Google Cloud console → Credentials → Create credentials → OAuth client ID → Web application.
- Under Authorised redirect URIs, add
http://localhost:3001/api/auth/callback/google. - Enable the Gmail API and the Calendar API for the project.
- Copy the client ID and secret into
.env.
The Google client is only used to link Gmail and Calendar. The API boots without it; the Google connection card then shows that it is not configured. Set the pair together or not at all. 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 Zoho OAuth client
- Create a server-based application in the Zoho API Console.
- Add
http://localhost:3001/api/auth/oauth2/callback/zohoas its redirect URI. - Put the client ID and secret in
.env. - EU accounts use the defaults. For another data centre, set both
ZOHO_ACCOUNTS_URLandZOHO_MAIL_URL, for example.comfor the US.
The CRM requests read-only account and message scopes. The first sync starts from now, so connecting an established mailbox does not backfill its history.
Getting the Microsoft app registration
- Azure portal → Microsoft Entra ID → App registrations → New registration. Microsoft's own walkthrough is Register an application.
- Under Supported account types, pick Accounts in this organizational directory
only if everyone signing in is on your tenant. Anything wider is fine too —
ALLOWED_SIGN_INstill decides who gets an account — but the narrow choice turns outsiders away at Microsoft rather than at our door. - Set the Redirect URI to Web and
http://localhost:3001/api/auth/callback/microsoft. In production this ishttps://<your-api-host>/api/auth/callback/microsoft— the API's origin, not the app's, because the API is what serves/api/auth/*. - API permissions → Add a permission → Microsoft Graph → Delegated
permissions → tick User.Read and Mail.Read.
offline_accessis requested at sign-in and needs no entry here. If your tenant requires admin consent, click Grant admin consent so reps are not each asked. - Certificates & secrets → New client secret. Copy the Value, not the Secret ID — the value is shown once and never again.
- Overview → copy the Application (client) ID.
- Put the two into
.envasMICROSOFT_CLIENT_IDandMICROSOFT_CLIENT_SECRET.
Mail.Read is read-only: the CRM can list and read messages and can never send,
reply, move or delete. Reading is forward-only — the first check records the current
time and imports nothing, so connecting an old mailbox does not dump years of mail into
the CRM.
To refuse other tenants at Microsoft rather than relying on ALLOWED_SIGN_IN, 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:
ALLOWED_SIGN_IN="acme.com" # everyone at your company
ALLOWED_SIGN_IN="acme.com,contractor@gmail.com" # …plus one outsider
ALLOWED_SIGN_IN="you@gmail.com" # a one-person installThere 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 required values above, everything is optional. .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. |
OPENROUTER_API_KEY |
Runs the agent model and Perplexity Sonar web research with citations. |
LINKDAPI_API_KEY / RAPIDAPI_KEY |
Lets the agent read LinkedIn profiles for identity. Direct LinkDAPI is preferred. |
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. |
| 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.
dev:session creates a local development session and prints its cookie. It refuses to
run with NODE_ENV=production.
The root docker-compose.yml is the complete production
stack: web, API, agent, migrations, mailbox/rate cron, Postgres and Redis. Only the
web service is public. It proxies /api/* and /eve/* to the private services, so
the whole CRM uses one hostname and one same-origin session cookie.
- Create a Docker Compose application from this repository and select
release. - Use
docker-compose.ymland attach your domain to thewebservice on port 3000. - Set
BETTER_AUTH_SECRET,ALLOWED_SIGN_INandOPENROUTER_API_KEYin Coolify. - Optionally set any Google, Microsoft or Zoho client pair. Register
https://<your-domain>/api/auth/callback/google,https://<your-domain>/api/auth/callback/microsoft, orhttps://<your-domain>/api/auth/oauth2/callback/zohowith that provider. - Deploy. Coolify generates the database, Redis, bridge and cron secrets; the
one-shot
migrateservice applies migrations before the API starts.
External apps can use the same web hostname at https://<your-domain>/api/v1.
Create scoped personal access tokens in Settings → API access. A separate API
subdomain is optional; if a browser client is hosted elsewhere, add its origin
to the comma-separated APP_URL value so CORS allows it.
RAPIDAPI_KEY and GITHUB_TOKEN are optional. MinIO and its persistent volume are
included in this Compose stack, so no external Blob account or STORAGE_* values
are required in Coolify. The Context key is entered during onboarding rather than
configured in Coolify. Telemetry is off by default in this Compose stack.
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.
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.
MIT.


