Brian Newman Labs 01 / 21

Beyond
the chat

Designing typed AI workflows with Pydantic AI

Brian Newman · Newman Labs

An invoice, engineering notebook, and laptop arranged on a deep navy work surface
Invoice PDFPydantic AITyped object
Brian Newman

Brian Newman

Python, data engineering, and applied AI

I build software for real business workflows.

QR code for Brian Newman's portfolio Portfoliobriannewman.info

The central shift

Who owns the loop?

Chat interface

The human drives the loop

  1. Ask or paste context
  2. Read the answer
  3. Judge whether it is useful
  4. Prompt again or take action

Workflow interface

The application owns the sequence

  1. A business event occurs
  2. The model interprets ambiguity
  3. Python validates the result
  4. The workflow returns explicit state

Use the right tool

Automation is exact. Inputs are not.

Traditional automation

Rules, calculations, required fields, database lookups, and repeatable actions.

Messy inputs

PDFs, emails, tickets, contracts, photos, notes, and inconsistent language.

Model interpretation

Map ambiguity into a typed object that deterministic code can use.

Use the model for interpretation. Keep exact work in Python.

Working definition

A typed application boundary around a model call

Pydantic AI is the engineering layer around how Python calls, validates, retries, tests, observes, and composes model behavior.

01Typed output 02Bounded retries 03Runtime dependencies 04Constrained tools 05Tests and traces

Optional abstraction

Direct model calls are still valid

Direct

Python → provider SDK → model

Good when the interaction is small and the provider response is already the application boundary.

With Pydantic AI

Python → agent contract → provider → model

Useful when the model call needs typed output, validation, retries, tests, tools, dependencies, or traces.

Swap the model string. The Agent contract stays.

The primary interface

An Agent configures one model interaction

ModelThe LLM that generates a response
InstructionsHow the Agent should behave
AgentConfigured model runner
Output typeA Pydantic schema for typed validationSupportDecision(category="billing")
ToolA Python function that can reach an API, database, or service

Structured output

Define the object before writing the prompt

The application needs an invoice object, not prose that happens to look like JSON.

PYschemas.py labs / invoice_parser / selected fields
class ParsedInvoice(NewmanLabsModel):
    invoice_number: str = Field(min_length=1, max_length=100)
    issue_date: date
    purchase_order_number: str | None = Field(default=None, max_length=100)
    currency: str = Field(pattern=r"^[A-Z]{3}$")
    seller: InvoiceParty
    subtotal: Decimal | None = None
    tax_total: Decimal | None = None
    total: Decimal = Field(
        gt=0,
        description="Final total printed on the invoice.",
    )

class InvoiceAgentOutput(NewmanLabsModel):
    invoice: ParsedInvoice
    supplier_match: SupplierMatch | None = None

Agent contract

Configure behavior without hiding the application

Instructions describe the current job. Dependencies provide typed per-run context. The output type defines what must come back.

PYfunctions.py labs / invoice_parser / simplified excerpt
invoice_extractor: Agent[SupplierLookupState, InvoiceAgentOutput] = Agent(
    name="invoice_parser",
    deps_type=SupplierLookupState,
    output_type=InvoiceAgentOutput,
    instructions=(
        "Extract invoice fields from the document. "
        "Use only values explicitly shown. "
        "Call search_supplier_candidates exactly once..."
    ),
    retries={"tools": 3, "output": 3},
)

Output validators + ModelRetry

Validation can send focused feedback back to the model

Model attemptTyped outputOutput validator
Valid → continueRecoverable → retry
PYfunctions.py output validator
expected_total = (
    invoice.subtotal
    - (invoice.discount_total or Decimal(0))
    + (invoice.shipping_total or Decimal(0))
    + invoice.tax_total
)
if expected_total != invoice.total:
    raise ModelRetry(
        "The extracted total does not reconcile with the subtotal, discount, shipping, and tax. "
        "Re-read those printed values and correct extraction errors without inventing values."
    )

Retry interpretation problems. Don’t retry reality.

One controlled capability

The model may ask. Python executes.

The tool calls a read-only fake ERP backed by committed JSON. It cannot write the database or approve an invoice.

PYfunctions.py labs / invoice_parser
@invoice_extractor.tool(require_parameter_descriptions=True)
def search_supplier_candidates(
    ctx: RunContext[SupplierLookupState],
    printed_name: str,
) -> list[SupplierMatch]:

    candidates = search_suppliers(printed_name=printed_name)
    ctx.deps.supplier_query = printed_name
    ctx.deps.supplier_candidates = tuple(candidates)
    return candidates

What the model receives

The request carries definitions beside the messages

JSONfunction tool truncated real schema
{
  "name": "search_supplier_candidates",
  "description": "Search the fake ERP for a printed invoice seller name.",
  "parameters": {
    "properties": {
      "printed_name": {
        "description": "Seller name exactly as it appears on the invoice.",
        "type": "string"
      }
    }
  }
}
JSONoutput tool truncated real schema
{
  "name": "final_result",
  "description": "Invoice fields plus the optional fake ERP supplier match.",
  "parameters": {
    "$defs": {"ParsedInvoice": {
      "properties": {
        "invoice_number": {"minLength": 1, "maxLength": 100},
        "currency": {"pattern": "^[A-Z]{3}$"},
        "total": {
          "description": "Final total printed on the invoice.",
          "anyOf": [
            {"exclusiveMinimum": 0.0, "type": "number"},
            {"pattern": "…", "type": "string"}
          ]
        }
      },
      "required": ["invoice_number", "issue_date",
        "currency", "seller", "total"]
    }}
  }
}

Docstrings describe tools. Pydantic fields contribute constraints and required structure; descriptions appear when declared.

What the run records

The whole exchange becomes inspectable data

PYfunctions.py result handoff
all_agent_messages=result.all_messages(),

Tool and output definitions are request metadata. Message history records what happened.

JSONall_agent_messages real run · truncated
[
  {"kind": "request", "parts": [
    {"part_kind": "user-prompt", "content": "Here is the invoice content: …"}
  ]},
  {"kind": "response", "model_name": "minimax-m3:cloud", "parts": [
    {"part_kind": "thinking", "content": "… extract invoice fields …"},
    {"part_kind": "tool-call", "tool_name": "search_supplier_candidates",
     "args": "{\"printed_name\":\"Northstar Industrial Supply LLC\"}"}
  ]},
  {"kind": "request", "parts": [
    {"part_kind": "tool-return", "content": [], "outcome": "success"}
  ]},
  {"kind": "response", "parts": [
    {"part_kind": "text", "content": "{\"invoice\": {…}, \"supplier_match\": null}"}
  ]}
]
Synthetic Pacific Industrial Supply invoice

Synthetic US invoice

One familiar workflow with real failure modes

Valid PDFAccepted
Printed sellerPacific Industrial Supply
ERP candidatesOne result
Supplier matchSUP-3007

The live lab path

Drop a PDF. Get a typed invoice back.

01Drop

Select or drop one invoice. Extraction starts immediately.

02Job

The managed job scans, reads, looks up the supplier, and validates.

03Review

You get the original PDF beside a typed invoice you can inspect.

Live lab

Start with permanent demos. Upload when the room is ready.

Pydantic validates structure, not reality.

Validatedtotal is a positive Decimal
Not provenThe Decimal came from the correct place
Validatedsupplier_match fits the schema
Verified by PythonThe match equals the one ERP candidate

Before release

Test the code. Evaluate the model.

Unit testsRules, totals, retries, routes, and failures
FunctionModelExact tool arguments and structured responses
EvaluationsLabeled invoices against real models over time

After release

Trace the whole run

FastAPI dispatchPrefect flowDocFirewall + DoclingAgent + toolTyped result

Prefect shows the job. Logfire connects application and model traces.

Deterministic decision

One candidate continues. Ambiguity stops.

Supplier lookup tool returns candidates How many matches did the fake ERP return?
Exactly 1 candidate

Use that supplier ID.

Continue →
Either 0 or 2+ candidates

Return no supplier ID.

Human review

Python applies the rule. The model never chooses among ambiguous records.

Unstructured input
Inspectable document evidence
Bounded model interpretation
Typed result
Deterministic Python
Action or human review

The model interprets. Python validates, integrates, and controls what happens next.

Questions

What would you put beyond the chat?

The slides, synthetic invoices, live workflow, and source code are already public at the links on this slide.

Start with one model boundary you can explain, test, and defend.

Slide overview

    ←/→ navigate · P presenter · O overview · F fullscreen