Try it
Download notebook
In [ ]:
# Install required packages
!pip install --upgrade --quiet ipywidgets 'pydantic-ai-slim[openrouter,web-fetch]' python-dotenv pymupdf requests

import os
import urllib.request
import zipfile

# Download and extract data files
url = 'https://github.com/jsoma/workshop-ai-agents/raw/main/docs/03-pydantic-ai-orchestration/03-pydantic-ai-orchestration-data.zip'
print(f'Downloading data from {url}...')
urllib.request.urlretrieve(url, '03-pydantic-ai-orchestration-data.zip')

print('Extracting 03-pydantic-ai-orchestration-data.zip...')
with zipfile.ZipFile('03-pydantic-ai-orchestration-data.zip', 'r') as zip_ref:
    zip_ref.extractall('.')

os.remove('03-pydantic-ai-orchestration-data.zip')
print('✓ Data files extracted!')
In [ ]:
import os
from getpass import getpass

from dotenv import load_dotenv
from pydantic_ai import Agent
from pydantic_ai.capabilities import WebFetch, WebSearch

load_dotenv()

if not os.getenv("OPENROUTER_API_KEY"):
    os.environ["OPENROUTER_API_KEY"] = getpass("OpenRouter API key: ")
if not os.getenv("BRAINTRUST_API_KEY"):
    os.environ["BRAINTRUST_API_KEY"] = getpass("Braintrust API key: ")

print("Initialized.")

Agent orchestration: Controlling your agent setup

If we zoom out from "give an LLM a lot of toys to play with," we have to examine the question of how much control do we really want to give up? Do we let the LLM control the process start to finish, or do we set up stages, checkpoints and roles?

This is agent orchestration. In the example below, we're going to leave behind the single-agent setup and move into a world of multiple agents that each have specialized roles.

Investigating zoning appeals

Let's get a little wild. Let's say I'm reporting on zoning appeals in a small area in Virginia. How can I use agents to help speed up my work?

In [ ]:
from datetime import date
from typing import Literal
from pydantic import BaseModel, Field

MODEL = 'openrouter:anthropic/claude-sonnet-4-5'

class Motion(BaseModel):
    related_case_id: str | None = None
    text: str
    vote: str | None = Field(
        default=None,
        description="Compact vote result, e.g. '6-0', '5-1', or 'passed unanimously'",
    )
    outcome: str | None = None


class ZoningCase(BaseModel):
    case_id: str | None = Field(
        default=None,
        description="Example: V25-0002, A24-0002",
    )
    case_type: Literal["variance", "appeal", "minutes", "election", "other"]
    applicants: list[str] = []
    property_address: str | None = None
    parcel_id: str | None = None
    voting_district: str | None = None

    request: str
    staff_recommendation: str | None = None
    public_conflict: str | None = Field(
        default=None,
        description="Brief summary of major support/opposition or dispute",
    )
    outcome: str | None = None


class MeetingDetails(BaseModel):
    meeting_date: date
    location: str | None = None

    members_present: list[str] = []
    staff_present: list[str] = []

    cases: list[ZoningCase]
    motions: list[Motion]
    summary: str

meeting_agent = Agent(
    MODEL,
    output_type=MeetingDetails,
    instructions="""
        Extract meeting details from the provided minutes.
    """,
)

Step one: Extract details of the zoning appeals meeting

In [ ]:
import pymupdf

doc = pymupdf.open("20250715 BZA min.pdf")
text = "\n\n".join(page.get_text() for page in doc)

response = await meeting_agent.run(text)
details = response.output.model_dump()

Step two (and three): Step through the properties mentioned, looking each one of them up. Also look up the people!

In [ ]:
import requests
from datetime import date
from decimal import Decimal

from pydantic import BaseModel, Field

ASSESSMENT_URL = 'https://apps.spotsylvaniacountyva.gov/assessment/assessment/results.cfm'

def search_assessments_by_parcel_id(parcel_id: str) -> str:
    """
    Search for assessments related to a given parcel ID.
    If initial search fails, try appending a "-" on the end of the parcel ID and searching again.
    """

    data = {
        'streetnum': '',
        'streetname': '',
        'parcelid': parcel_id,
        'submitsearch': 'Go',
    }

    response = requests.post(ASSESSMENT_URL, data=data)
    cleaned = response.text.replace("Info.cfm?", "https://apps.spotsylvaniacountyva.gov/assessment/assessment/Info.cfm?")
    return cleaned

def search_assessments_by_address(street_num: str, street_name: str) -> str:
    """
    Search for assessments related to a given street address.
    Street name should be only the name and must NOT include RD, DR, ST, AVE
    """

    data = {
        'streetnum': street_num,
        'streetname': street_name,
        'parcelid': '',
        'submitsearch': 'Go'
    }
    response = requests.post(ASSESSMENT_URL, data=data)
    
    cleaned = response.text.replace("Info.cfm?", "https://apps.spotsylvaniacountyva.gov/assessment/assessment/Info.cfm?")
    return cleaned

class RealEstateRecord(BaseModel):
    parcel_id: str
    physical_address: str
    legal_description: str
    deeded_square_feet: Decimal | None = None

    assessment_year: int
    land_value: Decimal
    building_value: Decimal
    improvement_value: Decimal
    total_value: Decimal

    building_id: str | None = None
    year_built: int | None = Field(
        default=None,
        description="Use None if the source says 0 or unknown",
    )
    building_type: str | None = None
    total_area_sqft: Decimal | None = None
    bedrooms: int | None = None
    full_bathrooms: int | None = None
    half_bathrooms: int | None = None
    has_composition_shingle: bool | None = None
    has_frame_siding_vinyl: bool | None = None
    has_heat_pump: bool | None = None
    wood_deck_sqft: Decimal | None = None

    owner_name: str
    mailing_street: str
    mailing_city: str
    mailing_state: str
    mailing_zip: str

    sales_as_of_date: date | None = None
    date_of_transfer: date | None = None
    transfer_type: str | None = None
    instrument_number: str | None = None
    sale_price: Decimal | None = None

    google_maps_url: str | None = None


assessment_agent = Agent(
    MODEL,
    output_type=RealEstateRecord,
    capabilities=[WebFetch(local=True, native=False)],
    tools=[
        search_assessments_by_parcel_id,
        search_assessments_by_address,
    ],
    instructions="""
        Extract one property assessment record from county assessment pages.
        REMEMBER: Assessment records are located at https://apps.spotsylvaniacountyva.gov/assessment/assessment/Info.cfm?OID=XXXXXX
    """,
)
In [ ]:
class ResearchResult(BaseModel):
    research_topic: str
    results_summary: str | None = None
    source_ledger: list[str]

research_agent = Agent(
    MODEL,
    output_type=ResearchResult,
    capabilities=[WebSearch(native=False, local=True), WebFetch(native=False, local=True)],
    instructions="""
        Act as an investigative journalist researching a zoning case.
        Research the applicant(s) and provide a brief summary of who they are,
        what they do, and any relevant background information that might be 
        helpful for understanding their perspective in a zoning case.
    """,
)
In [ ]:
for case in details['cases']:
    print("Researching case:", case['case_id'])

    # Researching the assessment
    prompt = """
       Acquire the property assessment record for the following parcel:
        - Parcel ID: {parcel_id}
        - Address: {property_address}
    """.format(**case)

    response = await assessment_agent.run(prompt)
    case['assessment_record'] = response.output.model_dump()

    # Researching the people
    names = ", ".join(case['applicants'])

    prompt = """
        Applicant names: {names}
        Property address: {property_address}
        Zoning issue: {request}
        Public conflict summary: {public_conflict}
        """.format(**case, names=names)

    if 'assessment_record' in case and case['assessment_record']:
        prompt += """ 
        Owner name from assessment record: {owner_name}
        Owner full address from assessment record:
            {mailing_street}
            {mailing_city} {mailing_state}
            {mailing_zip}
        """.format(**case['assessment_record'])

    response = await research_agent.run(prompt)
    case['applicant_research'] = response.output.model_dump()
In [ ]:
details

Alternative approaches

Todo lists, worker-supervisor, planner-executor...

  • What other approaches to research might be useful?
  • Should we research companies separately from people?
  • What if we want to look up each owner separately/applicant separately?
  • What happens if we encounter new properties when researching a person online?
    • What if we encounter many addresses when researching people?