Skip to content
TheThinkLoop

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:

PartThe judgmentNatural question type
OwnerWhich team or queue handles this?choice
PriorityHow quickly does it need a response?score
RiskDoes it involve legal, security, or money at risk?predicate per risk
AutonomyCan 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:

One choice doing four jobsjavascript
// 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:

One request, four independent judgmentsjavascript
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.

A routing policytypescript
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:

FailureSensible default
A question is refusedSend to triage with the reason. Other answers in the same request are still valid.
The provider times out or is downFall back to your pre-AI routing, such as a default queue or keyword rules. Never drop or hold messages.
An unexpected answer shapeTreat it as a failure, not a guess. Log it and route to triage.
A sudden shift in answersAlert 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:

A decision recordjson
{
  "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

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.