Typed I/O and schemas
Pydantic models are the contract between your code and the model. Bridle validates every value that crosses.
Every value the model produces in a Bridle program is typed. step, branch, and loop each take a schema= and return a value matching that schema. @agent(input=, output=) validates the function's input and return value. Pydantic does the validation; Bridle wires it in at every boundary.
What a schema can be
The schema= parameter accepts:
- A Pydantic
BaseModel. The common case. - A bare type:
bool,int,str,float,list[X],dict[K, V]. Bridle wraps these in a synthetic Pydantic root model. - An
Enum. For multi-way decisions in abranch. - A
Literal[...]. Same idea as an enum, lighter weight.
A BaseModel is the right default. Bare types are useful for the simplest possible step — "give me the next number" — and enums are the right shape for branching on more than two outcomes.
from enum import Enum
from typing import Literal
class Verdict(Enum):
APPROVE = "approve"
REJECT = "reject"
ESCALATE = "escalate"
verdict = branch("decide", schema=Verdict, context=case)
match verdict:
case Verdict.APPROVE: ...
case Verdict.REJECT: ...
case Verdict.ESCALATE: ...
# Same shape, lighter:
verdict: Literal["approve", "reject", "escalate"] = branch("decide", schema=Literal["approve", "reject", "escalate"], context=case)How validation runs
Inside a step, Bridle injects a synthetic __bridle_return__ tool whose parameter schema is your schema=. The model can call other tools you provided, but it ends the step by calling __bridle_return__ with arguments that match the schema.
If the arguments do not validate, Bridle feeds the validation error back to the model as a corrective tool result and lets it try again. By default it tries up to three times before raising SchemaSatisfactionError. Tune the budget per call:
step("...", schema=Brief, context=ctx) # default: 3 schema retries
# Coming in v0.1: max_schema_retries as a first-class kwarg.
# Today, override via Call.options:
call = step("...", schema=Brief, context=ctx)
call.options["max_schema_retries"] = 5The retries happen inside one step, not across multiple calls. The trace records each as a retry event with reason: "schema".
Use BaseModel aggressively
The schemas that work best in practice are explicit and narrow:
class Source(BaseModel):
url: str
summary: str # one or two sentences, not the full page
class Plan(BaseModel):
topics: list[Topic]
rationale: strA few rules of thumb:
- Fields are nouns.
headline,topics,rationale. The model fills nouns better than instructions-disguised-as-fields. - Constrain ranges. Use
Field(min_length=, max_length=, ge=, le=)where it matters. Validation enforces the constraint. - Use enums for closed sets. Don't make the model invent strings from a fixed list.
- Keep nested depth shallow. Two or three levels max. Deep nesting confuses both the model and the reader.
- Document fields when the name is not enough. Pydantic descriptions get into the schema and the model sees them.
Input validation on agents
@agent(input=Schema, output=Schema) validates the first positional argument against input and the return value against output:
class Query(BaseModel):
topic: str
depth: Literal["shallow", "deep"] = "shallow"
@agent(input=Query, output=Brief, model="claude-sonnet-4-6")
def brief_writer(q: Query) -> Brief:
...If the caller passes a dict, Bridle coerces it through the schema:
brief_writer({"topic": "Mars weather", "depth": "deep"}) # validated, coercedIf the body returns something that does not match output, Pydantic raises a ValidationError at the agent boundary.
Schemas in caching
The cache key includes a fingerprint of the schema. Two calls with the same prompt and context but different schemas have different cache keys — a structural change to your BaseModel invalidates the cache automatically. See caching for the full key composition.