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.instructionsis 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.
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 |
|---|---|
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 State: what you send Jev 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
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.NoulCriteriais theTypedDictin_core/question_types.py. A differentNoulCriteriamsgspecStructexists in the generated_schemas/models.py; 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(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.
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(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.
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 TypedDicts 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:
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:
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
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
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 |
# 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]. - 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. - 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.pyand negative cases intests/typing/negative/questions.py.
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/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)