Escalation and handover — why it stopped, and what the human receives
Every reason the assistant hands a conversation to a person, which half are your homework and which are correct, the brief the agent receives, and who gets notified.
Every handover records a reason. Half of them are the operator's homework and close permanently once you write something down; the other half are the product working exactly as designed and must never be "fixed".
Telling them apart is the whole job of this page, because a screen that ranks
them by volume opens with money_issue — which is the single most correct thing
this addon does.
Where you read a reason
Two screens show it and they show different things.
Live Inbox → the case pane → Brief tab. The reason for this conversation,
humanised mechanically — operator_policy_undocumented:fees renders as
"Operator policy undocumented — fees" — with the raw token printed underneath
in monospace. The humanisation is deliberately mechanical rather than a lookup
table: the reasons are an open list the backend extends, and a table would print
nothing at all for a value added later. An unknown reason still draws a row.
Overview → the handover report. Every reason across a window, with what it means and the next action.
Takes days (1–365, default 30). Its ordering is the point: incidents first,
then the fixable, then the by-design, and by volume inside each band. Sorted
purely by count it would open with the guardrails you should leave alone.
The three kinds
| Kind | Read it as |
|---|---|
incident |
Something is broken. Needs attention today. |
fixable |
Homework, usually with a known answer. |
by_design |
Working as intended. Not a defect, and not yours to fix. |
An unmapped reason falls back to fixable with the generic action "Review
recent conversations". A reason nobody mapped is exactly the one you most need
to see, and dropping it would turn "we escalated 40 conversations" into a screen
that adds up to 34.
The catalogue
Incident — act today
| Reason | What happened | Where it points |
|---|---|---|
provider_error |
Your infrastructure, not the customer's question — credentials, balance, or the provider being down. Conversations are reclaimed automatically when the customer writes again, but the answers you paid for stopped arriving. | Provider → Test connection |
Fixable — go and write something
| Reason | What happened | Where it points |
|---|---|---|
no_documentation |
It found nothing it could stand behind, said so, and handed over rather than guessing. | Gaps |
low_confidence |
Something was retrieved, not clearly enough to answer on. Usually the same fix — a clearer article. | Gaps |
ungrounded |
It wrote an answer and then failed the groundedness check: too many claims with nothing behind them. The answer was withheld. | Gaps |
turn_limit |
The conversation used up its attempts. A few is healthy; a lot means questions are arriving your documentation almost covers. | Gaps |
repeated_question |
The customer asked the same thing again, which usually means the previous answer did not land. These show you exactly which article is unclear. | Live Inbox |
complex |
Multi-part, unusual, or needing judgement. Some of these always exist; a recurring shape is worth an article. | Live Inbox |
frustrated |
Frustration was detected and a person was brought in early. Worth reading — these are the conversations that become reviews. | Live Inbox |
The catalogue's own words for it: every one of these is a question your customers really ask, and answering it once answers it for ever.
It is also the reason most likely to be misread as a defect. Nothing is broken — the assistant found nothing it could stand behind and said so.
By design — leave them alone
| Reason | What happened |
|---|---|
customer_requested |
They pressed Talk to a person, or typed "human". The assistant stepped aside immediately. |
money_issue |
A missing deposit, a rejected withdrawal, a balance that looks wrong. These never get an automated answer — the assistant cannot move funds and must not reassure anyone about them. |
account_locked |
Suspected takeover, lost 2FA, a frozen account. Always a person, always immediately. |
legal |
Chargebacks, disputes, law-enforcement requests, anything an answer could bind you to. Handed over untouched. |
refund_promise |
The drafted answer said something like "we'll refund you" and the tripwire stopped it before the customer saw it. That sentence could bind you and you did not authorise it. The answer was discarded. |
forbidden_topic |
The provider refused the request outright (result.refused in engine.ts), or the model chose this label itself when it called request_human. It is not what an operator rule records — a rule match records rule — even though the report's own wording for it says otherwise. |
rule |
A rule in ai_support_rule decided this conversation needed a person. |
Both of those rows carry the action Review your escalation rules pointing at
/admin/ai/support/agents — the href is set in escalation-catalog.ts — and
the Agents screen has no rule interface. Neither does any other:
agents/client.tsx and agents/columns.tsx contain no rule UI, and there is no
rule route anywhere under backend/src/api/(ext)/admin/ai/support/. Follow the
link and you will land on the list of agents wondering what you missed.
ai_support_rule is a database-only table in this build. utils/rules.ts
reads it on every customer turn — rows with status true, priority ascending —
and the only thing that ever writes it is the seeder, which puts three examples
on a fresh install (hand off after three AI replies, hand off when retrieval is
weak, refuse anything mentioning tax) and deletes the table's contents on
rollback. Adding, editing or disabling a rule means writing to that table
yourself: name, priority, matchType, matchValue, action, actionValue,
status.
Two of the seven matchType values do nothing if you write them. INTENT and
KYC_FEATURE need a classifier and a user context the evaluator is never
handed, so they fall through its default and never match — deliberately, since
treating an unrecognised type as a match would escalate every conversation. A
rule of either type is inert, not broken, and nothing reports it.
The other five have no action, on purpose: there is nothing to do.
no_documentation is two gates wearing one name
Both gates record escalationReason: no_documentation, deliberately — the
customer-facing outcome is identical. Only skipReason distinguishes them.
skipReason |
Which gate | The fix |
|---|---|---|
below_retrieval_floor |
Nothing relevant retrieved at all | An article on the subject |
operator_policy_undocumented:<topic> |
Plenty retrieved, none of it written by you | An article of yours 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 in Errors |
The second one is the most actionable signal in the addon. Measured behaviour: "what are your withdrawal fees" retrieves at confidence 0.80 from a chain-specific documentation page — high, confident, and the wrong operator's numbers. So the test at that gate is the source, not the score.
The Overview report breaks that topic out and matches it to the seeded onboarding questions that would close it, question already written. The five topics are fees, minimums and maximums, how long things take, which countries you serve, and refunds.
The triggers that are not about the question
The conversation ran too long
Settings → Channels → Handover. Range 1–20. At the start of a turn, if the
session's turnCount has already reached it, the conversation escalates as
turn_limit before anything is retrieved or generated.
Low is safer. A customer going round in circles with an assistant is worse than a queue.
The customer asked for a person
Two independent paths, and both work at every autonomy rung.
The button posts to the customer-side route:
It aborts any generation already in flight before transitioning, so an answer being written cannot land on top of the request. It requires authentication and the caller must own the ticket; a closed ticket is a 403.
The tool is request_human, offered to the model on every turn. Its
description tells the model to call it whenever you are uncertain, whenever
money may be missing, and whenever the customer asks — say plainly that you are
bringing in a person, then stop, never guess first. It takes one of eight
reasons: customer_requested, money_issue, account_locked,
no_documentation, forbidden_topic, repeated_question, frustrated,
complex, plus a two-line summary for whoever picks it up.
Separately, a built-in regex layer runs on the customer's message before any model call, and it cannot be switched off: an explicit request for a person, legal language, money-loss language and account-compromise language escalate straight away. The last three also refuse — the assistant does not even draft, because a drafted answer about missing money is a sentence somebody might send.
The budget ran out, or stopped being measurable
Three reasons come from the spend gate. All three record SKIPPED, move the
session to AI_SUSPENDED and notify the whole desk — the assistant has
stopped answering every customer on the install, no queue surfaces that, and
nobody owns it.
skipReason |
The notification | What ends it |
|---|---|---|
daily_budget |
AI support paused: budget reached | The UTC day rolling over, or a higher cap |
monthly_budget |
AI support paused: budget reached | The 1st, or a higher cap |
unpriced_model |
AI support paused: spend cannot be measured | Pricing the endpoint, or setting both caps to 0 |
The third is the one that behaves differently afterwards. A session suspended for
either budget reason is resumed automatically the next time the customer writes,
once the cap allows again. unpriced_model is not in that set, so fixing the
cause leaves the parked conversations parked: press Release on each one in
the Live Inbox. New conversations are unaffected, which is what makes it easy to
miss.
None of the three is a fault in the assistant. See Cost and budgets and Error codes.
The provider failed
A gateway with no spare capacity, an exhausted balance, an unreachable host. The
conversation escalates as provider_error and the turn records the machine-readable
cause in errorCode.
It is filed as provider_error rather than complex on purpose: complex is a
verdict about the question, and misattributing downtime to the customer's
request corrupts the handover log, the Overview report, and any later reading of
what the assistant cannot handle. A missing environment variable is different
again — that records SKIPPED / no_provider and does not escalate. See
Errors.
What the human receives
The brief
Written once, when the conversation is handed over, and stored on the session as
handoverSummary. Three lines:
Wants: ...
Established: ...
Blocking: ...
--- suggested reply ---
...Established is what was established, not what was said — "deposit was sent to
the right address on the wrong network", never "the customer explained their
problem". If the assistant never found out, it says so.
Below the marker is a suggested reply, addressed to the customer, ready to edit. The Brief tab renders it separately with a Use this draft button that loads it into the composer — it is never sent. The escalation already established that the assistant should not be answering, so a person reads it, edits it and takes responsibility for it. The draft moves the work, not the authority.
Where the reply needs a fact only the agent has, the model leaves a
[square bracket] placeholder rather than guessing. A draft that invents a
refund date is worse than no draft, because a tired human at 11pm will send it.
Four properties worth knowing:
- It is skipped on short conversations. Fewer than three messages and there is nothing to summarise that the thread does not show at a glance.
- It is written once per conversation. A conversation can bounce between AI and human several times; re-summarising each bounce would overwrite a brief the agent has already read.
- It never blocks the handover. It is generated after the transition and not awaited. A summariser outage delays a brief; it must never delay the escalation itself.
- It costs a little, on the cheap model, and is skipped entirely when the budget cap is reached. The conversation is redacted before the summariser sees it, exactly as it is for Teach from this ticket.
The message the customer gets
One sentence, once per handover. If the assistant produced text this turn, that text is the reply and it is posted as an ordinary assistant message; if it produced nothing, the interface says so in its own voice as a centred system chip.
The suppression rule is worth knowing because both ways of getting it wrong have shipped:
| Situation | What happens |
|---|---|
| The model wrote something | Post it. That text is the reply. |
| No standing handover promise | Post the notice. |
| A promise stands, and the customer has spoken since | Acknowledge, in different words. |
| A promise stands, and nothing has changed | Stay quiet. |
Always posting produced a customer collecting an identical "passed to the support team" chip every time they spoke. Always suppressing was worse: a customer whose question was still unanswered asked a new one and received nothing at all — no answer, no chip, no acknowledgement — while every admin got a notification about it.
On a policy refusal the notice also explains why there is no single answer and where the real one lives, without stating any number: "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". A path is only offered when the route catalogue says the page exists on this install.
Where office hours are configured, "someone will reply here" gains a when.
Who is told
| Event | Recipients |
|---|---|
| An ordinary handover | The assigned agent only. Silent if the ticket is unassigned. |
| The customer pressed Talk to a person | The assigned agent — or, if unassigned, the whole desk. |
| Budget cap reached, prompt-cache collapse | Everyone holding view.support.ticket. |
The narrow default is not an oversight. On a real install view.support.ticket
is held by 34 people, so one customer asking one question about fees produced 34
notification rows, 34 database writes and 34 WebSocket pushes. An admin notified
about every conversation the assistant cannot answer learns to dismiss everything
this system sends — including the two messages that actually need them.
An escalation already sets the ticket to OPEN, which is how unassigned work is
found. The queue is the notification.
Custody, and the field that stops two agents answering at once
The case pane's first block never collapses and never scrolls away, because it answers the sharpest question on the screen: who owns this conversation right now, and what happens if you type.
| What it says | Session state |
|---|---|
| The assistant is handling this | AI_ACTIVE / AWAITING_USER, no humanAgentId |
| Waiting for a person | HUMAN_REQUESTED, no humanAgentId |
| You have this | humanAgentId is you |
| Another agent has this | humanAgentId is somebody else |
| AI is off for this conversation | AI_SUSPENDED |
humanAgentId on ai_support_session is what makes the fourth row possible, and
two agents opening the same escalation is the failure it prevents. The queue puts
HUMAN_REQUESTED first, so the conversation you and a colleague are most likely
to open simultaneously is exactly the one at the top of both your screens. When
somebody already holds it the button reads Take over anyway rather than
Take over — it is not blocked, it is named.
Both are hidden — not disabled — without edit.support.ticket. A view-only agent
gets the evidence and no write control, rather than a button that 403s.
supportTicket.status is the whose-turn axis the desk queue, the SLA clock and
the stats screen all read. Taking a conversation over writes no status
change: writing REPLIED would drop the ticket out of the queue the agent just
claimed it from, and freeze its clock.
An escalation writes OPEN — waiting on us — for the same reason in reverse.
Only an actual answer writes REPLIED.
release accepts keepAiOff: true, which parks the session in AI_SUSPENDED
instead of handing back. Use it when the topic is one the assistant should never
handle.
The ticket's assignee is a different field from who holds the AI session, and the two can disagree silently — you can be running a conversation the ticket reports as unassigned. The pane draws them separately on purpose, with an Assign to me link, because their disagreement is the thing worth seeing.
When the assistant may take a conversation back
A conversation stuck in HUMAN_REQUESTED on an install where nobody is watching
the desk is a customer talking to nobody, permanently, under a screen that says a
person is coming. Measured on a live install: a fees question escalated at 14:53,
two more customer messages at 16:03 and 16:05 both recorded SKIPPED /
not_owner, and no human had touched it in over an hour.
So when a new customer message arrives, the assistant may reclaim the
conversation — but only from HUMAN_REQUESTED, only when humanAgentId is
still null, and only for these reasons:
provider_error · no_documentation · ungrounded · low_confidence ·
complex
Every one of those was a verdict about one question, or about our own plumbing,
and a new message is a new question. The reasons deliberately absent are the ones
about the conversation or about what the customer wants —
customer_requested, money_issue, account_locked, legal,
forbidden_topic, frustrated, repeated_question, turn_limit and rule
are never reclaimed. Overriding a customer who asked for a person is the single
most infuriating thing this product could do.
The moment anyone presses Take over, the conversation is theirs regardless of why it was escalated.
Barge-in — when a human types mid-draft
Settings → Channels → Handover.
The claim on the session row is the fence. A generation holds a
generationToken; Take over aborts the in-flight request and clears the
claim, and the append is re-checked inside the transaction under the row lock —
because on a multi-process deployment the agent's request may have landed on a
different worker where the in-process abort does not exist. The database is the
only place both processes can agree.
What the setting governs is narrower than it sounds. If the answer was already generated when the human arrived, it was already paid for, and discarding it buys nothing back:
- The turn records
skipReason: barge_inandwasSent: false, and the text is kept on the row either way. - With the switch on, the turn stays
SUCCEEDED— the generation succeeded; the delivery did not — and the text is offered to the agent as a draft. The console's draft panel matches exactly that shape:SUCCEEDED, adraftText, not sent. - With it off, the turn is moved to
CANCELLED. That status change is the withholding: the text stays on the row, but aCANCELLEDturn is filtered out of the draft panel, so nobody is offered it. - Either way the customer never sees it.
An abort that lands before any text exists also records CANCELLED /
barge_in — with nothing on the row. So CANCELLED on its own does not tell
you which of the two happened; the presence of draftText does.
Related: Live Inbox for the screen itself,
Autonomy for which rung sends without a human,
Teaching for turning a handover into an article, and
Errors for decoding skipReason and errorCode.