Context
A&O Relocation is a real-estate agency in Valencia - rentals and sales. I had been their client, so I knew how the search looked from the other side of the channel. Nobody asked me to build anything: I built a working version on the agency's real listings first and showed it, and the conversation about money started from a running service rather than a deck.
Deal format: a build fee plus a monthly subscription for running and supporting the service.
Problem
For a client, a Telegram channel is a feed that lives for a day. You can't filter by district or price, you can't see listings on a map, you can't send a foreigner a link in their language, and a week-old listing looks exactly like a stale one. Months of accumulated listings were useless for finding a flat.
The constraint: the agency keeps working in Telegram as it always has. No second workflow, no manual data entry.
Approach
- The channel stays the source of truth. A worker pulls new posts every 10 minutes and rebuilds the catalog from scratch. Raw posts are append-only; the catalog is fully derived, so a parsing mistake is fixed by changing a rule and rebuilding - history never gets corrupted. Only moderator decisions (hide a listing, pin it as current) survive a rebuild.
- The parser's behavior is the spec. Parsing is deterministic, and every rule change has to pass a parity test against a reference corpus captured from the prototype - or consciously bump the parser version.
- LLM only at the edges. In parsing, the model is advise-only: disagreements land in a suggestions table, and rules change only through a commit. The one place the model writes into the product is translation into three languages, and it sits behind a gate: numbers must match, the amenity list must match, and a Ukrainian text that stayed Russian is caught. A suspicious translation is flagged and never shown.
- Deduplication of reposts by shared photos plus a structural fingerprint with an address check; the rejected alternatives are documented in the code, with numbers.
What I built
- A public catalog for rent and sale: 13 server-side filter groups with live counters and a 4-level location tree down to the barrio, a clustered map, listing pages with a photo and video gallery and one-tap WhatsApp, call or Telegram to the listing's agent; four locales with enforced key parity.
- An admin panel for the agency: hide or restore listings, rebuild the catalog, review the parser's suggestions, switch the LLM model without a redeploy, and see anonymous contact-click analytics.
- An ingestion worker: a 6-stage cycle every 10 minutes - Telegram posts, deterministic parsing, geo-binding against 88 committed barrio polygons, repost deduplication, photo and video storage, catalog rebuild, translation stage.
- One shared definition of what is public and what is stale (30 days since the last repost, unless a moderator pins it) - a contract between the worker and the site, not two copies of the same rule.
- Operations: CI/CD on every green push, database backups, security headers with a nonce-based CSP, and alerts from hosting, error tracking and an uptime monitor routed into one Telegram chat.
- Mid-project, a full move from a VPS to serverless Cloudflare (Workers, D1, R2) with a parity report before and after the cutover.
Result
- A production service for a paying client, built and sold solo in 3 weeks from prototype.
- A self-filling catalog: the agency keeps posting to Telegram and does nothing extra.
- Its own deduplication, geo-binding and translation pipeline - not a wrapper around someone else's service.
Takeaways
- Proactive work sells better than a presentation: a service running on the client's own data answers "will it work?" before anyone talks about price.
- A deterministic core with the LLM at the edges is cheaper and easier to trust than handing parsing to a model. Here the model advises and translates; the code decides.
- Moving to serverless is not a change of hosting but a change of data and deployment model - the platform's limits have to be known before the cutover, not discovered during it.