Guide
AI decision routing: from one-off judgment calls to an operational decision system
How to design AI routing for support and operations: split a judgment call into questions, combine the answers into an action, handle refusals, and log it.
Updated - 8 min read
Routing looks like one decision: where should this message go? In practice it is several judgments made together. Who owns it, how fast it needs an answer, whether it carries risk, and whether anyone should look before something happens. Treating those as one question is the most common reason AI routing works in a demo and disappoints in production. This guide covers how to separate the judgments, recombine them with a rule you control, and keep a record you can audit.
The anatomy of a routing decision
Most operational routing in a B2B SaaS team breaks down into the same four parts:
| Part | The judgment | Natural question type |
|---|---|---|
| Owner | Which team or queue handles this? | choice |
| Priority | How quickly does it need a response? | score |
| Risk | Does it involve legal, security, or money at risk? | predicate per risk |
| Autonomy | Can it proceed without a person? | Your policy, using the answers above |
The first three are questions about the input. The fourth is not: it is a business rule that consumes the other answers. Keeping that distinction is most of the design.
Decompose the judgment call
The tempting design is one big list of destinations:
// Tempting, and a trap: four different judgments folded into one list.
choices: [
{ value: "auto_reply" },
{ value: "support_tier_1" },
{ value: "support_tier_2_urgent" },
{ value: "engineering" },
{ value: "legal" },
{ value: "billing_urgent" },
// ...and every new combination needs a new option
]It fails in predictable ways. Options overlap, so probability is split between “support_tier_2_urgent” and “engineering” when both are partly right, and the confidence for either looks low. Every new combination of team and priority needs a new option. And you can no longer ask simple questions of your data, such as “how often do we misjudge urgency?”, because urgency is not a separate answer.
Instead, ask one question per dimension. The Decisions API evaluates several questions against the same input in a single request, so this costs one round trip:
const decision = await client.decisions.create({
model: "gpt-6-luna",
input: ticketText,
questions: [
{
type: "choice",
name: "team",
instructions: "Which team should own this customer message?",
choices: [
{ value: "sales", description: "New purchases, pricing, demos, and quotes." },
{ value: "support", description: "Troubleshooting and technical problems." },
{ value: "billing", description: "Invoices, payments, and refunds." },
{ value: "other", description: "Anything that fits none of the above." },
],
},
{
type: "score",
name: "urgency",
instructions: "How urgent is this message?",
levels: [
{ label: "Low" }, { label: "Normal" }, { label: "High" }, { label: "Critical" },
],
},
{
type: "predicate",
name: "legal_threat",
instructions: "The customer mentions a chargeback, bank dispute, or legal action.",
},
{
type: "predicate",
name: "security_issue",
instructions: "The message reports unauthorized access, a breach, or a leaked credential.",
},
],
});Each question can now be evaluated, calibrated, and reworded on its own. When urgency is wrong, you fix the urgency rubric without touching team routing.
Combine answers into an action
The rule that turns answers into an action belongs in your code, not in the question text. Code can be versioned, reviewed, unit-tested with fixed answers, and changed without re-evaluating the model.
type Route =
| { to: string; priority: "normal" | "high" | "page"; automated: true }
| { to: "triage"; reason: string; automated: false };
// The policy lives in code: versioned, reviewable, testable without a model.
export function route(answers: Answers, account: Account): Route {
const { team, urgency, legal_threat, security_issue } = answers;
// 1. Anything unanswered goes to a person. Never drop a message.
if ([team, urgency, legal_threat, security_issue].some((a) => !a || a.type === "refusal")) {
return { to: "triage", reason: "a question was not answered", automated: false };
}
// 2. Vetoes: low bars, because missing one of these is expensive.
if (security_issue.probability >= 0.3) return { to: "security", priority: "page", automated: true };
if (legal_threat.probability >= 0.4) return { to: "legal_review", priority: "high", automated: true };
// 3. Facts from your own systems beat inferences from text.
const pCritical = urgency.probabilities.find((p) => p.label === "Critical")?.probability ?? 0;
const priority = pCritical >= 0.35 || account.tier === "enterprise" ? "high" : "normal";
// 4. The routing choice itself, with a review band.
if (team.choice === "other" || team.confidence < 0.75) {
return { to: "triage", reason: `unsure: ${team.choice} at ${team.confidence}`, automated: false };
}
return { to: team.choice, priority, automated: true };
}A few choices in that function are worth calling out:
- Priority reads a level’s probability, not the averaged score. A ticket that is either trivial or an outage can average to “Normal”. The question-types guide explains why.
- Uncertainty has somewhere to go. A triage queue receives what the policy can’t place confidently, with the reason attached.
- “Other” is a real answer. It means the message fits none of your destinations, which is information about your routing design.
Vetoes and overrides
Some answers should override everything else, and they deserve low thresholds. Missing a security report costs far more than sending a harmless message to the security queue, so a 30% probability is enough to act on. This is the asymmetric-threshold idea applied to routing.
Overrides should come from data you already hold. If your CRM knows an account is on an enterprise plan, use that field; don’t ask a model to infer it from the tone of the email. A model is the right tool for reading unstructured text and the wrong tool for recalling facts your own systems store.
Design for failure
Every routing system eventually meets these, so decide the behavior in advance:
| Failure | Sensible default |
|---|---|
| A question is refused | Send to triage with the reason. Other answers in the same request are still valid. |
| The provider times out or is down | Fall back to your pre-AI routing, such as a default queue or keyword rules. Never drop or hold messages. |
| An unexpected answer shape | Treat it as a failure, not a guess. Log it and route to triage. |
| A sudden shift in answers | Alert when the share of any destination moves sharply day over day. It usually means the input changed. |
Set a time budget. If routing has to happen before an auto-acknowledgement goes out, the fallback must kick in before that deadline, whatever the provider is doing.
Low-confidence answers need a decision in advance too. A routing rule that always picks a destination will pick one for messages it can’t place; the abstention guide covers when to send those to triage instead.
Record every decision
A routing system that keeps no record cannot be calibrated, debugged, or audited. Store a decision record for each message:
{
"decisionId": "dec_01J9…",
"receivedAt": "2026-10-08T09:14:03Z",
"inputRef": "ticket_88412", // a reference, not the message text
"questionSet": "inbound-routing@v7", // wording is versioned like code
"model": "gpt-6-luna",
"answers": {
"team": { "choice": "billing", "confidence": 0.91 },
"urgency": { "score": 1.2, "confidence": 0.64 },
"legal_threat": { "probability": 0.07 },
"security_issue": { "probability": 0.01 }
},
"policy": "routing-policy@v3",
"action": { "to": "billing", "priority": "normal", "automated": true },
"review": null // filled in later if a person checks it
}Three details make records useful later. Reference the input instead of copying it, so sensitive text stays in the system that already governs it. Version the question set, because changing the wording of a question changes the decision. And leave room for the review outcome: every time a person confirms or corrects a route, you gain a labeled example for calibration.
This is the layer TheThinkLoop is building toward: versioned decisions, hosted endpoints, and decision history, so teams don’t each rebuild it. Early access is open for teams with a concrete routing problem.
Related
- Customer message routing template: the owner question, ready to adapt.
- Support escalation template: a risk predicate with evidence checks.
- Human-in-the-loop AI: designing the triage queue this policy feeds.
Related guides
- Guide
Human-in-the-loop AI
When should AI decide and when should it ask? Review patterns, what reviewers need to see, sizing the queue, and keeping oversight real.
- Guide
Abstention
Abstaining is different from review and from refusal. The coverage trade-off, setting the threshold band by band, signals beyond confidence, and owning the queue.
- Guide
Predicate vs choice vs score
Most decisions can be phrased three ways, and each returns different information. Which type fits which decision, with worked examples and common mistakes.
Templates that use these ideas
Put it into practice
Try a decision, check it against labeled examples, and talk to us if you want help building one for your workflow.