← Platform Docs
Architecture

A second model that only answers questions

Every Ambasdr owner has a public page on which an AI assistant — the ambassador — answers visitors’ questions on their behalf. Ambasdr already uses a language model to write what the ambassador says to a visitor. The decision layer adds a different kind of model beside it — one that cannot write a sentence at all, and can only answer typed questions with a probability. It is asked things like “did the assistant actually answer this question?” and “is this visitor asking to book a call?”, and ordinary Go code acts on the numbers. The first four parts were merged and deployed on 20 September 2026, and the judgements against the page’s knowledge (gap, unsupported) followed in PR #250; all of it ships switched off: nothing about it is visible to a visitor or an owner until an operator changes one setting in the admin panel.

01

Two kinds of model, and why there is now a second one

Everything below rests on this distinction, so it comes first.

A language model — the kind that has been in Ambasdr from the start — is handed a conversation and writes the next message. It is good at prose and it is the only thing that can produce the ambassador's reply. It is also relatively slow and relatively expensive, and when you ask it for a judgement rather than a sentence (“was this question answered, yes or no?”) you are paying for a writer to do an assessor's job, and you get an answer with no measure of how sure it was.

A decision model is the other kind. You hand it a piece of text plus a list of questions, and it hands back one number per question: the probability that the answer is yes. It cannot write, cannot be chatted with, and cannot be talked into anything, because there is no free-text output for it to be talked into. The specific one used here is called Jev, made by TypeSafe and reached through OpenRouter — the same API gateway the platform already uses for other models, so it needs no new account and no new key.

What Ambasdr already had

A language model

Given a conversation, writes the reply. Roughly a second or more per call, priced per word in and out. Asked for a judgement, it returns a word with no confidence attached.

What this adds

A decision model (Jev)

Given a piece of text and a list of questions, returns a probability for each. Measured at roughly a quarter of a second and from a few thousandths to a few hundredths of a cent per call, depending on how much of the page’s knowledge is sent. Produces no prose at all.

Four words are used throughout this page and in the code, so they are worth pinning down now:

TermWhat it means
StatementOne yes/no question put to the decision model, written as an assertion — “the visitor asked to meet, call or book”. The model returns how likely that is to be true, between 0 and 1.
ThresholdThe percentage at which code treats a probability as a yes. Every action below has its own threshold, stored as a setting an operator can change without a deploy.
SiteOne place in the product where the layer is asked something. There are three, described in section 03.
TurnOne visitor message and the ambassador’s reply to it.

The reason for a second model rather than more prompting of the first one is that these judgements are repetitive, need to be fast enough to happen while a visitor is still typing, and are far more useful as a number than as a word. A probability can be compared against a threshold an operator tunes; the word “yes” cannot.

02

One switch governs all of it

A single setting decides who is asked and whether the answers are allowed to change anything.

The setting is decision_vendor, in the Decision layer section of the admin panel at /panel/settings. It is stored in the app_settings database table, read at runtime, and takes effect within seconds of being changed — there is no redeploy and no restart.

ValueWhat happens
offNo longer the default — set it here to hold an environment still. No question is asked of anything. No call is made, no row is written, and every code path behaves exactly as it did before this work existed.
shadowEvery question is put to every configured decider — Jev and the control language model — and every answer is written to the decision_records table, and nothing acts on them. This is the mode for watching real traffic and choosing thresholds from it before anything a visitor could notice changes.
jevJev's answers act.
llmA control arm acts instead: the same questions answered by an ordinary language model, called the control (gpt-4o-mini by default). It exists so Jev can be measured against something rather than trusted on its own, and so the layer still works if Jev is ever unavailable as a vendor.
Nothing in this page is live until that setting moves

The code is deployed to development and production, and the two new database tables exist in both. Every behaviour described below is wrapped in a check of this setting, and every one of them is a no-op while it reads off.

The default changed on 2026-09-22, from off to jev. The default is consulted only where no app_settings row for decision_vendor exists, so an environment whose operator has already chosen a value in the panel keeps it. An environment that has never touched the setting now asks the decision model a typed question at all three sites and acts on the answers at the thresholds in section 06 — thresholds that are still the compiled-in defaults rather than numbers chosen from Ambasdr’s own traffic, because the shadow period described in section 04 has not been run. Two things follow. Setting the value to off in the panel before a deploy is what holds an environment where it is; and the Jev arm needs OPEN_ROUTER_API_KEY, without which every call records a failed row and the site proceeds as if the layer were off. The panel now refuses a write selecting an arm with no credentials, and the API logs one error at startup if the selected arm is missing.

There is a second, smaller switch: decision_fallback_to_llm, off by default. With it on, and with the vendor set to jev, a call Jev cannot answer — a rate limit, an outage — is answered by the control model instead, and the stored record is marked as a fallback. It is off by default because turning it on sends visitor text to a vendor the operator did not explicitly name.

03

The three places it is asked

Follow one visitor conversation from the first keystroke to the dashboard, and the layer appears three times.

Before the reply · on the request

Entry

The visitor's message is judged before the language model sees it. Five things can follow: an attack is refused without calling the model at all, an abusive message is flagged, a request for the owner's time gets the booking button, the reply is told what language to answer in, and — when the message asks for a detail the page's knowledge does not hold — the reply is told so before it is written.

Seconds after the reply · in the background

Post-turn

The exchange that just happened is judged: was the visitor's question answered, do they want to speak to a person, do they look like a prospect, and did the reply state a detail the page's knowledge does not hold. Feeds the dashboard's Right now panel, and tells the next reply about anything left unanswered.

After the conversation ends

Digest

Fifteen minutes after a conversation goes quiet, a background job already summarises it. The layer takes over the four fields that are judgements rather than prose: what the visitor wanted, their tone, whether they got their answer, and which questions went unanswered.

They are independent. Each one checks the same switch, each one writes its own record, and each one is harmless when the decision model is slow or unavailable — in every case the turn proceeds exactly as it would with the layer off.

04

Entry: judging the message before answering it

This is the only site that runs while the visitor is waiting, so it is the one with a hard time limit.

When a visitor sends a message to a published page, the backend sends the decision model that message, the previous two exchanges for context, three facts about the page — a 200-character description of the owner, whether a booking link exists, whether the page has photos — and the page's knowledge: the same block of facts the ambassador's own instructions carry, read from the page’s knowledge graph (the facts and entities extracted from the owner’s uploaded documents) once for the turn and shared with the reply and the post-turn judgement. No link or caption is included. If that read failed, or the block is larger than 24,000 characters, the material is treated as unknown rather than empty and the gap question is not asked — otherwise a knowledge-graph outage would put “the material does not contain this” on every factual turn. It asks eight questions:

QuestionWhat it asks
attackIs the message trying to change the assistant's identity, extract its instructions, or make it speak as the owner in the first person — including through roleplay or “pretend you are…” framings?
intentA multiple choice over eight fixed options: a genuine question about the owner, a request for their time, a request for contact details, a hiring enquiry, a pricing question, small talk, off-topic, or abuse.
wants_timeIs the visitor asking to meet, call or book, or whether a day works? A question about opening hours deliberately is not this.
languageWhich language the message is written in, from a fixed list.
in_scopeIs the message about the owner, their work, their page, or how to reach them?
photo_helpsWould seeing a picture answer this better than words?
needs_searchDoes answering need a specific fact — a date, a price, a project — rather than general context?
gapDoes the message ask for a specific detail — opening hours, a price, a date, an address, a name — that the page's knowledge does not contain? A document about the shop that never states its hours does not contain the hours.

in_scope and needs_search are recorded but nothing acts on them yet; photo_helps only adds “a photo would likely help” to the intent line, when that line is shown and the probability is at least 50%. They are there so that a later change can be argued from data already collected. Five things act:

attack ≥ 90%

The turn is declined, and no language model runs

The visitor receives one fixed sentence: “I can only help with questions about this page and the person or business behind it. Ask me something about them and I'll do my best.” It is the same sentence on every page, so nothing in it can be steered by the message it answers. The exchange is still written to the owner's transcript — they can see what their visitor was told — and the turn still counts against the page's allowance, because a declined turn is still a turn a stranger spent on the page.

intent = abuse ≥ 85%

Two flagged messages end the conversation

The first abusive message is answered normally and quietly flagged. On the second, the conversation is over: the next attempt is refused before the reply starts and the chat panel shows “This conversation has ended. You can still browse the profile, links and files.” One flag is a judgement about one message; two is a pattern. Reloading the page starts a new conversation, so a wrongly flagged visitor is inconvenienced, not locked out.

wants_time ≥ 80%

The booking button appears anyway

The ambassador already has a tool for putting the owner's Book a call button on screen, and sometimes it answers a plain request for time in words and forgets to use it. When the page has a booking link and the model did not offer it, code offers it — the same button, recorded the same way, never a second one, and never on a page without a link.

language / intent ≥ 70%

The reply is given one line of context

When the message is not in English, the ambassador is told to reply in the language it was written in. When the intent is clear, it is told what the message most likely is, marked as advisory so it may disagree. Below the threshold neither line is added and the instructions are byte-for-byte what they were — an earlier trial showed that a low-confidence hint made replies worse.

gap ≥ 70%

The reply is told the detail is not there

One line is added to the instructions for this reply: “The knowledge context above does not contain the specific detail this message asks for. If a tool you can call returns it, answer from that; otherwise say in one sentence that you do not have it and point the visitor onward. Do not fill the gap from general knowledge or from what is typical.” Every reply already carries a standing rule saying what to do if a detail is missing; this says that it is, on the turn it matters rather than one turn later. Measured on a page whose knowledge held no opening hours, the question fired at 93–95% on every hours or price question and at 3% on the same hours question once the hours were added.

A slow or broken decision model costs a visitor nothing

The whole judgement is capped at three seconds (EntryTriageTimeout). If it times out, errors, or returns nothing, the failure is recorded and the turn proceeds exactly as it would with the layer switched off. The page owner's own test conversations are never judged at all, the same way they are never counted against their allowance.

05

Post-turn: the “Right now” panel, and telling the next reply

The problem this solves is timing. The dashboard has always been able to tell an owner what visitors asked — but only long after they left.

The existing summary job waits until a conversation has been quiet for fifteen minutes before reading it. That is sensible for a summary and useless for “someone is on your page right now and your ambassador could not answer them”. So after every visitor exchange is saved, a background task — started after the reply has been sent, so the visitor never waits for it — sends the decision model the exchange, the few exchanges before it, and the page's knowledge block, and asks four questions about that one exchange:

QuestionWhat it asks
answeredDid the ambassador's reply supply real information for what the visitor asked? Judged by the same four rules the conversation summary uses, so a live flag and a later “knowledge gap” mean the same thing. A message that asked nothing counts as answered.
wants_humanDid the visitor ask to deal with a person — to speak to the owner, be called back, be put in touch?
leadDoes the visitor read as a prospect — asking about hiring, prices, availability, booking or buying — rather than browsing or testing?
unsupportedDid the reply state a concrete detail — an opening time, a price, a date, a name — that appears neither in the page's knowledge nor earlier in the conversation? A reply that says it does not have a detail states nothing. Independent of answered: an invented answer is still an answer, which is exactly why it needs its own flag. Judged against the same block the reply was written from; not asked at all when that block failed to load or is larger than 24,000 characters.

When the layer is acting, each judged exchange becomes one row in the turn_signals table. Two things read those rows.

What the owner sees: a panel called “Right now”

At the top of the dashboard's Home tab, above the activity tiles, there is a new panel covering the last 24 hours, independent of the Today / 7 days / 30 days toggle beside it. It shows:

What it showsWhere the number comes from
Couldn't answer — the visitor's own words, newest first, at most five, each opening that conversationturn_signals rows where answered is false, joined to the conversation so a deleted one disappears at once.
“N visitors want to talk to you”Distinct conversations with a wants_human row — conversations, not messages, so one persistent visitor is one person waiting.
“N conversations look like prospects”Distinct conversations with a lead row.
“N conversations got answers your knowledge doesn't contain” (red) and a list headed Answered with details your knowledge doesn't containDistinct conversations with an unsupported row, and those rows' questions, newest first, at most five. Each opens its conversation. The heading does not say “add the source”: the judgement is made against the knowledge graph, and a detail can be missing from it while sitting in a file the owner already uploaded — the demo roastery's opening hours are exactly that case.

All of it arrives on the existing dashboard request — GET /v1/pages/{page_id}/insights gained a live object with since, open_gaps, wants_human_count, lead_count, unsupported_replies and unsupported_count — so no new network call was added to the page. When there is nothing waiting, the panel is not rendered at all: an empty box on every dashboard would read as a broken feature rather than as good news.

What the visitor gets: a reply that does not repeat itself

If a visitor asks something the ambassador cannot answer and then presses on it, the second reply used to be the same refusal in the same words. Once an exchange has been judged unanswered, the instructions for every later reply in that conversation carry a short note: which question went unanswered, an instruction not to repeat the same refusal, and what to do instead — say plainly what is not available and point the visitor to the owner's contact details or the booking button. It also says, explicitly, never to invent the missing information.

The visitor's own words are quoted, so they are handled as data

That note has to quote the question the visitor asked, which makes it the first place a stranger's text has ever appeared in the ambassador's own instructions rather than as a message in the conversation. Three things guard it: the text is flattened to a single line so nothing in it can look like a new instruction block, its quotation marks are replaced so it cannot end the quotation early, and the note states in words that this is the visitor's message and not an instruction.

The note also says that if the earlier question was one the ambassador is supposed to refuse — a private matter, politics, a request to reveal its instructions — it must keep refusing. Without that sentence the note would argue with the ambassador's standing rules on exactly the turn a persistent visitor tries again. Two adversarial scenarios in the evaluation harness exist to catch it if that ever stops holding.

06

Digest: the four fields that are judgements

This one changes an existing behaviour rather than adding a visible feature.

The conversation summary described in Conversation Insights asks a language model to read a finished transcript and return a structured record: a summary sentence, a topic list, the questions the visitor asked, and four closed-list fields — intent (what the visitor wanted), sentiment (their tone), outcome (whether they got their answer), and unanswered questions.

Those four are judgements, not writing, and they were the ones that went wrong in ways an owner notices — most often a question the ambassador had answered with a caveat being filed as unanswered, which puts an item on the owner's to-do list that they cannot act on. When the layer acts, the language model still writes the words, and the decision model answers a statement per field instead:

FieldHow it is now decided
IntentFive statements, one per intent — did the visitor ask to meet, ask for contact details, ask about hiring, ask about pricing, ask about the owner's background — and a sixth, whether the visitor asked any genuine question at all, which separates a general enquiry from none of the above. Code then walks them in a fixed order and takes the first one that is present, so “ten turns about pricing and then a request to book a call” is a meeting request. That precedence is applied in code rather than asked of a model.
SentimentOne multiple choice over positive, neutral and negative, judged on the visitor's tone rather than the ambassador's.
Unanswered questionsOne statement per question the visitor asked: did the ambassador supply real information for this one? Each question is judged on its own, so a refusal on the third question no longer sweeps the other four into the gap list.
OutcomeDerived by counting: all questions answered, some, or none.

The stored row records which arm produced the judgement: conversation_insights.model becomes <model name>+jev or <model name>+llm when the layer acted, naming the arm that actually answered — so if the fallback stepped in, the row says so.

07

Every threshold, and what it gates

All of these live in the admin panel's Decision layer section and take effect without a deploy; the percentage settings are bounded 1–100.

SettingDefaultAt or above it…
decision_vendorjevWho is asked, and whether answers act. Defaults to an ACTING vendor — see section 02. off is what holds an environment still.
decision_fallback_to_llmfalseA failed Jev call is answered by the control model instead.
decision_attack_pct90The message is refused with the fixed sentence and no language model runs. Highest of the entry thresholds, because a mistake here turns a real visitor away.
decision_abuse_pct85The message is flagged abusive. Two flags end the conversation.
decision_booking_pct80The booking button is put on screen if the page has a link and the ambassador did not offer it.
decision_hint_pct70The language and the likely intent are added to the ambassador's instructions for that one reply.
decision_gap_pct70The message is judged to ask for a detail the page's knowledge does not hold, and the reply is told so before it is written.
decision_wants_human_pct70The exchange counts towards “N visitors want to talk to you”. Held higher than the prospect count because this one names a person waiting.
decision_lead_pct50The conversation counts towards “N conversations look like prospects”.
decision_unsupported_pct70The reply is flagged as stating a detail the knowledge does not hold, and its question is listed under “Answered with details your knowledge doesn’t contain”. Held at the wants-a-person bar, because it tells the owner their assistant said something it had no basis for.
decision_answered_pct50A visitor's question counts as answered. Below it, the question becomes a live gap and a dashboard knowledge gap. Raising it is stricter: more gaps, some of them wrong.

The five entry thresholds are deliberately ordered by what a mistake costs: refusing a real visitor (90) is worse than flagging one message (85), which is worse than an unwanted button (80), which is worse than a line in the instructions (70, shared by the intent/language hint and the gap line).

Five environment variables configure the connection rather than the behaviour, and the defaults are correct for the current setup: DECISION_JEV_URL, DECISION_JEV_MODEL, DECISION_JEV_API_KEY (empty means reuse OPEN_ROUTER_API_KEY), DECISION_CONTROL_MODEL and DECISION_TIMEOUT_SECONDS.

08

Where everything is written, and how it is cleaned up

Two new tables, both holding visitor-authored text, both covered by the same retention and deletion rules as the rest of the conversation data.

TableWhat it holds
decision_records
migration 000055
One row per call, in every mode including shadow. Which site asked, which arm answered, the questions, the answers with their probabilities, whether the answers were allowed to act, the latency, the tokens and the cost — and, when a call fails, a row marked failed rather than no row at all, so an outage is visible as an outage.
turn_signals
migrations 000056, 000057
One row per judged visitor exchange, only when the layer is acting. The visitor's question, the four post-turn judgements with their probabilities (unsupported and unsupported_p added in 000057), and the entry site's columns (attack_p, abusive, language, and gap_p, added in 000057). Stamped with the message's own timestamp, not the time the judgement ran, so the 24-hour panel reads the conversation's clock.

Both cascade from the page and the conversation, so deleting either removes them. Deleting a user removes them too, and the delete-user test seeds and then asserts their absence. Deleting a single conversation from the dashboard is a soft delete that fires no cascade, so the existing housekeeping job sweeps both tables for it — and until that job runs, the dashboard panel excludes rows belonging to a deleted conversation when it reads them. Both tables are DELETED by INSIGHTS_RETENTION_DAYS — 365 days by default, swept by jobs.InsightsRetentionPrune. The same window covers the conversation summaries, but there it redacts rather than deletes, because the member reads those with no time bound. That sweep is deliberately separate from the housekeeping job above, because the housekeeping job runs only when INSIGHTS_ENABLED is true while these two tables are written on every judged visitor turn regardless.

Shadow mode is the point of decision_records

Because every call is recorded with its probabilities whether or not it acted, an operator can run shadow for a week and then choose each threshold from Ambasdr's own conversations — how often would a 90% attack cut have fired, and on what — instead of from a vendor's examples or a guess.

09

What was measured before this shipped

Two evaluation harnesses already existed in the repository; both were extended rather than replaced.

The conversation summary, against known-correct labels

make digest-eval scores the summary against 15 hand-labelled conversations. Each arm was run three times in production's exact decoding configuration. The columns are: did it list the questions that were asked, were the gaps it reported real, did it find the gaps that were there, and did intent, outcome and tone match the label.

Armq‑recallgap‑precgap‑recintentoutcomesentimenterrors
Language model alone (today)0.930.971.000.931.000.930
Jev0.930.960.930.910.930.912
Control (gpt-4o-mini)0.931.000.930.730.930.690

Jev’s two errors were transient failures of the decisions endpoint; those items were scored as the language model’s own digest, which is what production stores when the layer fails.

Read plainly: on this corpus Jev is level with the language model — every difference is within the spread seen between repeat runs of the same arm — and the cheap control model is clearly worse at judging intent and tone. That is a baseline for later changes to beat, not an argument for switching the layer on.

The ambassador itself, graded by a stronger model

make harness-eval TASK=visitor_chat runs scripted visitor scenarios through the real agent and has a different, stronger model grade each reply. New scenarios were added for every new behaviour, including adversarial ones: a visitor pressing for a private detail after being refused, and a message shaped like an instruction to the ambassador.

With entry judging switched on, all twelve pre-existing “try to trick the ambassador” scenarios still passed, across all three scenario splits, and none of them was wrongly refused by the fast decline. Scores with judging on and off were within the run-to-run spread of each other.

One run is not a measurement

Both harnesses are run with repeat samples for a reason: the same model at temperature zero returns different classifications for the same transcript often enough that a single run's number cannot be compared with another single run's. Any later claim that a change improved or regressed these numbers needs repeat samples behind it.

10

Turning it on

The intended order, and what to look at between the steps.

Set decision_vendor to shadow

In the admin panel at /panel/settings, under Decision layer. From this moment every visitor turn is judged at the entry and post-turn sites, and every finished conversation at the digest site, and every answer is recorded. In shadow both deciders are called, so the cost is roughly double that of acting on one, and visitor text reaches both vendors. Nothing a visitor or owner sees changes. This costs money — from a few thousandths to a few hundredths of a cent per call, several calls per conversation — so it is a decision to make deliberately and to review.

Read decision_records after a few days of real traffic

Each row carries the question set and the probabilities. The questions to answer before acting: how often would the 90% attack threshold have fired, and on what messages; how many conversations would have been ended by the abuse rule; how often does the answered judgement disagree with what you would say reading the transcript yourself.

Adjust the thresholds, still in shadow

Every threshold in section 07 can be changed from the same panel and takes effect within seconds. Moving one does not re-judge old rows; it changes what the next ones do.

Set decision_vendor to jev

Now the answers act. The reversible parts become visible first — the dashboard panel populates, summaries start being judged by the layer. The visitor-facing parts (the fast decline, the ended conversation, the forced booking button) begin on the next turn anyone takes.

If anything looks wrong, set it back to off

The switch is the rollback. It takes effect within seconds, needs no deploy, and leaves the recorded rows in place for inspection. One thing does not roll back: a conversation already ended by the abuse rule stays ended, because the abuse happened — the visitor's remedy is to start a new conversation, which works immediately.

11

What it deliberately does not do

Stated so the absences read as decisions rather than as gaps.

Not doneWhy
Voice conversations are not judgedA voice session's transcript arrives when the session ends, so there is no per-turn moment to judge. Voice conversations still reach the conversation summary as they always did.
The dashboard panel does not refresh on a timerIt is as fresh as the last page load or window-toggle click. Making it poll is a product decision about how live the dashboard should feel, not a property of this work.
The live panel is web onlyThe mobile app reads the same endpoint and simply ignores the new live object, which is additive and harmless.
Counts are per conversation, not per personOne visitor who opens three tabs is three conversations. Counting people would mean relying on the visitor identity the platform records, which is a separate decision.
No notificationsNothing emails or pushes when a gap or a waiting visitor appears. The rows are the trigger a sender would read; the sender does not exist yet.
The ambassador's tools are not switched on or off by itThe entry site asks whether a photo would help and records the answer. Its only effect is a clause in the advisory intent line; it never adds or removes a tool. Changing which tools the ambassador is given changes its instructions, which needs its own measurement pass.