Choice questions
TL;DR
{"type": "choice", "instructions": "...", "criteria": {"option": "description or null", ...}}. The answer is{"type": "choice", "choice": "<top option>", "probabilities": {option: float summing to 1}, "confidence": 0..1}. Up to 255 options. Add anotheroption when your list may not cover every input; readprobabilitiesandconfidence, not justchoice.
When to use / when not to use
Use a Choice when the answer is one of a fixed set of options: which team handles a ticket, which category a product belongs to, which language a code snippet is written in.
- If the answer is a position on a spectrum → Score questions.
- If it's a yes or no → Noul (yes/no) questions.
- Full comparison: Choosing between Choice, Score, Noul.
Example questions from raw/docs/primitives__choice.md:
"What programming language is this code written in"
→ options: python, javascript, typescript, go, rust, other
"What type of meeting is this based on the title and description"
→ options: standup, planning, retrospective, one on one, brainstorm, none of the above
"Which product category does this item belong to"
→ options: electronics, clothing, home garden, food and beverage
Request contract
The POST body to the TypeSafe API has three top-level fields: state (the content to evaluate), model, and questions (a map from question ids you choose to question objects).
| Field | Type | Required | Description |
|---|---|---|---|
type |
"choice" |
yes | Always "choice". |
instructions |
string | object | array |
yes | What the model should decide — the question it answers. |
criteria |
map<string, string | null> |
yes | The answer options. Each key is an option name; each value is a description of that option, or null when the option needs no extra detail. |
Field types are as spelled in raw/docs/api.md. Per Structured instructions, options, levels, criteria, instructions and each criteria value may also be an object or an array (both are EntryType, which also permits null).
You choose the question id; the answer comes back under the same id, and the model never sees the id. The option names and their descriptions are both sent to the model, so write descriptions that separate the options from each other.
Minimal request
{
"state": "My running shoes arrived in the wrong size. Can I swap them for a size 10?",
"model": "jev-latest",
"questions": {
"department": {
"type": "choice",
"instructions": "Which team should handle this?",
"criteria": {
"returns": "Exchanges, refunds, wrong or damaged items",
"shipping": "Delivery status, delays, lost packages",
"billing": "Charges, invoices, payment problems"
}
}
}
}
The same question in the Python SDK:
from typesafe_sdk import Choice, TypeSafeClient
with TypeSafeClient() as client:
response = client.system_one(
state="My running shoes arrived in the wrong size. Can I swap them for a size 10?",
questions={
"department": Choice(
instructions="Which team should handle this?",
criteria={
"returns": "Exchanges, refunds, wrong or damaged items",
"shipping": "Delivery status, delays, lost packages",
"billing": "Charges, invoices, payment problems",
},
),
},
)
print(response.answers["department"].choice)
Use the system_one method or the https://api.typesafe.ai/v1/systemone endpoint. The model field selects which model handles the request. In the JavaScript SDK the helper is choice(instructions, criteria), returning a ChoiceQuestion<T> — see JavaScript/TypeScript SDK: install, client, choice/score/noul.
Response contract
| Field | Type | Description |
|---|---|---|
type |
"choice" |
Matches the question type. |
choice |
string |
The option with the highest probability. |
probabilities |
map<string, number> |
Every option mapped to its probability. The values sum to 1. |
confidence |
number |
0 to 1, computed from how probabilities is spread. A flat shape (probability spread across several options) means low confidence; a single peak means high confidence. |
{
"model": "jev-latest",
"answers": {
"department": {
"type": "choice",
"choice": "returns",
"confidence": 1.0,
"probabilities": {
"shipping": 0.0,
"returns": 1.0,
"billing": 0.0
}
}
},
"usage": {
"input_tokens": 330,
"output_tokens": 34
}
}
This ticket is an easy one, so all of the probability is on returns and confidence is 1.0. A ticket that mentioned a wrong size and a missing refund would split probability between returns and billing, and confidence would drop. See Confidence vs probability.
Option limits and coverage
- A Choice question accepts up to 255 options.
- Adding options costs a few tokens each, so give the model the full list of teams, categories, or products rather than a shortlist.
- Add an
otherornone of the aboveoption when the list might not cover every input, so the model can say none of the others fit. - For a deep hierarchy or large taxonomy, chain Choice questions level by level; Cookbook: Hierarchical classification runs a beam search over Choice probabilities, keeping the best
Kcandidate paths at each level instead of committing to a single greedy path.
Ask more than one Choice per call
Ask every Choice question your code might need in a single request rather than one request per question. Questions are evaluated in parallel; adding questions barely changes the response time, and the code can ignore answers it doesn't need. Extra questions still cost tokens. See Primitives: Choice, Score, Noul and Speculative fan-out.
Worked example: five Choice questions, one ambiguous ticket
State: "Shoes arrived two weeks late and in the wrong size. Also I see two charges on my card. What are you going to do about this?"
{
"state": "Shoes arrived two weeks late and in the wrong size. Also I see two charges on my card. What are you going to do about this?",
"model": "jev-latest",
"questions": {
"department": {
"type": "choice",
"instructions": "Which team should handle this?",
"criteria": {
"returns": "Exchanges, refunds, wrong or damaged items",
"shipping": "Delivery status, delays, lost packages",
"billing": "Charges, invoices, payment problems"
}
},
"return_reason": {
"type": "choice",
"instructions": "If the customer wants to return something, why?",
"criteria": {
"wrong_size": "The item doesn't fit",
"wrong_item": "A different product was delivered",
"damaged": "The item arrived broken or faulty",
"changed_mind": "The item is fine, the customer no longer wants it",
"other": "A return reason that fits none of the above"
}
},
"shipping_issue": {
"type": "choice",
"instructions": "If this is a shipping problem, which kind is it?",
"criteria": {
"not_delivered": "The package never arrived",
"delayed": "The package is late but still on its way",
"wrong_address": "The package went to the wrong place",
"damaged_in_transit": "The package arrived damaged",
"other": "A shipping problem that fits none of the above"
}
},
"requested_resolution": {
"type": "choice",
"instructions": "What does the customer want to happen?",
"criteria": {
"exchange": "Swap the item for a different one",
"refund": "Money back",
"replacement": "The same item sent again",
"information": "Just an answer, no action needed"
}
},
"tone": {
"type": "choice",
"instructions": "What is the customer's tone?",
"criteria": {
"calm": null,
"frustrated": null,
"angry": null
}
}
}
}
Two of these are speculative: return_reason only matters if department is returns, and shipping_issue only if it's shipping. The tone question uses null descriptions because the option names are clear on their own.
Response:
{
"model": "jev-latest",
"answers": {
"department": {
"type": "choice",
"choice": "returns",
"confidence": 0.39,
"probabilities": {
"shipping": 0.02,
"billing": 0.38,
"returns": 0.6
}
},
"return_reason": {
"type": "choice",
"choice": "wrong_size",
"confidence": 1.0,
"probabilities": {
"wrong_size": 1.0,
"wrong_item": 0.0,
"other": 0.0,
"changed_mind": 0.0,
"damaged": 0.0
}
},
"shipping_issue": {
"type": "choice",
"choice": "delayed",
"confidence": 0.53,
"probabilities": {
"delayed": 0.63,
"other": 0.37,
"damaged_in_transit": 0.0,
"not_delivered": 0.0,
"wrong_address": 0.0
}
},
"requested_resolution": {
"type": "choice",
"choice": "exchange",
"confidence": 0.16,
"probabilities": {
"information": 0.1,
"exchange": 0.37,
"replacement": 0.24,
"refund": 0.29
}
},
"tone": {
"type": "choice",
"choice": "frustrated",
"confidence": 0.88,
"probabilities": {
"angry": 0.08,
"frustrated": 0.92,
"calm": 0.0
}
}
},
"usage": {
"input_tokens": 588,
"output_tokens": 212
}
}
How to read it:
departmentisreturnsat probability 0.60, butbillinghas 0.38 because of the double charge, which lowers confidence to 0.39. The top option is clear enough to act on, but the second option is not noise.return_reasoniswrong_sizeat confidence 1.0 — the ticket says so clearly.shipping_issueis split betweendelayed(0.63) andother(0.37). It is speculative anddepartmentdidn't come back as shipping, so the code ignores it.requested_resolutionconfidence is 0.16 because the distribution is flat: the customer didn't say what they want.toneisfrustratedat probability 0.92, confidence 0.88.
Reading those answers in code
from typesafe_sdk import Choice, TypeSafeClient
TRIAGE_QUESTIONS = {
"department": Choice(
instructions="Which team should handle this?",
criteria={
"returns": "Exchanges, refunds, wrong or damaged items",
"shipping": "Delivery status, delays, lost packages",
"billing": "Charges, invoices, payment problems",
},
),
"return_reason": Choice(
instructions="If the customer wants to return something, why?",
criteria={
"wrong_size": "The item doesn't fit",
"wrong_item": "A different product was delivered",
"damaged": "The item arrived broken or faulty",
"changed_mind": "The item is fine, the customer no longer wants it",
"other": "A return reason that fits none of the above",
},
),
"shipping_issue": Choice(
instructions="If this is a shipping problem, which kind is it?",
criteria={
"not_delivered": "The package never arrived",
"delayed": "The package is late but still on its way",
"wrong_address": "The package went to the wrong place",
"damaged_in_transit": "The package arrived damaged",
"other": "A shipping problem that fits none of the above",
},
),
"requested_resolution": Choice(
instructions="What does the customer want to happen?",
criteria={
"exchange": "Swap the item for a different one",
"refund": "Money back",
"replacement": "The same item sent again",
"information": "Just an answer, no action needed",
},
),
"tone": Choice(
instructions="What is the customer's tone?",
criteria={"calm": None, "frustrated": None, "angry": None},
),
}
def triage(ticket: str) -> None:
with TypeSafeClient() as client:
response = client.system_one(
state=ticket,
questions=TRIAGE_QUESTIONS,
)
answers = response.answers
department = answers["department"]
if department.confidence < 0.3:
# Not clear which team to send to. Let a person decide.
send_to_manual_triage(ticket)
return
if department.choice == "returns":
# return_reason answer is only used here
assign(ticket, team="returns", issue=answers["return_reason"].choice)
elif department.choice == "shipping":
# shipping_issue answer is only used here
assign(ticket, team="shipping", issue=answers["shipping_issue"].choice)
else:
assign(ticket, team="billing")
# A second team with a real share of the probability gets a copy
for team, probability in department.probabilities.items():
if team != department.choice and probability > 0.25:
notify(ticket, team=team)
resolution = answers["requested_resolution"]
if resolution.confidence < 0.5:
# The customer hasn't said what they want. Ask, don't guess.
ask_customer_what_they_want(ticket)
elif resolution.choice == "refund":
flag_for_refund_approval(ticket)
if answers["tone"].choice == "angry":
flag_for_senior_agent(ticket)
For the ticket above this assigns the ticket to the returns team with issue wrong_size, sends the billing team a copy, and asks the customer what they want. The code does not use the shipping_issue answer. One request, five answers, and the routing logic is ordinary if statements. Adding another Choice question keeps the request count at one.
The smart home assistant demo evaluates every user request against a long list of Choice questions in one call: request category, room, device, and action — most irrelevant to any one request, and the code ignores them.
Structured instructions and criteria
Start with a one-line description per option. When two options are similar and the model keeps confusing them, describe each one with an object instead of a string: what the option covers, what belongs to a neighboring option instead, and a few example inputs.
{
"state": "I sent the shoes back a week ago. When do I get my money?",
"model": "jev-latest",
"questions": {
"return_topic": {
"type": "choice",
"instructions": {
"question": "Which returns topic is the customer asking about?",
"focus": "Classify the information the customer wants."
},
"criteria": {
"return_policy": {
"what": "Whether and how an item can be returned",
"not_for": "Progress of a return already sent",
"examples": [
"Can I return shoes I've worn once?",
"How long do I have to return an order?"
]
},
"return_status": {
"what": "Progress of a return already sent",
"not_for": "Whether and how an item can be returned",
"examples": [
"Has my return arrived yet?",
"When will my refund be paid?"
]
}
}
}
}
}
{
"model": "jev-latest",
"answers": {
"return_topic": {
"type": "choice",
"choice": "return_status",
"confidence": 1.0,
"probabilities": {
"return_policy": 0.0,
"return_status": 1.0
}
}
},
"usage": {
"input_tokens": 407,
"output_tokens": 32
}
}
The field names question, focus, what, not_for, and examples are not part of the API, and none are reserved. You choose them, the same way you choose option names. The model sees the names along with the values, so use short names that label what follows. More shapes — including taxonomy subtrees as option values — in Structured instructions, options, levels, criteria.
Gotchas
choicealone throws away information. A 0.60/0.38 split and a 1.00/0.00 split both produce the samechoice. Gate onconfidenceor inspectprobabilities(Confidence-gated routing).- An option not in
criteriacan never be returned. Without another, the probability mass lands on whichever listed option is least wrong. - A Choice is relative (which option), while a Noul is absolute (is this true). Per Jev 1.13 jaggedness: known failure modes, a Choice over options and one Noul per option answer different questions, and a threshold tuned on one does not carry over to the other.
- Do not use numbers as option names hoping for ordering — that is what Score is for.
Related
- Primitives: Choice, Score, Noul — the three types and how to batch them
- Score questions, Noul (yes/no) questions — the other two primitives
- Choosing between Choice, Score, Noul — decision table
- Writing instructions and criteria that Jev reads correctly — phrasing rules and before/after examples
- Structured instructions, options, levels, criteria — JSON options, rubrics, taxonomies
- Confidence vs probability — what
confidencemeans - HTTP API: POST /v1/systemone and GET /v1/models — wire contract
- Python SDK question types (Noul, Choice, Score) —
Choicein the Python SDK - Intent routing, Speculative fan-out, Confidence-gated routing
- Cookbook: Hierarchical classification — beam search over Choice probabilities
Sources
- raw/docs/primitives__choice.md (https://docs.typesafe.ai/primitives/choice)
- raw/docs/api.md (https://docs.typesafe.ai/api)
- raw/docs/primitives.md (https://docs.typesafe.ai/primitives)