Production system · Python + FastAPI + LLM extraction · deployed for a real estate agency
A small real estate agency was hand-typing every listing into their CRM's web form, and re-typing client submissions that arrived as scattered emails and photos. This replaces both: a feed the CRM pulls on its own schedule, and an email inbox an LLM turns into a draft — that a human still has to open, check, and approve before anything publishes.
Every listing went in by hand through the CRM's own web form — a dozen fields, one at a time, per property. Client submissions arrived as email: a paragraph of facts and a batch of phone photos, in no particular order, sometimes with a document or two mixed in among the photos by accident. Turning that into a clean, correctly-tagged listing was manual work every time, and it didn't scale past whoever had the patience to do the retyping.
Every listing — whether typed into the form or drafted by the model — carries the same stable identifier from creation onward. The CRM matches on that identifier, so re-publishing the feed updates the existing listing instead of creating a second one. That single invariant is what makes "regenerate the whole feed on every save" safe to do without thinking about it.
Not a staged list — these are the five things that went wrong while getting a real listing live and the email intake running, in the order they happened.
The vendor's own field documentation renders client-side and returns nothing to a plain fetch. The first feed was built from secondary sources and failed their validator on four counts — a renamed ID field, a renamed room-count field, a whole contact-phone block that didn't exist yet, and three fields that turned out to need a wrapper element around them. The fix was to stop treating secondary sources as ground truth and let the validator's own error messages drive the schema.
The domain sits behind a CDN in "Full" SSL mode, which encrypts CDN→origin as well as visitor→CDN — the origin server was only listening on port 80. Every request came back 521 until the origin got a certificate. It doesn't need to be publicly trusted for "Full" (only "Full strict" checks that), so a self-signed one on port 443 was enough.
IMAP's standard whole-message fetch has a side effect it doesn't advertise in its name: retrieving a message flips it to \Seen regardless of what the caller does next. A failed run on the very first batch of test emails silently marked all of them read — they'd have been invisible to the next "fetch unread mail" pass forever, unprocessed and unflagged as failed.
The inbox that receives client emails also receives whatever else lands in a mailbox — in this case, a new account's own welcome and security-alert emails from its provider. With nothing telling it otherwise, the model dutifully invented a plausible-looking address and price from account-security boilerplate. The fix wasn't a keyword filter bolted on afterward — it was making "is this actually a listing?" part of the extraction schema itself, so the model has to commit to that judgment before it's allowed to fill in anything else.
Among the first real batch of client photos, two files were scans of an ownership certificate and a cadastral passport — the owner's full name and national insurance number, readable, saved into the same folder as the kitchen and bedroom shots. Nothing flagged them automatically; they were caught by looking at every photo before anything went live, which is the actual policy now: photo review is a manual step in the workflow, not something the pipeline is trusted to get right on its own. See below for why that's a deliberate line, not a gap to close later.
Every draft the model produces — from the web form or from an email — lands in an unpublished state. Nothing reaches the CRM until a person opens it, fixes what's wrong (an address the model couldn't find, a phone number that wasn't in the text, a photo that shouldn't be there), and explicitly approves it. That's not a missing feature; findings 04 and 05 above are exactly why it's the design: a model that will confidently invent an address from a security email, or forward whatever's in a photo folder without looking at it, is a drafting tool, not a publisher. The review step is what makes the rest of the automation safe to trust.
Live for one agency, low volume by design — this replaces an afternoon of retyping per listing, not a high-throughput pipeline. The reusable part isn't specific to real estate: a structured feed sync with a stable identifier per record, and an LLM-drafted intake path that a human approves before anything goes out, is the same shape regardless of what's being listed.