---
title: "Python SDK question types (Noul, Choice, Score)"
type: reference
tags: [python, sdk, questions, noul, choice, score]
created: 2026-09-17
updated: 2026-09-21
confidence: high
sources:
  - raw/docs/sdk__python__api__types__questions.md
  - raw/docs/sdk__python__api__types__common.md
  - raw/docs/sdk__python__usage.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/_core/json_types.py
  - raw/github/typesafe-sdk-python/src/typesafe_sdk/_schemas/models.py
jev_version: "jev-1.13.0"
sdk_python: "0.7.1"
summary: "Every field of Noul, Choice and Score in typesafe-sdk 0.7.1, their TypedDict equivalents, JSONContent typing, client-side validation, and the pydantic extra=forbid / closed=True rules."
---

# 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, and 0.7.x keeps that form. `instructions` is optional everywhere. Since 0.7.0 the question objects are Pydantic models with `extra="forbid"`, and the dict forms are `closed=True` `TypedDict`s.

## 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 (0.7.x) |
|---|---|
| `JSONValue` | `TypeAliasType("JSONValue", "str \| int \| float \| bool \| Sequence[JSONValue \| None] \| Mapping[str, JSONValue \| None]")` |
| `JSONContent` | `TypeAliasType("JSONContent", "str \| Mapping[str, JSONValue \| None] \| Sequence[JSONValue \| None]")` |

The *members* are unchanged; 0.7.0 only changed how the recursion is declared. Both were `typing.TypeAlias` through 0.6.0 and are now `typing_extensions.TypeAliasType`, because, per the module docstring, the aliases must "build a Pydantic core schema without hitting the recursion limit that a plain recursive `TypeAlias` triggers".

`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 **Pydantic models** as of 0.7.0, each subclassing a private `_Question` base plus the generated wire model — `class Noul(_Question, wire.NoulQuestion)`. Consequences:

- **Keyword arguments only**, as before (Pydantic `BaseModel.__init__` is keyword-only).
- `_Question` sets `model_config = ConfigDict(extra="forbid")`, so an unrecognised keyword raises `pydantic.ValidationError` at construction. In 0.6.0 the msgspec wire struct tolerated more.
- `_Question` also installs a wrap serializer that drops any top-level field still equal to `None` from the wire body — the 0.7.x replacement for msgspec's `omit_defaults=True`. Its comment: "An unset optional field (`None`) is left off the wire, while user-supplied `None` values nested inside `criteria`/`instructions` are preserved." So `Choice(criteria={"calm": None})` still sends `{"calm": null}`.
- Each class redeclares `type` as a defaulted `Literal` (`type: Literal["noul"] = "noul"`), so you still never set `type` yourself — but unlike 0.6.0's msgspec `tag_field`, `type` is now an ordinary model field and appears in `model_dump()`.

### `Noul`

```python
class Noul(_Question, wire.NoulQuestion)
```

Fields, in the order the docs list them: `type` (`Literal['noul']`), `instructions`, `criteria`.

| 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, closed=True)` as of 0.7.0 — both keys optional, **extra keys no longer type-check** (0.6.0 declared `extra_items=JSONValue | None`):

| 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` model exists in the generated `_schemas/models.py` (a `pydantic.BaseModel` since 0.7.0, a msgspec `Struct` before); 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(_Question, wire.ChoiceQuestion)
```

Fields: `type` (`Literal['choice']`), `criteria`, `instructions`.

| 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(_Question, wire.ScoreQuestion)
```

Fields: `type` (`Literal['score']`), `criteria`, `instructions`.

| 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 — but note the 0.7.0 change: all three `TypedDict`s are now declared `closed=True` (they were `extra_items=JSONValue | None` in 0.6.0), so an extra key is a **type error**. It is still serialized and sent at runtime, and the docs keep it as the sanctioned escape hatch: "Unknown fields are a forward-compatibility escape hatch. Ignore their type-checking errors and prefer upgrading the SDK instead."

| 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 (verbatim from the usage guide; the `"weight"` key now trips a type checker under `closed=True`, which upstream tells you to ignore):

```python
from typesafe_sdk import TypeSafeClient

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

There is no dict-shaped escape hatch on the *object* side any more: `Noul(instructions="...", weight=2)` raises `pydantic.ValidationError` because `_Question` sets `extra="forbid"`. Use a dict question, or `extra_body`, instead.

## 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` (still current in 0.7.x)

Release 0.6.0 (2026-09-15) changed `Score.criteria` to **accept an ordered sequence instead of a dictionary keyed by integers**. 0.7.0's msgspec → pydantic switch did not touch it: the annotation is still `Sequence[JSONContent]`.

| Version | `Score.criteria` form |
|---|---|
| ≤ 0.5.7 | dictionary keyed by integer score |
| **0.6.0 – 0.7.1** | 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]` (whose `ResponseT`, bound to `pydantic.BaseModel`, now lives in `_core/schemas/base.py`).
- 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`. With `closed=True` the match must now be exact.
- 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`, negative cases in `tests/typing/negative/questions.py`, and — new in 0.7.0 — `tests/typing/pydantic_response_models.py` for the `response_model` overloads.

## Version notes

- Described for `typesafe-sdk` **0.7.1** (repo captured at commit `0ffd094c72ed9445223060b24ffd7a56aa781fb4`, 2026-09-21).
- 0.7.0 moved the question objects from msgspec `Struct` to `pydantic.BaseModel` (`extra="forbid"`) and the dict forms from `extra_items=...` to `closed=True`. Field names, types, defaults, and the wire JSON are otherwise unchanged. See [[reference/python-sdk-changelog]].
- `_core/questions.py` — the client-side validation above — is byte-identical between the 0.6.0 and 0.7.1 snapshots.

## 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/docs/sdk__python__usage.md (https://docs.typesafe.ai/sdk/python/usage.md) — the forward-compatibility escape hatch
- 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 @ 0ffd094c72ed9445223060b24ffd7a56aa781fb4, captured 2026-09-21)
- raw/docs/sdk__python__changelog.md (https://docs.typesafe.ai/sdk/python/changelog.md)
