Skip to content
/

Typed results

Production patternsChapter 12 of 144 min read

Extract structured, runtime-validated Python objects from agent tasks using Pydantic schemas instead of unstructured markdown or fragile JSON strings.


The problem#

Prompting language models for JSON without schema enforcement introduces parsing failures. Test this behavior in extract_json.py:

extract_json.py
# extract_json.py

import json

from crewai import Agent, Crew, Task
from crewai_tools import FileReadTool
from dotenv import load_dotenv

load_dotenv()

reader = Agent(
    role="Contract analyst",
    goal="Pull every commercially important term out of a contract, with its section number",
    backstory="You read contracts line by line for a procurement team.",
    tools=[FileReadTool()],
)

extract = Task(
    description="Read the contract at {contract_path} and pull out its key terms.",
    expected_output=(
        "A JSON object with these keys: provider, customer, effective_date, "
        "initial_term_months, auto_renews, renewal_term_months, notice_days, annual_fee_usd."
    ),
    agent=reader,
)

result = Crew(agents=[reader], tasks=[extract]).kickoff(
    inputs={"contract_path": "contracts/acme-saas-agreement.md"}
)
print(result.raw)

terms = json.loads(result.raw)
print(terms["effective_date"])

Run the script to observe the error:

Terminal
(env)$ python extract_json.py

The execution fails because the model wraps the JSON string in markdown code fences:

Output
```json{  "provider": "Acme Analytics, Inc.",  "customer": "Northwind Traders LLC",  "effective_date": "March 1, 2026",  "initial_term_months": 12,  "auto_renews": true,  "renewal_term_months": 12,  "notice_days": 60,  "annual_fee_usd": 18000}```Traceback (most recent call last):  ...json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)

Core failure modes of unvalidated JSON generation:

  1. Markdown code fences: Surrounding triple backticks break standard json.loads parsing.
  1. Inconsistent type formatting: Dates oscillate between "March 1, 2026" and "2026-03-01".
  1. Missing schema guarantees: Keys may be omitted or returned as strings rather than numbers.

Delete the prototype script:

Terminal
(env)$ rm extract_json.py

Pydantic models#

Define typed data schemas with Pydantic in contract_crew/models.py:

contract_crew/models.py
# contract_crew/models.py

from datetime import date

from pydantic import BaseModel, Field


class Clause(BaseModel):
    section: str = Field(description="The section number, like 2.2")
    topic: str = Field(description="A short label for the clause, like Auto-renewal")
    text: str = Field(description="The clause, quoted word for word from the contract")


class ContractTerms(BaseModel):
    provider: str = Field(description="Legal name of the company providing the service")
    customer: str = Field(description="Legal name of the customer")
    effective_date: date = Field(description="The date the agreement starts")
    initial_term_months: int = Field(description="Length of the initial term, in months")
    auto_renews: bool = Field(description="True if the agreement renews automatically")
    renewal_term_months: int | None = Field(description="Length of each renewal term in months, or null if it does not renew")
    notice_days: int | None = Field(description="Days of notice needed before a term ends to stop it renewing, or null")
    annual_fee_usd: float = Field(description="The annual fee, in US dollars")
    key_clauses: list[Clause] = Field(
        description="Every clause that affects cost, deadlines, data, or liability, or that lets one party change the deal"
    )

Each field description guides the language model during extraction, while Pydantic guarantees type casting and date parsing at runtime.

Attach ContractTerms to extract_terms in contract_crew/crew.py:

contract_crew/crew.py
# contract_crew/crew.py

from crewai import Agent, Crew, Process, Task
from crewai.project import CrewBase, agent, crew, task
from crewai_tools import FileReadTool

from contract_crew.models import ContractTerms


@CrewBase
class ContractReviewCrew:
    """Reads a contract, flags the risks, and checks the memo against the source."""

    agents_config = "config/agents.yaml"
    tasks_config = "config/tasks.yaml"

    def __init__(self):
        self.read_file = FileReadTool()

    @agent
    def reader(self) -> Agent:
        return Agent(config=self.agents_config["reader"], tools=[self.read_file], verbose=True)

    @agent
    def risk_analyst(self) -> Agent:
        return Agent(config=self.agents_config["risk_analyst"], verbose=True)

    @agent
    def fact_checker(self) -> Agent:
        return Agent(config=self.agents_config["fact_checker"], tools=[self.read_file], verbose=True)

    @task
    def extract_terms(self) -> Task:
        return Task(config=self.tasks_config["extract_terms"], output_pydantic=ContractTerms)

    @task
    def assess_risks(self) -> Task:
        return Task(config=self.tasks_config["assess_risks"])

    @task
    def fact_check(self) -> Task:
        return Task(config=self.tasks_config["fact_check"])

    @crew
    def crew(self) -> Crew:
        return Crew(
            agents=self.agents,
            tasks=self.tasks,
            process=Process.sequential,
            verbose=True,
        )

Update contract_crew/config/tasks.yaml to focus purely on extraction criteria, since the Pydantic schema defines output structure:

YAML
extract_terms:
  description: >
    Read the contract at {contract_path}. List its key terms: parties, dates,
    term and renewal, fees and payment, service levels, data handling,
    termination, liability, and any clause that lets one party change the
    deal on its own.
  expected_output: >
    The contract's key terms, with every important clause quoted word for
    word along with its section number.
  agent: reader

Access the parsed model instance in main.py:

main.py
# main.py

from dotenv import load_dotenv

from contract_crew.crew import ContractReviewCrew

load_dotenv()


def main():
    result = ContractReviewCrew().crew().kickoff(
        inputs={
            "contract_path": "contracts/acme-saas-agreement.md",
            "customer": "Northwind Traders",
        }
    )

    terms = result.tasks_output[0].pydantic
    print(f"Effective date: {terms.effective_date}")
    print(f"Renews automatically: {terms.auto_renews}")
    print(f"Notice needed: {terms.notice_days} days")
    print(f"Clauses found: {len(terms.key_clauses)}")
    print()
    print(result.raw)


if __name__ == "__main__":
    main()

Run the pipeline:

Terminal
(env)$ python main.py

Review the typed output:

Output
Effective date: 2026-03-01Renews automatically: TrueNotice needed: 60 daysClauses found: 20

The output provides native Python types (datetime.date, bool, int), giving downstream agents and application code structured, validated inputs.