trycompai/crm is a CRM where the agent isn't a chat box bolted onto a contacts table. It's a separate deployment, on eve, that leases its own work off a queue, decides what to look at next, and refuses to write anything to a contact record unless the evidence backing it clears a floor.
What it does
It's a self-hostable CRM — contacts, companies, deals — with a research agent that fills in the blanks: title, employer, LinkedIn URL, seniority. The agent runs on its own schedule against its own work queue, not in response to a rep opening a record.
Why I starred it
Most "AI CRM" products are a form with a chat widget next to it. The interesting decision here is architectural: the agent is a separate app (apps/agent) with its own deploy, its own sandbox, and its own database access pattern. And the rule baked into the tool layer — no tool accepts a confidence score — is a real answer to a real failure mode. A model asked to grade its own certainty will, and it'll be wrong in the direction that makes it look useful. This repo prices evidence instead of trusting self-reported confidence.
How it works
The work queue lives in apps/agent/agent/lib/tasks.ts. claimDue() is raw SQL, not Prisma's query builder:
UPDATE "agentTask" AS t
SET "leasedUntil" = ${until}, "attempts" = t."attempts" + 1
FROM (
SELECT t2.id FROM "agentTask" AS t2
WHERE t2."finishedAt" IS NULL
AND t2."dueAt" <= ${now}
AND (t2."leasedUntil" IS NULL OR t2."leasedUntil" < ${now})
AND t2."attempts" < ${MAX_ATTEMPTS}
ORDER BY t2."priority" DESC, t2."dueAt" ASC
LIMIT ${limit}
FOR UPDATE SKIP LOCKED
) AS due
WHERE t.id = due.id
RETURNING ...
FOR UPDATE SKIP LOCKED is the part that matters: two dispatchers can poll the same table at once and each walks away with a disjoint set of rows, no coordination service required. The cron entry in apps/agent/agent/schedules/dispatch.ts runs every minute and just calls drainAll over whatever's due — it doesn't decide what's due, the lease does. If a run dies mid-task, the lease expires and the row is up for grabs again. "Recheck this contact in 90 days" is a row with a dueAt, not a second cron job.
The part I actually stopped to reread is apps/agent/agent/lib/evidence.ts. Every fact the agent wants to write — a title, an employer, a LinkedIn URL — comes with a list of Evidence entries, each tagged with a kind (crm.signature-block, github.account-identity, linkedin.employer-and-name...) and a fixed weight:
export const WEIGHTS = {
"profile.email-match": { weight: 0.95, primary: true, ... },
"crm.signature-block": { weight: 0.8, primary: true, ... },
"web.cited-claim": { weight: 0.4, primary: false, ... },
"handle.name-form": { weight: 0.35, primary: false, ... },
contradiction: { weight: 0, primary: false, ... },
} satisfies Record<EvidenceKind, Weighting>;
scoreEvidence() combines them the way you'd combine independent probabilities of detection — not by averaging, by treating each piece as a chance the claim is wrong and multiplying those chances down:
const combined = evidence.reduce(
(remaining, item) => remaining * (1 - WEIGHTS[item.kind].weight),
1,
);
let score = Math.min(CEILING, 1 - combined);
Stack a handle.name-form (0.35) on top of a search.cites-profile (0.35) and you get 1 - (0.65 × 0.65) = 0.5775 — enough to clear POSSIBLE (0.3) but nowhere near VERIFIED (0.85), and bandFor() in the same file won't grant VERIFIED at all without at least one primary: true entry, no matter how high the score climbs. Weak circumstantial evidence can pile up all it wants; it never becomes strong evidence. That's the actual engineering answer to "the agent hallucinated a job title" — not a bigger prompt, a scoring function two people could review in five minutes and agree on.
record_fact.ts (the tool the agent calls) and facts.ts (where it lands) enforce a second rule on top of the score: fillsBlank() checks humanOwns() first — if a rep already set the field, the agent's evidence doesn't matter, the write is refused. Strong evidence fills a blank. It never overwrites a human.
The sandbox is the other half of the honesty story. apps/agent/agent/sandbox/sandbox.ts is four real lines:
export default defineSandbox({
backend: defaultBackend({
vercel: { networkPolicy: "deny-all" },
docker: { networkPolicy: "deny-all" },
microsandbox: { networkPolicy: "deny-all" },
}),
});
The agent gets bash, grep, glob, and a /workspace — enough to diff this month's enrichment against last month's — but no egress and, per the README, no DATABASE_URL either. Web fetches happen in the app runtime, not the sandbox, so there's no path by which a shell command could exfiltrate a customer's inbox. It's a small design choice that a lot of "give the agent a computer" projects skip.
Using it
git clone https://github.com/trycompai/crm.git && cd crm
cp .env.example .env
bun install
docker compose up -d # Postgres on :5432
bun run db:deploy
bun run db:seed # a believable pipeline to poke at
bun run dev
apps/agent/package.json lists seven dependencies total, and none of them is a model provider SDK — model calls route through the Vercel AI Gateway, so switching models is a config change, not a rewrite. schedule_recheck.ts is worth reading as a tool definition on its own: the reason field has a .min(10) and the description tells the model explicitly what a good one looks like ("a job change here would move the Acme deal," not "scheduled recheck") — that reason gets shown to the rep, so a vague one is visible as vague.
Rough edges
apps/agent has zero *.test.ts files. The repo overall carries about a hundred tests, but the scoring function and the task-leasing SQL — the two pieces this review spent the most time on — have none of them. docker compose plus Bun plus a Postgres seed is a heavier local setup than most repos I cover, and getting the agent tab to actually talk to you requires setting AGENT_BRIDGE_SECRET in two processes, which is easy to skip and get a silently unconfigured tab instead of an error. The repo is young enough (v1.14.0, commits landing multiple times a day as of this writing) that some of these tools — record_job_change.ts, write_workspace_profile.ts — read like they're still finding their final shape.
Bottom line
If you're building anything where an LLM writes facts into a system of record — not just chats about them — evidence.ts and facts.ts are worth reading even if you never run this CRM: the pattern of "weight the source, combine as independent detections, require a primary source to reach the top band, never overwrite a human" is more portable than the app around it.
