The most opinionated decision in trycompai/crm is a refusal: no tool the agent can call accepts a confidence score. Everything else in the design follows from that.
The reasoning in the README is blunt. A model asked to grade its own certainty will happily produce a number, and it will be wrong in the direction that makes it look useful. So the tools do not report belief. They report what they observed, under names like crm.signature-block and github.account-identity, and a ledger prices the evidence. Strong evidence writes to the record. Weak evidence becomes a suggestion a human settles. The justification is the kind of thing anyone who has cleaned up a contacts table recognizes: a confidently wrong fact about a customer is worse than a blank field, because nobody can tell it is wrong.
The agent owns the schedule
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 keeps sessions alive across a redeploy so work resumes where it stopped. The inventory is small and legible: 18 authored tools, 4 skills written as prose the agent reads and the team versions like code, and exactly one schedule.
That schedule, dispatch.ts, decides nothing. It leases what is due and starts a session per row. The queue lives in lib/tasks.ts, where claimDue leases with FOR UPDATE SKIP LOCKED, so two dispatchers pick up disjoint work and a run that dies releases its row when the lease expires. The README draws the line explicitly: anything that sounds like "every N minutes, the oldest ten contacts" belongs in a task's dueAt, not in a cron expression.
The follow-up rule is the part worth stealing. When the agent wants another look at someone it calls schedule_recheck and has to say why, and that reason gets shown to the rep. An agent that cannot explain why it will be back in fourteen days does not have a reason. It has a default.
A shell with nothing worth taking
The sandbox gives the model bash, grep, glob and a /workspace, which is what separates a tool-caller from something that can keep a dossier, diff this month's profile against last month's, and grep a thread for a signature block. It runs with deny-all egress, and the README argues that costs nothing, because web_fetch runs in the app runtime and web_search at the model provider. What the denial removes is the one path by which a customer's email body could leave through a shell command.
The second half of that rule is an absence: the sandbox is never handed DATABASE_URL. A shell with credentials and network access is exfiltration-shaped even inside an internal tool. A shell with neither is a text processor. This is a cheaper and more durable control than any prompt instruction telling the model not to leak things.
Zero keys is a supported configuration
Every outside data source is optional, and running with none of them is the designed baseline. With no API keys, read_crm_history still reads your own threads, meetings and signature blocks, which the project calls the best evidence available, since no vendor can sell you a reply from the person's own address. Each additional key opens one more place to look, and the agent is told at session start which ones this install has, so it plans around what it actually holds instead of discovering gaps one failed call at a time. Startup prints the list, on or off, line by line.
One key is asked for rather than configured. Company brand data and LinkedIn both come from Context, and that key lives in a database row because the onboarding collects it and the General page under Settings changes it later. The stated reason is practical: a self-hoster's admin cannot redeploy to set an environment variable.
The stack is a Turborepo monorepo on Bun. Next.js App Router in front, NestJS with nestjs-trpc behind it, Prisma over Postgres, Better Auth with a single sign-in allow list. The tRPC router type is generated from the Nest routers, so types hold from the Prisma row to the table cell, and list state lives in the URL so copying the address bar reproduces the view. The model layer goes through Vercel AI Gateway, which means no provider SDK in the tree. There are also no organizations, deliberately: single tenant, because an organizationId that is always the same value is a column, an index and a permissions check that buys nothing while reading like a real one at review time.
Every contact, company and deal carries an Agent tab showing the steps as they happen, the leads discarded and why, and the questions the agent could not settle on its own. It turns on when AGENT_BRIDGE_SECRET matches in both processes. Leave it unset and the tab says so, and the agent keeps working its own queue anyway.
GitHub: https://github.com/trycompai/crm
Curated by Agent Palisade — practical AI for small and mid-sized businesses.











