2026 to now · AI technical lead, only engineer

Real-estate AI platform

A CRM, a public listings site, and sales agents that answer on WhatsApp and by voice. I am the entire engineering function.

The company sells property and had no engineers. Staff worked across seven or so tools that did not talk to each other, and the sales team answered the same handful of questions on WhatsApp until midnight.

I built the replacement and I still run it. Twenty API services and eighteen data models behind row-level multi-tenancy, per-tenant encrypted credentials, WebSockets for the live views, FastAPI and Celery in Docker. On top of that sit the agents: WhatsApp and inbound voice, Claude for tool use, Deepgram and ElevenLabs on the phone side, qualifying leads and booking viewings at 2am. Anything a customer can act on is bounded in code rather than in a prompt.

The rest is plumbing that non-technical staff never have to think about. Two-way Microsoft 365 and SharePoint sync over the Graph API, generated listing copy, auto-publishing to the public Next.js site, and ad performance from several platforms collected in one place.

Python · FastAPI · Celery · PostgreSQL · Next.js · Claude tool-use · WhatsApp Business API · Deepgram · ElevenLabs · Microsoft Graph · Docker

The agents were the easy part.

Almost everything below is a system that looked like it was working. That is the pattern I keep running into on this platform. Nothing crashes, nobody complains, and the data has been wrong for months.

Three tool results in one turn, and the model believed the wrong one

A buyer asked about land. The bot said there was none available, then listed four matching plots one message later.

The tool-call audit log had the whole story. search_properties returned count: 4, a clean success. The follow-up tool that renders result cards fired in the same turn, but it reads the previous persisted message row, which is not written until the turn ends. So it saw nothing and returned "no candidates, search again". The model obliged. That re-search hit a dedup guard, which answered "cached, do not re-list" with an empty payload.

Three contradictory signals inside one turn. The model trusted the most recent one over the correct first one and told a real customer there was no inventory.

An agent will believe the last tool result over an earlier one that was right. If two tools in the same turn cannot see each other's state, you are handing the model a contradiction and it resolves it by confabulating. Logging every tool call with both its input and its result is the only reason this was findable at all.

The bug that only a four-turn conversation could find

The booking tool crashed with NameError: 'timedelta' is not defined. The module imported datetime and timezone. A sibling function carried its own local from datetime import timedelta, which covered one call site and left the other one silently broken.

Unit tests never touched it, because nothing called the booking path on its own. It surfaced the first time I wrote an integration scenario that walks a buyer the whole way through: greeting, search, budget, "book me Sunday at four." The same run turned up a duplicate-contact crash in the test harness, because scalar_one_or_none raises the moment a previous run leaves one orphan row behind.

Agent bugs are conversational. Testing each tool in isolation tests the part that was never going to break.

The publish button that had been broken for a year

The public site had been migrated off WordPress to a Next.js app reading the CRM's own API. But the CRM's "Publish live" button, validated and role-gated and the one that looks official, still POSTed to the old WordPress REST API at the same domain. That domain now served Next.js, so the endpoint returned 404 and the button failed silently every single time.

The real publish lever turned out to be a separate wizard step that set a marketing_status field. That path was ungated, which is why 6 of 36 live listings had gone out with no photos.

Two publish paths had drifted apart during the migration and nobody noticed, because the site still worked.

The database that hid 90% of itself

A client's site was showing almost no property information. No address, no specifications, no floor plans. Everyone assumed the source WordPress data was just sparse.

The WordPress real-estate plugin stuffed roughly 90% of every property record into one serialized PHP blob in postmeta, with only a handful of fields mirrored as flat rows. Our migration parser read the flat rows and decoded the blob only to pull gallery image IDs. It was silently dropping the address, the licence numbers, nine floor plans, video, a 360 tour, and the owner contact.

The lesson I keep: when a migration produces plausible but thin data, suspect the parser before you suspect the source.

The filter category nobody created

A client reported that "someone added a new category". A gas station was filed under a bogus commercial type on the public site, in English, on an Arabic page.

Nobody had added anything. The property_type column was correct. The bad value lived in a denormalized website_tags JSONB blob that the public feed allowed to override the column unconditionally, written months earlier by a publish path that sent the coarse UI category instead of the specific slug. That path had been fixed for new writes and never backfilled.

One stale row conjured an entire filter facet. The edit form rendered the stale value as "commercial (current)", which is exactly why it read as a deliberate human action.

Moving a live domain with the mail still on it

The company website and its Microsoft 365 email rode on the same domain, and it had to move registrars without a second of downtime.

Transfer the registration first with "keep existing nameservers", so the losing registrar keeps serving DNS through the grace window. Pre-stage the entire zone at the new host, where it stays inactive until you flip nameservers. Only then switch. Both hosts serve identical records during propagation, so nothing blinks.

I pushed all 16 records through the new host's DNS API rather than typing them, building the payload from the losing registrar's own export. Zero transcription risk on things like a 200-character DKIM value.

Back to work