n4nAI

CrewAI real-world example: legal contract review

Build a CrewAI pipeline that extracts, classifies, and flags risky clauses in legal contracts with runnable code and verification steps.

n4n Team3 min read674 words

Audio narration

Coming soon — every post will get a voice note here.

CrewAI works well for legal contract review because the task naturally decomposes into specialized roles: a parser that handles messy PDFs, an extractor that pulls structured clauses, a classifier that tags risk categories, and a reviewer that surfaces issues for human sign-off. This tutorial walks through a complete, runnable pipeline you can adapt to your own contract types and risk taxonomy. We’ll use a local LLM via Ollama for data privacy, but the same structure works with any OpenAI-compatible endpoint.

Step 1: Set up the environment and dependencies

Create a fresh virtual environment and install the minimal set. We’ll use pdfplumber for text extraction, pydantic for structured outputs, and crewai with langchain-community for the Ollama integration.

python -m venv .venv && source .venv/bin/activate
pip install crewai==0.28.8 pdfplumber pydantic pydantic-settings langchain-community ollama

Verify Ollama is running and pull a capable model. llama3.1:8b balances speed and reasoning for clause classification; swap in llama3.1:70b if you have VRAM.

ollama serve &
ollama pull llama3.1:8b

Create a config.yaml to keep model and path settings out of code:

# config.yaml
llm:
  model: "ollama/llama3.1:8b"
  base_url: "http://localhost:11434"
  temperature: 0.1

paths:
  contracts_dir: "./contracts"
  output_dir: "./output"

risk_taxonomy:
  - "payment_terms"
  - "termination"
  - "liability_cap"
  - "indemnification"
  - "ip_ownership"
  - "confidentiality"
  - "data_privacy"
  - "force_majeure"
  - "governing_law"
  - "assignment"

Load it with a tiny settings class:

# settings.py
from pathlib import Path
from pydantic_settings import BaseSettings, SettingsConfigDict
import yaml

class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_file=".env", extra="ignore")
    
    llm_model: str = "ollama/llama3.1:8b"
    llm_base_url: str = "http://localhost:11434"
    llm_temperature: float = 0.1
    contracts_dir: Path = Path("./contracts")
    output_dir: Path = Path("./output")
    risk_taxonomy: list[str] = []

    @classmethod
    def from_yaml(cls, path: Path) -> "Settings":
        with open(path) as f:
            data = yaml.safe_load(f)
        # Flatten nested yaml into flat fields
        flat = {
            "llm_model": data["llm"]["model"],
            "llm_base_url": data["llm"]["base_url"],
            "llm_temperature": data["llm"]["temperature"],
            "contracts_dir": Path(data["paths"]["contracts_dir"]),
            "output_dir": Path(data["paths"]["output_dir"]),
            "risk_taxonomy": data["risk_taxonomy"],
        }
        return cls(**flat)

settings = Settings.from_yaml(Path("config.yaml"))

Step 2: Define the data models for structured outputs

Pydantic models give you validation and make the crew’s hand-offs explicit. Each agent returns a typed object the next agent can consume without string parsing.

# models.py
from pydantic import BaseModel, Field
from typing import Literal
from enum import Enum

class RiskCategory(str, Enum):
    PAYMENT_TERMS = "payment_terms"
    TERMINATION = "termination"
    LIABILITY_CAP = "liability_cap"
    INDEMNIFICATION = "indemnification"
    IP_OWNERSHIP = "ip_ownership"
    CONFIDENTIALITY = "confidentiality"
    DATA_PRIVACY = "data_privacy"
    FORCE_MAJEURE = "force_majeure"
    GOVERNING_LAW = "governing_law"
    ASSIGNMENT = "assignment"

class Severity(str, Enum):
    LOW = "low"
    MEDIUM = "medium"
    HIGH = "high"
    CRITICAL = "critical"

class Clause(BaseModel):
    clause_id: str
    heading: str | None
    text: str
    page_start: int
    page_end: int

class ClassifiedClause(Clause):
    category: RiskCategory
    severity: Severity
    rationale: str = Field(description="Why this severity and category")
    redline_suggestion: str | None = Field(default=None, description="Suggested rewrite if problematic")

class ContractAnalysis(BaseModel):
    contract_id: str
    filename: str
    total_clauses: int
    classified_clauses: list[ClassifiedClause]
    summary: str
    high_risk_count: int
    critical_risk_count: int

Step 3: Build the PDF parser agent

This agent owns one job: turn a PDF into a clean list of Clause objects. It handles multi-column layouts, headers/footers, and page boundaries. We keep the LLM out of this step — deterministic extraction is faster and cheaper.

# agents/parser.py
import pdfplumber
import re
from pathlib import Path
from typing import Iterator
from models import Clause
from settings import settings

class ContractParser:
    def __init__(self, contracts_dir: Path = None):
        self.contracts_dir = contracts_dir or settings.contracts_dir

    def extract_clauses(self, pdf_path: Path) -> list[Clause]:
        clauses = []
        clause_counter = 0
        
        with pdfplumber.open(pdf_path) as pdf:
            full_text = ""
            page_map = []  # (page_num, char_offset)
            
            for i, page in enumerate(pdf.pages):
                text = page.extract_text() or ""
                page_map.append((i + 1, len(full_text)))
                full_text += f"\n--- PAGE {i+1} ---\n" + text

        # Split on common clause delimiters: numbered sections, ALL CAPS headings
        # This regex catches "1.1", "Section 3", "ARTICLE V", "Confidentiality", etc.
        pattern = re.compile(
            r'(?:\n|^)'
            r'(?:'
            r'(?:\d+(?:\.\d+)+)\s+'           # 1.1, 2.3.4
            r'|(?:Section|Article|ARTICLE|SECTION)\s+\d+[A-Z]?'  # Section 3, Article V
            r'|(?:[A-Z][A-Z\s]{3,}:)'        # CONFIDENTIALITY:
            r')'
            r'(.+?)(?=\n(?:\d+(?:\.\d+)+|Section|Article|[A-Z]{4,}:)|\Z)',
            re.DOTALL | re.MULTILINE
        )

        for match in pattern.finditer(full_text):
            clause_text = match.group(0).strip()
            if len(clause_text) < 50:  # skip noise
                continue
            
            # Determine page range
            start_offset = match.start()
            end_offset = match.end()
            page_start = self._find_page(page_map, start_offset)
            page_end = self._find_page(page_map, end_offset)
            
            # Extract heading from first line
            first_line = clause_text.split('\n')[0][:120]
            
            clauses.append(Clause(
                clause_id=f"cl_{clause_counter:04d}",
                heading=first_line if len(first_line) > 3 else None,
                text=clause_text,
                page_start=page_start,
                page_end=page_end
            ))
            clause_counter += 1

        # Fallback: if regex found nothing, chunk by page
        if not clauses:
            for i, page in enumerate(pdf.pages):
                text = page.extract_text() or ""
                if len(text) > 100:
                    clauses.append(Clause(
                        clause_id=f"cl_{clause_counter:04d}",
                        heading=f"Page {i+1}",
                        text=text,
                        page_start=i+1,
                        page_end=i+1
                    ))
                    clause_counter += 1

        return clauses

    def _find_page(self, page_map: list[tuple[int, int]], offset: int) -> int:
        for i in range(len(page_map) - 1):
            if page_map[i][1] <= offset < page_map[i+1][1]:
                return page_map[i][0]
        return page_map[-1][0] if page_map else 1

    def process_all(self) -> dict[str, list[Clause]]:
        results = {}
        for pdf_file in self.contracts_dir.glob("*.pdf"):
            print(f"Parsing {pdf_file.name}...")
            results[pdf_file.stem] = self.extract_clauses(pdf_file)
        return results

Verify it works:

mkdir -p contracts
# Drop a sample contract PDF in contracts/
python -c "
from agents.parser import ContractParser
from settings import settings
parser = ContractParser()
results = parser.process_all()
for name, clauses in results.items():
    print(f'{name}: {len(clauses)} clauses')
    for c in clauses[:2]:
        print(f'  {c.clause_id} p{c.page_start}-{c.page_end}: {c.heading}')
"

You should see clause counts and first few headings per contract.

Step 4: Create the classifier agent with structured prompting

This agent receives Clause objects and returns ClassifiedClause with category, severity, and rationale. We use a single prompt that enforces the taxonomy and outputs JSON matching our Pydantic model.

# agents/classifier.py
from crewai import Agent, Task, Crew, LLM
from pydantic import BaseModel
from typing import List
from models import Clause, ClassifiedClause, RiskCategory, Severity
from settings import settings
import json

# Configure the LLM for CrewAI
llm = LLM(
    model=settings.llm_model,
    base_url=settings.llm_base_url,
    temperature=settings.llm_temperature,
)

# Taxonomy description injected into prompt
TAXONOMY_DESC = "\n".join([
    f"- {cat.value}: {cat.value.replace('_', ' ').title()}"
    for cat in RiskCategory
])

CLASSIFIER_PROMPT = f"""You are a senior contract attorney classifying clauses by risk category and severity.

Risk taxonomy (use EXACT category names):
{TAXONOMY_DESC}

Severity definitions:
- LOW: Standard language, minimal negotiation needed
- MEDIUM: Non-standard terms, review recommended
- HIGH: Unfavorable terms, negotiation required
- CRITICAL: Deal-breaker risk, immediate escalation

For each clause, output JSON matching this schema:
{{
  "clause_id": "string",
  "category": "exact_taxonomy_value",
  "severity": "low|medium|high|critical",
  "rationale": "specific reason referencing clause language",
  "redline_suggestion": "optional rewrite if severity >= medium"
}}

Return ONLY a JSON array of classifications, one per input clause. No markdown, no commentary.
"""

classifier_agent = Agent(
    role="Contract Risk Classifier",
    goal="Classify each clause by risk category and severity with precise rationale",
    backstory="You have 15 years reviewing SaaS, MSA, and NDA agreements for enterprise legal teams.",
    llm=llm,
    verbose=True,
    allow_delegation=False,
)

def classify_clauses(clauses: List[Clause]) -> List[ClassifiedClause]:
    # Batch clauses to stay within context window (8 clauses ~ 4k tokens)
    batch_size = 8
    all_results = []
    
    for i in range(0, len(clauses), batch_size):
        batch = clauses[i:i+batch_size]
        
        # Build compact input
        input_json = [
            {
                "clause_id": c.clause_id,
                "heading": c.heading,
                "text": c.text[:3000],  # truncate long clauses
                "page_start": c.page_start,
                "page_end": c.page_end,
            }
            for c in batch
        ]
        
        task = Task(
            description=f"Classify these {len(batch)} contract clauses:\n{json.dumps(input_json, indent=2)}",
            expected_output="JSON array of classifications matching the schema",
            agent=classifier_agent,
        )
        
        crew = Crew(agents=[classifier_agent], tasks=[task], verbose=True)
        result = crew.kickoff()
        
        # Parse JSON from CrewAI result (string)
        try:
            classifications = json.loads(str(result).strip())
        except json.JSONDecodeError as e:
            print(f"JSON parse error on batch {i//batch_size}: {e}")
            print(f"Raw output: {result}")
            continue
        
        # Map back to Pydantic models
        for cls_data in classifications:
            # Find original clause for metadata
            orig = next(c for c in batch if c.clause_id == cls_data["clause_id"])
            all_results.append(ClassifiedClause(
                **orig.model_dump(),
                category=RiskCategory(cls_data["category"]),
                severity=Severity(cls_data["severity"]),
                rationale=cls_data["rationale"],
                redline_suggestion=cls_data.get("redline_suggestion"),
            ))
    
    return all_results

Test the classifier on one contract:

python -c "
from agents.parser import ContractParser
from agents.classifier import classify_clauses
from settings import settings

parser = ContractParser()
clauses = parser.extract_clauses(list(settings.contracts_dir.glob('*.pdf'))[0])
classified = classify_clauses(clauses)
for c in classified:
    print(f'{c.clause_id} [{c.severity.value}] {c.category.value}: {c.rationale[:80]}...')
"

Step 5: Build the reviewer agent for human-readable reports

The reviewer synthesizes classified clauses into an executive summary, flags critical items, and produces a markdown report your legal team can drop into a ticket or Notion page.

# agents/reviewer.py
from crewai import Agent, Task, Crew
from models import ClassifiedClause, ContractAnalysis, Severity
from settings import settings
import json
from datetime import datetime

llm = LLM(
    model=settings.llm_model,
    base_url=settings.llm_base_url,
    temperature=0.2,  # slightly higher for synthesis
)

reviewer_agent = Agent(
    role="Senior Legal Reviewer",
    goal="Produce a concise risk summary and actionable report for legal counsel",
    backstory="You summarize contract risk for GCs and CFOs. You write crisp, executive-ready memos.",
    llm=llm,
    verbose=True,
)

REVIEWER_PROMPT = """You are writing a contract risk memo for the General Counsel.

Input: JSON array of classified clauses with category, severity, rationale, and optional redline.

Produce a markdown report with:
1. **Executive Summary** (3-4 sentences: overall risk posture, top concerns)
2. **Critical & High Risk Findings** (table: Clause ID | Category | Severity | Key Issue | Suggested Action)
3. **Medium Risk Items** (bullet list grouped by category)
4. **Low Risk / Standard** (one line: count only)
5. **Negotiation Priorities** (ranked 1-3 with specific clause references)

Be specific. Reference clause IDs. No fluff.
"""

def generate_report(contract_id: str, filename: str, classified: list[ClassifiedClause]) -> ContractAnalysis:
    # Prepare compact input for LLM
    input_data = [
        {
            "clause_id": c.clause_id,
            "category": c.category.value,
            "severity": c.severity.value,
            "heading": c.heading,
            "rationale": c.rationale,
            "redline_suggestion": c.redline_suggestion,
        }
        for c in classified
    ]
    
    task = Task(
        description=f"Contract: {filename}\nClassified clauses:\n{json.dumps(input_data, indent=2)}",
        expected_output="Markdown risk memo per the template",
        agent=reviewer_agent,
    )
    
    crew = Crew(agents=[reviewer_agent], tasks=[task], verbose=True)
    markdown_report = str(crew.kickoff())
    
    # Compute summary stats
    high_risk = sum(1 for c in classified if c.severity == Severity.HIGH)
    critical_risk = sum(1 for c in classified if c.severity == Severity.CRITICAL)
    
    # Extract a one-paragraph summary from the report (first paragraph after executive summary)
    summary_lines = []
    in_summary = False
    for line in markdown_report.split('\n'):
        if line.startswith('## Executive Summary') or line.startswith('# Executive Summary'):
            in_summary = True
            continue
        if in_summary and line.startswith('##'):
            break
        if in_summary and line.strip():
            summary_lines.append(line.strip())
    
    analysis = ContractAnalysis(
        contract_id=contract_id,
        filename=filename,
        total_clauses=len(classified),
        classified_clauses=classified,
        summary=" ".join(summary_lines)[:500] if summary_lines else "See full report",
        high_risk_count=high_risk,
        critical_risk_count=critical_risk,
    )
    
    # Save markdown report
    output_path = settings.output_dir / f"{contract_id}_review.md"
    settings.output_dir.mkdir(parents=True, exist_ok=True)
    output_path.write_text(markdown_report)
    
    # Save structured JSON for downstream systems
    json_path = settings.output_dir / f"{contract_id}_analysis.json"
    json_path.write_text(analysis.model_dump_json(indent=2))
    
    return analysis

Step 6: Wire the pipeline end-to-end

A single orchestration script chains parser → classifier → reviewer for every PDF in the input directory. It also produces a portfolio-level summary CSV for tracking across contracts.

# run_pipeline.py
from pathlib import Path
import csv
from agents.parser import ContractParser
from agents.classifier import classify_clauses
from agents.reviewer import generate_report
from settings import settings

def main():
    parser = ContractParser()
    all_analyses = []
    
    pdf_files = list(settings.contracts_dir.glob("*.pdf"))
    if not pdf_files:
        print(f"No PDFs found in {settings.contracts_dir}")
        return
    
    for pdf_path in pdf_files:
        contract_id = pdf_path.stem
        print(f"\n{'='*60}")
        print(f"Processing: {contract_id}")
        print(f"{'='*60}")
        
        # Step 1: Parse
        clauses = parser.extract_clauses(pdf_path)
        print(f"  Extracted {len(clauses)} clauses")
        
        # Step 2: Classify
        classified = classify_clauses(clauses)
        print(f"  Classified {len(classified)} clauses")
        
        # Step 3: Review & Report
        analysis = generate_report(contract_id, pdf_path.name, classified)
        all_analyses.append(analysis)
        print(f"  Report saved to {settings.output_dir}/{contract_id}_review.md")
        print(f"  Critical: {analysis.critical_risk_count}, High: {analysis.high_risk_count}")
    
    # Portfolio summary CSV
    csv_path = settings.output_dir / "portfolio_summary.csv"
    with open(csv_path, 'w', newline='') as f:
        writer = csv.writer(f)
        writer.writerow([
            "contract_id", "filename", "total_clauses",
            "critical", "high", "medium", "low",
            "top_categories"
        ])
        for a in all_analyses:
            sev_counts = {}
            cat_counts = {}
            for c in a.classified_clauses:
                sev_counts[c.severity.value] = sev_counts.get(c.severity.value, 0) + 1
                cat_counts[c.category.value] = cat_counts.get(c.category.value, 0) + 1
            top_cats = sorted(cat_counts.items(), key=lambda x: -x[1])[:3]
            writer.writerow([
                a.contract_id, a.filename, a.total_clauses,
                sev_counts.get("critical", 0),
                sev_counts.get("high", 0),
                sev_counts.get("medium", 0),
                sev_counts.get("low", 0),
                "; ".join(f"{c}({n})" for c, n in top_cats)
            ])
    
    print(f"\nPortfolio summary: {csv_path}")
    print("Done.")

if __name__ == "__main__":
    main()

Run it:

python run_pipeline.py

Step 7: Verify success and iterate

Open the generated markdown report for one contract. You should see:

  1. Executive Summary — a 3-4 sentence risk posture
  2. Critical & High Risk Findings — a table with clause IDs you can trace back to the PDF
  3. Negotiation Priorities — ranked, specific, actionable

Check the JSON output (output/<contract>_analysis.json) validates against your Pydantic model:

python -c "
from models import ContractAnalysis
import json
with open('output/your_contract_analysis.json') as f:
    data = json.load(f)
analysis = ContractAnalysis(**data)
print(f'Valid: {analysis.contract_id}')
print(f'Clauses: {analysis.total_clauses}')
print(f'Critical: {analysis.critical_risk_count}')
print(f'High: {analysis.high_risk_count}')
for c in analysis.classified_clauses[:3]:
    print(f'  {c.clause_id} [{c.severity.value}] {c.category.value}')
"

Open output/portfolio_summary.csv in Excel or csvlens to compare risk profiles across your contract corpus.

Common failure modes and fixes

Symptom Cause Fix
Classifier returns “UNKNOWN” categories LLM hallucinated taxonomy value Lower temperature to 0.0; add few-shot examples to prompt
Clauses split mid-sentence Regex delimiter too aggressive Tune parser regex; add negative lookahead for common abbreviations
Context window exceeded Batch size too large Reduce batch_size in classifier; truncate clause text at 2000 chars
Missing critical risks Taxonomy incomplete Add domain-specific categories (e.g., auto_renewal, audit_rights)
Redline suggestions too generic Prompt lacks jurisdiction context Inject governing law into prompt; add “assume Delaware law”

Step 8: Extend for production use

Three changes make this pipeline production-ready:

1. Swap Ollama for a hosted gateway — if you route through an OpenAI-compatible endpoint that supports automatic fallback and per-token metering, you get provider diversity without code changes. Update config.yaml:

llm:
  model: "openai/gpt-4o-mini"
  base_url: "https://api.n4n.ai/v1"  # or your gateway
  temperature: 0.1

2. Add caching — wrap the classifier with a disk cache keyed by clause hash. Identical clauses across contracts (standard NDAs, boilerplate) skip the LLM entirely.

# agents/classifier.py (add at top)
import hashlib
import diskcache as dc

cache = dc.Cache("./cache/classifier")

def clause_hash(clause: Clause) -> str:
    return hashlib.sha256(clause.text.encode()).hexdigest()[:16]

# In classify_clauses, before calling crew:
cached = cache.get(clause_hash(c))
if cached:
    all_results.append(ClassifiedClause(**cached))
    continue
# ... after classification ...
cache.set(clause_hash(c), cls_data)

3. Human-in-the-loop queue — export critical/high findings to a review tool (Linear, Jira, Notion) with clause text, page reference, and suggested redline. The JSON output already has everything needed.

# agents/escalation.py
import requests
from models import ClassifiedClause, Severity

def escalate_to_linear(classified: list[ClassifiedClause], contract_id: str, api_key: str, team_id: str):
    critical_high = [c for c in classified if c.severity in (Severity.CRITICAL, Severity.HIGH)]
    for c in critical_high:
        payload = {
            "title": f"[{c.severity.value.upper()}] {contract_id}: {c.category.value} - {c.clause_id}",
            "description": f"**Clause:** {c.clause_id} (pp. {c.page_start}-{c.page_end})\n"
                          f"**Category:** {c.category.value}\n"
                          f"**Issue:** {c.rationale}\n"
                          f"**Text:**\n```\n&#123;c.text[:1500]&#125;\n```\n"
                          f"**Suggested Redline:** {c.redline_suggestion or 'TBD'}",
            "teamId": team_id,
            "labels": ["legal-review", c.category.value, c.severity.value],
        }
        requests.post(
            "https://api.linear.app/graphql",
            json={"query": "mutation IssueCreate($input: IssueCreateInput!) { issueCreate(input: $input) { success } }", "variables": {"input": payload}},
            headers={"Authorization": api_key, "Content-Type": "application/json"},
        )

This pipeline gives you a repeatable, auditable contract review process. The parser is deterministic, the classifier is constrained by a fixed taxonomy, and the reviewer produces artifacts your legal team can act on immediately. Swap the LLM backend, extend the taxonomy, or hook the escalation path — the structure holds.

Tagscrewaireal-world-exampleslegaluse-case

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All crewai real-world crew examples posts →