CRM
一个开源的、Agent 优先的 CRM。
一个持久化的研究 Agent 就是产品本身。数据库只是它做记录的地方。
The agent ·
Stack ·
Quick start ·
Configuration ·
Deploying ·
Contributing
## 这是什么
大多数 CRM 只是一个前面带表单的数据库。那些带有 AI 的 CRM 不过是在那个表单旁边加了个聊天框。这两者都把真正的工作——发现真相并将其记录下来——留给了有更好事情要做的普通人。
这个项目的构建方式恰恰相反。**Agent 不是 CRM 的一个功能;CRM 才是 Agent 用来保存笔记的地方。** 它运行在自己的部署中,按照自己的时间表,处理自己的工作队列。它决定接下来看什么,安排自己的后续跟进,消耗研究预算,并在预算耗尽时停止。它完全不是请求-响应式的:即使关闭浏览器,它也会继续运行。
API 故意完全没有智能。NestJS 只报告*发生了一些事情*——一个会话被摄入了,一个公司被创建了,一个参会者是未知的——通过向队列写入一行数据。Agent 租用这一行并决定它意味着什么。调用丰富化 API 的 Nest 服务被视为 bug,[`docs/api.md`](./docs/api.md) 解释了导致这条规则的那次故障。
Agent 自己从不打破的规则:**绝不猜测关于个人的任何信息。** 没有工具接受置信度分数,因为要求模型对自己把握程度进行评级,它一定会评,而且错误的方向通常会让它看起来很有用。工具报告它们*观察到的*——`crm.signature-block`、`github.account-identity`——然后由账本为证据定价。强有力的证据会被写入记录。微弱的证据会变成由人类来决定的建议。关于客户的自信却错误的判断还不如一个空白字段,因为没人能看出它是错的。
它在设计上是单租户和内部的。登录使用 Google,允许列表是一个环境变量,进入的每个人都能看到所有内容。这就是完整的授权模型——在你将其指向真实客户数据之前,请参阅 [SECURITY.md](./SECURITY.md)。
## 截图
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.
|
## Agent
[`apps/agent`](./apps/agent) 是其独立的部署,基于
[**eve**](https://eve.dev) —— Vercel 为持久化 Agent 打造的文件系统优先框架。
工具是一个文件,技能是一个 markdown 文件,时间表是一个文件,而 runtime 负责处理持久化的部分:能在重新部署后幸存的 session,以及从中断处恢复的工作。
| | |
| --- | --- |
| **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`,它不做任何决定。它只租用到期的任务并为每一行开启一个 session。 |
| **一个沙盒** | `bash`, `grep`, `glob` 和一个 `/workspace`,带有 **`deny-all` 出站流量** |
**它自己运行自己。** `lib/tasks.ts` 是工作队列:`claimDue` 使用
`FOR UPDATE SKIP LOCKED` 租用行,因此两个调度器会处理不相交的工作,而一个意外终止的运行会在租约到期时释放其行。任何看起来像“每 N 分钟处理最旧的十个联系人”的逻辑都属于任务的 `dueAt`,而不是 cron 表达式。当 Agent 想要再看某人一眼时,它会调用 `schedule_recheck` 并说明原因——而这个原因会展示给业务代表,因为一个不能说出它为什么会在十四天后回来的 Agent 并没有理由,它只有一个默认设置。
**每个外部数据源都是可选的,并且它被设计为在没有它们的情况下也能运行。**
在完全没有 API 密钥的情况下它依然能工作:`read_crm_history` 会读取你自己的会话、会议和签名块,这是免费的,也是最好的证据——没有任何数据供应商能卖给你一个来自这个人自己地址的回复。每个密钥都会多打开一个可供查看的地方。在每个 session 开始时会告知它此安装拥有哪些密钥,因此它会根据实际拥有的东西制定计划,而不是在一次又一次失败的调用中发现漏洞,并且它会在启动时打印出列表:
```
[agent] on LinkedIn (RAPIDAPI_KEY)
[agent] off Web research (PERPLEXITY_API_KEY)
[agent] off Company brand data (Settings → General)
```
**公司品牌数据由 [Context](https://link.context.dev/crm) 提供** —— Logo、颜色、行业以及域名背后的真实名称,这正是账户以真实面貌呈现与其作为一个带有首字母缩写的灰色方块呈现之间的区别。它是唯一一个要求输入而不是配置的密钥:它存在于一份数据记录中,onboarding 时会要求提供,之后可以通过 **Settings → General** 进行更改,因为自托管的管理员无法为了设置一个环境变量而重新部署。
**沙盒没有网络也没有数据库。** 开启它就是赋予模型一个 shell——这正是一个仅仅调用工具的东西与一个能够保存档案、对比本月与上月资料差异、在一个会话中 grep 签名块的东西之间的区别。`deny-all` 出站流量没有任何成本,因为 `web_fetch` 运行在应用 runtime 中,而 `web_search` 运行在模型提供商端。它移除的是客户邮件正文可能通过 shell 命令泄露的唯一途径。这条规则的另一半是某种缺失:**永远不会向沙盒提供 `DATABASE_URL`。** 在内部工具中,一个带有凭据和出站流量的 shell 也是以数据泄露为形态的;而两者都没有的 shell 只是一个文本处理器。
**你可以与它对话,并观看它工作。** 每个联系人、公司和交易都有一个 **Agent** 标签页——里面有它执行的步骤、它抛弃的线索及原因,还有当它无法在两个人之间做出决定时当场回答的问题。会话是持久化的,并在重新加载后依然保留;记录存在于一个已签名的 token 中,而不是被硬塞进你消息的开头。在两个进程中将 `AGENT_BRIDGE_SECRET` 设置为相同的值即可开启此功能。如果不设置,该标签页会报告未配置,而 Agent 会继续按照自己的时间表运行。
[`docs/agent.md`](./docs/agent.md) 是完整的说明文档。
## 技术栈
一个基于 [Bun](https://bun.com) 的 [Turborepo](https://turborepo.dev) monorepo,部署在
[Vercel](https://vercel.com) 上。
| | |
| --- | --- |
| **Agent** | [eve](https://eve.dev) —— 持久化的 session、工具、技能、时间表、沙盒 |
| **模型** | [Vercel AI Gateway](https://vercel.com/docs/ai-gateway) —— 无需 provider SDK,Vercel 上的 OIDC 意味着无需管理密钥 |
| **沙盒** | 生产环境使用 [Vercel Sandbox](https://vercel.com/docs/vercel-sandbox),本地使用 Docker 或 microsandbox |
| **前端** | [Next.js](https://nextjs.org) App Router · [shadcn/ui](https://ui.shadcn.com) · 使用 [nuqs](https://nuqs.dev) 管理 URL 状态 |
| **API** | 带有 [nestjs-trpc](https://nestjs-trpc.io) 的 [NestJS](https://nestjs.com) —— HTTP、auth、tRPC、Google 同步 |
| **数据** | [Prisma](https://prisma.io) · Postgres ([Neon](https://neon.tech)) · 可选的 Redis ([Upstash](https://upstash.com)) |
| **认证** | [Better Auth](https://better-auth.com),仅限 Google,单一允许列表 |
| **文件** | [Vercel Blob](https://vercel.com/docs/vercel-blob) —— 备份个人资料图片,以防数据源失效 |
| **工具链** | [Biome](https://biomejs.dev) · 全栈使用 TypeScript |
应用通过 **tRPC** 与 API 通信,router 类型是从 NestJS router 生成的——因此前端从 Prisma 数据行到表格单元格都是类型安全的。列表状态(过滤器、排序、分页)存在于 URL 中,因此复制地址栏即可重现当前视图。
### 布局
| 路径 | |
| --- | --- |
| `apps/agent` | 研究 Agent —— 工具、技能、时间表、沙盒 |
| `apps/app` | Next.js 前端 · :3000 |
| `apps/api` | NestJS API —— HTTP、auth、tRPC、Google 同步 · :3001 |
| `packages/db` | Prisma schema、migrations、共享的 Postgres 客户端 |
| `packages/auth` | Better Auth 配置和登录允许列表 |
| `packages/ui` | shadcn/ui 组件,Tailwind 主题 |
| `packages/env` | 查找并加载根目录下的 `.env` |
### 代码库坚持的三条规则
写在实际工作发生的地方,而不是在风格指南中:
- **智能绝不存在于 API 中**([docs/api.md](./docs/api.md))。Nest 报告发生了什么;Agent 决定它意味着什么。身份匹配器的两个副本曾经发生过漂移,直到其中一个匹配了地球上的所有雇主。
- **`packages/ui` 是 UI 的唯一来源**([docs/design.md](./docs/design.md))。禁止在调用处覆盖样式。
- **没有组织。** 刻意设计为单租户。一个总是相同值的 `organizationId` 只是一列、一个索引和一个权限检查,除了在代码审查时看起来像真的以外,毫无用处。
## 快速开始
你需要 [Bun](https://bun.com) 和 Docker。
```
git clone https://github.com/trycompai/crm.git && cd crm
cp .env.example .env # then fill in the four 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
```
应用在 [localhost:3000](http://localhost:3000),API 在
[localhost:3001](http://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 客户端
1. [Google Cloud console](https://console.cloud.google.com/apis/credentials) → **Credentials** → **Create credentials** → **OAuth client ID** → **Web application**。
2. 在 **Authorised redirect URIs** 下,添加 `http://localhost:3001/api/auth/callback/google`。
3. 为项目启用 [Gmail API](https://console.cloud.google.com/apis/library/gmail.googleapis.com) 和 [Calendar API](https://console.cloud.google.com/apis/library/calendar-json.googleapis.com)。
4. 将 client ID 和 secret 复制到 `.env` 中。
Google 是克隆项目一开始使用的登录方法,并且同一个客户端会读取 Gmail 和日历——因此几乎每个安装都需要它。尽管如此,它也是这四个值中唯一一个 API 在没有它的情况下也能启动的:如果一个安装通过自己的身份提供者登录(在 **Settings → SSO** 上添加),则将两者留空即可,既没有 Google 按钮也没有邮件同步。要么一起设置要么不设置;只有一半就等于是一个在 Google 处会失败的按钮。如果你的帐号位于 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
```
## 配置
**在 repo 的根目录下只有一个 `.env`**,由所有三个进程读取。真正的环境变量总是优先,因此在托管平台上,你在那里进行配置,而该文件纯粹是为了本地方便。
除了上面的四个值之外,一切都是可选的,应用程序在没有它们的情况下也能运行。[`.env.example`](./.env.example) 是包含每一项说明的完整列表;简短版本如下:
| | |
| --- | --- |
| `API_URL` / `APP_URL` | 这两半部分(应用和API)的托管位置。仅在非 localhost 时需要。 |
| `PERPLEX_API_KEY` | 让 Agent 能够搜索开放网络,并提供引用。 |
| `RAPIDAPI_KEY` | 让 Agent 能够读取 LinkedIn 个人资料以确认身份。 |
| `AGENT_BRIDGE_SECRET` | 允许业务代表从联系人的 **Agent** 标签页与 Agent 对话。 |
| `REDIS_URL` | 共享缓存。如果没有,则按实例在内存中缓存。 |
| `CRON_SECRET` | 保护 Gmail/Calendar 同步路由。使用此功能必须设置。 |
## 任务
| 命令 | |
| --- | --- |
| `bun run dev` | 所有内容,在 watch 模式下运行 |
| `bun run build` | 构建所有应用和包 |
| `bun run test` | 运行测试套件 |
| `bun run check-types` | 在任何地方运行 `tsc --noEmit` |
| `bun run lint` / `format` | [Biome](https://biomejs.dev) |
| `bun run db:migrate` | 创建并应用 migration |
| `bun run db:seed` | 补充演示流水线(幂等) |
| `bun run db:studio` | Prisma Studio |
| `bun run --filter=api trpc:generate` | 重新生成 `AppRouter` 类型 |
| `bun run --filter=api dev:session` | 打印本地用户的 session cookie |
你可以使用 Turborepo 过滤器来限定它们的作用域:`bun run dev --filter=api`。
因为 Google 是唯一的入口,所以没有办法从终端获取 session——
`dev:session` 会写入 Better Auth 本会写入的数据行,并打印它本会设置的 cookie。它拒绝在 `NODE_ENV=production` 下运行。
## 部署
三个部署和一个 Postgres:Next.js 应用、NestJS API 和 Agent。
它们是相互独立的,它们唯一必须达成一致的是 `DATABASE_URL` 和
`BETTER_AUTH_SECRET` —— API 生成 session cookie,应用验证它,因此不匹配会导致重定向循环,而不是报错。
将 `API_URL` 和 `APP_URL` 设置为真实的 origin,如果两者位于同一父域名的不同子域上,请将 `AUTH_COOKIE_DOMAIN` 设置为该父域,以便一个 cookie 就能覆盖两者。将 `http://your-api-host/api/auth/callback/google` 添加到 OAuth 客户端的重定向 URI 中。设置 `CRON_SECRET` 并让调度程序指向
`POST /internal/sync/google` 以保持邮箱同步运行。
`apps/api/src/generated/server.ts` 已被提交,并且 `build` 绝不能重新生成它——因为生成器需要比大多数构建镜像更新的 GLIBC 版本。请在本地重新生成,并连同导致它的 router 更改一起提交。
## 贡献
我们宁愿要你写的一段话,也不愿要 Agent 写的 pull request。参见
[CONTRIBUTING.md](./CONTRIBUTING.md)。
安全问题请通过 [SECURITY.md](./SECURITY.md) 私下处理,不要提交公开 issue。
## 许可证
[MIT](./LICENSE)。