One customer record your AI agents can actually write to.
folo answers one question well: who is this person, and what has passed between us?
It stores the part of a customer relationship that exists nowhere else — conversations, support requests, promises you made, preferences they stated, and links to the changelog items or feature requests that concern them. Agents read it before replying and write to it the moment anything happens.
Runs entirely on Cloudflare's free tier. No VPS, no container, no database server.
folo does not mirror Stripe or your product databases.
Money lives in Stripe. Product usage lives in the product. folo stores the identity that ties them together — a person's Stripe customer id, their team id in each product — so an agent can find someone here and then query those systems live.
This is the whole design bet. A copy of billing data starts drifting the day after you write it, and the CRM slowly becomes a place people stop trusting. An index doesn't drift.
- Something arrives with one string attached — an email address, a Stripe id →
find_customer - Read everything known about them before replying →
get_customer - Write down what just happened →
log_entry - Close it when it's done →
resolve_entry
Every other tool supports those four.
| Org | A company you deal with. Matched on domain first, then name. |
| Person | A human you deal with, optionally at an Org. |
| Identity | The same human's id in another system — Stripe, a product, GitHub, an email address. The join key. |
| Entry | One thing that happened, on a timeline. |
| Preference | Current state rather than history: "prefers WhatsApp", "no calls before 10am". |
Two entry kinds carry state — support_request (something they need) and promise
(something you said you'd do). Both stay open until resolved, and both show up in the
Inbox and the daily digest. Everything else (conversation, preference, feedback,
decision, lifecycle, link, note) is a plain record.
The Inbox sorts oldest first. The thing that has been waiting longest is the thing most likely to cost you a customer, and newest-first buries it forever.
Browser ──── /api/* (session cookie) ─┐
├──► WorkspaceDO (SQLite, source of truth)
Agents ──── /mcp (Bearer token) ───┘ │
└─► WebSocket broadcast to open tabs
D1: workspaces, members, api_tokens — auth only, never customer data
One Durable Object per workspace, not per customer. A CRM's hot questions are all cross-customer — "who has an open request?", "search everything for this phrase" — so keeping the whole workspace in one DO makes each of them a local indexed SQL query instead of a fan-out across thousands of objects. It also removes the need for a denormalized search index in D1, which is the component most likely to silently drift out of sync.
The cost is that all writes for a workspace serialize through one single-threaded object.
That is the feature: two agents upserting the same customer from two different conversations
cannot interleave, so upsertPerson's match-then-write is atomic by construction with no
locking anywhere in the codebase.
Search is plain LIKE rather than FTS5. At CRM scale (thousands of rows, not millions) an
indexed scan inside one DO is already sub-millisecond, and FTS would mean a second copy of
every body of text to keep in sync.
git clone https://github.com/lomeyollc/folo && cd folo
npm install
npx wrangler d1 create folo # paste the id into wrangler.jsonc
npm run d1:migrate:remote
npx wrangler secret put GOOGLE_CLIENT_ID
npx wrangler secret put SESSION_SECRET # openssl rand -base64 48
npm run deployThen set routes in wrangler.jsonc to your own hostname (or delete it and use the
*.workers.dev URL), and add that URL to your Google OAuth client's Authorized JavaScript
origins — sign-in fails silently otherwise.
Optional: TELEGRAM_BOT_TOKEN + TELEGRAM_CHAT_ID turn on the new-request ping and the
daily digest of what's still open. Leave them unset and notifications are simply skipped.
Create a Bearer token in Settings, then point any MCP client at https://<your-host>/mcp.
Stateless streamable HTTP — every call authenticates itself, no session handshake.
{
"mcpServers": {
"folo": {
"type": "http",
"url": "https://folo.example.com/mcp",
"headers": { "Authorization": "Bearer folo_…" }
}
}
}Claude Code:
claude mcp add --transport http folo https://folo.example.com/mcp \
--header "Authorization: Bearer folo_…"A token acts only inside workspaces its creating human is an active member of — it never has a wider view than the person who made it.
find_customer · get_customer · list_customers · search · upsert_customer ·
upsert_org · list_orgs · add_identity · delete_identity · log_entry ·
list_entries · list_open · resolve_entry · reopen_entry · delete_entry ·
set_preference · list_preferences · list_workspaces · workspace_stats
POST /api/workspaces/<id>/import takes batches of orgs, people and entries and runs them
through the same upsert paths as every other write — so running an import twice does not
duplicate anything, which matters because a partial run is the normal outcome of a large
import.
scripts/migrate-folocrm.py is a working reference implementation (Laravel + D1 → folo,
~3,200 rows). The useful part is the payload shape and what it deliberately doesn't carry
over: plan, MRR and trial dates went in as one dated note rather than live fields, with the
product ids stored as identities so an agent queries the real source instead.
That route accepts a session cookie or an agent Bearer token — a migration is run from a script, and requiring a browser cookie for it just makes people paste their data somewhere worse.
Cloudflare Workers · Durable Objects (SQLite) · D1 · React 19 · React Router · Tailwind v4 ·
Vite · TypeScript · @modelcontextprotocol/sdk
Sibling project: Hive, the same stack applied to task boards.
MIT © Lomeyo LLC