---
title: "Choice questions"
type: concept
tags: [choice, primitives, classification, probabilities, confidence]
created: 2026-09-17
updated: 2026-09-17
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.6.0"
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 \| 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 [[concepts/advanced-structure]], `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

```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, refunds, 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, 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 [[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-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 [[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 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 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:

```json
{
  "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:

- `department` is `returns` at probability 0.60, but `billing` has 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_reason` is `wrong_size` at confidence 1.0 — the ticket says so clearly.
- `shipping_issue` is split between `delayed` (0.63) and `other` (0.37). It is speculative and `department` didn't come back as shipping, so the code ignores it.
- `requested_resolution` confidence is 0.16 because the distribution is flat: the customer didn't say what they want.
- `tone` is `frustrated` at probability 0.92, confidence 0.88.

### 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, 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 [[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-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 [[concepts/advanced-structure]].

## Gotchas

- `choice` alone throws away information. A 0.60/0.38 split and a 1.00/0.00 split both produce the same `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)
