Intent routing
TL;DR Put Jev in front of your handlers. One call returns
intent(Choice) andcomplexity(Score); code sendsorder_statusto a plain database lookup, two intents to different specialist LLMs, and complex or low-confidence cases to a human. "The expensive resources only get invoked for the requests that actually need them."
Problem
From raw/docs/patterns__intent-routing.md:
Not every user request needs the same kind of handler. Some can be answered with a database lookup. Some need an LLM with domain-specific context. Some need a human.
If every inbound message goes through an expensive LLM just to discover what kind of message it is, you pay frontier-model cost and latency on requests a SELECT could have answered — and you still have to parse the LLM's answer to route on it.
Pattern
TypeSafe can sit in front of all of these as a fast, cheap classifier that determines which handler to invoke.
Two steps: one classification call, then a dispatch table in code. The classification call carries more than the intent — a second question about how hard the request is to resolve turns "which handler" into "which handler, and is automation safe here at all."
This is speculative fan-out (two questions, one call) with a confidence gate on top.
Implementation
The documented example is customer service routing: "Messages come in and need to be routed to the right handler. Rather than sending every message through an expensive LLM to figure out what kind of request it is, you classify first and route accordingly."
Step 1: classify intent and complexity
Questions, verbatim from the source (the customer message is the state; add "state": ... and "model": "jev-latest" for a complete request, see HTTP API: POST /v1/systemone and GET /v1/models):
{
"intent": {
"type": "choice",
"instructions": "The primary intent of this customer message",
"criteria": {
"order_status": "Asking about an existing order",
"product_question": "Asking about a product before buying",
"return_exchange": "Wants to return or exchange something",
"complaint": "Unhappy with experience, wants resolution"
}
},
"complexity": {
"type": "score",
"instructions": "How complex is this request to resolve",
"criteria": [
"Simple lookup or standard procedure",
"Requires some judgment or multi-step process",
"Unusual situation, edge case, or escalation needed"
]
}
}
Step 2: route to the optimal handler
def route_ticket(ticket_id, response):
intent = response.answers["intent"]
complexity = response.answers["complexity"]
if intent.confidence < 0.5:
# If we don't have enough confidence to classify, route to a human agent
return route_to_human_agent(ticket_id)
if intent.choice == "order_status":
handle_order_status(ticket_id)
elif intent.choice == "product_question":
handle_with_llm(ticket_id, PRODUCT_SPECIALIST)
elif intent.choice == "return_exchange":
handle_with_llm(ticket_id, RETURNS_SPECIALIST)
elif intent.choice == "complaint":
low_confidence = complexity.confidence < 0.5
# A higher complexity.score leans toward the "escalation needed" end of the scale.
if complexity.score > 1 or low_confidence:
# Too complex for safe automation, or we're not sure about the complexity; route to a human.
route_to_human_agent(ticket_id)
else:
handle_with_llm(ticket_id, COMPLAINT_RESOLUTION)
The same router in TypeScript with @typesafe-ai/sdk 0.6.0 — adapted from the Python sample (not in upstream docs), same questions, thresholds, and branches. choice() / score() build the questions; answers carry choice / probabilities / confidence and score / confidence; see JavaScript/TypeScript SDK: install, client, choice/score/noul.
import { choice, score, TypeSafeClient } from "@typesafe-ai/sdk";
const client = new TypeSafeClient(); // reads TYPESAFE_API_KEY
export async function routeTicket(ticketId: string, message: string) {
const { answers } = await client.systemOne({
state: message,
questions: {
intent: choice("The primary intent of this customer message", {
order_status: "Asking about an existing order",
product_question: "Asking about a product before buying",
return_exchange: "Wants to return or exchange something",
complaint: "Unhappy with experience, wants resolution",
}),
complexity: score("How complex is this request to resolve", [
"Simple lookup or standard procedure",
"Requires some judgment or multi-step process",
"Unusual situation, edge case, or escalation needed",
]),
},
});
const { intent, complexity } = answers;
if (intent.confidence < 0.5) {
// If we don't have enough confidence to classify, route to a human agent
return routeToHumanAgent(ticketId);
}
if (intent.choice === "order_status") {
handleOrderStatus(ticketId);
} else if (intent.choice === "product_question") {
handleWithLlm(ticketId, PRODUCT_SPECIALIST);
} else if (intent.choice === "return_exchange") {
handleWithLlm(ticketId, RETURNS_SPECIALIST);
} else if (intent.choice === "complaint") {
const lowConfidence = complexity.confidence < 0.5;
// A higher complexity.score leans toward the "escalation needed" end of the scale.
if (complexity.score > 1 || lowConfidence) {
// Too complex for safe automation, or we're not sure about the complexity; route to a human.
routeToHumanAgent(ticketId);
} else {
handleWithLlm(ticketId, COMPLAINT_RESOLUTION);
}
}
}
The source's summary:
One intent routes to deterministic code with no LLM involved. Two route to different specialist LLMs, each loaded with different context. One uses the complexity score to decide between an LLM and a human. TypeSafe handles the classification all in a single quick call; the expensive resources only get invoked for the requests that actually need them.
And on the second gate:
Note the additional confidence check on the complexity score. As discussed in Confidence, it is always important to consider the meaning of a low confidence score in the context of the system and the stakes of the decision.
Three things worth copying: complexity is asked speculatively (only the complaint branch reads it); uncertainty about the complexity is treated the same as high complexity, both meaning "a human should look"; and 0.5 appears twice as two independent policy constants, not one model constant.
When it fails
- No escape hatch in
criteria. These four options cover the documented domain; a message that fits none of them still produces achoice. The agent skill: "Include a no-match outcome when nothing may fit; use a separate presence judgment when it is independently useful." Compare theotheroption in Confidence-gated routing. - Overlapping intents. Choice "picks one option; its distribution compares competing options." A message that is genuinely two requests splits probability and trips the
< 0.5gate. If several labels can be true at once, use one Noul per label instead — see Noul (yes/no) questions and Choosing between Choice, Score, Noul. - Confidence read as permission. "Choice/Score confidence summarizes distribution concentration, not overall workflow correctness or permission to act." The gate filters ambiguity, not incorrectness.
- Thresholds carried over from this page.
0.5andcomplexity.score > 1are this example's numbers; evaluate yours on your data — see Testing and evaluating a Jev workflow. The agent skill: "Treat cookbook thresholds and demo results as examples to evaluate, not universal rules." - Routing that needs facts the state does not contain. If picking a handler requires looking up the order first, that is a second request by design — "A second request is warranted when an earlier answer is needed to fetch evidence, construct new state, or determine the next options."
- Too many intents for one Choice. High-cardinality routing is better done hierarchically; see Cookbook: Hierarchical classification and the cardinality note in System One Models.
Variants
- Route and fill arguments together. Ask for the handler's typed parameters in the same call as the intent: "A request can select a handler and its typed parameters." See Cookbook: Function calling.
- Hierarchical routing. Coarse category first, then a second call into that category's sub-intents — see Cookbook: Hierarchical classification.
- LLM fallback for the conversational branch. Smart home assistant demo walkthrough routes "general information or conversation" to a generative LLM and everything else to deterministic device commands.
- Guardrail in front of the router. Screen the message before routing it; see Cookbook: Guardrails for LLMs.
- A third dimension beyond intent and complexity. Urgency or sentiment added to the same call, consumed by whichever branch wants it (inferred; mirrors the always-consumed
frustrationquestion in Speculative fan-out).
Related
- Patterns overview — the four-pattern catalog
- Speculative fan-out — why both questions ride in one call
- Confidence-gated routing — the gating layer this pattern reuses
- Choice questions —
choice,probabilities,confidence - Cookbook: Function calling — routing plus typed arguments
- Cookbook: Hierarchical classification — routing over many categories
- Smart home assistant demo walkthrough — intent routing with an LLM fallback
Sources
- raw/docs/patterns__intent-routing.md (https://docs.typesafe.ai/patterns/intent-routing)
- raw/docs/patterns.md (https://docs.typesafe.ai/patterns)
- raw/github/skills/skills/typesafe-ai/SKILL.md (https://github.com/typesafe-ai/skills/blob/main/skills/typesafe-ai/SKILL.md)