Nico Roig NR-01

AI messaging

Answered a paying client's live WhatsApp, Instagram and phone line in production: qualified leads, booked viewings, and handed over to a human when it should.

Real estateOn standby1,946 words

What I built

An AI assistant that answers a real-estate agency's own WhatsApp, Instagram and phone line: it qualifies leads, searches that agency's property catalogue, books viewings in their calendar, follows up, and hands the conversation to a human when it should. I was co-founder and technical lead; I designed and wrote the platform behind it — one codebase and one database serving every agency — and it ran a paying client's live inbound channel in production until we put the company on hold.

At a glance

Result
Ran a paying client's live inbound channel in production until the company was put on hold
Role
Co-founder and technical lead; designed and wrote the platform
Channels
WhatsApp, Instagram, inbound phone calls, ad lead forms
Platform
One codebase and one Postgres database serving every agency, isolated with row-level security
Quality control
An offline judge grades each thread; corrections stay scoped to one client
Known gaps
No automated tests; voice-provider signature verification written but switched off
Status
Standby: deliberately inert, nothing deleted, every step recorded and reversible
Public code
meta-webhooks, phone-identity, supabase-multitenant-rls
Further reading
architecture-notes

Architecture

WhatsApp / Instagram Ad lead forms Inbound phone calls Check signature, then resolve to one client account Idempotent ingest — a redelivered webhook changes nothing Session layer — may the assistant speak right now? Agent turn — catalogue search, booking, CRM, escalation Delivery — short messages; a failed send becomes a job Durable job table: retries, follow-ups, callbacks, reactivation, reviews Daily schedule, plus a drain after every webhook already answered Offline judge — grades threads, corrects that client only

Scroll sideways to see the whole diagram.

One deployment serves every client. A webhook arrives with no idea whose it is, so the signature is verified first and an external identifier resolves it to a client account; from there every read and write carries that account explicitly. Anything that has to survive a crash — an unsent message, a follow-up, a callback, a reactivation, a nightly review — is a row in a job table rather than a timer inside a request. Clients log into a panel that reads the same database through a different role, and never sees the engine's side of it.

Engineering decisions

1. Isolating one client's data from another's

Problem
Several agencies, all of their leads' private conversations, one product.
Options
A separate database per client — physical isolation, more surface to operate and migrate — or one pooled database with row-level security.
Chose
Pooled, with two different enforcement paths. The panel authenticates as the end user and row-level security confines it, including a second level inside a client: a sales agent only sees the contacts assigned to them, enforced in the database rather than in the UI, so a missing filter in a query can't widen it. The engine deliberately runs with a role that bypasses row-level security, because a webhook arrives before anyone knows whose it is. The tables holding provider tokens and per-client API keys have row-level security on with no policy at all for the panel's role: zero rows, always.
Cost
On the engine side isolation is a discipline, not a guarantee — one forgotten account filter would cross clients. That is why catalogue search is a single shared function that takes the account as its first argument, instead of a query written at each call site.

2. Durable work lives in the database

Problem
Retries, follow-ups, callbacks, reactivations and nightly reviews can't live in the request that started them.
Options
The hosting platform's durable-workflow product, or a job table in the Postgres I already had.
Chose
The table. Work is claimed atomically with skip-locked; the attempt counter increments at claim time, so an attempt that kills the process still counts against its budget; a lease reclaims work abandoned by a dead worker; failures back off exponentially into a dead-letter state; and the idempotency key is unique only across active states, so a job can legitimately be scheduled again once the previous one is done. The deciding reason was that the client panel has to show this work as inspectable rows, and finding cold leads is a scan of the same tables.
Cost
I gave up the platform's own backoff and orchestration primitives, and the hosting plan refuses schedules more frequent than daily — it fails the deploy rather than degrading it. So the schedule is only a floor: real progress comes from draining a few jobs after each webhook has already been answered, which means throughput follows traffic.

3. One person, several channels

Problem
The same person writes on chat, then phones in, and a human colleague sometimes replies from their own handset. Four possible speakers on one thread, and any two of them talking at once looks broken to the client.
Options
Keep state per channel and per conversation, or make the person the unit and arbitrate between channels — and if arbitrating, do it with a model or in code.
Chose
The person is the unit of memory; arbitration is code. Chat reads summaries of past calls, a call opens with a compact summary of the chat. Identity is the phone number normalised to one canonical form at every entry point, which mattered because the telephony carrier hands the caller id in national format while chat delivers it with the country code — the missing prefix is derived from the other end of the call rather than hardcoded. Then the guards: don't answer in chat while the person is on a call; pausing for a human pauses them on every channel, not just the thread they wrote in; and when a colleague answers from their handset the assistant steps back and a later job asks the model whether to resume, defaulting to staying quiet.
Cost
Guards on every path that can produce a message, and a conservative default that sometimes leaves the assistant paused longer than it needed to be. I preferred that to two of them replying to the same person.

4. The context window is where both the cost and the invention live

Problem
Everything expensive and everything dangerous happens in the same place: what the model is shown, and what it is allowed to say about it.
Options
Give the model more context and more freedom and correct the output afterwards, or narrow the input and constrain the output.
Chose
Narrow both. Old history is folded into a rolling per-person summary behind a watermark instead of being replayed. A burst of messages from a lead becomes one turn rather than one turn per line. Listing photos never enter the context at all: the model cites a reference, and a delivery layer reads that reference out of the finished message and attaches the right images. On the output side, no specific listing, price or reference may be mentioned unless the search tool returned it in that same turn; outside the messaging platform's 24-hour service window the model picks a key from a closed catalogue of pre-approved templates and may answer "none", but never writes that copy. Commercial preference — showing the agency's own listings ahead of partner stock — is applied as ordering after the filters, never as a filter, so it can change what a lead sees first but not what matches.
Cost
More machinery between the message arriving and the model seeing it, and that machinery is invisible until output quality drops (see below). Features that depend on approved templates ship live and do nothing until a client's catalogue is approved.

5. Grading the assistant, and letting it correct itself within limits

Problem
Quality is invisible. Nobody reads the conversations, and by the time a client complains the damage is already done.
Options
Sample conversations by hand, or have a model grade them — and if it grades them, decide whether anything is allowed to change automatically.
Chose
An offline judge scores each person's thread against the rules the assistant is actually operating under for that client, and labels failures from a fixed nine-category vocabulary; the most severe is mentioning a listing the search tool never returned. Two things mattered more than the scoring itself: the judge is calibrated to reserve the worst verdict for material failures, because a grader that finds something wrong in every conversation is noise; and the transcript is author-aware, so messages a human colleague wrote are marked as not the assistant's and are not held against it. A bad verdict may produce a correction, but never in the shared prompt — corrections are rows scoped to one client, deduplicated by failure category so recurrence reinforces rather than accumulates, capped per day and in total, with a cooldown and a probation period, and reversible by deleting the row.
Cost
It is deliberately slow to change anything, and off unless a client is opted in, so it only helps where someone turned it on. One practical trap: the provider endpoint this runs on rejects numeric bounds in structured-output schemas, which failed the whole evaluation silently — the range is asked for in the prompt and clamped in code instead.

A production incident, start to finish

Shipping listing photos made the assistant forget prices — over the phone.

Photos were the obvious next feature: a listing without images doesn't sell. They were built so the model never handles them — it writes a message citing a reference, and the delivery layer looks up that listing and sends its images behind the text. No extra model call, no URLs in the context.

The symptom appeared somewhere else. On calls, the assistant could describe a property but stopped being able to say what it cost. Chat looked fine.

Every message sent is persisted, images included — an image record whose body is just a URL. The conversation history the model reads is a fixed-size window over the person's recent messages across all channels. Sending six photos wrote six records. The window filled with URLs and pushed out the listing card, which is where the price was. The voice side receives a compressed slice of that same window, so it ran out of the number first, and only there. The feature that delivered the photos had evicted the data the photos were about.

The fix had two parts. Image records whose body is only a URL are excluded from the window — they are delivery bookkeeping, not conversation, while images sent by the client keep their description because that is conversation. And because filtering shrinks the window, the query now over-fetches and trims, so the window is still N real messages rather than N minus however many photos happened to be sent. Separately, the voice side was given the ability to look a listing up by its reference mid-call, so the number can be recovered instead of remembered.

What I took from it: any record written into a shared bounded resource has to declare whether it is conversation or bookkeeping, because the cost of getting that wrong shows up in a different feature, on a different channel, as a quality problem rather than an error. The same reasoning fixed the photo counter — it is kept per person rather than per conversation, so someone who comes back a week later on another channel isn't sent the same photos again.

A process failure, and what I know is missing

Three finished features sat dead in production: re-engagement of cold leads, alerts when matching stock arrives, and the quality judge. Each waits behind a per-client switch or an external dependency, and nobody remembered to turn them on. The fix wasn't code. It's a diagnostic that walks the whole chain for one client — panel access, channels, provider permissions, approved templates, catalogue, which switches are on — and prints both what is broken and what is built but off, with what each one needs. The rule that came with it is that a feature isn't finished until it appears there. This domain fails silently by default: a missing subscription, a permission granted to a business but not to the token that has to use it, an unverified account. None of them raise an error. They just mean no message ever arrives.

  • There are no automated tests. Type-checking and the build are the gate, and for the parts with real branching — the queue, the session guards — that isn't enough.
  • Signature verification for the voice provider is written but switched off until it can be validated against a real request. The security notes state exactly what that leaves exposed and that it has to be on before the line is public, rather than leaving it as a comfortable unknown.
  • The platform is currently in standby: deliberately inert, nothing deleted, with a document recording what was switched off, in what order, and the exact reverse of each step.