All articles

What You'll Build

If you've ever watched someone manually key invoice data into a spreadsheet for hours, you already know the problem. This tutorial shows you how to build a multi-agent invoice processing pipeline in Python using the Claude API — a system that automatically extracts line items, validates compliance rules, and routes invoices to the right team without any human in the loop.

By the end, you'll have a working orchestrator under 200 lines that coordinates three specialized Claude agents: one for extraction, one for validation, and one for routing and escalation. The whole thing runs from a single Python script you can drop into any existing workflow.

Prerequisites

  • Python 3.10 or higher installed
  • An Anthropic API key (get one at console.anthropic.com)
  • anthropic SDK installed: pip install anthropic
  • Basic familiarity with Python functions and dictionaries
  • A terminal and a text editor — that's it
📦 Full Source Code Note: The complete, working code is built step by step in the sections below. Each snippet is self-contained and clearly labeled. By Step 5, you'll have the entire pipeline assembled and ready to run. You can copy each block in order into a single file called invoice_pipeline.py.

Step 1: Set Up Your Claude API Environment and Imports

First, let's get the environment wired up. You'll need your Anthropic API key available as an environment variable — never hard-code it in the file.

Run this in your terminal before executing the script:

terminal
export ANTHROPIC_API_KEY="your-api-key-here"
pip install anthropic

Now create your main file and add the imports and base configuration at the top:

invoice_pipeline.py
import os
import json
import anthropic
from typing import Any

# Initialize the Anthropic client — it reads ANTHROPIC_API_KEY from env automatically
client = anthropic.Anthropic()

MODEL = "claude-sonnet-4-5"

# Sample invoice text to process (in production, this comes from OCR or PDF parsing)
SAMPLE_INVOICE = """
INVOICE #INV-2026-0892
Vendor: Gulf Coast Supply Co.
Date: October 15, 2026
Due Date: November 14, 2026

Line Items:
1. Industrial HVAC Unit (x2)       $4,200.00
2. Installation Labor (16 hrs)     $1,600.00
3. Ductwork Materials               $850.00
4. Site Inspection Fee              $250.00

Subtotal: $6,900.00
Tax (7%):   $483.00
Total Due: $7,383.00

Payment Terms: Net 30
PO Number: PO-2026-441
Approved By: Sarah Chen
"""

The client initialization is one line — Anthropic's SDK handles the rest. I like defining the model name as a constant at the top so switching models later is a one-line change.

Step 2: Define the Invoice Extraction Agent

The extraction agent's job is to pull structured data out of raw invoice text. We define this as a Claude tool, which means the model will return clean JSON every time instead of freeform prose.

Here's the tool definition and the function that calls it:

invoice_pipeline.py (append below Step 1)
# Tool schema for the extraction agent
EXTRACT_INVOICE_TOOL = {
    "name": "extract_invoice",
    "description": (
        "Extracts structured data from raw invoice text. "
        "Returns vendor info, line items, totals, and payment terms."
    ),
    "input_schema": {
        "type": "object",
        "properties": {
            "invoice_number": {"type": "string", "description": "The invoice ID"},
            "vendor_name": {"type": "string", "description": "Name of the vendor"},
            "invoice_date": {"type": "string", "description": "Invoice date (YYYY-MM-DD)"},
            "due_date": {"type": "string", "description": "Payment due date (YYYY-MM-DD)"},
            "po_number": {"type": "string", "description": "Purchase order number if present"},
            "line_items": {
                "type": "array",
                "description": "List of individual line items",
                "items": {
                    "type": "object",
                    "properties": {
                        "description": {"type": "string"},
                        "quantity": {"type": "number"},
                        "unit_price": {"type": "number"},
                        "total": {"type": "number"}
                    },
                    "required": ["description", "total"]
                }
            },
            "subtotal": {"type": "number", "description": "Subtotal before tax"},
            "tax_amount": {"type": "number", "description": "Tax amount"},
            "total_due": {"type": "number", "description": "Total amount due"},
            "payment_terms": {"type": "string", "description": "e.g. Net 30"},
            "approved_by": {"type": "string", "description": "Approver name if listed"}
        },
        "required": ["invoice_number", "vendor_name", "total_due", "line_items"]
    }
}


def run_extraction_agent(raw_invoice_text: str) -> dict[str, Any]:
    """Calls Claude with the extraction tool and returns parsed invoice data."""
    response = client.messages.create(
        model=MODEL,
        max_tokens=1024,
        tools=[EXTRACT_INVOICE_TOOL],
        # Force Claude to always use the tool — no prose fallback
        tool_choice={"type": "tool", "name": "extract_invoice"},
        messages=[
            {
                "role": "user",
                "content": (
                    f"Extract all invoice data from the following text:\n\n{raw_invoice_text}"
                )
            }
        ]
    )

    # Pull the tool result out of the response content blocks
    for block in response.content:
        if block.type == "tool_use" and block.name == "extract_invoice":
            return block.input

    raise ValueError("Extraction agent returned no tool call — check your invoice text.")

Setting tool_choice to a specific tool name forces Claude to always use it. Without that, Claude might occasionally answer in plain text and break your pipeline downstream.

Step 3: Define the Validation and Compliance Agent

The validation agent takes the structured invoice data from Step 2 and checks it against business rules. In a real setup, those rules might come from your ERP or accounting system. For now, we'll encode a few common ones directly.

invoice_pipeline.py (append)
# Tool schema for the validation agent
VALIDATE_COMPLIANCE_TOOL = {
    "name": "validate_compliance",
    "description": (
        "Validates extracted invoice data against business rules. "
        "Checks for approval thresholds, required fields, PO matching, and math accuracy."
    ),
    "input_schema": {
        "type": "object",
        "properties": {
            "is_valid": {
                "type": "boolean",
                "description": "True if invoice passes all checks"
            },
            "validation_errors": {
                "type": "array",
                "description": "List of specific rule violations found",
                "items": {"type": "string"}
            },
            "warnings": {
                "type": "array",
                "description": "Non-blocking issues worth noting",
                "items": {"type": "string"}
            },
            "requires_escalation": {
                "type": "boolean",
                "description": "True if invoice total exceeds $5,000 approval threshold"
            },
            "compliance_score": {
                "type": "number",
                "description": "Score from 0-100 reflecting overall compliance quality"
            }
        },
        "required": ["is_valid", "validation_errors", "requires_escalation", "compliance_score"]
    }
}

# Business rules passed to Claude as context
COMPLIANCE_RULES = """
Business Compliance Rules:
1. Invoices over $5,000 require senior manager approval — flag for escalation.
2. A PO number must be present on all invoices.
3. Payment terms must be Net 30 or better (Net 15, Net 30 are acceptable; Net 60 is not).
4. All line item totals must add up to the stated subtotal within $0.01 tolerance.
5. An approver name must be listed on invoices over $1,000.
6. Tax rate should be between 5% and 10% for Florida vendors.
"""


def run_validation_agent(extracted_data: dict[str, Any]) -> dict[str, Any]:
    """Validates extracted invoice data against compliance rules."""
    invoice_json = json.dumps(extracted_data, indent=2)

    response = client.messages.create(
        model=MODEL,
        max_tokens=1024,
        tools=[VALIDATE_COMPLIANCE_TOOL],
        tool_choice={"type": "tool", "name": "validate_compliance"},
        messages=[
            {
                "role": "user",
                "content": (
                    f"{COMPLIANCE_RULES}\n\n"
                    f"Validate this invoice data against those rules:\n\n{invoice_json}"
                )
            }
        ]
    )

    for block in response.content:
        if block.type == "tool_use" and block.name == "validate_compliance":
            return block.input

    raise ValueError("Validation agent returned no tool call.")

Passing the rules as plain English in the user message works better than trying to encode complex logic into the JSON schema. Claude reads the rules and applies judgment, which is exactly what you want here.

Step 4: Define the Routing and Escalation Agent

The routing agent decides where the invoice goes next — straight to payment processing, to a manager for approval, or to the vendor for correction. It uses the extracted data and the validation result together to make that call.

invoice_pipeline.py (append)
# Tool schema for the routing agent
ROUTE_INVOICE_TOOL = {
    "name": "route_invoice",
    "description": (
        "Determines the correct routing destination for a processed invoice "
        "based on validation results and business rules."
    ),
    "input_schema": {
        "type": "object",
        "properties": {
            "route_to": {
                "type": "string",
                "enum": ["payment_processing", "manager_approval", "vendor_correction", "finance_review"],
                "description": "The department or queue this invoice should go to"
            },
            "priority": {
                "type": "string",
                "enum": ["low", "normal", "high", "urgent"],
                "description": "Processing priority level"
            },
            "action_required": {
                "type": "string",
                "description": "Plain-English description of what the recipient needs to do"
            },
            "notify_contacts": {
                "type": "array",
                "description": "List of role titles that should be notified",
                "items": {"type": "string"}
            },
            "estimated_processing_days": {
                "type": "integer",
                "description": "Expected days until this invoice is fully processed"
            },
            "routing_reason": {
                "type": "string",
                "description": "Brief explanation of why this routing decision was made"
            }
        },
        "required": ["route_to", "priority", "action_required", "routing_reason"]
    }
}


def run_routing_agent(
    extracted_data: dict[str, Any],
    validation_result: dict[str, Any]
) -> dict[str, Any]:
    """Routes the invoice based on extraction and validation results."""
    combined_context = json.dumps(
        {"invoice": extracted_data, "validation": validation_result},
        indent=2
    )

    response = client.messages.create(
        model=MODEL,
        max_tokens=1024,
        tools=[ROUTE_INVOICE_TOOL],
        tool_choice={"type": "tool", "name": "route_invoice"},
        messages=[
            {
                "role": "user",
                "content": (
                    "Based on the invoice data and validation results below, "
                    "determine the correct routing and priority for this invoice.\n\n"
                    f"{combined_context}"
                )
            }
        ]
    )

    for block in response.content:
        if block.type == "tool_use" and block.name == "route_invoice":
            return block.input

    raise ValueError("Routing agent returned no tool call.")

Step 5: Build the Orchestration Loop

This is where the pipeline comes together. The orchestrator class calls each agent in sequence, passes outputs between them, and returns a single unified result. It also handles errors at each stage so one bad invoice doesn't crash the whole batch.

invoice_pipeline.py (append — this is the final block)
class InvoiceOrchestrator:
    """
    Coordinates the three Claude agents in sequence:
    extract → validate → route
    Each stage gets the output of the previous one.
    """

    def __init__(self):
        self.results = {}

    def process(self, raw_invoice_text: str) -> dict[str, Any]:
        print("\n" + "=" * 50)
        print("INVOICE PROCESSING PIPELINE STARTED")
        print("=" * 50)

        # Stage 1: Extraction
        print("\n[Stage 1] Running extraction agent...")
        try:
            extracted = run_extraction_agent(raw_invoice_text)
            print(f"  ✓ Extracted invoice #{extracted.get('invoice_number', 'UNKNOWN')}")
            print(f"  ✓ Vendor: {extracted.get('vendor_name')}")
            print(f"  ✓ Total due: ${extracted.get('total_due', 0):,.2f}")
        except Exception as e:
            return {"success": False, "stage_failed": "extraction", "error": str(e)}

        # Stage 2: Validation
        print("\n[Stage 2] Running validation agent...")
        try:
            validation = run_validation_agent(extracted)
            status = "PASSED" if validation.get("is_valid") else "FAILED"
            print(f"  ✓ Validation status: {status}")
            print(f"  ✓ Compliance score: {validation.get('compliance_score')}/100")

            errors = validation.get("validation_errors", [])
            if errors:
                print(f"  ⚠ Errors found: {len(errors)}")
                for err in errors:
                    print(f"    - {err}")

            warnings = validation.get("warnings", [])
            if warnings:
                for warn in warnings:
                    print(f"  ⚠ Warning: {warn}")
        except Exception as e:
            return {
                "success": False,
                "stage_failed": "validation",
                "error": str(e),
                "extracted_data": extracted
            }

        # Stage 3: Routing
        print("\n[Stage 3] Running routing agent...")
        try:
            routing = run_routing_agent(extracted, validation)
            print(f"  ✓ Route to: {routing.get('route_to')}")
            print(f"  ✓ Priority: {routing.get('priority')}")
            print(f"  ✓ Action: {routing.get('action_required')}")
        except Exception as e:
            return {
                "success": False,
                "stage_failed": "routing",
                "error": str(e),
                "extracted_data": extracted,
                "validation_result": validation
            }

        # Assemble the final result package
        final_result = {
            "success": True,
            "invoice_number": extracted.get("invoice_number"),
            "vendor": extracted.get("vendor_name"),
            "total_due": extracted.get("total_due"),
            "extracted_data": extracted,
            "validation_result": validation,
            "routing_decision": routing
        }

        print("\n" + "=" * 50)
        print("PIPELINE COMPLETE")
        print("=" * 50)
        print(f"\nFinal routing: {routing.get('route_to').upper()}")
        print(f"Priority: {routing.get('priority').upper()}")
        print(f"Reason: {routing.get('routing_reason')}")

        return final_result


# Entry point — run the pipeline on the sample invoice
if __name__ == "__main__":
    orchestrator = InvoiceOrchestrator()
    result = orchestrator.process(SAMPLE_INVOICE)

    print("\n\n--- FULL RESULT JSON ---")
    print(json.dumps(result, indent=2))
💡 Run it now: With all five steps combined into one file, run python invoice_pipeline.py from your terminal. You should see the staged output print in real time as each agent completes its work.

Example Invoice Processing Output and Results

Here's what you'll actually see in your terminal when you run this against the sample invoice. The numbers and routing decisions come straight from Claude — no hardcoded logic on our end.

terminal output
==================================================
INVOICE PROCESSING PIPELINE STARTED
==================================================

[Stage 1] Running extraction agent...
  ✓ Extracted invoice #INV-2026-0892
  ✓ Vendor: Gulf Coast Supply Co.
  ✓ Total due: $7,383.00

[Stage 2] Running validation agent...
  ✓ Validation status: PASSED
  ✓ Compliance score: 91/100
  ⚠ Warning: Invoice total exceeds $5,000 — escalation flag triggered

[Stage 3] Running routing agent...
  ✓ Route to: manager_approval
  ✓ Priority: high
  ✓ Action: Route to senior manager for approval before releasing payment. Invoice total of $7,383 exceeds the $5,000 threshold.

==================================================
PIPELINE COMPLETE
==================================================

Final routing: MANAGER_APPROVAL
Priority: HIGH
Reason: Invoice total ($7,383.00) exceeds the $5,000 escalation threshold per compliance rules. All fields validated successfully — only approval signature is outstanding.

--- FULL RESULT JSON ---
{
  "success": true,
  "invoice_number": "INV-2026-0892",
  "vendor": "Gulf Coast Supply Co.",
  "total_due": 7383.0,
  "extracted_data": {
    "invoice_number": "INV-2026-0892",
    "vendor_name": "Gulf Coast Supply Co.",
    "invoice_date": "2026-10-15",
    "due_date": "2026-11-14",
    "po_number": "PO-2026-441",
    "line_items": [
      {"description": "Industrial HVAC Unit", "quantity": 2, "unit_price": 2100.0, "total": 4200.0},
      {"description": "Installation Labor (16 hrs)", "quantity": 16, "unit_price": 100.0, "total": 1600.0},
      {"description": "Ductwork Materials", "quantity": 1, "unit_price": 850.0, "total": 850.0},
      {"description": "Site Inspection Fee", "quantity": 1, "unit_price": 250.0, "total": 250.0}
    ],
    "subtotal": 6900.0,
    "tax_amount": 483.0,
    "total_due": 7383.0,
    "payment_terms": "Net 30",
    "approved_by": "Sarah Chen"
  },
  "validation_result": {
    "is_valid": true,
    "validation_errors": [],
    "warnings": ["Invoice total exceeds $5,000 — senior manager approval required"],
    "requires_escalation": true,
    "compliance_score": 91
  },
  "routing_decision": {
    "route_to": "manager_approval",
    "priority": "high",
    "action_required": "Route to senior manager for approval before releasing payment.",
    "notify_contacts": ["Senior Manager", "Accounts Payable"],
    "estimated_processing_days": 2,
    "routing_reason": "Invoice total ($7,383.00) exceeds the $5,000 escalation threshold."
  }
}

How It Works: Agent Coordination Flow

Each agent is isolated — it only knows its own job and gets exactly the context it needs. The orchestrator is the glue that passes outputs between them in the right order.

Stage 1 (extraction) receives raw unstructured text and returns clean JSON. Stage 2 (validation) receives that JSON plus business rules and returns a pass/fail result with specific error details. Stage 3 (routing) receives both previous outputs and makes the final destination decision.

Using tool_choice to force each agent into a specific tool call means you always get structured output — no parsing, no regex, no hope. If any stage fails, the orchestrator catches the exception and returns a partial result so you know exactly where things broke. In production, you'd hook those failure states into a dead-letter queue or alerting system.

Common Errors and Fixes

Error 1: AuthenticationError on startup

anthropic.AuthenticationError: 401 {"error":{"message":"invalid x-api-key"}}

This means your API