---
title: "Choice questions"
type: concept
tags: [choice, primitives, classification, probabilities, confidence]
created: 2026-09-17
updated: 2026-09-21
confidence: high
sources:
  - raw/docs/primitives__choice.md
  - raw/docs/api.md
  - raw/docs/primitives.md
jev_version: "jev-1.13.0"
sdk_python: "0.7.1"
summary: "Choice picks one option from a fixed set: send type/instructions/criteria, get back choice, probabilities over every option, and confidence."
---

# 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 an `other` option when your list may not cover every input; read `probabilities` and `confidence`, not just `choice`.

## 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 → [[concepts/score]].
- If it's a yes or no → [[concepts/noul]].
- Full comparison: [[guides/choosing-a-primitive]].

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 [[reference/http-api|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 \| object \| array \| 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. "You can have a maximum of 255 options per Choice." |

Field types are as spelled in raw/docs/api.md (2026-09-21 wording: before that refresh api.md typed `criteria` as `map<string, string | null>` and did not state the 255-option maximum on the API page — the limit itself is unchanged, it is now documented in two places). Per [[concepts/advanced-structure]], `instructions` and each `criteria` value may be a string, an object, or an array; `criteria` values may also be `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

```json
{
  "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, wrong or damaged items",
        "shipping": "Delivery status, delays, lost packages",
        "billing": "Charges, invoices, payment problems"
      }
    }
  }
}
```

The same question in the Python SDK:

```python
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, 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 [[reference/javascript-sdk]].

## 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. |

```json
{
  "model": "jev-1.13.0",
  "answers": {
    "department": {
      "type": "choice",
      "choice": "returns",
      "confidence": 1.0,
      "probabilities": {
        "shipping": 0.0,
        "returns": 1.0,
        "billing": 0.0
      }
    }
  },
  "usage": {
    "input_tokens": 328,
    "output_tokens": 34
  }
}
```

(As of the 2026-09-21 docs refresh, the documented responses report `model` as the resolved version `jev-1.13.0` rather than the `jev-latest` alias that was sent. The field has always reported the version that answered — see [[reference/models-and-pricing]] — but earlier examples on this page showed `jev-latest`.)

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 [[concepts/confidence]].

## 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 `other` or `none of the above` option 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; [[cookbooks/hierarchical-classification]] runs a beam search over Choice probabilities, keeping the best `K` candidate 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 [[concepts/primitives]] and [[patterns/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 of $120 on my card. What are you going to do about this?"`

```json
{
  "state": "Shoes arrived two weeks late and in the wrong size. Also I see two charges of $120 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, 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:

```json
{
  "model": "jev-1.13.0",
  "answers": {
    "department": {
      "type": "choice",
      "choice": "returns",
      "confidence": 0.42,
      "probabilities": {
        "shipping": 0.04,
        "billing": 0.35,
        "returns": 0.61
      }
    },
    "return_reason": {
      "type": "choice",
      "choice": "wrong_size",
      "confidence": 1.0,
      "probabilities": {
        "other": 0.0,
        "wrong_size": 1.0,
        "changed_mind": 0.0,
        "damaged": 0.0,
        "wrong_item": 0.0
      }
    },
    "shipping_issue": {
      "type": "choice",
      "choice": "delayed",
      "confidence": 0.67,
      "probabilities": {
        "wrong_address": 0.0,
        "other": 0.26,
        "not_delivered": 0.0,
        "damaged_in_transit": 0.0,
        "delayed": 0.74
      }
    },
    "requested_resolution": {
      "type": "choice",
      "choice": "refund",
      "confidence": 0.2,
      "probabilities": {
        "replacement": 0.34,
        "refund": 0.4,
        "information": 0.02,
        "exchange": 0.24
      }
    },
    "tone": {
      "type": "choice",
      "choice": "frustrated",
      "confidence": 0.76,
      "probabilities": {
        "frustrated": 0.84,
        "angry": 0.16,
        "calm": 0.0
      }
    }
  },
  "usage": {
    "input_tokens": 589,
    "output_tokens": 212
  }
}
```

The key order in `probabilities` is not the order you sent the options in — do not rely on it.

How to read it:

- `department` is `returns` at probability 0.61, but `billing` has 0.35 because of the double charge. The ticket belongs to two teams, and the split confidence of 0.42 reflects that. **The second option is not noise.**
- `return_reason` is `wrong_size` at confidence 1.0 — the ticket says so clearly.
- `shipping_issue` is split between `delayed` (0.74) and `other` (0.26). It is speculative and `department` didn't come back as shipping, so the code ignores it.
- `requested_resolution` leans to `refund` at 0.40, with `replacement` (0.34) and `exchange` (0.24) sharing most of the rest, at confidence 0.20. The double charge suggests money back and the wrong size suggests a swap; the customer never says which.
- `tone` is `frustrated` at probability 0.84, confidence 0.76.

(Upstream re-ran this example on 2026-09-21 with a slightly different state — "two charges of $120" instead of "two charges" — and `requested_resolution` flipped from `exchange` to `refund`. **A superseded reading:** the 2026-09-17 docs described the same setup with `department` 0.60/0.38 at confidence 0.39 and `requested_resolution` `exchange` at 0.16. Treat documented numbers as illustrations, never as expected outputs.)

### Reading those answers in code

```python
from typesafe_sdk import Choice, TypeSafeClient

TRIAGE_QUESTIONS = {
    "department": Choice(
        instructions="Which team should handle this?",
        criteria={
            "returns": "Exchanges, 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 because its 0.35 share clears the 0.25 threshold, and asks the customer what they want because the resolution confidence of 0.20 is under 0.5. 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 [[guides/smart-home-demo|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.

```json
{
  "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?"
          ]
        }
      }
    }
  }
}
```

```json
{
  "model": "jev-1.13.0",
  "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 [[concepts/advanced-structure]].

## Gotchas

- `choice` alone throws away information. A 0.61/0.35 split and a 1.00/0.00 split both produce a `choice`. Gate on `confidence` or inspect `probabilities` ([[patterns/confidence-routing]]).
- An option not in `criteria` can never be returned. Without an `other`, 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 [[concepts/jaggedness-jev-1-13]], 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

- [[concepts/primitives]] — the three types and how to batch them
- [[concepts/score]], [[concepts/noul]] — the other two primitives
- [[guides/choosing-a-primitive]] — decision table
- [[guides/writing-instructions-and-criteria]] — phrasing rules and before/after examples
- [[concepts/advanced-structure]] — JSON options, rubrics, taxonomies
- [[concepts/confidence]] — what `confidence` means
- [[reference/http-api]] — wire contract
- [[reference/python-sdk-questions]] — `Choice` in the Python SDK
- [[patterns/intent-routing]], [[patterns/fan-out]], [[patterns/confidence-routing]]
- [[cookbooks/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)
