---
title: "Python SDK question types (Noul, Choice, Score)"
type: reference
tags: [python, sdk, questions, noul, choice, score]
created: 2026-09-17
updated: 2026-09-17
confidence: high
sources:
  - raw/docs/sdk__python__api__types__questions.md
  - raw/docs/sdk__python__api__types__common.md
  - raw/github/typesafe-sdk-python/src/typesafe_sdk/_core/question_types.py
  - raw/github/typesafe-sdk-python/src/typesafe_sdk/_core/questions.py
  - raw/github/typesafe-sdk-python/src/typesafe_sdk/_schemas/models.py
jev_version: "jev-1.13.0"
sdk_python: "0.6.0"
summary: "Every field of Noul, Choice and Score in typesafe-sdk 0.6.0, their TypedDict equivalents, JSONContent typing, client-side validation, and the 0.6.0 Score.criteria breaking change."
---

# Python SDK question types (Noul, Choice, Score)

> **TL;DR** Build questions either as objects (`Noul`, `Choice`, `Score` — all keyword-only) or as plain dicts with a `"type"` key (`NoulModel`, `ChoiceModel`, `ScoreModel`); you may mix both in one `questions` mapping. `Choice.criteria` is a `Mapping[str, JSONContent | None]` of labels; **since 0.6.0 `Score.criteria` is an ordered `Sequence[JSONContent]`, one entry per score starting at 0**, not an int-keyed dict. `instructions` is optional everywhere.

## The `questions` argument

`system_one(state, questions, ...)` takes `questions: Mapping[str, Question]`, where the keys are names *you* choose — the same names come back on the answers. The mapping must be nonempty.

```python
Question: TypeAlias = Noul | Choice | Score | QuestionModel
QuestionModel: TypeAlias = NoulModel | ChoiceModel | ScoreModel
Questions: TypeAlias = Mapping[str, Question]
```

`Questions` is exported for annotating your own helpers:

```python
from typesafe_sdk import Choice, Questions, Score

QUESTIONS: Questions = {
    "tone": Choice(instructions="What is the tone?", criteria={"calm": None, "angry": None}),
    "urgency": Score(instructions="How urgent?", criteria=["low", "medium", "high"]),
}
```

## `state` and the JSON content types

`state` is the text or JSON object the questions are asked about. It **cannot be `None`**, but values inside an object may be `None`.

| Alias | Definition |
|---|---|
| `JSONValue` | `str \| int \| float \| bool \| Sequence["JSONValue \| None"] \| Mapping[str, "JSONValue \| None"]` |
| `JSONContent` | `str \| Mapping[str, JSONValue \| None] \| Sequence[JSONValue \| None]` |

`JSONContent` is what `state`, `instructions`, and every criterion description accept: a plain string, a JSON object, or an array. The abstract `Mapping`/`Sequence` (rather than `dict`/`list`) annotations landed in 0.6.0. See [[concepts/state]] for what to put in `state`.

## Question objects

All three are msgspec `Struct` subclasses of the generated wire structs, declared `kw_only=True, omit_defaults=True` — so **you must use keyword arguments**, and fields left at their default are omitted from the JSON body. Each carries the wire `type` tag automatically (`tag_field="type"`), so you never set `type` on an object.

### `Noul`

```python
class Noul(wire.NoulQuestion, kw_only=True, omit_defaults=True)
```

| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| `instructions` | `JSONContent \| None` | no | `None` | The question to ask, as text, a JSON object, or an array. |
| `criteria` | `NoulCriteria \| None` | no | `None` | Optional descriptions of the yes and no outcomes. |

`NoulCriteria` is a `TypedDict(total=False, extra_items=JSONValue | None)` — both keys optional, extra keys allowed:

| Key | Type | Required | Description |
|---|---|---|---|
| `true` | `JSONContent \| None` | no | Description of the yes outcome; `None` leaves it undescribed. |
| `false` | `JSONContent \| None` | no | Description of the no outcome; `None` leaves it undescribed. |

> Naming collision to be aware of: the **public** `typesafe_sdk.NoulCriteria` is the `TypedDict` in `_core/question_types.py`. A *different* `NoulCriteria` msgspec `Struct` exists in the generated `_schemas/models.py`; it is private and not exported.

```python
from typesafe_sdk import Noul

Noul(
    instructions="Is this ticket about billing?",
    criteria={
        "true": "The customer mentions a charge, invoice, refund or subscription",
        "false": "Anything else",
    },
)
```

### `Choice`

```python
class Choice(wire.ChoiceQuestion, kw_only=True, omit_defaults=True)
```

| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| `criteria` | `Mapping[str, JSONContent \| None]` | **yes** | — | Labels mapped to text/object/array descriptions, or `None` for an undescribed label. |
| `instructions` | `JSONContent \| None` | no | `None` | The question to ask. |

The mapping keys are the labels the model may return in `ChoiceAnswer.choice`.

```python
from typesafe_sdk import Choice

Choice(
    instructions="What is the customer's tone?",
    criteria={"calm": None, "frustrated": None, "angry": None},
)

Choice(
    instructions="Route this ticket",
    criteria={
        "billing": "Charges, invoices, refunds, subscriptions",
        "technical": {"includes": ["errors", "outages", "integration bugs"]},
        "other": None,
    },
)
```

### `Score`

```python
class Score(wire.ScoreQuestion, kw_only=True, omit_defaults=True)
```

| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| `criteria` | `Sequence[JSONContent]` | **yes** | — | A **nonempty, ordered** list of descriptions, one per score **starting from zero**. |
| `instructions` | `JSONContent \| None` | no | `None` | The question to ask. |

Index `i` of the sequence describes score `i`. Entries may not be `None` (the element type is `JSONContent`, not `JSONContent | None`) — unlike `Choice.criteria` values.

```python
from typesafe_sdk import Score

Score(
    instructions="How urgent is this ticket?",
    criteria=["can wait", "this week", "today"],  # 0, 1, 2
)
```

## Question dictionaries

Any question may be a plain dictionary carrying a `"type"` key: `"noul"`, `"choice"`, or `"score"`. Dictionaries and objects mix freely in the same `questions` mapping. Use dictionaries when you need a field a given SDK version does not model yet — all three `TypedDict`s are declared `extra_items=JSONValue | None`, so extra keys type-check and are sent through.

| Object | Dict equivalent | Required keys | Optional keys |
|---|---|---|---|
| `Noul(...)` | `NoulModel` | `type: Literal["noul"]` | `instructions` (`NotRequired[JSONContent \| None]`), `criteria` (`NotRequired[NoulCriteria \| None]`) |
| `Choice(...)` | `ChoiceModel` | `type: Literal["choice"]`, `criteria: Mapping[str, JSONContent \| None]` | `instructions` (`NotRequired[JSONContent \| None]`) |
| `Score(...)` | `ScoreModel` | `type: Literal["score"]`, `criteria: Sequence[JSONContent]` | `instructions` (`NotRequired[JSONContent \| None]`) |

Side-by-side:

```python
from typesafe_sdk import Choice, Noul, Score, TypeSafeClient

objects = {
    "billing": Noul(instructions="Is this about billing?"),
    "tone": Choice(instructions="What is the tone?", criteria={"calm": None, "angry": None}),
    "urgency": Score(instructions="How urgent?", criteria=["low", "medium", "high"]),
}

dicts = {
    "billing": {"type": "noul", "instructions": "Is this about billing?"},
    "tone": {
        "type": "choice",
        "instructions": "What is the tone?",
        "criteria": {"calm": None, "angry": None},
    },
    "urgency": {
        "type": "score",
        "instructions": "How urgent?",
        "criteria": ["low", "medium", "high"],
    },
}

with TypeSafeClient() as client:
    a = client.system_one("I was charged twice. Please help.", objects)
    b = client.system_one("I was charged twice. Please help.", dicts)
    assert a.choices["tone"].choice in {"calm", "angry"}
    assert b.choices["tone"].choice in {"calm", "angry"}
```

Forward-compatible extra field on a dict question:

```python
with TypeSafeClient() as client:
    client.system_one(
        "I was charged twice.",
        {"billing": {"type": "noul", "instructions": "About billing?", "weight": 2}},
    )
```

## Client-side validation

`_core/questions.py:normalize_questions` runs before encoding and raises `TypeSafeError` (no HTTP request is made):

| Condition | Message |
|---|---|
| `questions` is empty | `At least one question is required.` |
| A `Score` object with empty `criteria` | `Score question "<name>" has no criteria; at least one score is required.` |
| A dict question that is not a dict, or whose `"type"` is missing / not a str / empty | `Question "<name>" must be a question object or a dictionary with a nonempty string "type".` |
| A dict with `type` `"choice"` or `"score"` and no `"criteria"` key | `Question "<name>" requires "criteria".` |
| A dict with `type == "score"` and empty `"criteria"` | `Score question "<name>" has no criteria; at least one score is required.` |

Gotchas that validation does **not** catch (verified against `normalize_questions`):

- A `Choice` with an **empty** `criteria` mapping is not rejected client-side; it reaches the API. Only `score` criteria emptiness is checked.
- An unknown `"type"` string in a dict question is not rejected client-side as long as it is a nonempty string — the server decides.
- `Noul`/`Choice` objects skip criteria checks entirely (`Choice.criteria` is a required constructor field, so it cannot be missing, only empty).

## 0.6.0 breaking change: `Score.criteria`

Release 0.6.0 (2026-09-15) changed `Score.criteria` to **accept an ordered sequence instead of a dictionary keyed by integers**.

| Version | `Score.criteria` form |
|---|---|
| ≤ 0.5.7 | dictionary keyed by integer score |
| **0.6.0** | ordered `Sequence[JSONContent]`, index = score, starting at 0 |

```python
# 0.6.0 and later — correct
Score(instructions="How urgent?", criteria=["can wait", "this week", "today"])

# pre-0.6.0 form — no longer the documented shape
# Score(instructions="How urgent?", criteria={0: "can wait", 1: "this week", 2: "today"})
```

Note the asymmetry that survives the change: the **question** `criteria` is a sequence, but the **answer** `ScoreAnswer.legend` and `ScoreAnswer.probabilities` are still keyed by integer score (`dict[int, ...]`). See [[reference/python-sdk-responses]].

A tuple works anywhere a list does, since the annotation is `Sequence`:

```python
LEVELS = ("can wait", "this week", "today")
Score(instructions="How urgent?", criteria=LEVELS)
```

## Typing and generics

- The SDK ships `py.typed`; all public names are annotated.
- There are **no generic/parameterized question classes** — `Question`, `QuestionModel` and `Questions` are `TypeAlias` unions, not `Generic` types. The only `Generic` in the package is the private `_core.transport.Request[ResponseT]`.
- Because `Question` is a union that includes `TypedDict`s, a plain `dict` literal type-checks only when its keys match one of the three models; `type` must be the literal string, not a variable of type `str`.
- Annotate collections with `Mapping`/`Sequence` (the 0.6.0 inputs accept abstract types), and use `Questions` for the whole mapping.
- The repo pins these guarantees with `tests/typing/valid.py` and negative cases in `tests/typing/negative/questions.py`.

## Related

- [[reference/python-sdk]] — install, clients, `system_one()`
- [[reference/python-sdk-responses]] — the answers these questions produce
- [[reference/python-sdk-changelog]] — the 0.6.0 breaking change in context
- [[concepts/primitives]] — Choice, Score, Noul as concepts
- [[concepts/choice]] · [[concepts/score]] · [[concepts/noul]] — per-primitive semantics
- [[concepts/advanced-structure]] — structured instructions, options, levels, criteria
- [[guides/writing-instructions-and-criteria]] — how to phrase them
- [[reference/http-api]] — the JSON these objects serialize to

## Sources

- raw/docs/sdk__python__api__types__questions.md (https://docs.typesafe.ai/sdk/python/api/types/questions.md)
- raw/docs/sdk__python__api__types__common.md (https://docs.typesafe.ai/sdk/python/api/types/common.md)
- raw/github/typesafe-sdk-python/src/typesafe_sdk/_core/question_types.py, _core/questions.py, _core/json_types.py, _schemas/models.py (https://github.com/typesafe-ai/typesafe-sdk-python @ 420ef4ffb612d5a539a1e0f0fe883ff6770340af)
- raw/docs/sdk__python__changelog.md (https://docs.typesafe.ai/sdk/python/changelog.md)
