01/Blog
All articlesHow frunt answers restaurant staff from their own documents, and why allergens come from a table
frunt answers staff only from the restaurant's confirmed documents, cites the ones it used, and reads allergen facts from a table a manager has checked.
By Matthew Kay//7 min read
frunt's staff assistant answers questions from one restaurant's confirmed documents, cites only the documents the answer used, and says so when they don't cover the question. Allergen facts never come from the model. They come from a table a manager has checked, and a rule that isn't AI throws away any allergen claim the table can't back.
Why can't a restaurant assistant just guess?
A new starter mid-service wants the closing checklist, the ID policy or the fridge temperature in ten seconds, and the answer is somewhere in a binder. A language model will always produce something. For "when do we open on Sundays?" a slightly wrong answer costs a phone call. For "does the brownie have nuts in it?" it can put a customer in hospital.
An audit of frunt's retrieval in June 2026 found allergen questions taking the same path as every other question: vector search over document text, phrased by a model told to answer what it could. That leaves two ways to be wrong. Retrieval surfaces the wrong passage, or none, and the model answers from nearby text. Or the right passage arrives and the model misreads the table. The allergen design below closes both.
How does it answer only from the restaurant's documents?
Nothing reaches an answer until a manager has confirmed it. For each upload, frunt suggests a type, a tier (1 for compliance, 2 for reference, 3 for background) and the roles that can see it, and the manager confirms them. The document is then split at its headings, embedded with an OpenAI embedding model, and stored with pgvector in Supabase.

When a staff member asks, the question is embedded and the eight closest passages are fetched, filtered in SQL to their restaurant, their role and the current version of each document. Taking a document away from a role takes it out of that role's answers. A Claude model then writes the answer from those passages only, under rules that change with the tier. Tier 1 sources are quoted exactly: no rounding a temperature, no filling a gap from general knowledge. Tier 2 and 3 sources can support a partial answer.
The manager dashboard, the staff app, the manager's assistant and WhatsApp all call the same function. That is a safety decision: the allergen check runs inside it, and a second answering path would be a second place to get it wrong.
// Simplified. One function answers every surface.
async function answer(question: string, venue: string, role: string | null) {
const passages = await search(question, { venue, role, limit: 8 });
const facts = touchesAllergens(question)
? await allergenFacts(venue, question) // a SQL lookup, not retrieval
: null;
const draft = await model.write(prompt(passages, facts), question);
return gate(question, draft, facts); // a rule, not a second model
}How are sources attached and shown?
The staff app shows the documents an answer drew on as chips under it, each with the document title and section heading. A tap opens the document.
Until late September those chips were too generous. They listed every document among the eight passages retrieved, used or not, so an answer about ID checks could cite the fire procedure because it ranked in the top eight. The store screenshot below was made before the fix.

Now the model ends every response with a hidden line naming the numbered sources it used, such as <used>1,3</used>. Code reads it, cites only those documents and strips the line. An empty marker cites nothing. A missing or invalid marker shows the single best match, never all eight. Checked live on the test venue: an ID-check answer went from four sources to one, and a question about Christmas opening hours from seven to none, because the documents don't cover it.
What happens when the documents don't have the answer?
The model is told to say so. Anything missing goes into a separate note, which the app shows under the answer as "What's missing", with where to look. In the dry run described below, "What's the wifi password?" came back as not covered by any of its sources. For tier 1 material the rule is stricter: if the sources don't cover it, say exactly that instead of inferring.
How do you stop an AI guessing allergens?
By never letting it be the source. frunt separates allergen facts ("does this dish contain sesame?") from allergen mentions in anything the model writes.
Facts live in structured rows: one per dish, and one per dish and allergen, marked "contains" or "may contain". A plain table parser, with no AI call, lifts them from the allergen matrix the manager confirmed, and the manager can edit them in a grid. When a question touches allergens, code looks the facts up in both directions (dish to allergens, allergen to dishes), the whole answer runs under tier 1 rules, and the facts go into the prompt marked as overriding every document.
Then a gate reads the finished answer. It is a word list, not a model: the 14 UK named allergens plus the derivatives real incidents hinge on, so marzipan counts as tree nuts, whey as milk and semolina as gluten. If the answer makes a claim about a specific allergen, there are two outcomes:
- The table backs it. Code appends a footer written from the database, headed "Verified from your allergen matrix (read from the structured record, not AI-generated)", and cites the matrix.
- The table doesn't. The whole answer is replaced with "I can't verify this allergen information, so I won't guess", followed by what to do: check the matrix, ask the allergen champion.
We rejected a second model checking the first. A checker built like the writer shares its blind spots, so the answers the first gets wrong are the ones the second is likeliest to miss. A word list fails differently. It over-triggers on purpose: a false alarm costs a refusal, a miss can cost a customer. Each gated answer records the word list's version, so any past answer traces to the list that screened it.
How does a manager verify the facts?
Confirming the matrix document at review signs off every row in it. A dish typed into the grid by hand isn't signed off until the manager clicks Checked on that row. Ticking one allergen cell doesn't count, because one tick would vouch for the other thirteen. On an unchecked row a blank cell means nobody has looked yet, not free-of, so a question about that dish gets a refusal that names it. While any dish is unchecked, verified answers carry a line from code saying how many.
A menu check compares the live menus with the matrix and reports dishes whose description changed, dishes missing from it, and rows for dishes no longer sold. It names dishes and never supplies an allergen. There is no "add these for me" button: an empty row would read as free of everything.
What did testing find?
The gate, the citation rules and the parser are pure functions with unit tests that run in CI before anything merges. They assert, among other things, that an unbacked claim is refused even when only the answer names the allergen, and that a refusal cites nothing.
A live dry run on a fictional venue, nine documents and ten questions through the real answer route, found what the tests hadn't:
- A spreadsheet with a title row lifted no dishes. Every allergen question was refused while the readiness screen said allergen information was present. The reader now takes the first row that can be a header, and a matrix that lifts no dishes no longer counts as present.
- A column headed "Soya" was dropped. The parser knew "Soy" and "soybeans". An empty cell on a checked row means free-of, so a spelling variant could have produced an all-clear under the verified footer. Headers now match any term in the word list.
- The gate fired at a dish name. "What are tonight's specials?" got a refusal because one special was crab. A dish name isn't a claim. "No crab in the linguine" is. The gate now looks for claim words sentence by sentence, and "made with almond flour" still counts.
- A wrong document gives a wrong answer, with a citation. The fictional procedures gave a chicken cooking temperature below UK guidance, and frunt repeated it. A check of known regulatory values, built like the allergen word list, is proposed and not yet built.
The WhatsApp work found two more. A footer listed a different dish that shared one word with the dish asked about; a partial match is now labelled as not the dish asked about. And "What's missing" notes skipped the gate, so one naming an allergen is now replaced with a fixed line pointing at the matrix.
Where does it stand?
frunt is live with its first client. Staff ask in the app, managers on the dashboard, and every answer goes through one function and one allergen gate. The limits are written down. The gate checks that a claim is backed, not that the model's wording agrees with the footer, so the footer governs. A dish asked about by a nickname gets a refusal, never a wrong answer.
The short version
- Answer from the venue's confirmed documents only, filtered by role and current version, and cite only the documents the answer used.
- When the documents don't cover it, say so and show what's missing. Don't fill the gap from general knowledge.
- Keep safety-critical facts in structured rows a manager has checked, not in retrieved prose.
- Check the output with a rule that fails differently from the model, and fail closed: an allergen claim is backed by the table or replaced with a refusal.
- Test with real-shaped documents. A title row, a regional spelling and a specials board found what the unit tests didn't.
02/More build notes
More build notes
- 13 Oct 2026/7 minHow Run keeps recording with the screen off, on Android and iPhoneAndroid needs a foreground service of type location. iPhone needs the location background mode. Then the app has to notice when fixes stop arriving.
- 6 Oct 2026/5 minLet each product tell your studio site where it standsA studio site goes stale the moment a product ships. Ours reads each product's state, screens and sketch from the product's own site, so it can't.