MC.
CRM case studyBack to résumé ↗
CRM · data · security

From a spreadsheet pipeline to a private CRM.

How a real estate investor’s pipeline moved out of a many-tabbed spreadsheet and into a self-hosted CRM—with a deliberately narrow door for an AI assistant.

A reconstruction from the project’s commit history, decision records, and deployment notes

The spreadsheet was the system.

The client is a real estate investor. The business runs on the phone: thousands of owner-operators, investors, and brokers, each one a call, a note, and a date to call back.

All of it lived in one spreadsheet. Several sheets had been merged into a single flat table—one row per person, with the firm’s portfolio figures repeated on every row—plus a separate sheet of equity investors with their own vocabulary. It worked, until the questions got harder: who is due a call today, who has slipped, which firms own the kind of property a buyer is asking about, and when does a building’s loan come due?

The goal was not a CRM for its own sake. It was to make the daily call faster, keep follow-ups from falling through, and let an AI assistant help without handing it the keys.

6build phases, each with a gate
11actions the AI assistant may take
2logins before any record
0inbound web ports on the server

The build happened in layers.

Each phase had to ship something usable on its own, with a written gate that said how to tell it worked. Several gates are still open on purpose: they wait on real calls and real data, not on code.

An off-the-shelf core

EspoCRM on PostgreSQL, in Docker. Espo’s own documentation only shows MySQL, so Postgres support was checked against the image before anything was built on it. The scheduled-jobs daemon runs as its own service: without it, reminders silently never fire.

A real-estate model

Properties became first-class records with units, key regulatory dates, loan maturity, and roles linking people to buildings. Derived values such as price per unit are computed, never typed, because a stored copy is guaranteed to drift.

The phone call as the unit of work

An outbound queue, an overdue list, and a call log that records the outcome, notes, and next follow-up in one save—with one-click intervals like +1 week and +3 months. The phase gate is a stopwatch: twenty real calls, median under thirty seconds.

A narrow door for an AI assistant

Instead of giving an assistant the admin login, a small integration service exposes eleven verbs—search, log a call, schedule a follow-up, update a deal—over the Model Context Protocol. It has its own restricted user, an allowlist of writable fields, a do-not-call guard, and an append-only audit log of every write.

Imports that doubt themselves

Every source row is staged verbatim before anything reaches the CRM. Duplicate matching is biased toward keeping records apart, because a wrong merge destroys data and a missed one only costs a cleanup. Doubtful values are imported with a flag that says what to check, instead of being silently “fixed.”

A detour through a hosted demo

For the approval review, the whole stack was packed into a single container on a hosted platform with a site-wide password. The first live deploy showed the password was not being asked for at all. The hosting was dropped soon after in favor of a machine the client controls.

A private home

The CRM now runs on a small always-free ARM server behind Cloudflare Tunnel and Access. The data moved with checksums and table-by-table fingerprints, every credential was regenerated on the server, and the old ones were proven to fail.

Private by construction, not by configuration.

Nothing on the server accepts a web connection from the internet. The server dials out to Cloudflare; Cloudflare checks who you are before a single request is forwarded; and the CRM itself only listens on the server’s own loopback address.

   BROWSER (approved email only)
        │  1. Cloudflare Access: emailed one-time code
        ▼
   ┌──────────────────────────────────┐
   │  Cloudflare edge                 │  identity check, TLS
   └────┬─────────────────────────────┘
        │  2. down a tunnel the server opened (outbound only)
        ▼
   ┌──────────────────────────────────┐
   │  Server — loopback only          │
   │   EspoCRM ── PostgreSQL          │  3. the CRM's own login
   │   integration service (MCP)      │  AI assistant's narrow door
   └──────────────────────────────────┘
   host firewall: SSH from known addresses; everything else rejected

CRM core

  • EspoCRM with custom entities defined as metadata in Git, generated by a script.
  • Follow-up lists are saved filters, so each dashboard tile equals the list it opens.
  • Key fields keep a change history: old value, new value, who, when.
  • Do-not-call is enforced in the UI, the API service, and campaign lists.

AI integration

  • Its own API user and role; never the admin account.
  • Writes limited to an allowlist of fields per verb.
  • Every write logged with before, after, and the conversation that asked.
  • The one feature that needs a model proposes changes; it never applies them.

Imports

  • Every source row staged verbatim before it reaches the CRM.
  • Duplicate matching leans toward keeping records apart.
  • Doubtful values are flagged on the record, not silently fixed.
  • Every record read back and compared after loading.

Perimeter

  • Cloudflare Tunnel: no open web ports, no certificates on the server.
  • Cloudflare Access: only listed email addresses reach the login page.
  • Secrets generated on the server and never copied off it.
  • Deploys travel as git bundles over SSH; the server holds no repository credentials.
One important operational detail: Espo’s admin screens write schema and layout changes into files on the server, not into Git. The deploy script refuses to run if the server has drifted from the repository, so a routine deploy can never quietly overwrite a change made in the browser.

The bugs worth keeping.

Synthetic test data passed. The real spreadsheet, the real source files, and the real network did not—and each failure was quiet enough to have survived for months.

The 5 p.m. bug, twice

Dates were taken from UTC timestamps. After 5 p.m. Pacific, a call counted as tomorrow—and later, tomorrow’s follow-ups showed as due tonight.

A 200 that saved nothing

The CRM’s API can accept a write and drop a field. Imports are now checked by reading every record back and comparing it field by field.

“2.0” is not “2”

Spreadsheet readers return whole numbers as floats. A category code of 2.0 matched nothing that expected 2, and would have blanked a status field on every record.

ZIP codes that flipped

When two columns matched one field, which one won depended on Python’s per-process hash seed. Hundreds of ZIPs changed on alternate loads.

Everyone is local

Behind a proxy, every visitor arrives from the machine itself. That let a password gate wave everyone in, and would have let one person’s failed logins lock out everybody.

Two spellings, one firm

The same firm appeared under two slightly different names. Both mapped to one import key, and the first load created two records; rows are now grouped by key.

An import you cannot audit is a rumor. A response code is not a saved record.

The hard part was not the CRM.

Decide what the AI may write first

A narrow, audited set of verbs made it safe to connect an assistant at all. The interface is the permission model.

Check files, not dictionaries

Published data dictionaries disagreed with the files they describe. Loaders refuse a file that is missing a required column, and name it, instead of loading blanks.

Put the perimeter in front

Security that depends on where a request “comes from” breaks behind every proxy. Identity checked before the server is ever reached does not.