Skip to content
All work
Core operational · final validation before a private beta

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.

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

api/ — organized by domain
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 · schedulers

05Tenant 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

CompanyContactDealTaskInteraction

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.

docker-compose.yml — local services
services:
  web      # React + Vite
  api      # FastAPI
  worker   # background queue consumer
  db       # PostgreSQL
  cache    # Redis
Local development. The production topology (k3s behind Cloudflare) is designed, not yet deployed.

12Testing & 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.