Agentic CRM
For years I worked out of a CRM every day and watched the record rot, because keeping it current was always the task that lost. This is the fix I couldn't build then: a multi-tenant, AI-assisted CRM that reads a team's inbox, proposes the next action, and executes only what a person approves. I built it solo, front to back.
FastAPI · PostgreSQL · Redis · React · TypeScript · Docker · Kubernetes · Gmail OAuth
01Problem
Sales and account teams keep the CRM current by hand. Email arrives, context scatters across threads, and follow-ups slip. The system of record, the thing everything else depends on, goes stale, because updating it is a chore people do late, partially, or not at all.
I spent years on the business side watching this happen. I wanted a CRM that does the upkeep by reading the inbox and proposing the next move, without ever acting on its own.
02What works today
These parts run in development today:
- Multi-tenant workspaces with isolation enforced at the data-access layer.
- Gmail inbox sync over OAuth, running as background jobs.
- AI email analysis that produces structured SuggestedActions.
- Human approval, then dependency-resolved execution into a linked CRM graph.
- JWT and session auth, CSRF protection, and workspace-scoped RBAC.
- Stripe billing.
What isn't here yet: a production deployment, a public beta, users, or revenue. I don't claim any of them. Current work is deployment: containerizing the services, deploying onto Kubernetes, adding observability, and production hardening.
03How it works
The inbox is the input. The AI reads incoming email, extracts intent, and drafts SuggestedActions — create this company, add this contact, open this deal, schedule this task. The user reviews them as one package and decides. Approved actions execute as linked CRM records in the right dependency order, so keeping the CRM current is a quick review rather than an afternoon of data entry.
Pasting an email into a chatbot and asking “what should I do?” is easy, and it isn't the point. The workflow is a deliberately engineered pipeline: email → structured extraction → proposed actions → dependency resolution → human approval → durable database writes. The model handles the extraction and the proposals; the rest is conventional software resolving dependencies, validating, and writing consistently, with a person holding the approval. That's what makes it reliable, repeatable, and traceable rather than a single call to an LLM.
Inbox to CRM. Nothing executes before the approval gate.
Inbox
Gmail, synced over OAuth
AI analysis
extracts intent from email
SuggestedActions
structured proposals
Human approval
accept, edit, or reject
Linked execution
writes the CRM graph
04Architecture
A React and TypeScript front end talks to a FastAPI back end over a typed REST API. PostgreSQL is the system of record; Redis backs caching and the job queue; background workers handle inbox sync and AI analysis off the request path. The API spans roughly 120 routes, organized by domain rather than by layer, with clear lines between request handling, business logic, and persistence.
System architecture
Web app
React · TypeScript · Vite
FastAPI
typed REST API · organized by domain
PostgreSQL
system of record · workspace-scoped
Redis
cache · background job queue
Workers
inbox sync · AI analysis, off the request path
External: Gmail (OAuth, least-privilege) · Stripe (billing)
app/
├─ auth/ JWT · sessions · CSRF
├─ workspaces/ tenancy · RBAC · members
├─ crm/ company · contact · deal · task
├─ email/ Gmail OAuth · sync · import
├─ ai/ analysis · SuggestedActions
├─ billing/ Stripe · plans · webhooks
└─ workers/ queue consumers · schedulers05Tenant isolation
Multi-tenancy sounds like a column: add a workspace_id, filter by it, done. That framing is how isolation bugs ship. Workspace isolation has to hold as an invariant across every query, join, and background job, not only the ones you remember to filter.
So workspace scope is a property of the data-access layer itself: an unscoped query isn't something you can express by accident. The boundary holds because the system enforces it, not because everyone stayed careful. RBAC is kept separate — isolation decides which workspace a row belongs to; RBAC decides what a member may do inside it.
Tenant isolation — scope enforced at the data-access layer
Workspace A
- companies
- contacts
- deals · tasks
Workspace B
- companies
- contacts
- deals · tasks
Workspace C
- companies
- contacts
- deals · tasks
Unscoped queries are unrepresentable — every row carries a workspace, and the access layer requires it.
06Gmail OAuth & inbox sync
Users connect Gmail over OAuth. Every scope requested is a surface to defend, so the integration asks for the least it needs, read access for a system that reads and proposes, then designs around that limit. Tokens are living credentials: they expire, refresh, and get revoked, so authorization, refresh, and revocation live in one place, and tokens are treated as sensitive material, encrypted at rest, never logged, never sent to the client.
Sync runs as background jobs off the request path, and those jobs fail: networks drop, rate limits hit, a refresh races a revocation. So they're idempotent and retryable, and a failed sync never loses an email or duplicates one.
07The approval boundary
The most important decision was that the AI cannot write to the CRM. It produces SuggestedActions: structured, reviewable proposals, each carrying enough context for a person to judge in a second or two. Nothing executes until someone approves the package.
That boundary is what makes the AI safe to point at real customer data. It also made the system much easier to reason about: there is exactly one place where AI output becomes durable state, and a human guards it. Read freely, propose freely, execute only with approval.
08Linked execution
Approved actions rarely stand alone, and the CRM isn't a fixed hierarchy — it's a graph. A contact can stand alone or belong to a company; a deal can link contacts, companies, or both; a task can attach to any combination. So execution isn't a fixed sequence, it's dependency resolution: the engine works out what each approved action needs, creates and links entities in the right order inside a transaction, and writes a consistent graph where every reference resolves. Get it wrong and you get orphaned rows or half-applied state — the quiet corruption a system of record can never have.
The CRM is a graph, not a hierarchy
09Reliability
Inbox sync and AI analysis are slow and untrusted, so they run as background jobs off the request path, behind a Redis-backed queue. The consumers are idempotent and retryable, so re-running converges to the same state instead of corrupting it, and failures are isolated: one failing job doesn't take the others down, and the API stays responsive regardless of worker load.
10Security
Authentication layers JWT and server-side sessions, with CSRF protection on state-changing requests. RBAC governs what a member can do within a workspace. Gmail access is limited to the scopes the product needs.
None of these is hard alone; the bugs live in the seams — a mutation that skips CSRF, a worker that runs without the right identity, a scope wider than the feature needs. Security here is several layers that have to fit together without leaving a gap, plus the hard boundary between what the AI proposes and what actually executes.
11Deployment
Every service runs in Docker, orchestrated with Docker Compose for local development. The production architecture is Docker, PostgreSQL, Redis, and Kubernetes: a k3s cluster behind Cloudflare, with horizontally scalable web, API, and worker pods and PostgreSQL and Redis as dependencies. Current deployment work is containerizing the services, building the Kubernetes deployment, adding observability, and production hardening — the infrastructure for the private beta. It is not a running production deployment yet.
services:
web # React + Vite
api # FastAPI
worker # background queue consumer
db # PostgreSQL
cache # Redis12Testing & validation
Validation focuses on the parts most likely to break trust: tenant isolation, the auth and CSRF paths, the OAuth token lifecycle, and the approval boundary that guarantees AI never executes without sign-off. The cross-tenant access attempt is a first-class test — it has to fail everywhere, including in async paths. The goal isn't a coverage number; it's confidence that the invariants making the product safe actually hold.
13Hard decisions
The problems that took the most design thought — the constraint, and the call I made.
Tenant isolation
- Constraint
- A single row leaking across tenants is unrecoverable, and the boundary has to hold across every query, join, and background job — not just the obvious ones.
- Decision
- Workspace scope is an enforced invariant at the data-access layer, not a per-query convention. Background jobs carry workspace context so async work can't escape the boundary.
Human-in-the-loop safety
- Constraint
- AI output is probabilistic and occasionally confident and wrong. Letting it write directly to customer data would be fast and unsafe.
- Decision
- An explicit approval boundary separates analysis from execution. The AI only ever produces SuggestedActions; nothing mutates the CRM until a person approves them.
Gmail OAuth lifecycle
- Constraint
- Tokens expire, refresh, and get revoked; scopes must stay minimal; and a failed sync can't silently lose or duplicate email.
- Decision
- Authorization, refresh, and revocation are centralized and scope-limited. Sync runs as idempotent, retryable jobs, so failures recover cleanly.
Linked execution
- Constraint
- Approved actions form a graph — a task may need a deal, a contact, and a company that don't exist yet — so naive writes break on unresolved references.
- Decision
- A dependency-resolution step orders writes and builds the linked graph in one transaction, so every reference resolves and partial state never persists.
14Trade-offs
Every one of these was a choice with a cost. I'd make them all again.
- An approval step over autonomy. It costs a click and rules out zero-touch magic. It is also the only reason the AI is safe to point at a customer's real data.
- A strict tenant invariant over developer convenience. Unscoped queries are harder to write, on purpose. I'd rather fight the access layer than ship a cross-tenant leak.
- Gmail first over a universal connector. It ships sooner and covers most users; other providers come in behind one ingestion layer built to expect them.
- A private beta over a splashy launch. Fewer users now, but no one's real data goes in until isolation and auth have been hardened.
15If I started over
In hindsight, a few things I'd change, and a couple I'd keep untouched.
- Design the SuggestedAction schema and the execution engine together. I built the proposals first and the dependency-resolving executor after, and paid for the mismatch when approved actions didn't line up with what the model produced.
- Scope to the workspace in the data-access layer before the first feature endpoint. Retrofitting an invariant is nervous work; starting from it is calm.
- Cut one thin slice end to end — inbox to approved write — before building any domain in breadth, so the integration seams show up first instead of last.
Two things I'd keep exactly: the approval boundary, which I assumed I'd relax later and never wanted to, and treating tenant isolation as the wall the rest of the system bends around.
16Limitations & what's next
Where it stands, plainly:
- In final validation before a small private beta. Not operating at production scale; no users or revenue.
- AI quality depends on email content — ambiguous threads produce weaker proposals that lean harder on review.
- Gmail is the first inbox integration; the ingestion layer is built to support more (Outlook is next).
- Full observability, multi-region, and advanced rate limiting are designed for but still being built.
Next milestones: Outlook alongside Gmail, the k3s production deployment, the private beta, and audit/observability tooling for real use.