We rebuilt our CRM with Claude, one table at a time
From a no-code Airtable system to our own app on Postgres, without ever switching the old one off. What worked, what broke, and the rules that came out of it.
AI · immagine generataFor years our CRM was a system built on top of Airtable: eight bases, around a hundred and forty tables, some seventy views. It worked, and that was exactly why nobody wanted to touch it. This summer we started rebuilding it from scratch as our own application on Postgres, written largely in sessions with Claude. It isn't finished, and we'd rather say so up front: some tables still arrive as copies from the old system. But it's where all of us work today, and it's far enough along that we can say what we've learned.
Why we didn't just switch the old one off and the new one on
An agency can't stop for a weekend to migrate. Clients write in, viewings get booked, portals send enquiries at all hours. So we took the slow road: the new system grows alongside the old one, and tables move across one at a time.
At first a one-way copy brings data from Airtable into Postgres: every half hour for the live tables (contacts, viewings, calls, properties), overnight for everything else. Each table has its own cut-over schedule. "Cutting" a table means exactly one thing: removing it from the copy. And before that, the new system must have a place where the table can be edited. A table that still needs the old system to be corrected hasn't been cut, whatever the plan says.
We say this because it happened to us. The cut-over of the most important table, contacts, was set for a specific date at the end of August. It was postponed that same day, because we decided to move a piece of work we'd meant to leave on the old system entirely into the new one. The timeline got longer. The rule stayed the same.
The copy that deletes
The first serious failure came from the most harmless-looking part: the copy. To stay faithful, every run deletes rows from the new database that no longer exist in Airtable. Fine, as long as nobody writes to the new one. As soon as we started writing here, a row saved in the evening vanished at three in the morning, with no error and no log, because it "wasn't there any more" on the other side.
The fix had two parts. Rows born in the new system carry a marker that the copy respects. Every edit made here also goes into a log that is replayed after each copy. Row and log entry are written in the same transaction: both or neither.
The rules we paid for
One publishing channel: git push. At first we deployed by hand, from a local folder. With several work sessions running in parallel, whoever deployed second put their own disk back online. The other person's work disappeared from production while still sitting in the code history. It happened twice, on the old system and on the new one. Today the project is connected to the repository: a push to the main branch builds and deploys, and nobody deploys from their own machine. Before every push we check that the remote branch hasn't moved on. If it has, we merge and check again. A side trap: if the commit author isn't recognised by the hosting service, the build is blocked silently. The push succeeds and it just looks as if things "didn't update".
Empty checkboxes aren't false, they're NULL. In Airtable an unticked box is simply empty. Copied into Postgres it becomes NULL. In SQL, "not done" isn't true for a NULL: it's unknown. The first query looking for incomplete tasks returned zero rows instead of thirty-seven. This affects dozens of columns, so the rule is mechanical: write is not true, never not. A close cousin: comparing with NULL isn't false, it's NULL. In "unread / not done" counts the number comes out plausible and wrong in exactly the case you care about.
Dates are formatted inside the database. Taken out as date objects, they slipped back a day because of the time zone. Now they're formatted in SQL, full stop.
Column names aren't guessed. Ask the database before writing a query. Four out of four "obvious" names were wrong.
Every write to the old system is declared, and read back. For a while the new system also has to write to Airtable, because some old processes still read there. We learned two things. First, these writes have to be declared at the top of the module that makes them, with the table, the reason and who reads it. Second, a 200 response is not proof. We've had the API answer "ok" and, on re-reading, found the old value still in place. Since then every write of this kind reads the field back. If it doesn't match, it says so plainly: the rejection is explained, not reduced to a generic "try again".
Invisibility, which isn't loss
There's a subtler failure than losing data: the data is there, but people still working on the other side can't see it. A new feature created a follow-up file only in the new database. The client showed up as a priority, but the reminder engine, still running on the old system, couldn't see it. Result: a lit-up star and no follow-up, ever. Now every module that writes has to state what the old system sees and what it doesn't. Where invisibility does damage, we write over there too.
The document that lied
The strangest lesson was about instructions, not code. In August we'd written in the project instructions that the new system would never write to the old one. A month later, when we counted, the code did exactly that in twenty-seven different modules, each for a good reason. A "never" broken twenty-seven times protects nothing. It teaches people that the document lies, and it pushes them wrong in both directions: those who believe it duplicate a write that already existed, those who don't add a new one without declaring it. We rewrote the rule as it actually stood. The old decision is struck through but still legible, with its date, because it explains why the code from that time looks the way it does.
Count before you build
Before rebuilding a section, we look at how it worked in the old system and count the rows. One section that sounded like a whole project turned out to be seventeen records. Another, which looked marginal, held hundreds of phone-call transcripts that nobody was reading. A thing's name doesn't tell you how much it weighs. And we read the old code from the published version, not from the local copy: ours was hundreds of commits behind, and the gap kept growing.
What working this way with Claude is like
Almost all the new code was written in sessions with Claude. It worked well on volume: whole sections rewritten in days. But the model didn't invent the rules above. They came from failures, and each one became a line in the instructions every session reads before touching anything. The value isn't in getting a machine to write code. It's in keeping an honest notebook of what broke, and making sure whoever works next reads it, person or model.
