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 onequestionsmapping.Choice.criteriais aMapping[str, JSONContent | None]of labels; since 0.6.0Score.criteriais an orderedSequence[JSONContent], one entry per score starting at 0, not an int-keyed dict, and 0.7.x keeps that form.instructionsis optional everywhere. Since 0.7.0 the question objects are Pydantic models withextra="forbid", and the dict forms areclosed=TrueTypedDicts.
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.
Question: TypeAlias = Noul | Choice | Score | QuestionModel
QuestionModel: TypeAlias = NoulModel | ChoiceModel | ScoreModel
Questions: TypeAlias = Mapping[str, Question]
Questions is exported for annotating your own helpers:
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 State: what you send Jev 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). _Questionsetsmodel_config = ConfigDict(extra="forbid"), so an unrecognised keyword raisespydantic.ValidationErrorat construction. In 0.6.0 the msgspec wire struct tolerated more._Questionalso installs a wrap serializer that drops any top-level field still equal toNonefrom the wire body — the 0.7.x replacement for msgspec'somit_defaults=True. Its comment: "An unset optional field (None) is left off the wire, while user-suppliedNonevalues nested insidecriteria/instructionsare preserved." SoChoice(criteria={"calm": None})still sends{"calm": null}.- Each class redeclares
typeas a defaultedLiteral(type: Literal["noul"] = "noul"), so you still never settypeyourself — but unlike 0.6.0's msgspectag_field,typeis now an ordinary model field and appears inmodel_dump().
Noul
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.NoulCriteriais theTypedDictin_core/question_types.py. A differentNoulCriteriamodel exists in the generated_schemas/models.py(apydantic.BaseModelsince 0.7.0, a msgspecStructbefore); it is private and not exported.
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
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.
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
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.
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 TypedDicts 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:
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):
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
Choicewith an emptycriteriamapping is not rejected client-side; it reaches the API. Onlyscorecriteria 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/Choiceobjects skip criteria checks entirely (Choice.criteriais 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 |
# 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 Python SDK responses, answers, usage, models.
A tuple works anywhere a list does, since the annotation is Sequence:
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,QuestionModelandQuestionsareTypeAliasunions, notGenerictypes. The onlyGenericin the package is the private_core.transport.Request[ResponseT](whoseResponseT, bound topydantic.BaseModel, now lives in_core/schemas/base.py). - Because
Questionis a union that includesTypedDicts, a plaindictliteral type-checks only when its keys match one of the three models;typemust be the literal string, not a variable of typestr. Withclosed=Truethe match must now be exact. - Annotate collections with
Mapping/Sequence(the 0.6.0 inputs accept abstract types), and useQuestionsfor the whole mapping. - The repo pins these guarantees with
tests/typing/valid.py, negative cases intests/typing/negative/questions.py, and — new in 0.7.0 —tests/typing/pydantic_response_models.pyfor theresponse_modeloverloads.
Version notes
- Described for
typesafe-sdk0.7.1 (repo captured at commit0ffd094c72ed9445223060b24ffd7a56aa781fb4, 2026-09-21). - 0.7.0 moved the question objects from msgspec
Structtopydantic.BaseModel(extra="forbid") and the dict forms fromextra_items=...toclosed=True. Field names, types, defaults, and the wire JSON are otherwise unchanged. See 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
- Python SDK: install, clients, system_one() — install, clients,
system_one() - Python SDK responses, answers, usage, models — the answers these questions produce
- Python SDK changelog — the 0.6.0 breaking change in context
- Primitives: Choice, Score, Noul — Choice, Score, Noul as concepts
- Choice questions · Score questions · Noul (yes/no) questions — per-primitive semantics
- Structured instructions, options, levels, criteria — structured instructions, options, levels, criteria
- Writing instructions and criteria that Jev reads correctly — how to phrase them
- HTTP API: POST /v1/systemone and GET /v1/models — 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)