Speculative fan-out
TL;DR Send all the questions your system could need in a single
POST /v1/systemonecall — including ones that only matter on branches you have not taken yet — then branch in code. "All questions are evaluated in parallel, so adding more questions to a call typically doesn't add any latency to the response."
Problem
A decision tree has dependent branches: you classify a support ticket, and only if it is a bug report do you need its severity and whether it has reproduction steps; only if it is billing do you need to know whether a refund was requested.
The obvious implementation asks the classifying question first, waits, then issues a second call for the branch-specific questions. That serializes two round trips for what is logically one decision, and the second call re-sends the same state.
Pattern
From raw/docs/patterns__fan-out.md:
Because TypeSafe supports sending many questions in a single API call, we recommend putting all of the questions your system needs in a single request, and then using code to decide what is relevant after the fact. All questions are evaluated in parallel, so adding more questions to a call typically doesn't add any latency to the response.
A speculative question is one you ask before you know whether its answer will be used. The source's definition:
Speculative questions:
bug_severityandhas_reproducible_stepsonly matter if the ticket is a bug report.refund_requestedonly matters for billing. We include all upfront because there is no speed cost for additional questions. If the ticket turns out to be a feature request, the bug severity result will be irrelevant, in which case your code path simply ignores it.
Two steps: fan out, then route with code.
Implementation
Step 1: speculative fan-out
The documented example is support ticket triage. State and questions, verbatim from the source (the source renders these as a playground example; state and questions are the two top-level fields of the request body — add "model": "jev-latest" to make it a complete HTTP request, see HTTP API: POST /v1/systemone and GET /v1/models):
{
"state": "Hi, I placed an order (#98423) last Thursday and was charged twice. I also can't log in after the site update, and adding Apple Pay would be really helpful. This is getting frustrating.",
"questions": {
"category": {
"type": "choice",
"instructions": "Determine the broad category of this support ticket",
"criteria": {
"bug_report": "The user is reporting something that is broken or producing errors",
"billing": "Charges, invoices, refunds, subscriptions",
"feature_request": "The user is requesting new functionality",
"account": "Login, permissions, profile, security"
}
},
"bug_severity": {
"type": "score",
"instructions": "How severe is the reported issue",
"criteria": [
"Cosmetic; no impact to functionality",
"Broken or degraded feature; workaround exists",
"Blocking issue; no workaround exists"
]
},
"has_reproducible_steps": {
"type": "noul",
"instructions": "The user describes specific steps to reproduce the issue"
},
"refund_requested": {
"type": "noul",
"instructions": "The user is explicitly asking for a refund or credit"
},
"frustration": {
"type": "score",
"instructions": "How frustrated the user appears",
"criteria": ["Calm, matter-of-fact", "Frustrated but civil", "Very angry"]
}
}
}
Note the mix: one Choice, two Scores, two Nouls — all in one call. See Choice questions, Score questions, Noul (yes/no) questions.
Step 2: route with code
Your code decides what is relevant based on the classification result:
category = response.answers["category"]
bug_severity = response.answers["bug_severity"]
bug_repro = response.answers["has_reproducible_steps"]
refund = response.answers["refund_requested"]
frustration = response.answers["frustration"]
if category.choice == "bug_report":
if bug_severity.score > 1.5 and bug_repro.noul > 0.6:
escalate_to_engineering(ticket_id, severity="high")
else:
add_to_bug_backlog(ticket_id)
elif category.choice == "billing":
if refund.noul > 0.7:
route_to_billing_with_flag(ticket_id, refund_likely=True)
else:
route_to_billing(ticket_id)
elif category.choice == "feature_request":
log_feature_request(ticket_id)
# Frustration is useful regardless of category
if frustration.score > 1.5:
flag_for_priority_response(ticket_id)
The source's closing point: "Everything needed for the full decision tree comes from one call. Speculative questions are ignored when irrelevant and save a round trip when they are not."
Note that frustration is not speculative — it is consumed on every branch. Fan-out mixes both kinds freely.
Sending it
The example above is JSON for POST /v1/systemone. To run the same fan-out through an SDK, build the same questions map with Choice / Score / Noul (Python) or choice() / score() / noul() (JS) — see Quickstart: first call in HTTP, Python, JS for a complete runnable call, and Python SDK: install, clients, system_one() / JavaScript/TypeScript SDK: install, client, choice/score/noul for signatures.
When it fails
- The next question depends on the previous answer, not just a branch. The agent skill draws the line: "A second request is warranted when an earlier answer is needed to fetch evidence, construct new state, or determine the next options." Fan-out only collapses trees whose questions can all be written against the state you already have.
- A speculative question with an unstated premise.
bug_severityis asked of tickets that may not describe a bug. The skill's rule: "State each speculative premise explicitly; code consumes the applicable answers." A question that silently assumes its branch produces meaningless numbers on the other branches — which is fine while you ignore them, and a bug the moment you do not. - Tokens are not free even though latency is. "Adding more questions to a call typically doesn't add any latency" is a latency claim, not a cost claim. The skill: "Extra questions still use tokens; measure actual request budgets, cost, and end-to-end latency." The
usageobject reportsinput_tokensandoutput_tokensper call; see Models, aliases, pricing, rate limits, context. - Reading a speculative answer's confidence as a system-health signal. Low confidence on an unused branch means nothing. The skill: "Ignore uncertainty on unused branches."
- Very wide fan-outs. The pattern page gives no ceiling on question count; check Models, aliases, pricing, rate limits, context for the context window and HTTP status codes, rate limits, retry semantics before fanning out to dozens of questions (inferred — the source states no limit).
Variants
- Fan-out plus a confidence gate. Intent routing asks
intentandcomplexitytogether and gates onintent.confidencebefore consuming either. - Fan-out plus an LLM fallback. Smart home assistant demo walkthrough fans out a long question list per utterance and hands the request to an LLM only when the classification says the user wants conversation.
- Fan-out as typed argument filling. The agent skill's "Route and fill known arguments" direction: "A request can select a handler and its typed parameters. Ask useful branch-specific questions up front and consume only the relevant answers." See Cookbook: Function calling.
- Fan-out over independent items. The same call shape applied per document, in parallel; see Cookbook: Parallel questions.
Related
- Patterns overview — the four-pattern catalog
- Intent routing — classification plus routing, built on this pattern
- State: what you send Jev — the payload every fanned-out question shares
- How to build software with System One — decomposing a workflow into atomic questions
- HTTP API: POST /v1/systemone and GET /v1/models — the
questionsmap on the wire - Smart home assistant demo walkthrough — fan-out in a running demo
Sources
- raw/docs/patterns__fan-out.md (https://docs.typesafe.ai/patterns/fan-out)
- 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)