🔶 CRM:一款开源、Agent 优先的 CRM
英文标题:🔶 CRM: An open-source, agentic-first CRM
该项目将“持久化研究 Agent”而非数据库视为 CRM 的核心:Agent 可持续处理工作队列、自主安排跟进、使用研究预算,并在浏览器关闭后继续运行。系统坚持“API 不承载智能”和“绝不猜测人物信息”两项关键原则,依据可观察证据及证据账本决定是写入记录还是提交人工确认。项目采用单租户内部部署设计,基于 Bun、Turborepo、Next.js、NestJS、Postgres 及 Vercel 的 eve 框架构建,并提供 18 个工具、4 项技能和可选的外部数据源集成。对开发者而言,它展示了一种将 Agent 作为独立、可恢复执行单元来构建业务软件的架构,同时通过无网络沙箱、无数据库凭据和证据分级机制降低数据外泄与模型幻觉风险。
417.5 KB
365.3 KB
299.0 KB
260.5 KB
293.4 KB
254.7 KB
234.1 KB
204.7 KB
32.3 KB
中文
一款开源、Agent 优先的 CRM。
持久化研究 Agent 才是产品;数据库只是它记录信息的地方。
Agent · 技术栈 · 快速开始 · 配置 · 部署 · 参与贡献

大多数 CRM 都是在数据库前面放一张表单。AI CRM 则是在这张表单旁边加一个聊天框。两者都把真正的工作——找出什么是真实的,以及将其记录下来——留给了本来有更重要事情要做的人类。
这个项目的构建方式恰好相反。Agent 不是 CRM 的一个功能;CRM 是 Agent 存放笔记的地方。 它运行在自己的部署环境中,按自己的调度执行,处理自己的工作队列。它会决定接下来调查什么、自行安排后续跟进、消耗研究预算,并在预算耗尽时停止。它并非请求—响应式系统:即使关闭浏览器,它仍会继续运行。
API 被刻意设计为完全不包含智能。NestJS 通过向队列写入一行记录来报告某件事发生了——例如导入了一个邮件线程、创建了一家公司、某位参会者身份未知。Agent 租用该行记录并判断它意味着什么。一个调用数据补全 API 的 Nest 服务会被视为 Bug;docs/api.md 说明了促成这一规则的一次故障。
Agent 自身绝不违反的规则是:绝不猜测有关个人的信息。 没有工具接受置信度评分,因为要求模型评估自身确定性时,它一定会给出评分,而且会在让自己看起来有用的方向上出错。工具只报告其观察到的内容——crm.signature-block、github.account-identity——并由账本为证据定价。强证据会写入记录;弱证据会变成由人工裁决的建议。一条自信却错误的客户事实,比空白字段更糟糕,因为没有人能看出它是错的。
它从设计上就是单租户、内部使用的系统。登录方式为 Google,允许列表由一个环境变量控制,而每一位能够进入的人都可以看到全部内容。这就是完整的授权模型——在将其接入真实客户数据之前,请先阅读 SECURITY.md。
交易——筛选、排序和页码位于 URL 中,因此一个视图就是一个链接。 |
联系人——其中大部分由邮箱同步创建,而非人工录入。 |
|---|---|
公司——Logo、行业和所在地会自行到达。 |
总览——可在 URL 中切换查看个人数据或整个团队的数据。 |
apps/agent 是独立部署的应用,构建于 eve 之上——这是 Vercel 面向持久化 Agent 的文件系统优先框架。一个工具是一个文件,一项技能是一个 Markdown 文件,一个调度也是一个文件;运行时负责持久化部分:即使重新部署仍能存活的会话,以及可从中断处恢复的工作。
|
---|---
18 个自建工具 | read_crm_history、search_crm、identify_contact、research_person、enrich_company、record_fact、schedule_recheck…
4 项技能 | evidence.md、identity-matching.md、data-boundaries.md、writing-a-brief.md——供 Agent 阅读、像代码一样进行版本管理的文本
1 个调度器 | dispatch.ts,它不做任何决策,只租用到期工作,并为每一行启动一个会话。
一个沙箱 | bash、grep、glob 与一个 /workspace,并采用 deny-all 出站访问控制
它自行运行。 lib/tasks.ts 是工作队列:claimDue 使用 FOR UPDATE SKIP LOCKED 租用行,因此两个 dispatcher 会获取互不重叠的工作;如果某次运行终止,其行记录会在租约到期后被释放。任何看起来像“每 N 分钟处理最早的 10 位联系人”的逻辑,都应属于任务的 dueAt,而不是 cron 表达式。当 Agent 想再次查看某人时,它会调用 schedule_recheck 并说明原因;这个原因会展示给销售代表,因为一个无法说明自己为何会在 14 天后回来查看的 Agent,并没有理由,它只有一个默认值。
每个外部数据源都是可选的,系统也被设计为无需任何外部源即可运行。 即使完全没有 API Key,它仍可工作:read_crm_history 会读取你自己的邮件线程、会议和签名块;这些信息免费,而且是最好的证据——没有任何数据供应商能卖给你一封来自该人员本人地址的回复。每个 Key 都会额外打开一个可查询的数据源。在每次会话开始时,系统会告知它当前安装具备哪些能力,因此它会根据实际拥有的条件进行规划,而不会每次调用失败后才发现能力缺口;它还会在启动时打印列表:
[agent] on LinkedIn (RAPIDAPI_KEY)
[agent] off Web research (PERPLEXITY_API_KEY)
[agent] off Company brand data (CONTEXT_DEV_API_KEY)
沙箱没有网络,也没有数据库。 开启沙箱才会赋予模型 Shell;这也是普通工具调用者与能够维护档案、将本月资料和上月资料做 diff、或用 grep 从邮件线程中寻找签名块的系统之间的差异。deny-all 出站控制没有额外成本,因为 web_fetch 在应用运行时中执行,而 web_search 在模型提供商侧执行。它消除的是客户邮件正文通过 Shell 命令流出的唯一途径。这项规则的另一半是一个“缺失”:沙箱从不会获得 DATABASE_URL。 即使是在内部工具中,同时拥有凭据与出站访问能力的 Shell 也具备数据外泄的形态;两者都没有的 Shell 则只是文本处理器。
你可以与它交谈,也可以观察它工作。 每位联系人、每家公司和每笔交易都有一个 Agent 标签页——其中展示它执行的步骤、它丢弃的线索及原因,以及当它无法在两个人之间作出判断时,直接在原处获得回答的问题。对话是持久化的,刷新页面后仍会存在;记录通过签名 Token 传递,而不是硬塞在消息前面。在两个进程中将 AGENT_BRIDGE_SECRET 设为相同的值即可启用该功能。没有设置时,该标签页会提示尚未配置,而 Agent 仍会按自己的调度继续运行。
docs/agent.md 提供完整说明。
这是一个基于 Bun 的 Turborepo Monorepo,部署在 Vercel 上。
|
---|---
Agent | eve——持久化会话、工具、技能、调度器、沙箱
模型 | Vercel AI Gateway——不使用提供商 SDK;Vercel 上的 OIDC 意味着无需管理 Key
沙箱 | 生产环境使用 Vercel Sandbox,本地使用 Docker 或 microsandbox
前端 | Next.js App Router · shadcn/ui · 使用 nuqs 管理 URL 状态
API | NestJS 搭配 nestjs-trpc——HTTP、认证、tRPC、Google 同步
数据 | Prisma · Postgres(Neon)· 可选 Redis(Upstash)
认证 | Better Auth,仅限 Google,单一允许列表
文件 | Vercel Blob——镜像保存头像,使其在来源消失后仍可保留
工具链 | Biome · 全面采用 TypeScript
应用通过 tRPC 与 API 通信,路由器类型从 NestJS 路由器生成——因此从 Prisma 行记录到表格单元格,前端均具备类型安全性。列表状态(筛选、排序、页码)存放在 URL 中,因此复制地址栏即可复现该视图。
路径 |
---|---
apps/agent | 研究 Agent——工具、技能、调度器、沙箱
apps/app | Next.js 前端 · :3000
apps/api | NestJS API——HTTP、认证、tRPC、Google 同步 · :3001
packages/db | Prisma Schema、迁移、共享 Postgres 客户端
packages/auth | Better Auth 配置及登录允许列表
packages/ui | shadcn/ui 组件、Tailwind 主题
packages/env | 查找并加载根目录 .env
代码库坚持的三项规则
这些规则写在实际工作发生的地方,而不是风格指南中:
- 智能绝不存在于 API 中(docs/api.md)。Nest 只报告某件事发生了;Agent 决定其含义。曾经有两份身份匹配器逐渐产生偏差,直到其中一份匹配了地球上的每一位雇主。
packages/ui是唯一的 UI 来源(docs/design.md)。不得在调用位置覆盖样式。- 不存在 organizations。 有意采用单租户设计。一个始终为同一值的
organizationId,只是一列数据、一个索引和一项毫无收益的权限检查,并且在代码审查时看起来像是一项真正的权限设计。
你需要安装 Bun 和 Docker。
git clone https://github.com/trycompai/crm.git && cd crm
bun install
docker compose up -d # Postgres on :5432
cp .env.example .env # then fill in the four values below
bun run db:deploy # apply migrations
bun run db:seed # optional: a believable pipeline to look at
bun run dev
应用位于 localhost:3000,API 位于 localhost:3001。
打开 .env 并设置以下变量。文件中的其他所有配置均为可选项,且已被注释。
| 变量 | 填写内容 |
|---|---|
BETTER_AUTH_SECRET |
openssl rand -base64 32 |
ALLOWED_SIGN_IN |
你的邮箱域名,例如 acme.com;也可以是单个地址,例如 you@gmail.com。 |
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET |
一个 Google OAuth 客户端——耗时 2 分钟,具体步骤见下文。 |
DATABASE_URL 已经匹配 docker compose 启动的 Postgres,因此除非你自带数据库,否则请不要修改它。
获取 Google OAuth 客户端
- 进入 Google Cloud console → Credentials → Create credentials → OAuth client ID → Web application。
- 在 Authorised redirect URIs 下添加
http://localhost:3001/api/auth/callback/google。 - 为项目启用 Gmail API 和 Calendar API。
- 将客户端 ID 和 Secret 复制到
.env。
Google 登录是唯一入口,因此缺少这些配置时 API 不会启动。如果你的账户使用 Google Workspace 域名,请将同意屏幕设置为 Internal,这样组织外的任何人甚至无法进入授权提示页面。
ALLOWED_SIGN_IN 就是完整的授权模型——未设置该值意味着无人可以登录,这是一种安全的失败方向。它接受完整域名、单独邮箱地址,或两者混用:
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 install
只有一个 .env,位于仓库根目录,由三个进程共同读取。真实环境变量始终优先,因此在托管平台上应在那里配置环境变量,而该文件仅作为本地开发便利配置。
除上述四个必填值外,其他配置均为可选,应用即使没有它们也能运行。.env.example 列出了完整配置及每项说明;简要版本如下:
|
---|---
API_URL / APP_URL | 两部分服务的地址。仅在非 localhost 环境下需要。
PERPLEXITY_API_KEY | 允许 Agent 搜索开放网络,并附带引用。
RAPIDAPI_KEY | 允许 Agent 为身份识别读取 LinkedIn 个人资料。
CONTEXT_DEV_API_KEY | 根据域名获取公司 Logo、行业和社交信息。
AGENT_BRIDGE_SECRET | 允许销售代表在联系人的 Agent 标签页中与 Agent 交谈。
REDIS_URL | 共享缓存。没有它时使用单实例内存缓存。
CRON_SECRET | 保护 Gmail/Calendar 同步路由;使用该功能时必填。
English / 原文
An open-source, agentic-first CRM.
A durable research agent is the product. The database is just where it writes things down.
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 API deliberately has no intelligence in it at all. NestJS reports that something happened — a thread was ingested, a company was created, an attendee is unknown — by writing a row to a queue. The agent leases that row and decides what it means. A Nest service that calls an enrichment API is treated as a bug, and docs/api.md explains the outage that made that a rule.
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.
It is single-tenant and internal by design. Sign-in is Google, the allow-list is one environment variable, and everyone who gets in can see everything. That is the whole authorisation model — see SECURITY.md before you point it at real customer data.
Deals — filters, sort and page live in the URL, so a view is a link. |
Contacts — most of these were created by the mailbox sync, not typed. |
|---|---|
Companies — logo, industry and location arrive on their own. |
Overview — yours or the whole team's, toggled in the URL. |
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:
[agent] on LinkedIn (RAPIDAPI_KEY)
[agent] off Web research (PERPLEXITY_API_KEY)
[agent] off Company brand data (CONTEXT_DEV_API_KEY)
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 givenDATABASE_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 | 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, Google sync
Data | Prisma · Postgres (Neon) · optional Redis (Upstash)
Auth | Better Auth, Google-only, 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.
Path |
---|---
apps/agent | The research agent — tools, skills, schedules, sandbox
apps/app | Next.js front end · :3000
apps/api | NestJS API — HTTP, auth, tRPC, Google sync · :3001
packages/db | Prisma schema, migrations, shared Postgres client
packages/auth | Better Auth config and the sign-in allow-list
packages/ui | shadcn/ui components, the Tailwind theme
packages/env | Finds and loads the root .env
Three rules the codebase holds to
Written up where the work happens, not in a style guide:
- Intelligence never lives in the API (docs/api.md). Nest reports that something happened; the agent decides what it means. Two copies of an identity matcher once drifted until one matched every employer on earth.
packages/uiis the only source of UI (docs/design.md). No overriding styles at the call site.- There are no organizations. Single tenant, deliberately. An
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/trycompai/crm.git && cd crm
bun install
docker compose up -d # Postgres on :5432
cp .env.example .env # then fill in the four values below
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.
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. |
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET |
A Google OAuth client — 2 minutes, below. |
DATABASE_URL already matches the docker compose Postgres, so leave it alone unless you brought your own.
Getting the Google OAuth client
- Google Cloud console → Credentials → Create credentials → OAuth client ID → Web application.
- Under Authorised redirect URIs , add
http://localhost:3001/api/auth/callback/google. - Enable the Gmail API and the Calendar API for the project.
- Copy the client ID and secret into
.env.
Google sign-in is the only way in, so the API will not start without these. 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.
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 install
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 four required values, 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.
RAPIDAPI_KEY | Lets the agent read LinkedIn profiles for identity.
CONTEXT_DEV_API_KEY | Company logo, industry and socials from a domain.
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 Gmail/Calendar sync route. Required to use it.
Command |
---|---
bun run dev | Everything, in 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 Google is the only door, 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.
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 to the OAuth client's redirect URIs. Set CRON_SECRET and point a scheduler at POST /internal/sync/google 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.
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.
交易——筛选、排序和页码位于 URL 中,因此一个视图就是一个链接。
联系人——其中大部分由邮箱同步创建,而非人工录入。
公司——Logo、行业和所在地会自行到达。