Error codes, refusal verdicts and what each one actually means
Decoding the status, verdict, skipReason and errorCode on a turn — including the two failures that look identical and need completely different fixes.
Every attempt to answer writes a row to ai_support_turn — including the ones
that produced no text at all, because why did the assistant stay silent is the
question operators actually ask. That row carries four fields worth reading, and
this page is what each value means.
Where to read them
Live Inbox → the case pane → Evidence tab. The AI activity list shows the
last eight turns: the verdict (falling back to the status), skipReason,
escalationReason, retrieval confidence, groundedness, latency, model and cost.
errorCode is returned by the session route but is not drawn in that list.
For the code itself, read the backend log — every failure logs one line in this
shape — or query the column:
Generation failed for <ticketId> [NO_BACKEND] via mashdiv: <message>SELECT createdAt, status, verdict, skipReason, escalationReason,
errorCode, LEFT(errorMessage, 200) AS errorMessage, costUsd
FROM ai_support_turn
WHERE ticketId = :ticketId
ORDER BY createdAt DESC;Six checks run before a session exists, and none of them writes a turn:
| Nothing happened because | Fix |
|---|---|
aiSupportEnabled is off |
Settings → Guardrails |
The channel is off — aiSupportTicketsEnabled or aiSupportLiveChatEnabled |
Settings → Channels |
The ai_support extension row is switched off |
Admin → System → Extensions |
| The licence could not be verified | Renew, or restore outbound access — see below |
The ticket is CLOSED |
Reopen it |
| The ticket no longer exists | — |
Only one of the six can be a fault, and it is the one worth ruling out first
because on this screen it looks exactly like the switches: the licence gate
fails closed, so a lapsed licence and an unreachable licence service both
silence the assistant with no turn row, no error code and no verdict. The only
trace is an AI_SUPPORT warning in the backend log — "…is not licensed for it;
staying silent" or "License verification failed (…); staying silent". Grep for
staying silent.
The extension check fails closed for the same reason: route access is not gated
on extension.status by the platform, so the trigger reads the row itself and
treats "cannot establish that it is on" as off.
The shipped default has tickets on and live chat off.
Four fields, four different questions
| Field | Answers |
|---|---|
status |
Did the attempt run, and did it produce anything? |
verdict |
What did it decide about the customer's question? |
skipReason |
Which gate stopped it? |
errorCode |
What broke, machine-readably? |
Statuses
| Status | Meaning | Typically bills |
|---|---|---|
PENDING |
Recorded, not finished | No — the call has not returned |
STREAMING |
A member of the enum that nothing writes any more. The customer is shown no partial text at all — see below | No |
SUCCEEDED |
The generation succeeded — the answer may still have been withheld or undelivered | Yes |
REFUSED |
The provider returned a safeguard refusal | Yes |
FAILED |
The provider or the delivery failed | Yes |
CANCELLED |
Aborted mid-generation — a human took over, or a generated answer a barge-in is withholding | Yes, if the generation had already completed |
SKIPPED |
A gate stopped it before or instead of generating | No — no call was made |
The daily and monthly caps sum costUsd over every row that carries one. There
is no status allowlist, and there was never a good one: the caps used to sum
SUCCEEDED, REFUSED and FAILED, which reads as "every status that can have a
cost" and is not. A barge-in on an install with aiSupportDraftOnBargeIn off
moves a turn to CANCELLED after the generation completed and the cost is on
the row — so the busier the desk, the more the ceiling under-counted. The column
above says "typically" because the status describes what happened to the answer,
not whether the provider billed for it.
The sum spans both turn tables. Your own questions — the admin assistant, the
Handbook, Ask about this customer, Teach from this ticket, Test
connection — write to ai_support_admin_turn and count against the same two
ceilings your customers' answers do.
That is not an accounting quirk to work around — it is why an install with no operator articles can burn a day's budget escalating everything. The fix is articles, not a bigger cap.
SUCCEEDED is the one that misleads. It means the model produced text, not that
a customer read it. A turn withheld by the groundedness gate, a copilot draft
awaiting a human, and an answer a barge-in stopped from being appended are all
SUCCEEDED with wasSent: false — except that when aiSupportDraftOnBargeIn is
off, the barge-in turn is moved to CANCELLED instead. The text is still on
the row; the status is what keeps it out of the console's draft panel. The
provider had already been paid for it, and that cost counts against your caps
like any other — a CANCELLED turn is withheld from the agent, not refunded to
you.
STREAMING is the one you will never see. The provider is still driven in
streaming mode, but the tokens are held: the post-filters that catch an
invented link, a refund promise or an ungrounded claim can only run on the
finished text, so nothing is put in front of the customer until they pass. The
approved answer is released as one frame. A turn therefore goes from PENDING
to its final status with no intermediate row state, and a customer who was
promised a refund by a draft that was then discarded never read it.
Verdicts
| Verdict | Meaning |
|---|---|
ANSWERED |
Text was produced and passed the filters |
ESCALATED |
The conversation went to a person |
REFUSED |
The provider itself declined |
A crypto support agent trips cyber-category safeguards on entirely legitimate
questions — "someone accessed my account", "is my seed phrase safe" — so
REFUSED is a normal operating state, not an exception. It arrives as HTTP 200
with a possibly empty content array, which is why the refusal flag is checked
before the text is ever read. The conversation escalates as forbidden_topic.
skipReason, decoded
| Value | What stopped it | What to do |
|---|---|---|
below_retrieval_floor |
Retrieval confidence was under aiSupportRetrievalFloor |
Write the article. It is already on Gaps. |
operator_policy_undocumented:<topic> |
The question is about your fees, limits, timings, countries or refunds, and no article of yours covers it | Write your own article on that topic |
language_not_classifiable:unknown_language |
The question's language could not be classified and no operator source covers it | See the translation stop below |
daily_budget / monthly_budget |
A spend ceiling was reached | Raise it, or wait for the window |
unpriced_model |
A ceiling is set and something this month recorded no cost, so the ceiling cannot be enforced | Price the endpoint, or set the caps to 0 — see below |
no_provider |
No AI service is configured on this install | Provider screen, then restart |
turn_limit |
The conversation used up aiSupportMaxTurnsBeforeEscalation attempts |
Nothing, unless it is frequent |
translation_unavailable |
Multilingual answering is on and the translation failed | See below |
nothing_to_answer |
The last customer message was empty | Nothing |
barge_in |
A human took the conversation over mid-generation | Nothing |
not_owner:<detail> |
The assistant did not hold the conversation | Read the detail |
rule |
One of your escalation rules matched with the action Refuse | Agents → escalation rules |
legal / money_issue / account_locked |
A built-in trigger matched the customer's message and refused the turn | Nothing — these are by design |
A refusing rule writes the bare value rule. The rule's name is not recorded
on the turn, so do not go looking for it on the row — which of your rules
matched has to be worked out from their patterns on the Agents screen.
not_owner carries the state on the row precisely because the bare value is the
least useful reason in the addon — the three causes need completely different
responses:
| Detail | Meaning |
|---|---|
not_owner:busy |
A generation was already running. Harmless — the message is held and answered when it finishes. |
not_owner:human |
A person has taken it over. Correct. |
not_owner:<STATE>:<reason> |
Parked in a state nothing reclaims — a person has to answer or hand it back. |
The third is the one to watch. Which escalation reasons the assistant may take back is in Escalation and handover.
unpriced_model — a ceiling that cannot be measured is refused, not assumed safe
"Cost unknown" and "cost nothing" are not the same number, and the caps used to
treat them as one. The compatible-endpoint adapter reports no cost by design — it
cannot know whether the endpoint behind it is a free local model, a metered API
or a flat subscription — and any model missing from the price table reports the
same. Both land on the turn row as 0, so an install answering thousands of
tickets through a metered endpoint read $0.00 spent for ever and its daily
ceiling never fired.
So the gate now counts them. A turn is unpriceable when it moved tokens and recorded no cost; tokens are the discriminator, because a skipped turn, a refusal before the call and a direct answer from one of your own articles all legitimately cost nothing and all move nothing. If either cap is above zero and the current month holds even one unpriceable turn, the gate refuses:
- The turn records
SKIPPED/unpriced_modeland the session moves toAI_SUSPENDED. - The whole desk is notified — "AI support paused: spend cannot be measured" — with the number of unpriced calls and both ways out, because "raise the cap in Settings" is useless advice for this one.
Two ways out, and both are decisions only you can make:
- Price the endpoint. Set
AI_SUPPORT_OPENAI_INPUT_USD_PER_MTOKandAI_SUPPORT_OPENAI_OUTPUT_USD_PER_MTOKto what your endpoint charges, in USD per million tokens, and restart. Both are required — half a rate card is treated as none. - Set both caps to 0. Zero has always meant "no ceiling", and it is the honest answer for a local model that really is free. An install with no cap never reaches this gate.
The two budget reasons are lifted automatically: the next customer message on a
session suspended for daily_budget or monthly_budget resumes it once the cap
allows again. unpriced_model is not in that set, so a conversation parked
by it stays AI_SUSPENDED and every later message records SKIPPED /
not_owner.
After you price the endpoint or clear the caps, open the affected conversations in the Live Inbox and press Release. New conversations are unaffected, which is what makes this easy to miss.
Error codes
Two codes read identically as prose — it did not answer — and are completely different problems.
| Code | Where it comes from | What it is |
|---|---|---|
NO_BACKEND |
The managed gateway | Capacity at the gateway. Not your account, not your key, not your balance. |
INSUFFICIENT_FUNDS |
The gateway, HTTP 402 | The gateway wallet is empty. Top it up. |
PROVIDER_NOT_CONFIGURED |
This install | A missing environment variable here. Fix .env and restart. |
PROVIDER_REQUEST_FAILED |
Any provider | A transport or upstream error with no more specific cause — DNS, refused connection, TLS, an unparseable response, or a gateway job that timed out at 504 |
UNEXPECTED |
Anywhere | Something that was not a provider error at all |
The difference in behaviour matters as much as the label:
PROVIDER_NOT_CONFIGURED |
Everything else | |
|---|---|---|
| Turn status | SKIPPED |
FAILED |
skipReason |
no_provider |
— |
| Escalates? | No | Yes, as provider_error |
A missing key is a configuration state, not an incident, so it does not fill your queue with handovers. A gateway failure is an incident, and the customer must not be left watching a typing indicator, so it goes to a person.
ProviderRequestError.code is a hardcoded literal — PROVIDER_REQUEST_FAILED —
so every gateway failure recorded that one value on the turn and printed that one
word in the log, whether it was a capacity problem, an exhausted balance or a bad
request.
The precise cause did arrive: the gateway adapter passes the upstream
error.code into the error's third constructor argument. Nothing read it.
It is now preferred over the generic literal, which is what turns
PROVIDER_REQUEST_FAILED into NO_BACKEND or INSUFFICIENT_FUNDS on the turn
record and in the log — three states an operator has to act on completely
differently. On a NO_BACKEND the log line also spells out that this is capacity
at the gateway rather than your account or your key.
If you are looking at a PROVIDER_REQUEST_FAILED from before that change, the
real cause is in errorMessage on the same row.
The operator-policy gate
The retrieval floor catches "we have nothing on this subject". It does not catch "we have plenty on this subject, none of it written by this operator" — measured at confidence 0.80 for "what are your withdrawal fees", answered confidently from a chain-specific documentation page belonging to somebody else's install.
So five topics can only ever be answered from an article you wrote:
fees · limits · processing_time · countries · refunds
The test at that gate is the source, not the score. A high-confidence
retrieval from the shipped documentation fails it, deliberately, and records
operator_policy_undocumented:<topic>.
The topic patterns are English. A Spanish fee question that was never rendered into English is not recognised as a fee question, the gate does not fire, and the assistant states a fee it has no business stating — fluently, in Spanish, in your voice.
That is the worst failure this system can produce and it fails silently. Hence the two defences below.
The customer does not get an eleven-word brush-off. On a policy refusal they are told why there is no single answer and where the real one lives, with no number in it: "fees depend on the currency and the network you're using", "you'll see the exact fee on the Withdraw page before you confirm it". The page is only offered when the route catalogue says it exists on this install.
The translation stop
Settings → Retrieval. With it off, behaviour is byte-identical to an
install that never had the feature — and the language check still runs, because
turning translation off does not stop non-English customers writing in. A
non-English question is marked as such and the policy gate refuses anything it
cannot classify, which is where
language_not_classifiable:unknown_language comes from.
With it on, a non-English question is rendered into English on the cheap model before retrieval, because the corpus is English and BM25 matches words: a Spanish question shares almost no tokens with any passage, falls under the retrieval floor, and escalates for a question the documentation answers perfectly.
When that translation fails, the turn stops. skipReason: translation_unavailable, verdict ESCALATED, reason no_documentation.
It does not degrade and carry on with the original text, and the reason is the gate above: the same English rendering is what lets the policy gate read the question at all. Continuing would mean answering a question no gate in the system was able to read.
Once a translation succeeds, the strict posture is dropped — with a proper English rendering, "no policy topic" is a real answer rather than an absence of one. Leaving it on would escalate every ordinary non-English question, "where is my deposit" included, on any install without operator articles. That is not a safety gate, it is an outage.
citationMode — and why groundedness reads differently per provider
| Value | Meaning |
|---|---|
NATIVE |
The provider returned structured citation spans |
MARKER |
They were recovered from [n] markers the model typed, intersected against the documents actually sent |
| (null) | No citations were produced at all |
An OpenAI-compatible provider has no native citations. Documents are numbered
in the prompt, the model is required to mark each factual sentence, and the
markers are then extracted and range-checked — a model that types [9] when six
documents were sent has invented the citation, and clamping it to [6] would
attach a real URL to a claim that document does not support. The turn records
MARKER, and the customer sees a heading rather than a verbatim quote, because
showing a fake verbatim quote would be worse than showing none.
This changes how groundedness reads. The score is per-sentence coverage: what
share of factual sentences a citation actually reaches. Under NATIVE that is
span overlap; under MARKER it is whether the sentence contains a marker; with
neither, it falls back to a ratio, which is all that can be said.
The managed gateway declares that it supports citations and then picks an
inference backend per job — two of which cannot produce native citations. So the
field describes an intention and citationMode describes what actually
happened.
When documents were sent, no citations came back and no [n] markers were found
either, groundedness cannot be measured — so the floor is not applied to that
answer. The URL intersection and the refund tripwire still are, and those are the
two checks that actually protect you from a costly sentence.
Before that fallback existed, this install discarded 100% of generated
answers and recorded ungrounded on every one — blaming the model for an
infrastructure fault and pointing the operator at the wrong fix ("write more
documentation"). One of the discarded answers cost $0.2839.
The log line to look for: "No citations came back from … despite sending N document(s)".
aiSupportGroundednessFloor defaults to 0.4, and that number was
re-calibrated when the metric changed meaning. Measured after the change: a
well-sourced answer about enabling two-factor authentication came back with four
native citations and scored 0.43, because a support answer is mostly procedural
prose — "open the app", "scan the code" — and only some of those sentences sit
inside a cited span. At the old floor of 0.6 it was withheld and the customer got
a handover instead of the answer, at a cost of $0.25.
An answer that asserts facts with nothing behind it scores 0 and is refused at any floor above zero.
The effort parameter and the small fast model
aiSupportEffort says how much deliberation a question deserves. It reaches the
managed connection on every call; on your own key it is omitted for any model
that rejects it — the cheapest tier does, with a 400, and so does any model the
pricing table does not recognise. Omitting it costs default behaviour; sending it
to a model that refuses it costs the whole turn, so the adapter drops it rather
than gambling.
That distinction matters most on the internal models. aiSupportRerankModel and
aiSupportClassifierModel both default to the small fast tier
(mashdiv-lite, against mashdiv-core for answering), and they run on
reranking, triage and the handover brief — three calls per conversation that
nobody reads directly and that would otherwise dominate the bill.
Two related facts from the same adapter, in case you are debugging a 400 yourself:
- No
temperature,top_portop_kis sent, anywhere. The current top-tier models reject all three. Tone is a prompt concern. - Citations and structured output cannot be combined — that pairing is a 400. Which is why every machine-readable decision the agent makes comes from a tool call rather than a response format.
aiSupportMaxTokens defaults to 4,000 because extended thinking tokens count
against it: a smaller ceiling can be consumed entirely by thinking and return
empty content. That is a default, not a floor — the field accepts 1,024 to
64,000, on the settings screen and in the backend coercer alike, so nothing
stops you saving a value low enough to have answers cut off mid-sentence and
thrown away unsent. If you lower it, lower it a little and watch. (The own-key
adapter's hard minimum is 1,024, so 1,024 is the real floor everywhere.)
Test connection
It is not a string check and not a ping. It runs the whole path a customer's
question takes, and ok is false unless the generation step succeeded —
because that is the step that corresponds to a customer actually being answered.
A green tick over a mute assistant is a state this endpoint used to report as
success, and a buyer whose first impression is that does not file a bug.
What it actually reports
There is no three-valued verdict. Each step comes back as a tick or a cross with a sentence of detail, and the screen draws them as a list under the summary line. The steps are not the same on every provider:
| Provider | Steps |
|---|---|
| Managed connection | Gateway credentials → Account → Answering |
| Your own key | API key → Answering |
| Any compatible endpoint | Endpoint → Answering |
Only the managed connection has a middle step, because it is the only one with
something to ask: /v1/account returns your plan, balance and remaining spend
cap, and the detail line prints all three. That is the check that separates the
gateway wallet is empty from the credential is wrong — on the other two
providers both arrive as a failed Answering step and you read the detail to
tell them apart.
What the details actually say, so you know what you are looking at:
| Step result | The detail you get |
|---|---|
| Credentials failed | The variable that is not set, named: "ANTHROPIC_API_KEY is not set in the backend .env", or the endpoint URL for the compatible provider |
| Credentials passed | The first few characters of the loaded key, or the endpoint URL. Never the credential itself |
| Account failed | The gateway's own message — unreachable, or the credentials refused |
| Account passed | "Plan … Balance $… Spend cap remaining $…" |
| Answering failed | The upstream message verbatim: a rejected key, an exhausted balance, a refused connection, a timeout, or "The model refused a trivial request" |
| Answering passed | Which model answered and how long it took, plus the cost when the provider reports one |
A failed step short-circuits the ones after it, so the last line in the list is the one to read.
The backend reads .env at boot. Editing the file and pressing Test without
restarting reports the old state, which is indistinguishable from a bad key —
and this is the screen an operator opens precisely because the assistant is not
answering, so it is the mistake most likely to be made.
Two more things about it worth knowing:
- It spends money, and the spend is recorded. It performs a real generation,
returns
costUsd, and files a row against the same ceilings a customer's answer counts against — including when the probe fails, because a failing probe still ran the generation that billed and is the one an operator presses again immediately. It is refused with a 429 when a cap is already reached: there is no point learning the provider works if the budget will not let it answer anybody. - Read the step, not the summary. The steps that pass before the failing one are exactly why a single "Connected" was so misleading.
Related: Escalation and handover for what each
escalationReason means, Troubleshooting for the
symptom-shaped version of this page, Provider for the
.env variables, and Cost and budgets for what
a turn costs.