Nico Roig
Four systems I built, and the decisions behind them. This is about how they work and where they break, not what they use.
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.
Architecture
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.
What I built
A purchasing system for a restaurant: it predicts what the kitchen will sell, turns that into what has to be bought from each supplier before each delivery cut-off, and puts a ready-to-send order in front of the chef instead of a dashboard. It ran in production for the restaurant it was built for.
The forecast was not the hard part. The hard parts were that the cost of a wrong number is asymmetric and different for every ingredient, that the data a kitchen actually produces is worse than these systems assume, and that units are where a system like this produces a ten-fold error rather than a five-percent one.
Architecture
Sales, purchases and counts arrive in three different vocabularies and are reduced to one ingredient list first, because everything after that is arithmetic on quantities, and arithmetic on mismatched units is how this kind of system produces a catastrophe. Demand then reaches an ingredient by one of two routes, depending on what kind of ingredient it is. Stock is an estimate that gets corrected, not a fact that gets read. The output is an order for a specific supplier and a specific delivery day.
Engineering decisions
1. Two routes to the same number, chosen per ingredient
- Problem
- A single dish sells a handful of units a week, so its own history is sparse and a menu change invalidates it. But plenty of what a kitchen buys does not hang off any one dish at all.
- Options
- Forecast every ingredient directly from its own consumption — dense and simple, blind to menu changes, unable to explain a number. Or forecast dishes and explode them through recipes — explainable and menu-aware, hopeless where the series is thin.
- Chose
- Both, assigned per ingredient. Dish-driven ingredients go through how many people eat and what they order, and then through the recipes: covers is a dense series driven by day of week and calendar, menu mix is a set of slowly-moving shares, and the product of the two survives a menu change and explains itself — this much onion because we expect this many covers and this share of them takes the burger. Everything consumed regardless of what is ordered is forecast directly from its own rate, where there is no explanatory chain worth building.
- Cost
- Two paths to keep honest instead of one, and a judgement per ingredient about which path it belongs on — a judgement that has to be revisited when the menu changes. On the dish route errors also compound: an error in covers moves every ingredient at once and in the same direction, which is why the ordering step needs guards that do not depend on the forecast being right.
2. The objective is the cost of being wrong, not accuracy
- Problem
- Over-ordering fresh fish is a total loss within days. Under-ordering it takes a dish off the menu at nine in the evening. Over-ordering rice costs shelf space. A single accuracy metric averages all of that into a number nobody can act on.
- Options
- Minimise a symmetric error and add a flat safety margin on top, or choose the order quantity directly against a per-ingredient asymmetric cost.
- Chose
- The second. Demand is carried as a range rather than a point, and the quantity is the place in that range where the expected cost of running out meets the expected cost of throwing away — bounded by how much of an over-order would survive to be used at all, given shelf life and turnover. A kitchen cannot give you those two costs in euros, so it is never asked for them: an ingredient is classified once by how it fails, and the classification carries the trade-off.
- Cost
- The system stops being summarisable by one error number, and the thing being optimised is not directly observable — you see waste and stockouts, and both are under-recorded. Judging whether it is working takes more care than reading an accuracy score, and that is an ongoing cost rather than a one-off one.
3. Theoretical stock drifts; a count is a measurement
- Problem
- Depletion computed from sales assumes every gram ends up in a dish. Waste, over-portioning, staff meals and breakage mean the number on the screen walks away from the number on the shelf, and it does it quietly.
- Options
- Trust the running figure and count occasionally to reset it, or treat each count as evidence about the running figure itself.
- Chose
- The count does two jobs. It resets the level, and the gap between what should have been consumed and what actually was becomes an estimate of that ingredient's drift, which then corrects expected consumption going forward. The discrepancy is the signal, not the error to be thrown away.
- Cost
- Accuracy ends up capped by an operational habit that no software controls. Counts get entered by tired people at closing time, so one mistyped number would otherwise become a permanent drift rate for that ingredient — which is exactly why a count implying impossible consumption cannot be absorbed silently.
4. Units, and a guard that sits outside the forecast
- Problem
- Recipes in grams, purchases by the case, suppliers quoting kilos at variable pack weights, and the word "unit" meaning one thing to the chef and another to the supplier. The characteristic failure here is not five percent out, it is ten times out — and an order for ten times the fish is worse than having no system at all.
- Options
- One global unit table with conversions filled in wherever they are missing, or conversions bound to the exact thing they describe.
- Chose
- A conversion belongs to a specific ingredient, from a specific supplier, in a specific pack, and is established when that supplier is set up rather than inferred at order time. On top of that, an order line far above what that ingredient normally moves is stopped for a person to look at, whatever the forecast produced. That check is arithmetic and lives outside the model on purpose, so that improving the forecast can never quietly remove it.
- Cost
- More friction setting up a supplier, and a rule that will occasionally stop a genuinely large order before a big service. Blocking a legitimate order now and then is a far smaller problem than sending one absurd one, and that asymmetry is the entire reason the guard exists.
What I built
A desktop tool for tax professionals that replaces a manual browser workflow: given an article of a tax law, it searches the tax authority's public rulings database, reads each ruling in full, extracts the official answer, and gathers the related administrative-court criteria into a spreadsheet. It runs on a locked-down corporate machine with nothing installed and no credentials, and it shipped with the document its IT and security department needed in order to approve it.
Everything interesting about it is a restraint rather than a capability: what it refuses to automate, what it refuses to claim, and what it had to prove about itself before anyone would run it.
Architecture
Everything before the output is deterministic: search, fetch, verify, extract. Nothing is summarised by a model — the summary column is a verbatim extract of the ruling's own answer. The second output exists because the professional will want to interrogate the material with their own tools, and handing over the raw corpus is a better way to help with that than generating an interpretation.
Engineering decisions
1. Not automating the source that asks you not to
- Problem
- The most valuable cross-reference is Supreme Court case law. Its search engine is public and would have been no harder to drive than the other two.
- Options
- Drive it like the rest and hand over a complete table, or leave it out and give the user a worse tool.
- Chose
- Neither, exactly: the tool never connects to it, and instead writes the exact query into the spreadsheet — the search URL and every field, ready to paste — so the professional runs it themselves in the official search. The column exists; the automation does not.
- Cost
- A manual step in the middle of the workflow the tool exists to remove, in the one column users most want automatic. That is the trade I would make again: the alternative is a firm's traffic against a court's site in a way its terms do not invite, to save a professional thirty seconds.
2. Labelling the uncertain output as uncertain
- Problem
- There is no official identifier linking a tax ruling to a court decision. Any cross-reference is inferred from subject matter — same article, overlapping language — and inference in a legal document reads as authority.
- Options
- Present the matches as results, which is what makes the tool look finished, or present them as candidates, which makes it look unfinished.
- Chose
- Candidates, said plainly in the tool and again in its documentation: these are search aids on the same subject, they require professional review, they may include references that do not apply and may miss ones that do. The limitations section also separates the tool's own limits from the source's — a subsection search returns nothing when the ruling never spells the subsection out, which is a fact about the database, not a bug to be hidden.
- Cost
- It reads as a weaker product than it is, and a demo of it is less impressive. A research tool that quietly promotes an inferred link to an authority is worse than no tool, because the error it produces is invisible and lands in someone's advice.
3. Matching the law, not just the number
- Problem
- Article 74.3 exists in every tax law, and in the repealed versions of each of them. A number alone selects the wrong rulings with confidence.
- Options
- Search the number and let the professional discard what does not apply, or confirm each ruling against the law it actually cites.
- Chose
- Confirm: the requested tax maps to its statute, and each ruling is checked against the governing law it names, discarding same-numbered articles from other and superseded laws.
- Cost
- Recall drops — a ruling that does not state its governing law cleanly falls out of the results. In legal research that is the right direction to lose in: a citation that does not apply costs far more than one that was missed and can be found by widening the search.
4. Built to be approved, not installed
- Problem
- The deliverable is an executable that arrives by email at a company whose security policy blocks executables, and the person deciding has no reason to trust it.
- Options
- Ask for an exception and hope, or make approval cheap enough that saying yes is easy.
- Chose
- The second, as a design constraint rather than paperwork at the end. It uses the shell that already ships with Windows and the spreadsheet software already installed, so nothing is installed, no administrator rights are needed and nothing is left running afterwards; traffic is read-only to three named public domains; there are no credentials to store. It shipped with a dossier written for the reviewer: every endpoint contacted and why, the single file it writes, how to verify the traffic independently with a proxy, and a straight explanation of the one flag that looks alarming — the execution-policy bypass — with two alternatives if the policy prefers them. It also carried a costed fallback: the same logic reimplemented as a cloud script so that nothing executes on company machines at all, with what changes and what that version cannot do.
- Cost
- The dossier took as long as some of the features, and the fallback was designed for a decision that might never need it. Both are only worth it if you accept that a tool nobody is allowed to run has no value at all, however good it is.
Where the model isn't
This is the kind of task people reach for a language model to do: read hundreds of legal texts and tell me what they say. It does not. The only summary it writes is a verbatim extract of the ruling's official answer, because in this domain a paraphrase is a liability — the reader needs the administration's words, not a fluent version of them. Where the material genuinely needs interrogating, the tool prepares the corpus and hands it over: one text file per ruling with its full text and its candidate references, plus a cover page recording the search that produced them, so the professional can put it into a notebook tool and ask their own questions with the sources in front of them. The tool is deterministic and the judgement stays with the person who is accountable for it.
What I built
A training app built on one honest premise: spaced repetition reliably improves what you retain, adaptive working-memory and reasoning tasks reliably improve the task you train, and neither of them reliably raises general intelligence. It does both, and it tells the user which is which.
Technically the interesting part is that it works fully offline with no account, and that signing up later adopts the history you already have instead of starting you over.
Architecture
The browser is the whole application: it schedules reviews, generates the tasks, adapts difficulty and computes the statistics. The server exists only to hold a copy. That ordering is the design — not "offline support" added to a cloud app, but a local app that can optionally be backed up.
Engineering decisions
1. Local first, account optional and later
- Problem
- Daily-habit software is used for months or abandoned in one session. Asking for an account before the first exercise loses the people who would have stayed; but losing months of streaks to a cleared browser loses the ones who did stay.
- Options
- Require an account, keep everything local, or make the account a later, optional addition.
- Chose
- The third, and enforced it rather than promised it: the cloud routes answer that they are unavailable when no database is configured, so the app has to work without them by construction and cannot quietly grow a dependency. Creating an account adopts the local history that already exists instead of opening an empty one, which is the moment where most apps of this kind silently throw away the user's first month.
- Cost
- The local store can fail in ways you do not get to see — quota, private browsing — so every read and write has to tolerate being dropped, and the app must behave sensibly with no stored state at all rather than treating that as impossible.
2. Sync as one snapshot, last write wins
- Problem
- Two devices, one person, and state that changes on both.
- Options
- Sync per item with conflict resolution, or store one document per user and accept that the later write wins.
- Chose
- One snapshot per user, written as an upsert, with the statistics computed in the browser rather than stored. The data is small and single-user: a real merge strategy would have cost days and bought protection against a scenario — the same person training offline on two devices in the same window — that is rare and cheap to lose.
- Cost
- That scenario does lose work when it happens. The decision was to take a known, bounded loss deliberately instead of discovering later that a homegrown merge had corrupted a scheduler's state, which is the failure that would actually be unrecoverable.
3. The scheduler is not mine, on purpose
- Problem
- Review timing is the entire product. Get it wrong and the app is worse than paper.
- Options
- Write a heuristic — satisfying and almost certainly worse — or use the published scheduling algorithm this field has converged on.
- Chose
- The published one, configured rather than reimplemented: a retention target set explicitly, and interval fuzz on so that cards learned together do not stay clumped together for years. On top of it, the interval each answer button will produce is shown on the button, because the user's own rating is the only input the algorithm has and people rate more honestly when the consequence is visible.
- Cost
- A dependency at the core of the product, and the statistics needed a separate approximation of the forgetting curve because the library exposes scheduling rather than current recall probability. Reimplementing to avoid that would have traded a small annoyance for a large risk.
4. Difficulty comes from the rule, not from the numbers
- Problem
- The training only does anything at the edge of the user's capacity, so difficulty has to move — and the obvious way to move it is the wrong one. Bigger numbers make a reasoning task longer, not harder.
- Options
- Scale magnitude, which is trivial to implement and measurable, or scale subtlety, which has to be designed per task.
- Chose
- Subtlety. Operands stay small and the underlying rule gets less obvious as the level rises; each task type carries its own level configuration instead of a shared global difficulty, because "harder" means something different for a span task than for a pattern task. And there is a level picker, so someone who is already capable starts high instead of grinding up from the bottom — making a user spend a week below their capacity is not caution, it is the one thing that makes the training useless.
- Cost
- Every task type is tuned by hand and the levels are not calibrated against each other, so level 6 in one exercise is not level 6 in another. Fixing that properly needs data from real users, which is a reason to leave it visible rather than pretend the scale is uniform.
What it claims, and what it refuses to
The documentation states, module by module, what the evidence actually supports: strong for spaced repetition and active recall on retention, reasonable for training the specific capacity a task exercises, contested for any transfer to general intelligence — which is precisely the claim this category of app is sold on, and the one it does not make. Writing that down cost nothing except the most marketable sentence available, and it is the same discipline as labelling an inferred legal reference as a candidate rather than an authority: state the confidence you actually have, especially when a stronger claim would sell better.
Contact
Email nicoroigmeseguer@gmail.com
GitHub github.com/nicoroig-ai