Contracts

Parties, dates, and a clause-level breakdown for legal review queues.

Extract parties, effective dates, terms, and clauses into a typed structure for downstream review. Use stable clause categories so review tools can filter the result.

output_schema requests structured output and Agno attempts to parse it. A failed or unparseable run can leave content as text. Check RunStatus.completed and the expected Pydantic type before reading fields, indexing, or writing downstream. These examples stop on failure; an application can instead send the original input to a review queue. Schema validation checks the shape and constraints, so verify extracted facts against the source separately.

Before running these examples, create and activate a Python environment, then install the provider and set its key:

uv venv .venv --python 3.12
source .venv/bin/activate
uv pip install "agno[openai]"
export OPENAI_API_KEY="..."

Replace every https://example.com/... PDF URL with a real PDF URL accessible to the model provider, or use File(filepath="...") for a local PDF. Create the referenced local files before running the example. Extraction quality depends on the document and model; the output comments are illustrative.

from agno.run.base import RunStatus

from typing import List, Literal, Optional

from agno.agent import Agent
from agno.media import File
from agno.models.openai import OpenAIResponses
from pydantic import BaseModel, Field


ClauseCategory = Literal[
    "term_and_termination",
    "payment",
    "confidentiality",
    "indemnification",
    "limitation_of_liability",
    "warranty",
    "ip_assignment",
    "governing_law",
    "dispute_resolution",
    "non_compete",
    "other",
]


class Clause(BaseModel):
    category: ClauseCategory
    heading: Optional[str] = Field(None, description="Section heading as printed")
    text: str = Field(..., description="Clause text, verbatim")
    page: Optional[int] = Field(None, description="1-indexed page where the clause begins")


class Party(BaseModel):
    name: str
    role: Optional[str] = Field(None, description="e.g. Customer, Vendor, Licensor")
    address: Optional[str] = None


class Contract(BaseModel):
    title: Optional[str] = None
    contract_type: Optional[str] = Field(None, description="e.g. MSA, SOW, NDA, EULA")
    parties: List[Party] = Field(default_factory=list)
    effective_date: Optional[str] = None
    term: Optional[str] = Field(None, description="Stated term, e.g. '3 years from Effective Date'")
    governing_law: Optional[str] = None
    clauses: List[Clause] = Field(default_factory=list)


agent = Agent(
    model=OpenAIResponses(id="gpt-5.5"),
    instructions=(
        "Extract the contract header and every clause from the attached PDF. "
        "Clause text must be verbatim from the document. Assign each clause "
        "to the closest category; use 'other' if nothing fits. Do not "
        "summarize, paraphrase, or skip clauses."
    ),
    output_schema=Contract,
)

contract_run = agent.run(
    "Extract this contract.",
    files=[File(url="https://example.com/msa-acme.pdf")],
)
if contract_run.status != RunStatus.completed or not isinstance(contract_run.content, Contract):
    raise RuntimeError("No validated Contract; send the input to review before continuing")
contract = contract_run.content
# Contract(title='Master Services Agreement', contract_type='MSA',
#          parties=[Party(name='Acme Corp', role='Customer'),
#                   Party(name='Beta Labs', role='Vendor')],
#          effective_date='2026-01-15', term='3 years from Effective Date',
#          governing_law='State of Delaware',
#          clauses=[Clause(category='term_and_termination', ...), ...])

The Literal on category is what makes the output usable. Downstream review queues filter by category, so the categories must be a closed set. Free-text categories are unfilterable.

Review queues by clause type

Once clauses carry a category, route them by team. Indemnification and limitation-of-liability go to legal; payment and term go to finance.

def route_for_review(contract: Contract) -> dict[str, list[Clause]]:
    legal = {"indemnification", "limitation_of_liability", "warranty", "ip_assignment"}
    finance = {"payment", "term_and_termination"}

    buckets: dict[str, list[Clause]] = {"legal": [], "finance": [], "other": []}
    for clause in contract.clauses:
        if clause.category in legal:
            buckets["legal"].append(clause)
        elif clause.category in finance:
            buckets["finance"].append(clause)
        else:
            buckets["other"].append(clause)
    return buckets

route_for_review operates on the typed result. The model is asked to supply verbatim text and a page; reviewers should check both against the original PDF.

Diff against a template

For contract review, the question is often "what changed from our standard?" Extract both the incoming contract and your template into the same Contract schema, then diff by category.

Set incoming_url to the incoming PDF URL and place the reference contract at templates/msa-v3.pdf. Continue with the agent and schema defined above.

from agno.run.base import RunStatus

incoming_run = agent.run("Extract.", files=[File(url=incoming_url)])
if incoming_run.status != RunStatus.completed or not isinstance(incoming_run.content, Contract):
    raise RuntimeError("No validated Contract; send the input to review before continuing")
incoming = incoming_run.content
template_run = agent.run("Extract.", files=[File(filepath="templates/msa-v3.pdf")])
if template_run.status != RunStatus.completed or not isinstance(template_run.content, Contract):
    raise RuntimeError("No validated Contract; send the input to review before continuing")
template = template_run.content


def clauses_by_category(contract: Contract) -> dict[str, list[str]]:
    grouped: dict[str, list[str]] = {}
    for clause in contract.clauses:
        grouped.setdefault(clause.category, []).append(clause.text)
    return {category: sorted(texts) for category, texts in grouped.items()}


incoming_by_cat = clauses_by_category(incoming)
template_by_cat = clauses_by_category(template)

deltas = [
    (cat, incoming_by_cat.get(cat, []), template_by_cat.get(cat, []))
    for cat in set(incoming_by_cat) | set(template_by_cat)
    if incoming_by_cat.get(cat, []) != template_by_cat.get(cat, [])
]

Clauses group into lists because one category often holds several clauses (other usually does). Sorting keeps the comparison order-independent.

For a richer comparison, hand both contracts to a reviewer agent with output_schema set to a ClauseDelta model and let the model summarize the differences.

Long contracts and chunking

Agno sends the PDF to the model as a single file input: a URL reference when you pass File(url=...), or base64-encoded bytes when you pass filepath or content. Nothing splits the document on the way, so the provider's file-size and context limits bound what one call can take. For contracts over that limit, split by section in your own code and run the agent per chunk. The schema is the same; you concatenate the clauses lists at the end.

Next steps

TaskGuide
Schedule a nightly contract intake runBatch and durability
Send risky clauses to a human reviewerHuman routing and eval
Apply the same shape to intake formsForms and intake

Developer Resources