n4nAI

CrewAI real-world example: financial report summarization

Build a CrewAI financial report summarization pipeline with agents for extraction, analysis, and narrative generation — runnable code included.

n4n Team4 min read910 words

Audio narration

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

This crewai financial report summarization example shows you how to build a multi-agent pipeline that ingests earnings releases, extracts key metrics, compares them against consensus estimates, and produces a structured summary memo. You will wire together three specialized agents — an extractor, an analyst, and a writer — using CrewAI’s task delegation and context passing. The complete runnable code is included so you can adapt it to your own document formats and output requirements.

Step 1: Set up the environment and dependencies

Create a fresh virtual environment and install the minimal set of packages. CrewAI sits on top of LangChain, so you need an LLM provider configured. This example uses OpenAI-compatible endpoints; swap the base URL and model name for your preferred provider.

python -m venv .venv
source .venv/bin/activate
pip install "crewai[tools]" langchain-openai python-dotenv pypdf pandas

Create a .env file with your credentials. If you route through a gateway that normalizes 240+ models behind one OpenAI-compatible endpoint, you only need one base URL and key.

# .env
OPENAI_API_KEY=sk-...
OPENAI_API_BASE=https://api.your-gateway.example/v1
MODEL_NAME=gpt-4o-mini

Verify the environment loads correctly:

# test_env.py
import os
from dotenv import load_dotenv
from langchain_openai import ChatOpenAI

load_dotenv()
llm = ChatOpenAI(
    model=os.getenv("MODEL_NAME", "gpt-4o-mini"),
    api_key=os.getenv("OPENAI_API_KEY"),
    base_url=os.getenv("OPENAI_API_BASE"),
    temperature=0.1,
)
print(llm.invoke("Reply with OK if you see this.").content)

Run python test_env.py — you should see OK.

Step 2: Define the document ingestion utility

Financial reports arrive as PDFs, HTML, or raw text. Build a small loader that normalizes everything to plain text so the extractor agent receives consistent input. Keep it dependency-light; pypdf handles most earnings PDFs.

# ingestion.py
from pathlib import Path
from pypdf import PdfReader

def load_text(source: str) -> str:
    """
    Accept a file path or raw text. Return plain text.
    """
    path = Path(source)
    if path.exists() and path.suffix.lower() == ".pdf":
        reader = PdfReader(str(path))
        return "\n".join(page.extract_text() or "" for page in reader.pages)
    return source  # assume already text

Test it with a sample earnings PDF (download any 10-Q or press release):

python -c "from ingestion import load_text; print(load_text('sample_earnings.pdf')[:500])"

Step 3: Create the extractor agent and task

The extractor pulls structured fields from the raw text: revenue, EPS, guidance, segment breakdown, and any non-GAAP adjustments. Define the expected schema first so the agent’s output is parseable.

# schema.py
from pydantic import BaseModel, Field
from typing import Optional, List

class SegmentMetric(BaseModel):
    name: str
    revenue: Optional[float] = None
    yoy_change_pct: Optional[float] = None

class ExtractedFinancials(BaseModel):
    period: str = Field(description="Fiscal period, e.g., 'Q3 FY2024'")
    revenue: Optional[float] = Field(description="Total revenue in millions USD")
    eps_gaap: Optional[float] = Field(description="GAAP EPS")
    eps_non_gaap: Optional[float] = Field(description="Non-GAAP EPS")
    guidance_next_quarter_revenue: Optional[str] = None
    guidance_fy_revenue: Optional[str] = None
    segments: List[SegmentMetric] = []
    key_notes: List[str] = []

Now the agent. Use a low temperature and a system prompt that forces JSON output matching the schema.

# agents.py
from crewai import Agent, Task, Crew, Process
from langchain_openai import ChatOpenAI
from schema import ExtractedFinancials
import os, json

def make_llm():
    return ChatOpenAI(
        model=os.getenv("MODEL_NAME", "gpt-4o-mini"),
        api_key=os.getenv("OPENAI_API_KEY"),
        base_url=os.getenv("OPENAI_API_BASE"),
        temperature=0.1,
    )

extractor = Agent(
    role="Financial Data Extractor",
    goal="Extract precise financial metrics from earnings releases as structured JSON",
    backstory=(
        "You are a meticulous equity research associate. "
        "You read earnings press releases and 8-K filings and pull out every "
        "quantitative metric: revenue, EPS (GAAP and non-GAAP), segment revenue, "
        "guidance ranges, and notable one-time items. You never hallucinate numbers. "
        "If a value is not explicitly stated, you omit it."
    ),
    llm=make_llm(),
    allow_delegation=False,
    verbose=True,
)

extract_task = Task(
    description=(
        "Read the provided earnings release text and extract all financial metrics "
        "into the JSON schema. Return ONLY valid JSON matching the schema. "
        "Text:\n{report_text}"
    ),
    expected_output="Valid JSON matching the ExtractedFinancials schema",
    agent=extractor,
    output_json=ExtractedFinancials,
)

Step 4: Create the analyst agent and task

The analyst takes the extracted metrics and adds context: year-over-year growth rates, beat/miss versus consensus, margin trends, and flag items for the writer. This agent needs access to consensus estimates — either passed in as context or fetched from a data vendor. For this example, we pass consensus as a simple dict in the task context.

# agents.py (continued)
from typing import Dict, Any

class AnalystOutput(BaseModel):
    revenue_yoy_pct: Optional[float] = None
    eps_gaap_yoy_pct: Optional[float] = None
    revenue_vs_consensus_pct: Optional[float] = None
    eps_vs_consensus_pct: Optional[float] = None
    gross_margin_trend: Optional[str] = None
    operating_margin_trend: Optional[str] = None
    flags: List[str] = []
    narrative_bullets: List[str] = []

analyst = Agent(
    role="Equity Research Analyst",
    goal="Analyze extracted financials against consensus and historical trends, produce structured insights",
    backstory=(
        "You are a senior analyst covering this sector. You take raw extracted metrics, "
        "compare them to consensus estimates (provided in context), compute growth rates, "
        "assess margin trajectory, and flag anything unusual — one-time charges, "
        "guidance changes, segment inflections. Your output is structured for a writer."
    ),
    llm=make_llm(),
    allow_delegation=False,
    verbose=True,
)

analyze_task = Task(
    description=(
        "You receive extracted financials (JSON) and consensus estimates (JSON) in context. "
        "Compute year-over-year growth for revenue and EPS. Calculate beat/miss percentages "
        "versus consensus. Assess margin trends if enough data exists. List 3-5 flags and "
        "3-5 narrative bullets for the summary memo. Return ONLY valid JSON matching AnalystOutput."
    ),
    expected_output="Valid JSON matching the AnalystOutput schema",
    agent=analyst,
    output_json=AnalystOutput,
    context={"consensus": {}},  # populated at runtime
)

Step 5: Create the writer agent and task

The writer consumes the analyst’s structured insights and produces the final deliverable: a one-page memo with headline, key metrics table, commentary, and flags. Give it a style guide so output is consistent across runs.

# agents.py (continued)
writer = Agent(
    role="Research Memo Writer",
    goal="Produce a concise, professional equity research summary memo",
    backstory=(
        "You write the morning note that portfolio managers read before the market opens. "
        "Style: crisp, scannable, no fluff. Structure: Headline, Key Metrics table, "
        "Commentary (3-4 short paragraphs), Flags (bulleted). Use plain text with markdown tables."
    ),
    llm=make_llm(),
    allow_delegation=False,
    verbose=True,
)

write_task = Task(
    description=(
        "Using the analyst's structured output (JSON) and the original extracted financials (JSON), "
        "write a one-page research memo. Include:\n"
        "1. Headline: Company — Period — Beat/Miss/Inline\n"
        "2. Key Metrics table: Period, Revenue, YoY%, EPS GAAP, EPS Non-GAAP, Consensus Rev, Consensus EPS\n"
        "3. Commentary: 3-4 paragraphs covering top-line, profitability, guidance, segments\n"
        "4. Flags: bulleted list of items requiring attention\n"
        "Return plain text with markdown formatting."
    ),
    expected_output="Formatted research memo in markdown",
    agent=writer,
    context={"extracted": {}, "analyst": {}},  # populated at runtime
)

Step 6: Wire the crew and run the pipeline

CrewAI’s Process.sequential passes each task’s output to the next via context. You need a small orchestration script that loads the document, injects consensus estimates, kicks off the crew, and saves the memo.

# run_crew.py
import json
import os
from pathlib import Path
from crewai import Crew, Process
from ingestion import load_text
from agents import (
    extractor, extract_task,
    analyst, analyze_task,
    writer, write_task,
)

# --- Configuration ---
REPORT_PATH = "sample_earnings.pdf"  # or path to your PDF/text
CONSENSUS = {
    "revenue": 89200,      # in millions
    "eps_gaap": 1.42,
    "eps_non_gaap": 1.55,
}
OUTPUT_PATH = "memo.md"
# ---------------------

def main():
    # 1. Load document
    report_text = load_text(REPORT_PATH)
    if not report_text.strip():
        raise ValueError("Empty report text")

    # 2. Build crew with dynamic context injection
    # We'll mutate task contexts at runtime
    extract_task.context = {"report_text": report_text}
    analyze_task.context = {"consensus": CONSENSUS}
    # writer context will be filled by crew automatically from previous outputs

    crew = Crew(
        agents=[extractor, analyst, writer],
        tasks=[extract_task, analyze_task, write_task],
        process=Process.sequential,
        verbose=True,
    )

    # 3. Execute
    result = crew.kickoff()

    # 4. Save memo
    Path(OUTPUT_PATH).write_text(str(result))
    print(f"\nMemo written to {OUTPUT_PATH}")
    print("--- MEMO PREVIEW ---")
    print(str(result)[:1500])

if __name__ == "__main__":
    main()

Run it:

python run_crew.py

You should see the three agents execute in sequence, with verbose logs showing each agent’s reasoning. The final memo appears in memo.md.

Step 7: Verify success and iterate

Open memo.md and check for:

  1. Headline contains company, period, and beat/miss call
  2. Key Metrics table aligns with extracted numbers and consensus
  3. Commentary references specific segments, margins, guidance
  4. Flags surface actionable items (e.g., “Guidance lowered”, “One-time tax benefit inflated GAAP EPS”)

If any section is weak, adjust the corresponding agent’s backstory or task description. Common fixes:

  • Extractor misses a segment → add “Pay special attention to segment reporting tables” to extractor backstory
  • Analyst computes wrong YoY → provide prior-period numbers in context
  • Writer uses flowery language → tighten the style guide in writer backstory

Adding prior-period context for YoY calculations

The analyst needs prior-period data to compute growth rates. Extend the ingestion step to also load the year-ago release, or pass a small dict of historicals in analyze_task.context:

HISTORICALS = {
    "revenue_prior_yoy": 84500,
    "eps_gaap_prior_yoy": 1.31,
}
analyze_task.context = {"consensus": CONSENSUS, "historicals": HISTORICALS}

Update the analyst task description to reference historicals.

Step 8: Production hardening notes

This pipeline works for ad-hoc analysis. To run it repeatedly in production, consider:

Idempotency and caching — Hash the input PDF and skip re-extraction if the hash matches a stored result. Store extracted JSON alongside the source.

Structured logging — Replace verbose=True with a custom callback that emits JSON logs per agent step. This lets you trace latency and token usage per agent.

Error handling — Wrap crew.kickoff() in a retry loop with exponential backoff. Validate each agent’s JSON output against its Pydantic model before passing downstream; if validation fails, re-prompt the same agent once with the validation error.

Model routing — If you use a gateway that honors client routing directives, you can send the extractor to a cheaper model (e.g., gpt-4o-mini) and the writer to a higher-quality model (gpt-4o) by constructing separate ChatOpenAI instances per agent.

Evaluation — Build a small golden set of 10-20 earnings releases with human-written memos. Score each run on metric extraction accuracy (F1 on field-level), beat/miss classification accuracy, and memo quality (LLM-as-judge or human review). Track regression over model upgrades.

Step 9: Extending the pattern

The three-agent pattern — extract, analyze, write — generalizes to other document types:

Domain Extractor schema Analyst context Writer output
M&A announcements Deal terms, valuation multiples, synergies Precedent comps, accretion/dilution Deal memo
Credit agreements Covenants, baskets, maturity wall Peer leverage, rating agency criteria Credit review
ESG reports Scope 1/2/3 emissions, targets, frameworks Sector benchmarks, regulatory deadlines ESG summary

Swap the Pydantic models, backstories, and task descriptions. The orchestration skeleton stays identical.


You now have a working crewai financial report summarization example that you can drop into a scheduled job, wrap in an API, or extend with additional agents (e.g., a “risk flagger” that runs in parallel with the analyst). The key architectural decision is keeping each agent single-purpose and passing structured JSON between them — this makes the system debuggable, testable, and model-agnostic.

Tagscrewaireal-world-examplesfinancesummarization

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 →