AI agents

Routing Reason Codes to Queues: A Table and Fallbacks

Antonio Evans10 min read

A wide horizontal routing table pinned to a wall, drawn as a grid with a left column of reason code labels in plain monospace text and a right column of queue names.
Key points

Key points

  1. 1Build a routing table with exactly one owning queue per published reason code and a mandatory default queue for codes that are missing, unknown or below your confidence threshold.
  2. 2Route on the pair (code, confidence), not the code alone: a valid code with weak confidence goes to the default queue, not the specialist queue.
  3. 3Treat an invented code outside your vocabulary as a normal input, not an exception: log it, send it to the default queue, and review the vocabulary on a fixed cadence.
  4. 4Keep an after-hours rule so the default queue always maps to a covered queue, and measure routing quality by reassignment count rather than by deflection alone.

Why the code alone is the wrong routing key

An AI support agent that returns a reason code looks like it has done the hard part. It has not. The code is a label chosen by a language model, and language models are inconsistent about external label definitions: research on label receptivity found that models often fall back on their internal representations rather than the definitions you supplied, so the same situation can produce different labels across runs (Do LLMs Adhere to Label Definitions?). If your router trusts the label string on its own, that variance lands directly on your queues.

The second problem is that a code carries no signal about how sure the model was. A confident "wrong item" and a coin-flip guess between "wrong item" and "damaged on arrival" arrive in your system looking identical. Routing them to the same specialist queue wastes the specialist's time on the coin flip and hides the uncertainty from anyone looking at the queue.

The fix is to make the model emit two fields, not one: the code, and a confidence score you define and calibrate. Constrained decoding can guarantee the code comes from your published list, because grammar-constrained decoding masks out any token that would violate the schema (Structured Output and Constrained Decoding). What it cannot guarantee is that the code is semantically right, or that the confidence attached to it is meaningful. That is why the router reads both.

A useful framing: the code answers "which queue could handle this," and the confidence answers "is it safe to skip triage." You need both answers to assign work.

The routing table: one owner per code

The core artifact is a lookup table where every published code maps to exactly one owning queue. Not a primary and a fallback queue, not a list of interested teams. One owner. If two teams both believe they own "billing dispute," the dispute gets reassigned, and reassignment is the cost you are trying to remove. Support routing guidance is blunt about this: route on the skill the ticket needs, not on who happens to be free, because a fast wrong assignment costs a reassignment plus a customer explaining the problem twice (Tray: how to build support ticket routing).

CodeOwning queueConfidence thresholdAfter-hours behavior
billing_disputeBilling0.80Billing-oncall, then Default
refund_requestBilling0.80Billing-oncall, then Default
account_accessIdentity0.75Identity-oncall, then Default
shipping_delayLogistics0.70Default (no Logistics oncall)
damaged_itemLogistics0.70Default
wrong_itemLogistics0.70Default
cancellationRetention0.75Default
product_questionTier 10.60Default
default_queueTier 1n/aTier 1 always staffed

Read the table as a contract. The thresholds are not decoration: they encode how much a misfire costs. A billing dispute sent to Identity produces a slow, angry customer and a handoff, so the threshold is high. A product question sent to Tier 1 is cheap to correct, so the threshold is low. Set each threshold from the cost of the wrong destination, not from a single global number.

Two rules keep the table honest. First, the default queue accepts everything, including codes you have never seen, so the router never has a "no match" branch that drops work. Second, any code with no owner is a bug in the table, not a runtime decision, and the table should fail a build if a published code has no row.

How many codes? The returns-code literature offers a useful precedent: a starter taxonomy of six to eight top-level categories covers roughly 90 percent of ecommerce traffic before sub-codes are added (Returns Reason Codes). Apply the same discipline. Start with six to ten codes, one owner each, and resist adding codes until you can point to tickets in the default queue that a new code would have caught.

The default queue is not a fallback, it is a product

Most teams bolt on a default queue as a catch-all and then forget it. That is backwards. The default queue is where all the interesting work lives: invented codes, low-confidence codes, uncovered codes after hours. If it is understaffed, every routing gap becomes a customer-visible delay.

Treat it as a first-class queue with three properties:

  1. It is always staffed. The routing table above gives it a permanent owner (Tier 1) and no threshold. Nothing gates entry.
  2. It sees everything that does not match. Missing code, unknown code, low confidence, code with no row in the table. All of it lands here.
  3. It feeds the vocabulary. Every ticket in the default queue is a candidate for either a new code or a new owner for an existing code. Review that queue on a fixed cadence, weekly at first, and log why each ticket was there.

The evidence for keeping humans in this loop is not sentimental. Human-in-the-loop designs exist precisely because fully automated systems struggle with judgment, context and incomplete information, and because the cost of an error varies case by case (What is Human-in-the-Loop?). A routed-to-default ticket is a cheap human review of the model's uncertainty. That is the whole point.

There is a second, less obvious benefit: the default queue gives you a clean denominator. Share of tickets reaching default is your model's real accuracy at the routing task, measured on live traffic rather than on a hand-picked eval set. Escalation guidance from the support-automation world makes the same argument from the other direction: escalating too eagerly guts the value of automation, so the fix is narrowing triggers to match what the agent has actually proven it can handle (Designing AI escalation rules).

The decision rule: route on (code, confidence)

Here is the rule in full, and it fits on a sticky note.

Route to the code's owning queue only if all three hold:

  1. The returned code appears in the published vocabulary.
  2. The code has a row in the routing table with an owner.
  3. The confidence clears that row's threshold.

Otherwise, route to the default queue. Log which of the three conditions failed, because each failure has a different repair.

def route(code, confidence, at, table):
    if code not in table:
        return "default_queue", "unknown_code"
    row = table[code]
    if confidence < row.threshold:
        return "default_queue", "low_confidence"
    target = row.owner
    if not is_staffed(target, at):
        return row.after_hours_target, "after_hours"
    return target, "ok"

Four details make this survive contact with production.

Log the failure reason, not just the destination. "unknown_code" and "low_confidence" demand different responses. Unknown codes mean your vocabulary is behind reality. Low confidence usually means two codes are too close together ("damaged_item" vs "wrong_item") and the prompt or the definitions need separating.

Never let the model supply the destination. The model returns a code and a number. The router owns the mapping. If you let a model name a queue, an invented queue name becomes an invented destination, and you are back to dropping work.

Clamp and validate the confidence. A model that returns 1.5 or a string should fail validation into the default queue, not crash the pipeline. Provider-side structured output enforces syntax, not semantics, so your own validation layer is still required (Structured Output and Constrained Decoding).

Calibrate before you trust the thresholds. A confidence score is only useful if 0.7 means roughly seven in ten correct. Measure it on a labeled sample; if the score is systematically inflated, raise every threshold or recalibrate the model's instruction until it is not.

A worked example makes the arithmetic concrete. Suppose 1,000 tickets a day, and the table above sends 60 percent to owning queues and 40 percent to default. If each reassignment costs a specialist roughly ten minutes of re-reading and each default-queue ticket costs Tier 1 about five minutes of triage, then the default queue carries about 200 tickets and 1,000 minutes of triage a day, and every point of default-queue share you remove is roughly five specialist minutes recovered. Those are your own numbers to fill in; the point is that the rule turns an argument about whether the agent is "good" into a measurable line item.

After hours: keep the default queue covered

Routing tables usually encode the office org chart. That is fine at 3 p.m. and broken at 3 a.m. The after-hours rule exists so no queue goes uncovered, and it has one job: convert the table's owning queue into a queue that is actually staffed right now, and if none is, into the default queue.

The rule has three tiers:

  • The owning queue, if it is staffed at the current time.
  • The owner's on-call or follow-the-sun equivalent, where one exists.
  • The default queue, which is always staffed, and which flags the ticket for the owning queue's first shift.

Write the after-hours target into the table itself, as the last column in the example above. A column that people can read beats a conditional buried in code, and it makes gaps visible during review: the Logistics row says in plain text that there is no on-call, so a 2 a.m. damaged-item ticket sits with Tier 1 until morning. That is a business decision someone made, and it should be legible as one.

Then test it. Run the router against a fixed set of inputs at several simulated times of day and assert the destination, including the awkward cases: a valid code at a valid threshold at 02:00, an unknown code during business hours, a low-confidence code at exactly the threshold. Evals exist to make behavioral changes visible before they reach users, and multi-turn or stateful agent behavior is exactly where reactive production debugging gets expensive (Demystifying evals for AI agents). A routing table is stateful in the sense that matters here: it depends on the clock.

When the agent invents a code

An invented code is not a hallucination to be suppressed. It is a labeled data point from your own traffic, and the correct response is to route it, log it and read it.

Route it. Unknown code goes to default. The ticket still gets handled; the router does not fail open into a dropped ticket or a generic "unassigned" state.

Log it with the reasoning. Store the raw model output, including the code it wanted to use and the customer message it was reacting to. The invented code is often a precise description of a category you have not defined, and it is frequently better phrased than your own codes.

Decide on a cadence, not in the moment. Three outcomes, in order of preference:

Pattern in the logDecisionWhat changes
Invented code repeats, maps to an existing codeAlias itAdd the alias to the parser, no new queue
Invented code repeats, no existing code fitsPromote itNew row in the table, new owner
Invented code is a one-off, or mixes two ideasLeave itDefault queue handles it, no change

The middle column is where most teams overreact. Adding a code per week for a quarter produces a vocabulary nobody can defend and a table that no longer fits on a page. The first column does more work than most people expect, because models frequently invent near-synonyms ("delivery_delayed" for "shipping_delay") rather than genuinely new categories. Aliasing is cheap, reversible and keeps the vocabulary small.

There is also a prompt-level fix worth trying before any of this. Constrained decoding can restrict the code field to your enumerated list, so an out-of-vocabulary code becomes grammatically impossible (Structured Output and Constrained Decoding). That sounds like it removes the problem. It does not, and you should not treat it as a substitute for the default queue, for two reasons. First, the model forced into a list will pick the nearest valid code, which is a low-confidence guess dressed as a valid answer, and your threshold is the only thing that catches it. Second, refusals and edge cases still have to go somewhere. Keep the default queue regardless.

The same caution applies to label definitions generally. If you define your codes in the prompt, expect imperfect adherence: models do better with explicit definitions on domain-specific tasks and worse on general ones, and sometimes follow the definition while ignoring the label name entirely (Do LLMs Adhere to Label Definitions?). Write the definitions, then measure.

Measuring the router, not the agent

Most teams measure the agent (deflection rate, satisfaction) and never measure the router. That hides the failure you can actually fix. Three metrics cover it:

  • Share of tickets reaching default, split by failure reason (unknown code, low confidence, no owner, after hours). This is your routing accuracy in disguise.
  • Reassignment count after a correct code-to-queue match. The support-routing literature is direct about this: reassignment count is the honest measure of routing quality, and it tells you more than first response time (Tray).
  • Threshold drift. For each code, the actual accuracy of tickets above versus below its threshold. If tickets just above the threshold are wrong as often as those below, the threshold is set wrong.

When you tune, tune one threshold at a time against the reassignment number for that queue. The temptation is to lower every threshold to cut default-queue volume; that trades a visible triage cost for an invisible specialist cost and a worse customer experience. Building an evaluation set of real transcripts and grading the routing decision, not just the reply text, keeps the tuning honest (LLM evaluation metrics and methods). If you want the same discipline for the human side of the loop, judge every escalation rule by asking whether a good human agent would make the same call, in the same tone, in the same situation (Designing AI escalation rules).

The operational loop looks like this: start with topic detection on real conversation history and a small code set (a playbook for building an AI support agent recommends mapping real conversation topics before training anything, so the taxonomy comes from evidence rather than assumption, Crisp) (Domo). Add the confidence field. Stand up the table and the default queue. Review the default queue weekly, alias or promote codes, and adjust one threshold at a time.

If you are running several models behind one router (a small model for triage, a larger one for ambiguous cases), a single API key across providers removes a chunk of plumbing from the router's hot path; Quanta Inference (https://quantaintelligence.ai/inference) is OpenAI-compatible and lets you switch model per call without rewriting the client. The routing rules you wrote still decide the destination.

FAQ

FAQ

What confidence threshold should I start with?

Start at 0.75 for queues where a misfire costs a handoff and 0.6 for queues where a misfire is cheap to correct, then move each threshold based on measured accuracy above and below it. Do not use one global number: the cost of a wrong destination varies by queue, and a single threshold either over-triages your cheap queues or under-triages your expensive ones.

What happens if the model returns a code I have never published?

The ticket goes to the default queue and the raw output is logged with the message it was reacting to. Review the log on a fixed cadence and decide whether to alias the invented code to an existing one, promote it to a new row in the table, or leave it. Most invented codes are near-synonyms and should be aliased, not promoted.

Can I just force the model to return one of my codes and skip the default queue?

No. Constrained decoding makes an out-of-vocabulary code grammatically impossible, but a model forced into a list will pick the nearest valid code, which is a low-confidence guess wearing a valid label. The default queue is what catches that guess, and it is also where refusals and genuinely unseen categories go. Keep both.

How often should the routing table change?

Weekly while the agent is new, monthly once the default-queue share is stable. The signal for a change is repetition in the log, not a single odd ticket. Change one thing at a time (an alias, a new row, a threshold) and check the effect on reassignment count for the affected queue before changing the next.

Does routing by confidence hurt the customer experience?

It usually improves it, provided the default queue is staffed. A low-confidence ticket routed to a specialist who has to hand it back is slower than the same ticket triaged once by a generalist, and the customer explains the problem once instead of twice. The failure mode is an understaffed default queue, which turns the rule into a bottleneck rather than a safety net.

Sources

Sources

  1. Do LLMs Adhere to Label Definitions? Examining Their ...
  2. Structured Output and Constrained Decoding for Production AI ...
  3. How to build support ticket routing
  4. Returns Reason Codes: Best Practices for Ecommerce
  5. What is Human-in-the-Loop (HITL) in AI and ML?
  6. Designing AI Escalation Rules Your Customers Will Actually ...
  7. Demystifying evals for AI agents
  8. LLM evaluation metrics and methods, explained simply
  9. How to Build an AI Agent for Customer Support: A Step-by- ...
  10. Customer Support AI Agents: What They Are and How to ...
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles