Building a Multi-Agent Invoice Approval Crew with CrewAI: A Step-by-Step Guide

Table of Contents

  1. What We're Building
  2. What Is CrewAI? A Quick Primer for Beginners
  3. How This Project Started: Studio vs Code
  4. Project Structure Tour
  5. Step 1 — Scaffold the Project
  6. Step 2 — Define the Agents
  7. Step 3 — Define the Tasks
  8. Step 4 — Wire Agents and Tasks Together in the Crew
  9. Step 5 — Give Agents Tools to Read Invoices and CSVs
  10. Step 6 — Fix the Embedding Configuration Gotcha
  11. Step 7 — Feed the Crew Real Inputs
  12. Step 8 — Run the Crew Locally
  13. Step 9 — Trace a Full Run End to End
  14. Common Errors and How I Fixed Them
  15. Key Takeaways

1. What We're Building

An invoice processing and approval crew — a small team of four AI agents that hand work to each other in sequence, the same way a real accounts-payable department would:

PDF Invoice + PO CSV + Approval Thresholds
        │
        ▼
┌─────────────────────────────────────────────────────────────┐
│ Agent 1: Invoice Data Extraction Specialist                  │
│   Reads the invoice PDF, pulls out vendor, line items, total │
└───────────────────────┬───────────────────────────────────┘
                        ▼
┌─────────────────────────────────────────────────────────────┐
│ Agent 2: Purchase Order Validation Specialist                │
│   Cross-checks extracted data against the PO CSV             │
│   → APPROVED / FLAGGED / REJECTED                             │
└───────────────────────┬───────────────────────────────────┘
                        ▼
┌─────────────────────────────────────────────────────────────┐
│ Agent 3: Approval Workflow Coordinator                       │
│   Routes to Team Lead / Finance Manager / CFO by threshold   │
└───────────────────────┬───────────────────────────────────┘
                        ▼
┌─────────────────────────────────────────────────────────────┐
│ Agent 4: Financial Report Specialist                         │
│   Compiles everything into one markdown bookkeeping report   │
└─────────────────────────────────────────────────────────────┘

No custom orchestration code, no hand-rolled prompt chains — this entire pipeline is declared in two YAML files and about 200 lines of Python glue, using CrewAI, an open-source Python framework for building multi-agent systems.

I'm writing this for a reader who has never touched CrewAI before. Every concept gets introduced before it's used, and every code snippet is followed by an explanation of why it's written that way — with the exact file and path it lives in, so you can open the real project alongside this article.


2. What Is CrewAI? A Quick Primer for Beginners

CrewAI is a Python framework for building teams of LLM-powered agents that collaborate on a goal. Four ideas make up almost everything you need to know:

ConceptWhat it isAnalogy
AgentAn LLM configured with a role, a goal, a backstory, and optionally toolsAn employee with a job title and a toolbox
TaskOne unit of work, with a description and an expected outputA ticket assigned to one employee
ToolA Python function/class an agent can call (search a PDF, read a file, hit an API)The employee's equipment
CrewThe agents + tasks + an execution order, wrapped into one runnable objectThe whole department
┌───────────────────────────────────────────────────────┐
│                        Crew                            │
│                                                         │
│   Agent A ──Task 1──▶ Agent B ──Task 2──▶ Agent C      │
│                                                         │
│   process = Process.sequential   (or .hierarchical)    │
└───────────────────────────────────────────────────────┘

Two things make CrewAI approachable if you already know a bit of Python:

  • Agents don't share one giant prompt. Each agent only sees its own role/goal/backstory plus the specific task description it was handed — CrewAI builds the underlying prompt for you.
  • Tasks can depend on each other. A task can declare context: [previous_task], and CrewAI automatically injects the previous task's output into the next agent's prompt. That's the entire mechanism behind the 4-stage pipeline above — no manual string concatenation required.

CrewAI also ships a project convention: instead of writing Python dictionaries for every agent and task, you describe them in two YAML files (agents.yaml, tasks.yaml) and reference them from a Python class decorated with @CrewBase. That's the pattern this whole project follows, and it's what the rest of this guide walks through.


3. How This Project Started: Studio vs Code

There are two ways to build a CrewAI project:

  1. The CLI, crewai create crew <name>, which scaffolds the YAML + Python files locally.
  2. CrewAI's hosted Studio at app.crewai.com, a visual builder where you describe agents and tasks in a UI, and it generates the same project structure for you — which you then download or sync locally.

I built the first version of this crew in the Studio, describing the four roles and their goals in plain English. Studio generated the full project — agents.yaml, tasks.yaml, crew.py, main.py, pyproject.toml — ready to download as a normal Python project. From there, everything in this guide is exactly the same regardless of which path you took to get the files: the YAML and Python underneath is identical, and both the CLI and Studio ultimately produce a project you run and edit locally.

One consequence of using Studio worth knowing upfront: it generates placeholder configuration blocks for tools that need extra setup (like embedding config for RAG-style tools). Step 6 covers a real validation error this caused and how I fixed it.


4. Project Structure Tour

Here's the full project layout, generated by Studio/CLI and then customized:

invoice_processing_approval_crew_v1_crewai-project/
├── pyproject.toml                 # dependencies + CLI script entrypoints
├── uv.lock                        # locked dependency versions
├── knowledge/
│   └── user_preference.txt        # optional static knowledge file (unused by this crew)
└── src/invoice_processing_approval_crew/
    ├── main.py                    # entrypoints: run / train / replay / test
    ├── crew.py                    # defines agents, tasks, and the crew itself
    ├── config/
    │   ├── agents.yaml            # 4 agent definitions (role/goal/backstory)
    │   └── tasks.yaml             # 4 task definitions (description/expected_output)
    └── tools/
        └── custom_tool.py         # scaffolded example tool (not used in this crew)

The rule of thumb: YAML describes what the agents and tasks are; crew.py describes how they're built (which LLM, which tools, which process). This separation is deliberate — a non-engineer (or an SME) can tweak an agent's goal or a task's instructions in the YAML without touching Python at all.


5. Step 1 — Scaffold the Project

If you were doing this from the CLI instead of Studio, this is the one command that creates everything in the structure above:

pip install uv
uv tool install crewai
crewai create crew invoice_processing_approval_crew

This generates the skeleton with placeholder agents/tasks and a pyproject.toml already wired with the CrewAI dependency:

# pyproject.toml
[project]
name = "invoice_processing_approval_crew"
version = "0.1.0"
requires-python = ">=3.10,<3.14"
dependencies = [
    "crewai[file-processing,litellm,tools]>=1.15.3,<2.0.0",
]

[project.scripts]
invoice_processing_approval_crew = "invoice_processing_approval_crew.main:run"
run_crew = "invoice_processing_approval_crew.main:run"
train = "invoice_processing_approval_crew.main:train"
replay = "invoice_processing_approval_crew.main:replay"
test = "invoice_processing_approval_crew.main:test"

The [project.scripts] block is what lets you later run uv run run_crew from anywhere in the project instead of remembering a Python module path.

From here, everything is customization: replace the placeholder agents and tasks with the four real ones for invoice processing.


6. Step 2 — Define the Agents

Agents live in src/invoice_processing_approval_crew/config/agents.yaml. Each agent needs three things: a role (job title), a goal (what it's trying to accomplish this run), and a backstory (context that shapes how it approaches the goal — this becomes part of the system prompt CrewAI builds for it).

# src/invoice_processing_approval_crew/config/agents.yaml
invoice_data_extraction_specialist:
  role: Invoice Data Extraction Specialist
  goal: Extract all structured data from the invoice file at {invoice_file_path},
    including vendor name, invoice number, invoice date, due date, line items
    (description, quantity, unit price), subtotal, taxes, and total amount.
  backstory: You are an expert at reading and parsing invoices in any format —
    PDFs, scanned images, and digital documents. You use OCR and semantic search
    to extract every relevant field from invoices with high accuracy, outputting
    clean structured data for downstream processing.

purchase_order_validation_specialist:
  role: Purchase Order Validation Specialist
  goal: Cross-reference extracted invoice data against purchase orders in
    {po_csv_path} to identify discrepancies such as price mismatches, quantity
    mismatches, missing PO references, and unauthorized vendors.
  backstory: You are a meticulous accounts payable auditor with years of
    experience validating invoices against purchase orders...

Two things worth noticing immediately, because they trip up first-time CrewAI users:

1. {invoice_file_path} is a template variable, not a typo. CrewAI uses simple {curly_brace} interpolation across both YAML files. Whatever dictionary you pass to crew().kickoff(inputs={...}) gets substituted into every matching placeholder in every agent's goal and every task's description. This is how the same crew definition works on invoice #1 today and invoice #4,000 next month — nothing is hardcoded.

2. The role is not decorative. It becomes part of the actual system prompt CrewAI sends to the LLM (roughly: "You are {role}. Your goal is {goal}. {backstory}"), so specific, well-written roles and backstories measurably change how well the agent performs — this is the main lever you have over agent behavior before you even touch code or prompts directly.

The full file defines all four agents this way — I won't repeat every one here, but the pattern (role → goal with placeholders → backstory) is identical for the approval_workflow_coordinator and financial_report_specialist agents too.


7. Step 3 — Define the Tasks

Tasks live in src/invoice_processing_approval_crew/config/tasks.yaml. Each task needs a description (the actual instructions for the assigned agent), an expected_output (what a correct answer looks like — this constrains the agent's final answer format), an agent (who does it), and optionally a context (which earlier tasks' outputs to hand over).

# src/invoice_processing_approval_crew/config/tasks.yaml
extract_invoice_data:
  description: |-
    Read the invoice file at {invoice_file_path} using OCR (for images) and PDF
    search (for PDFs). Extract every relevant field from the document:
    - Vendor name and contact details
    - Invoice number and reference
    - Invoice date and due date
    - Line items: description, quantity, unit price, line total
    - Subtotal, taxes (type and rate), and total amount
    - Any PO number referenced on the invoice

    Use OCR first if the file is an image, then use PDF search for structured PDFs.
  expected_output: 'A structured JSON-like summary containing all extracted
    invoice fields...'
  agent: invoice_data_extraction_specialist

validate_against_purchase_orders:
  description: |-
    Using the extracted invoice data, cross-reference it against the purchase
    orders CSV at {po_csv_path}.

    Perform the following checks:
    1. PO Reference Match  2. Vendor Match  3. Price Validation (>5% flag)
    4. Quantity Validation  5. Unauthorized Vendors

    Assign an overall status: APPROVED / FLAGGED / REJECTED
  expected_output: 'A validation report listing each check performed...'
  agent: purchase_order_validation_specialist
  context:
    - extract_invoice_data

That context: [extract_invoice_data] line is the entire mechanism that turns four independent tasks into a pipeline. When CrewAI runs validate_against_purchase_orders, it automatically appends the full output of extract_invoice_data into this task's prompt — the agent never has to be told to "look at the previous result," CrewAI does it structurally.

The remaining two tasks, route_approval_decision and generate_bookkeeping_report, follow the same shape — each one declares its predecessor as context, chaining all four into one straight line:

extract_invoice_data
        │ context
        ▼
validate_against_purchase_orders
        │ context
        ▼
route_approval_decision
        │ context
        ▼
generate_bookkeeping_report

8. Step 4 — Wire Agents and Tasks Together in the Crew

YAML describes content; src/invoice_processing_approval_crew/crew.py describes construction. This file uses four decorators from crewai.project:

  • @CrewBase — marks the class as a CrewAI project, giving it access to the parsed YAML via self.agents_config and self.tasks_config.
  • @agent — marks a method that builds and returns one Agent.
  • @task — marks a method that builds and returns one Task.
  • @crew — marks the method that assembles everything into the runnable Crew.
# src/invoice_processing_approval_crew/crew.py
from crewai import LLM, Agent, Crew, Process, Task
from crewai.project import CrewBase, agent, crew, task
from crewai_tools import OCRTool, PDFSearchTool, CSVSearchTool, FileReadTool


@CrewBase
class InvoiceProcessingApprovalCrewCrew:
    """InvoiceProcessingApprovalCrew crew"""

    @agent
    def invoice_data_extraction_specialist(self) -> Agent:
        return Agent(
            config=self.agents_config["invoice_data_extraction_specialist"],
            tools=[OCRTool(), PDFSearchTool(config=embedding_config_pdfsearchtool)],
            reasoning=False,
            inject_date=True,
            allow_delegation=False,
            max_iter=25,
            llm=LLM(model="openai/gpt-4o-mini"),
        )

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

    @crew
    def crew(self) -> Crew:
        return Crew(
            agents=self.agents,   # every @agent-decorated method, auto-collected
            tasks=self.tasks,     # every @task-decorated method, auto-collected
            process=Process.sequential,
            verbose=True,
        )

Agent(config=self.agents_config["invoice_data_extraction_specialist"], ...) is the line that connects an agent's Python object back to its YAML definition — config= unpacks the role/goal/backstory from the YAML dict, and everything else (tools=, llm=, max_iter=, etc.) is Python-only configuration that doesn't belong in YAML because it's about how the agent runs, not what its job is.

process=Process.sequential is what makes CrewAI execute the four tasks strictly in the order they're returned by self.tasks — task 1 finishes completely before task 2 starts. CrewAI also supports Process.hierarchical, where a manager LLM dynamically delegates tasks to agents instead of running a fixed order — overkill for a 4-stage linear pipeline like this one, but worth knowing it exists for more dynamic workflows.

max_iter=25 caps how many think→act loops one agent can run on a single task before giving up — a safety valve against infinite tool-calling loops. inject_date=True automatically adds today's date to the agent's context, which is why every run in this project shows a "Current Date" line without any of the YAML mentioning it.


9. Step 5 — Give Agents Tools to Read Invoices and CSVs

Two agents in this crew need tools — ways to reach outside the LLM and touch real files:

tools=[
    OCRTool(),
    PDFSearchTool(config=embedding_config_pdfsearchtool),
]
tools=[
    CSVSearchTool(config=embedding_config_csvsearchtool),
    FileReadTool(),
]
ToolUsed byWhat it does
OCRToolExtraction agentSends an image to a vision-capable LLM and returns recognized text
PDFSearchToolExtraction agentBuilds a small RAG index over a PDF's content and lets the agent semantically search it
CSVSearchToolValidation agentSame RAG-search idea, applied to rows in a CSV
FileReadToolValidation agentReads a file's raw text content directly, no search involved

PDFSearchTool and CSVSearchTool are the interesting ones — under the hood, both are small RAG (Retrieval-Augmented Generation) tools: they chunk the document, embed each chunk into a vector, and let the agent run semantic queries like "find the vendor name" against those vectors instead of dumping the entire raw file into the prompt. That's powerful for large documents, but it means both tools need an embedding model configured — which is exactly the gap Studio leaves as a placeholder, covered next.


10. Step 6 — Fix the Embedding Configuration Gotcha

This is a real error I hit after building this crew in Studio and trying to validate it on app.crewai.com:

Project validation
The following issues must be resolved before going forward.

Configuration Issues (2)
  Tool PDFSearchTool requires embedding configuration
  Tool CSVSearchTool requires embedding configuration

The cause was sitting right in crew.py. Studio scaffolds an embedding config dict for every RAG-style tool, but leaves the actual provider and model empty:

# before — src/invoice_processing_approval_crew/crew.py
embedding_config_pdfsearchtool = dict(
    embedding_model=dict(
        provider="",
        config=dict(
            model="",
        ),
    ),
)

An empty provider/model means the tool has no way to know which embedding model to call when it indexes the PDF — so CrewAI's platform validator correctly refuses to let the project run. The fix is to point both tools at a real embedding model:

# after — src/invoice_processing_approval_crew/crew.py
embedding_config_pdfsearchtool = dict(
    embedding_model=dict(
        provider="openai",
        config=dict(
            model="text-embedding-3-small",
        ),
    ),
)

The same change applies to embedding_config_csvsearchtool in the purchase_order_validation_specialist agent method. I used text-embedding-3-small deliberately — it's an OpenAI model, and this project already authenticates with OPENAI_API_KEY for its LLM(model="openai/gpt-4o-mini") calls, so no second API key or provider account is needed.

Lesson for beginners: any CrewAI tool with "Search" in its name (PDFSearchTool, CSVSearchTool, WebsiteSearchTool, DirectorySearchTool, etc.) is doing RAG under the hood and needs an embedding model. Tools that just read raw content (FileReadTool) don't — there's nothing to embed if nothing gets chunked and searched.


11. Step 7 — Feed the Crew Real Inputs

src/invoice_processing_approval_crew/main.py is the only file meant to be edited every time you want to run the crew against a different invoice. It builds the inputs dictionary that fills in every {placeholder} from Steps 2 and 3:

# src/invoice_processing_approval_crew/main.py
from invoice_processing_approval_crew.crew import InvoiceProcessingApprovalCrewCrew

def run():
    inputs = {
        'invoice_file_path': 'sample_data/sample_invoice.pdf',
        'po_csv_path': 'sample_data/purchase_orders.csv',
        'approval_thresholds': 'Team Lead: up to $1,000; Finance Manager: '
                                '$1,000-$10,000; CFO: above $10,000',
    }
    InvoiceProcessingApprovalCrewCrew().crew().kickoff(inputs=inputs)

kickoff(inputs=inputs) is the single call that starts the whole pipeline: it substitutes these three values into every agent goal and task description that references them, then runs the sequential process from Step 8's Process.sequential all the way through the four tasks.


12. Step 8 — Run the Crew Locally

With the code in place, running it locally is three commands:

cd invoice_processing_approval_crew_v1_crewai-project

# 1. Add your API key (used for both the LLM and the embeddings from Step 6)
echo "OPENAI_API_KEY=sk-your-key-here" > .env

# 2. Install locked dependencies
uv sync

# 3. Run the crew
uv run run_crew

uv run run_crew works because of the [project.scripts] entry in pyproject.toml from Step 1 — run_crew = "invoice_processing_approval_crew.main:run" — so uv resolves that name straight to the run() function shown in Step 7, no need to remember the full module path.


13. Step 9 — Trace a Full Run End to End

Here's what actually happens, stage by stage, from a real local run against a sample invoice and PO CSV.

Stage 1 — Extraction. The agent tries OCRTool first (its task description literally says "use OCR first if the file is an image"), which correctly fails — the file is a PDF, not an image:

ERROR: OpenAI API call failed: invalid_image_format

CrewAI's agent loop treats a failed tool call as an observation, not a crash — the agent reads the error, reasons that OCR was the wrong choice, and retries with PDFSearchTool instead, which succeeds and returns the invoice's text content. This retry-and-recover behavior is the ReAct loop (Reason → Act → Observe) that underlies every CrewAI agent, and it's why one bad tool call doesn't end the whole run.

Stage 2 — Validation. The agent calls search_a_csvs_content (from CSVSearchTool) with the PO number extracted in Stage 1, gets back the matching PO row, and cross-checks vendor/price/quantity against it — this is the context: [extract_invoice_data] link from Step 3 in action, since the extracted data is already sitting in this agent's prompt before it even calls a tool.

Stage 3 — Routing. The Approval Workflow Coordinator applies the {approval_thresholds} string from Step 7 against the invoice total and the APPROVED/FLAGGED/REJECTED status from Stage 2, and picks a manager level.

Stage 4 — Reporting. The Financial Report Specialist has no tools at all — its task only needs the accumulated context from the three previous tasks (via its own context: chain in tasks.yaml) to compile the final markdown report.

extract_invoice_data → validate_against_purchase_orders
      → route_approval_decision → generate_bookkeeping_report

One practical gap I noticed: none of the four tasks in tasks.yaml set an output_file: key, so the final report only exists in terminal scrollback once the run ends. Adding one line fixes that:

generate_bookkeeping_report:
  description: |-
    ...
  expected_output: 'A complete, well-structured bookkeeping report...'
  agent: financial_report_specialist
  context:
    - route_approval_decision
  output_file: report.md

With output_file set, CrewAI writes the task's final answer straight to report.md in the project root after the crew finishes — no need to copy it out of the terminal.


14. Common Errors and How I Fixed Them

ErrorCauseFix
Tool PDFSearchTool requires embedding configurationStudio scaffolds an empty provider/model in the embedding config dictSet provider="openai", model="text-embedding-3-small" — see Step 6
Tool CSVSearchTool requires embedding configurationSame cause, different toolSame fix, applied to embedding_config_csvsearchtool
invalid_image_format from OCRTool on a PDFThe agent's task explicitly told it to "try OCR first," but OCR only accepts image formats (png/jpeg/gif/webp), not PDFNo fix needed — this is expected fallback behavior; the agent recovers by using PDFSearchTool next, per the ReAct loop described in Step 9
Report only visible in terminal outputNo task in tasks.yaml sets output_file:Add output_file: report.md to the final task, as shown in Step 9

15. Key Takeaways

YAML-first design keeps the crew editable without touching Python. Every agent's personality and every task's instructions live in agents.yaml/tasks.yaml — someone on the finance team could tune wording there without ever opening crew.py.

context: is the whole orchestration mechanism. There's no manual prompt-stitching anywhere in this project — declaring context: [previous_task] is the entire API for chaining four agents into one pipeline.

Search-style tools need embeddings; read-style tools don't. PDFSearchTool and CSVSearchTool are RAG under the hood and need a real embedding_model config to pass CrewAI's platform validation. FileReadTool just reads bytes — no embedding step, no config needed.

Failed tool calls aren't fatal. The extraction agent tried OCR on a PDF, got a clean API error back, and recovered by switching tools on its own — a direct demonstration of the Reason → Act → Observe loop that CrewAI agents run internally.

Studio and local code are the same project. Everything generated in app.crewai.com is a normal uv-managed Python project underneath — uv sync + uv run run_crew runs it exactly the same locally as it would in the platform, once the embedding config gap is filled in.


Project source: invoice_processing_approval_crew_v1_crewai-projectcrew.py, config/agents.yaml, config/tasks.yaml, and main.py referenced throughout this guide are the actual files from the working project.