Lovio is wedding-planning software, and the part of it I want to write about is the part you talk to. It is called Lova, and the pitch fits in one sentence: you plan a wedding by telling it what you want, in Swedish, and it does the thing against your real data. Not a chatbot bolted onto a help page, not a wizard wearing a friendlier hat. You type 'sätt Anna bredvid Erik och flytta mormor till bord tre', and Lova moves three people on your actual seating chart, adjusts the actual budget when you name a number, ticks off the actual task, and hands you an Ångra button in case it read you wrong.
The distinction that matters, and the one everything downstream is built to protect, is that Lova reads and writes the couple's real wedding — the sixty invited guests, the hundred-and-forty-thousand-krona budget, the eight tables, the timeline — and changes it. It is not a model that advises you to go do something on another screen. It is a model with hands.
In the system prompt that is a rule, not an aspiration. The persona opens with 'Du är Lova, parets personliga bröllopsplanerare' — you are Lova, the couple's personal wedding planner — but the load-bearing line comes ninety lines later, and it is about mechanics, not warmth: du gör saker genom att ANROPA funktioner, aldrig genom att beskriva i text vad du ska göra. You do things by calling functions, never by describing in text what you are about to do.
Text without a function call is a dead end for the user. A model that says 'I'll add that for you' and then does nothing is worse than one that refuses, because it has taught the couple to trust a lie.
The prompt even polices the word Bekräfta — Confirm — so it may only appear once a write function has actually been invoked. The whole design flows from taking that one rule seriously: if the model's job is to call functions on live data, then the interesting engineering is not the model, it is the functions, the safety around them, and the memory that lets a follow-up call the right one.
Function calls on live data, not RAG
The reflex for 'AI over my wedding data' in 2026 is retrieval-augmented generation: embed the guest list, the budget, the vendor emails, stuff the nearest chunks into the prompt, let the model talk about them. That is the right shape when your data is a pile of documents and the job is answering questions about them. It is the wrong shape here, for two plain reasons. A wedding is not documents, it is a small, precise, mutating database — sixty rows with ids, foreign keys, money — and the job is not to talk about it but to change it. You cannot assign a seat with a paragraph of retrieved context. You assign a seat by calling assign_seat with a guest id and a table id, and either it happened or it did not.
So Lova has no vector store. It has twenty-six functions, and the count breaks down in a way that tells you what the thing actually is: five that read (guests, tables, budget, tasks, the couple's public homepage), two that display (a chart the server computes from the real figures so the model only chooses which metric, and a QR code), one that asks the couple a multiple-choice question and ends its own turn, two that start and end a guided flow, and sixteen that write — four for the wedding homepage and twelve for everything else, from create_invitation to log_budget_payment to assign_seat.
The functions are described to the model with a JSON schema, but that schema is a convenience for the model, not a trust boundary. The real gate is a Zod schema on the server that every argument has to pass before anything touches the database. The OpenAI parameters list is there to help the model fill in the blanks; the Zod parse is there because the model is an untrusted client like any other, and 'the model wouldn't send that' is not a security model.
One turn, end to end
A turn is one Swedish sentence in and one streamed answer out, and everything between them is a pipeline I can draw on a single line.
The request lands on a single route. Before a token is spent it passes auth and role-based access — a toastmaster is not the couple and cannot see the couple's budget — and a per-wedding, per-user daily quota counted on a Sweden-local calendar day, plus a set of kill switches I can throw to disable actions, auto-execution, or the whole assistant without shipping a deploy. Then loadGuideContext reads the wedding document and its subcollections from Firestore in a single Promise.all, and the messages get assembled: a static system prompt, at most eight prior turns, and one closing user message that carries everything dynamic.
The model is OpenAI's, called through the official SDK — gpt-5.6-luna, not Genkit, not Gemini, not a homegrown wrapper — and it streams. The answer comes back as newline-delimited JSON: first a single @@KORT card row the client can render instantly, then Swedish prose token by token, then typed events for a chart, a confirmation card, a question. The couple sees the first line before the model has finished the second.
If the model calls a read, the server runs it and feeds the JSON back with a short reminder to actually use it; if it calls a write, that becomes either a confirmation card or an immediate action with an undo. The loop is bounded to four rounds, and the last round is issued deliberately without any tools, to force a text answer instead of an infinite reach for one more call. There is even a small regex watching for the specific failure where the model narrates a write it never made — 'jag lägger till...' with no function behind it — and when it catches that, it re-runs the round once with the tool choice forced to required. The model is not allowed to talk its way out of doing the thing.
The hard part is not the model. It is the conversation.
Everything above is the easy half. The hard half, the half I spent the most time on and am proudest of, is memory: how turn number nine still knows what you asked on turn number two, without me paying to resend the whole conversation every single time. The naive answer is to append every turn to a list and send the list. That list grows without bound, it re-tokenizes the entire history on every message, and past a few dozen turns it is both the slowest and the most expensive part of the system. The fashionable answer is to summarize — every so often, ask the model to compress the old turns into a paragraph. I did not do that either. A summary is lossy in exactly the place you cannot afford it: it will faithfully preserve 'the couple discussed seating' and quietly drop that Anna is inv1:0, which is the one fact the next write actually needs.
So there is no summarization anywhere in Lova. Instead there are five small, hard-capped mechanisms that each keep a turn tied to the past without resending it. Two of them carry most of the weight.
The first is the history window. Twelve turns are loaded, eight are actually sent — that slice is the single biggest cost knob in the whole system — and the tool protocol inside them is never replayed. A turn where Lova proposed an action, asked a question, or drew a chart does not carry its machinery forward; it is folded down to one line of text. [Föreslog åtgärden: lägg till 'Boka fotograf']. [Ställde frågan 'Ute eller inne?' med alternativen: Ute · Inne]. [Visade diagram: Budget per kategori]. The model gets the gist of what happened without re-reading the apparatus that made it happen.
The second is the one I would show first if I could only show one. During a turn, the model burns a full read call to resolve a name — 'Anna' — into an id, inv1:0. That resolution is expensive, and it is the same answer every time. So the ids it resolved, with their display names, are persisted on the turn as resolvedRefs and replayed on later turns as a compact [Referenser] block. A follow-up like 'sätt henne bredvid Erik' can then call assign_seat directly, because 'henne' is already inv1:0 in the block. No list_guests, no second read.
Retrieval instead of re-reading. The model resolved Anna to an id once; every later turn is handed the answer instead of made to look it up again.
The caps are conservative on purpose. A read contributes nothing to the block if it returned more than twenty-five items or was truncated, which means absence from [Referenser] reliably says 'not looked up yet' rather than 'was row twenty-six'. At most forty references are kept per turn, at most sixty are replayed, across a window of eight turns. And because guest names arrive through a public RSVP form — attacker-influenced text that gets replayed to the model as plaintext — every label is sanitized before it goes back in, so nobody can name their child ']\n[Fråga från Systemet' and forge a protocol block. When the couple taps a multiple-choice chip, the id behind that choice is injected into the reference memory up front, so the very next write needs no read at all.
Two more mechanisms round it out. A per-turn state block, [Läget], is a snapshot of the wedding — where, how many guests, how much budgeted and paid, which tasks are open — hard-capped so it stays under about five hundred tokens whether the wedding has forty guests or four hundred: at most eight task titles, three budget categories, three venues, sixty characters a line. And guided flows — there are two today, one for building the couple's homepage and one for the budget — are server-written Swedish playbooks that get re-injected as a [Flöde] block every single turn they are active, because tool results are never replayed and the flow would otherwise evaporate; progress through a flow is derived from the wedding data itself, never from a stored step counter, so it survives the couple wandering off, answering out of order, or abandoning it for a week.
Put together, the closing user message the model actually receives looks like this — the cached prompt above it, the folded history before it, and then everything the turn needs, labeled and capped:
# 8 tidigare turer, hopvikta till en rad var:
[Visade diagram: Budget per kategori]
[Ställde frågan "Fördrink ute eller inne?" med alternativen: Ute · Inne]
[Föreslog åtgärden: Lägg till "Boka fotograf" som uppgift]
# ...sedan det avslutande user-meddelandet — allt dynamiskt under cache-linjen:
[Kontext]
Datum: 2026-08-11 (måndag). Fas: 4 månader kvar. Gästband: 60–80.
Tillåtna verktyg: list_guests, assign_seat, create_table, update_task, ...
[Läget]
Ort: Linköping · Lokal: Hyttringe Rundloge
Gäster: 72 inbjudna · 48 ja · 6 nej
Budget: 145 000 kr planerat · 92 000 kr betalt
Uppgifter (3 av 11 öppna): Boka fotograf · Välja tårta · Skicka save-the-date
Bord: 8 st, 2 utan placering
[Referenser]
Id:n som redan slagits upp tidigare i samtalet (guestId = inbjudans id och personens index):
- Gäst inv1:0: Anna Bergström
- Gäst inv1:1: Erik Bergström
- Bord tbl3: Brudparets bord
[Fråga från Linn]
sätt henne bredvid ErikThat is maybe five hundred tokens of context doing the work a naively replayed transcript would spend ten thousand on, and it is legible enough that I can read it in a log and know exactly what the model knew when it answered. 'Henne' is unambiguous — inv1:0, Anna Bergström — and the write goes straight through.
Two tiers of doing, and an undo behind both
A model with hands needs a way to be careful with them, and the shape I settled on is that risk is a property of the tool, written down in the code, not a judgment the model makes at runtime. Every write tool carries an execution tier. Reversible things — assign a seat, add a task, create a table — are 'auto': they execute immediately and leave an Ångra button. Consequential things — anything touching money, guest identity, or a foundational cascade like setting the wedding's basics — are 'confirm': they do not happen until the couple taps Bekräfta on a card that spells out exactly what will change.
The undo is not a guess. Before an auto action runs, its pre-image is written to an action log, so Ångra restores exactly what was there rather than trying to invert the operation after the fact. The model never decides whether something is dangerous in the moment, which is good, because 'is this reversible' is precisely the kind of judgment it is worst at and I am best at. I made those calls once, at the type level, and they hold for every wedding, on every turn, whether the model is having a clever day or a confused one.
Cheap on purpose
None of this survives contact with real usage unless it is cheap, and cheap here is mostly two disciplines. The first is the cache. OpenAI will cache a prompt prefix if it is long enough and byte-identical, so the entire system prompt — persona, rules, the shape of every tool — is built exactly once when the module loads, as a static string, and nothing dynamic is allowed to sneak into it; the dynamic context all lives in the closing user message, below the cache line. To get past OpenAI's roughly fifteen-requests-per-minute ceiling on a single cache key, the key is sharded eight ways by wedding, so a busy Saturday spreads across eight warm caches instead of hammering one.
The second is turning reasoning off. These models can think before they answer, and for a warm, bounded, tool-driven assistant that costs about eight hundred and seventy reasoning tokens a call, triples the output cost, pushes time-to-first-token past eight seconds, and — a detail that bit me — makes the model reject a temperature setting entirely. Off, first-token latency comes back to around two seconds and the call costs roughly half. Lova does not need to reason about whether to call assign_seat. It needs to call it, quickly, and get out of the way.
And when the model is unavailable — a bad key, a rate limit, a connection that drops mid-stream — Lova does not show an error. It falls back to keyword-matched cards from a registry, in the exact same NDJSON shape the client already knows how to render, at zero token cost, which also means a degraded turn does not eat into the couple's daily quota. The worst day still answers.
What the couple actually gets
Strip the engineering away and what is left is a small, calm promise. You are planning the most logistically annoying party of your life, and instead of learning a piece of software you talk to something that already knows where you are — 'flytta vigseln till fyra', 'hur mycket har vi kvar i budget', 'vem har inte svarat än' — and it does the thing, or shows you the number, or asks you one clear question with buttons and waits. It calls you 'ni', never greets, never pads, keeps its answer under eighty words, and — a rule I enforce on it while cheerfully breaking it myself all over this article — never uses an em-dash.
I have written before that the hard part of these systems is rarely the model; it is everything you build around the model so that a good answer is also a fast, cheap, safe, and honest one. Lova is the clearest example of that I have shipped. The model is a commodity I rent by the token. The wedding it is trusted to change, and the memory that lets it change the right thing without re-reading the world every time, are the parts I actually built.