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.

11 min readUpdated 6 August 2026escalation, handover, inbox, takeover, notifications

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.

Handovers grouped by what would fix them, ranked within three bands.

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

How many attempts the assistant gets before handing the conversation to a person.

Settings → ChannelsHandover. 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:

The customer's Talk to a person control. Stops the AI, queues the conversation, tells the desk.

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.

Stops the AI on this conversation immediately, including a generation in flight, and marks it yours.
Returns control to the AI, or leaves it off for this conversation only.

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

Keep preparing a suggested reply after one of your team takes a conversation over. It is never sent.

Settings → ChannelsHandover.

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_in and wasSent: 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, a draftText, not sent.
  • With it off, the turn is moved to CANCELLED. That status change is the withholding: the text stays on the row, but a CANCELLED turn 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.