Skip to content

11. Typed decisions and pydantic-ai

A decision already has a type. A choice is a Literal, a yes/no is a bool, a set of decisions about one message is a pydantic model. ornotto lets you write that type and nothing else: the fields become the questions and the model's answers fill them in. You can do it three ways: with extract and a pydantic model, with a typed function under @decision, or through a pydantic-ai Agent whose output_type is the model.

Fill a pydantic model: extract

ornotto.extract(schema, state) asks one question per field of schema, all in one request, and returns a validated instance.

from typing import Literal
from pydantic import BaseModel, Field
import ornotto

class Triage(BaseModel):
    task: Literal["docs", "python", "fea"] = Field(description="What does the user want?")
    wants_code: bool = Field(description="Does the user want code they can run?")
    spam: float = Field(ge=0, le=1, description="Is this message spam?")

ornotto.extract(Triage, "Write a Python script that renames every .sc glyph")
# Triage(task='python', wants_code=True, spam=0.0056)

Each field type maps to one question kind:

Field type Question Value in the instance
Literal["a", "b", ...] of strings choice the chosen option
Enum whose values are strings choice over the values the Enum member
bool yes/no True when p(yes) ≥ 0.5
float with ge=0 and le=1 yes/no p(yes) itself

The field's description is the question. A field without one is asked by its name, with underscores turned into spaces and a question mark added: wants_code becomes "Wants code?". A short name makes a weak question, so describe every field.

Any other field type raises TypeError before a request is sent, and the message names the field. That includes int, str, optional fields and nested models. For rubrics, use rate (chapter 10) or the pydantic-ai route below, which accepts both.

extract takes decider= to pick the model and engine; without it, the shared default from ornotto.use() answers. ornotto.aextract is the same for asyncio. If you want the probabilities as well as the instance, ask the same questions yourself: ornotto.questions_for(Triage) returns the question dict that extract sends, and decide returns the full Decision for it.

d = ornotto.decide(text, ornotto.questions_for(Triage))
d.task.probabilities, d.wants_code.probability

Turn a function into a decision: @decision

@ornotto.decision turns a typed function with a docstring and no body into a decision.

from typing import Literal
import ornotto

@ornotto.decision
def route(request: str) -> Literal["docs", "python", "fea"]:
    """Which kind of help does this FontLab user want?"""

route("How do I open the Glyph window?")   # 'docs'

The docstring is the question, as written. It is not a template: the arguments do not get formatted into it. They become the state instead. One argument is the state as it is. Several arguments become a JSON state keyed by argument name, with defaults filled in, and dohnuts reads a JSON state natively (chapter 2).

@ornotto.decision
def wants_code(request: str, app: str = "FontLab 8") -> bool:
    """Does the user want code they can run?"""

wants_code("Write a Python script that renames every .sc glyph to .smcp")   # True
wants_code("How do I open the Glyph window?")                               # False
# state sent: {"request": "...", "app": "FontLab 8"}

The return annotation picks the question:

Return type Question Returns
Literal[...] or Enum of strings choice the option, or the Enum member
bool yes/no True or False
float yes/no p(yes)
a pydantic model one question per field, as extract the instance

Without a docstring, the function name stands in for the question. An unsupported return type raises TypeError when you call the function, not when you decorate it. @ornotto.decision(decider=...) binds the function to one Decider; without it, each call uses the shared default.

What the decorator borrows

The shape comes from libraries that turn a typed stub into an LLM call. promptic uses the docstring as the prompt. magentic's @prompt reads the return annotation to decide what the call must produce, and Mirascope builds calls from decorated functions too. ornotto takes the idea, not the code: it has no integration with any of them, and the function never reaches a language model that writes text. Where those libraries ask a model to generate output and then parse it into the return type, ornotto asks the engine to score the allowed values of the return type, so the result is always one of them.

Pick a label: classify

ornotto.classify(data, labels, instructions=None) follows marvin's classify: pick the label that fits data.

import enum

class Tone(enum.Enum):
    CALM = "calm"
    ANGRY = "angry"

ornotto.classify("I was charged twice and nobody answers my emails!", Tone)   # Tone.ANGRY
ornotto.classify("Build a kern feature", ["docs", "python", "fea"])            # 'fea'

labels is a list of strings or an Enum class; an Enum class returns a member. instructions is the question. Without it, the model sees only the labels, which is enough for a dedicated model on dohnuts and too little for a chat model on pcdServer (see chapter 3).

marvin 3 is built on pydantic-ai, so in principle a pydantic-ai model from ornotto could run its functions directly. It cannot today: as of September 2026, marvin 3 fails to import against pydantic-ai 2 (ImportError: cannot import name 'Usage' from 'pydantic_ai.usage'). classify and @decision are idioms that look like marvin's and promptic's, not integrations with them.

pydantic-ai

pydantic-ai has a model class for System One: TypeSafeModel, written for TypeSafe's hosted jev. It turns an agent's output_type into System One questions, sends the latest user prompt as the state, and builds the output from the answers. No text is generated. dohnuts speaks the same /v1/systemone protocol, so the same model class can run an agent on a local engine. ornotto.pydantic_ai.model() sets that up.

uv add "ornotto[pydantic-ai]"
from typing import Literal
from pydantic import BaseModel, Field
from pydantic_ai import Agent
from ornotto.pydantic_ai import model

class Triage(BaseModel):
    """A FontLab user's request."""
    task: Literal["docs", "python", "fea"] = Field(description="What does the user want?")
    wants_code: bool = Field(description="Does the user want code they can run?")

agent = Agent(model("decider-0.8b"), output_type=Triage, instructions="Classify a FontLab user's request.")
agent.run_sync("Build a kern feature for A V W T").output
# Triage(task='fea', wants_code=True)

model(model="decider-0.8b", *, engine=None, **kwargs) takes a model name with the same options as Decider, or a Decider you already have. It returns a TypeSafeModel whose provider points at that Decider's engine. pydantic-ai 2.48 prints an observability banner on the first run; set PYDANTIC_AI_NO_BANNER=1 to turn it off.

Which versions this chapter describes

The pydantic-ai extra asks for pydantic-ai-slim[typesafe] 2.48 or later. The lock file that the tests run against pins pydantic-ai-slim 2.48.0 and typesafe-sdk 0.7.1, and everything in this chapter was checked with those two versions. pydantic-ai moved on during September 2026 (below), and a fresh install without the lock may pull a later release. We have not run ornotto against pydantic-ai 2.50 or 2.51. If an import from pydantic_ai.models.typesafe or pydantic_ai.providers.typesafe fails after an upgrade, pin pydantic-ai-slim==2.48.0 until ornotto catches up.

The adapter is small on purpose. ornotto.pydantic_ai imports three names from outside the package: TypeSafeModel, TypeSafeProvider and AsyncTypeSafeClient. It adds no model class of its own. Everything the agent does with the answers, from thresholds to tool calls, is pydantic-ai's code, and ornotto only decides where the request goes.

How requests reach the engine

%%{init: {"flowchart": {"useMaxWidth": false}}}%%
flowchart TD
  A[Agent] --> T[TypeSafeModel]
  T -- "POST /v1/systemone" --> D[dohnuts]
  T -- "in-process transport" --> X[ornotto translator] -- "POST /v1/pcd/decode" --> P[pcdServer]
  • dohnuts: the provider is a TypeSafeProvider with the engine's loopback URL as base_url. The TypeSafe client sends its request straight to dohnuts, which answers it natively. The API key is a fixed placeholder, because the client requires one and the local engine checks none.
  • pcdServer: pcdServer does not speak System One. The provider's HTTP client has an in-process transport that catches each /v1/systemone request, translates the questions into pcdServer fields (chapter 3), calls the engine through the Decider, and returns a System One response. The transport also answers GET /v1/models with the one local model. Nothing leaves the process except the call to the loopback engine.
  • ollaya: ollaya answers /v1/systemone itself, as dohnuts does, so an ollaya model gets the same direct provider with the engine's loopback URL. ollaya's own site says that "The official TypeSafe Python SDK 0.7.1 works unchanged against a local server."1 The integration tests exercise the dohnuts and pcdServer paths; the ollaya path uses the same provider code as dohnuts but has no agent test of its own.

For pcdServer, one agent step makes three hops, all inside your machine:

%%{init: {"sequence": {"useMaxWidth": false}}}%%
sequenceDiagram
    participant A as Agent
    participant T as TypeSafeModel
    participant X as ornotto transport
    participant P as pcdServer
    A->>T: run: prompt<br>and output_type
    T->>X: POST /v1/systemone<br>state and questions
    X->>X: questions to<br>pcdServer fields
    X->>P: POST /v1/pcd/decode
    P-->>X: values and probabilities<br>per field
    X-->>T: System One answers
    T-->>A: validated output

The translation step is the same code that Decider.decide uses, so an agent on pcdServer and a plain decide call on pcdServer send the engine the same fields. The response the transport builds reports zero input and output tokens: a local engine has no bill, and the usage counters in pydantic-ai's run summary stay at zero.

What an output type may contain

pydantic-ai's TypeSafeModel decides which field types it can express, and refuses the rest with a UserError before any request. Its documentation lists:

Field type Question Output
bool yes/no True when p(yes) reaches the threshold, 0.5 by default
Literal[...] or Enum of two or more strings choice the chosen option
float with ge=0 and le=1 yes/no p(yes)
whole numbers 0, 1, 2, … (up to 10 levels) with a description per level in the schema score the nearest level
list of a Literal or Enum one yes/no per option the options answered yes
a nested model of these its fields, named outer.inner the model
Literal[...] or Enum, or None choice, or none of these the option, or None

This is wider than extract: rubrics, multi-select lists and nested models all work. A rubric needs its level descriptions in the JSON schema, as anyOf entries with a const and a description each:

urgency: int = Field(description="How urgent is it?", json_schema_extra={"anyOf": [
    {"const": 0, "description": "can wait"},
    {"const": 1, "description": "this week"},
    {"const": 2, "description": "today"}]})

On dohnuts with a decider model, a rubric field runs as an isolated score: one row per level, each asking whether that level fits (chapter 3).

The model's docstring and the agent's instructions go along as context. With an earlier conversation in message_history, the state becomes a JSON object with the latest text and a history list, which dohnuts takes as it is and pcdServer receives serialized as JSON text.

Reading the confidence

The run returns the output. The numbers behind it are on the last ModelResponse, in provider_details:

from pydantic_ai.messages import ModelResponse

result = agent.run_sync("Write a Python script that renames every .sc glyph")
details = [m for m in result.all_messages() if isinstance(m, ModelResponse)][-1].provider_details
details["confidence"]["task"], details["probabilities"]["task"]

confidence is per field, from 0 for undecided to 1; probabilities holds the full distribution of each choice and rubric field; scores holds each rubric field's expected level before rounding. On dohnuts these numbers are temperature-scaled. On pcdServer they are the raw softmax over allowed tokens (chapter 9).

Two model settings from pydantic-ai tune the thresholds: typesafe_boolean_threshold (default 0.5) sets how likely a yes must be before a bool field is True, and typesafe_tool_call_threshold (default 0.6) sets how likely a tool call must be before the model proposes it.

Tools and fallback

With tools on the agent, TypeSafeModel asks one more question: which tool, or the output, the text calls for. A tool without arguments, or one whose arguments use the types above, is called. If a picked tool has arguments it cannot fill, it raises ToolCallProposed, which is a ModelAPIError. pydantic-ai documents that a FallbackModel with a language model behind the TypeSafe model hands such a step to the language model, tools and all. That is the pattern for an agent that decides locally and calls a larger model only when a tool needs free-form arguments:

from pydantic_ai.models.fallback import FallbackModel

agent = Agent(FallbackModel(model("decider-0.8b"), "anthropic:claude-sonnet-5"), output_type=Triage)

TypeSafeModel refuses plain text output, native tools, and files in the prompt or history. A local engine cannot produce any of them either.

How it looked from the outside

On the Latent Space episode with TypeSafe's chief executive, Diogo Almeida, on 2026-09-21, the host swyx summed up the argument as one that "makes the case that you should always make one or 10 or 100 Jev calls for every one reasoning call that you make."2 A FallbackModel with a local engine in front is that ratio run on your own machine: the decisions stay local, and the language model is called only for the steps a decision cannot finish.

Which model to put behind an agent

On the FontLab router question, decider-0.8b on dohnuts is the faster of the two and Qwen3.5-4B-Hmm on pcdServer the more accurate (chapter 6 has the figures). Through pydantic-ai the prompt differs from the benchmark's, so treat that as a ranking, not a promise. In our own integration tests, decider-0.8b on pcdServer, which reads it without its trained answer slot, got the task of a short Python request wrong where the same model on dohnuts got it right. If you run a dedicated model, run it on dohnuts.

What changed in September 2026

TypeSafeModel is young. TypeSafe launched jev on 2026-09-15, TypeSafeModel arrived in pydantic-ai 2.45.0 on 2026-09-18, and pydantic-ai kept releasing through the rest of the month. The typesafe-sdk package that it depends on had two breaking releases in the same weeks. None of the changes below alters how ornotto's adapter works with the locked versions, but each one changes what a newer install looks like.

Date Release What changed
2026-09-15 typesafe-sdk 0.6.0 Score.criteria becomes an ordered sequence, not a dict keyed by integers (breaking)
2026-09-18 pydantic-ai 2.45.0 TypeSafeModel for jev
2026-09-18 typesafe-sdk 0.7.0 serialisation moves from msgspec to pydantic; response_model argument (breaking)
2026-09-19 pydantic-ai 2.46.0 tool-argument filling, typesafe_boolean_threshold, union outputs, a refusal when a question has too many options
2026-09-21 typesafe-sdk 0.7.1 early API-key validation; the version ornotto locks
2026-09-24 pydantic-ai 2.49.0 "None of these" handling for optional fields
2026-09-25 pydantic-ai 2.50.0 DecisionModel becomes the base class, TypeSafeModel one subclass
2026-09-25 pydantic-ai 2.51.0 latest release on 2026-09-30
2026-09-26 typesafe-sdk 0.7.2 an http2 extra

The dates come from the pydantic-ai releases page and the typesafe-sdk changelog.34

TypeSafeModel under DecisionModel

Pull request #8696, "Add DecisionModel, a base for Decisions-protocol models, and make TypeSafeModel one", was merged on 2026-09-24 and shipped in pydantic-ai 2.50.0 the next day.5 The release also added routing by name, a decision_route_threshold setting and decide spans for tracing. The change separates the protocol from the vendor: the part of the code that turns an output_type into questions and answers into an output now lives in the base class, and TypeSafeModel is the variant that talks to TypeSafe's client.

For ornotto this is good news in principle and untested in practice. ornotto.pydantic_ai.model() returns a TypeSafeModel, and under 2.50 a TypeSafeModel is still a TypeSafeModel, now with a parent class. Whether the provider and client classes the adapter imports kept their names and arguments is what an upgrade has to show. Until the tests run against 2.50 or later, treat the lock file as the supported combination.

SystemOneModel, not yet merged

Pull request #8942, opened on 2026-09-28, proposes a SystemOneModel "to run decision models such as CLM and Laya over the /v1/systemone API".6 On 2026-09-30 it was still open. If it lands, it names in pydantic-ai what ornotto does by indirection today: a decision model on a server that speaks System One, with no TypeSafe account behind it. ornotto's adapter would then no longer need the placeholder API key it passes to the TypeSafe client for dohnuts and ollaya. The pcdServer transport would stay, because pcdServer does not speak System One at all (chapter 3).

typesafe-sdk 0.7 and ordered criteria

Before typesafe-sdk 0.6.0, a score question could carry its levels as a dict keyed by integers. Since 0.6.0 the levels are an ordered list: the first entry is level 0, the second level 1, and so on. ornotto sends score levels as a list, in that order, to dohnuts, ollaya and the pydantic-ai adapter alike. When it receives a question dict through the pcdServer transport or through decide, it still accepts the older mapping and sorts its keys by number, so code written against an earlier SDK keeps working with ornotto. It will not keep working against TypeSafe's own client, which since 0.7.0 validates requests with pydantic models.

Two paths to jev

The book's jev rows went through OpenRouter's System One path (chapter 1). pydantic-ai issue #8552, opened on 2026-09-19, states that OpenRouter also serves the protocol at POST https://openrouter.ai/api/alpha/decisions.7 We have not called that path, and we do not know whether the two return the same answers or whether one of them will be retired. If you move a hosted agent between them, compare a few answers first.


  1. ollaya, "ollaya", read 2026-09-30. https://ollaya.dev ↩

  2. Latent Space, "Jev: System One models for Prod, not God, with Diogo Almeida, CEO, TypeSafe AI", 2026-09-21, at 01:37:25. https://www.latent.space/p/jev ↩

  3. pydantic, "pydantic-ai releases", read 2026-09-30. https://github.com/pydantic/pydantic-ai/releases ↩

  4. TypeSafe, "Python SDK changelog", read 2026-09-30. https://docs.typesafe.ai/sdk/python/changelog.md ↩

  5. pydantic, "pydantic-ai v2.50.0", 2026-09-25. https://github.com/pydantic/pydantic-ai/releases/tag/v2.50.0 ↩

  6. pydantic, "Add SystemOneModel to run decision models such as CLM and Laya over the /v1/systemone API", pull request #8942, 2026-09-28. https://github.com/pydantic/pydantic-ai/pull/8942 ↩

  7. pydantic, "Route Jev (TypeSafeModel) through OpenRouter's Decisions API", issue #8552, 2026-09-19. https://github.com/pydantic/pydantic-ai/issues/8552 ↩