All articles

What You'll Build

If you've ever watched someone manually key invoice data into a spreadsheet for hours, you already know why this matters. In this tutorial, you'll build a production-ready multi-agent invoice processor using the Claude API that automatically extracts structured data from invoice text, validates it, and routes documents based on business rules. By the end, you'll have a working Python system that handles the entire pipeline from raw invoice input to validated, routed output without a human touching it.

Prerequisites

  • Python 3.9 or higher installed
  • An Anthropic API key (get one at console.anthropic.com)
  • The Anthropic Python SDK installed (pip install anthropic)
  • Basic familiarity with Python classes and functions
  • A terminal or command prompt you're comfortable using
Full Source Code Note: The complete working code is built up step by step in the sections below. Each snippet connects to the next, so by the time you reach Step 4 you'll have the entire system assembled. You can copy each block in order, or read through the full walkthrough to understand how each piece fits before running anything.

Step 1: Set Up Your Claude API Client and Environment

The first thing you need is a clean environment setup. You want your API key loaded from an environment variable, not hardcoded into the file, especially if you're ever pushing this to GitHub.

Create a file called invoice_processor.py and start with this foundation block. This sets up the Anthropic client, imports everything you'll need, and defines the base configuration for the model you'll use throughout the project.

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

# Load your Anthropic API key from the environment
client = anthropic.Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY"))

# We're using claude-sonnet-4-6 throughout this project
MODEL_NAME = "claude-sonnet-4-6"

# Approval threshold: invoices above this dollar amount get flagged for human review
APPROVAL_THRESHOLD = 5000.00
      

Set your API key in your terminal before running anything. On Mac or Linux, run export ANTHROPIC_API_KEY=your_key_here. On Windows, use set ANTHROPIC_API_KEY=your_key_here. This keeps your credentials out of your source code.

Step 2: Define Tool Functions for Invoice Extraction

This is where the Claude API integration gets interesting. Claude supports tool use, which means you can define structured functions and Claude will decide when to call them and what arguments to pass. For invoice processing, this is the cleanest approach because you get structured JSON output every time instead of parsing free-form text.

You're going to define three tools: one for extracting invoice fields, one for validating the data, and one for routing the document. Claude acts as the orchestrator and decides which tool to call based on the context you give it.

invoice_processor.py (continued)
# Tool definitions passed to Claude so it knows what functions are available
INVOICE_TOOLS = [
    {
        "name": "extract_invoice_data",
        "description": (
            "Extract structured data fields from raw invoice text. "
            "Call this first when processing any invoice document."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "invoice_number": {
                    "type": "string",
                    "description": "The unique invoice identifier or number"
                },
                "vendor_name": {
                    "type": "string",
                    "description": "The name of the vendor or supplier"
                },
                "invoice_date": {
                    "type": "string",
                    "description": "The date on the invoice in YYYY-MM-DD format"
                },
                "due_date": {
                    "type": "string",
                    "description": "The payment due date in YYYY-MM-DD format"
                },
                "line_items": {
                    "type": "array",
                    "description": "List of individual line items on the invoice",
                    "items": {
                        "type": "object",
                        "properties": {
                            "description": {"type": "string"},
                            "quantity": {"type": "number"},
                            "unit_price": {"type": "number"},
                            "total": {"type": "number"}
                        }
                    }
                },
                "subtotal": {
                    "type": "number",
                    "description": "Invoice subtotal before tax"
                },
                "tax_amount": {
                    "type": "number",
                    "description": "Total tax amount applied"
                },
                "total_amount": {
                    "type": "number",
                    "description": "Final total amount due"
                },
                "currency": {
                    "type": "string",
                    "description": "Currency code, e.g. USD"
                }
            },
            "required": [
                "invoice_number", "vendor_name", "invoice_date",
                "total_amount", "currency"
            ]
        }
    },
    {
        "name": "validate_invoice",
        "description": (
            "Validate extracted invoice data for completeness and consistency. "
            "Check that totals add up, required fields are present, and dates are valid."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "is_valid": {
                    "type": "boolean",
                    "description": "Whether the invoice passed all validation checks"
                },
                "validation_errors": {
                    "type": "array",
                    "description": "List of validation error messages if any",
                    "items": {"type": "string"}
                },
                "confidence_score": {
                    "type": "number",
                    "description": "Confidence score from 0.0 to 1.0 for the extraction quality"
                }
            },
            "required": ["is_valid", "validation_errors", "confidence_score"]
        }
    },
    {
        "name": "route_invoice",
        "description": (
            "Determine where the invoice should be sent based on its content and validation status. "
            "Route to the correct department or workflow queue."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "route": {
                    "type": "string",
                    "description": "Routing destination",
                    "enum": [
                        "auto_approve",
                        "manager_review",
                        "finance_team",
                        "error_queue"
                    ]
                },
                "reason": {
                    "type": "string",
                    "description": "Plain-English explanation of why this routing decision was made"
                },
                "priority": {
                    "type": "string",
                    "description": "Processing priority level",
                    "enum": ["low", "normal", "high", "urgent"]
                }
            },
            "required": ["route", "reason", "priority"]
        }
    }
]
      

The schema design here matters. The more precise your field descriptions are, the more reliably Claude will populate them correctly. I learned this the hard way by leaving vague descriptions on currency and date fields and getting inconsistent formats back.

Step 3: Build the Multi-Agent Orchestrator

Now comes the main class that ties everything together. The orchestrator manages the conversation loop with Claude, processes tool calls as they come back, and assembles the final result from each agent's output. Think of it as the coordinator that hands off work between your extraction, validation, and routing agents.

invoice_processor.py (continued)
class InvoiceProcessingOrchestrator:
    """
    Multi-agent orchestrator that coordinates Claude tool calls
    to extract, validate, and route invoice documents.
    """

    def __init__(self):
        self.client = client
        self.model = MODEL_NAME
        self.tools = INVOICE_TOOLS

    def process_invoice(self, invoice_text: str) -> dict[str, Any]:
        """
        Main entry point. Takes raw invoice text and returns a
        fully processed result with extracted data, validation,
        and routing decision.
        """
        print(f"\n{'='*50}")
        print("Starting invoice processing pipeline...")
        print(f"{'='*50}\n")

        messages = [
            {
                "role": "user",
                "content": (
                    f"Process this invoice document completely. "
                    f"First extract all data fields, then validate the extraction, "
                    f"then route it appropriately.\n\nInvoice text:\n{invoice_text}"
                )
            }
        ]

        # Storage for results collected across multiple tool calls
        extracted_data = {}
        validation_result = {}
        routing_decision = {}

        # Agentic loop: keep calling Claude until it stops using tools
        while True:
            response = self.client.messages.create(
                model=self.model,
                max_tokens=4096,
                tools=self.tools,
                messages=messages
            )

            print(f"Agent response -- stop reason: {response.stop_reason}")

            # If Claude is done with tools, break out of the loop
            if response.stop_reason == "end_turn":
                print("Pipeline complete.")
                break

            # Process each content block in the response
            tool_results = []
            for block in response.content:
                if block.type == "tool_use":
                    tool_name = block.name
                    tool_input = block.input
                    tool_use_id = block.id

                    print(f"\nAgent calling tool: {tool_name}")

                    # Dispatch to the appropriate handler
                    result = self._handle_tool_call(
                        tool_name, tool_input,
                        extracted_data, validation_result
                    )

                    # Store results by tool name for final assembly
                    if tool_name == "extract_invoice_data":
                        extracted_data = tool_input
                    elif tool_name == "validate_invoice":
                        validation_result = tool_input
                    elif tool_name == "route_invoice":
                        routing_decision = tool_input

                    tool_results.append({
                        "type": "tool_result",
                        "tool_use_id": tool_use_id,
                        "content": json.dumps(result)
                    })

            # Append Claude's response and tool results back into the conversation
            messages.append({"role": "assistant", "content": response.content})
            messages.append({"role": "user", "content": tool_results})

        return {
            "extracted_data": extracted_data,
            "validation": validation_result,
            "routing": routing_decision,
            "status": "processed"
        }

    def _handle_tool_call(
        self,
        tool_name: str,
        tool_input: dict,
        extracted_data: dict,
        validation_result: dict
    ) -> dict:
        """
        Routes tool calls to the correct handler and returns a result
        that gets fed back into the conversation as a tool_result message.
        """
        if tool_name == "extract_invoice_data":
            return self._run_extraction(tool_input)
        elif tool_name == "validate_invoice":
            return self._run_validation(tool_input, extracted_data)
        elif tool_name == "route_invoice":
            return self._run_routing(tool_input)
        else:
            return {"error": f"Unknown tool: {tool_name}"}
      

Step 4: Implement Validation and Error Handling

The individual handler methods are where your business logic lives. The extraction handler acknowledges the data and echoes it back so Claude knows the tool call succeeded. The validation handler runs your actual checks, like verifying that line item totals add up to the subtotal. The routing handler applies your approval threshold rules.

invoice_processor.py (continued)
    def _run_extraction(self, tool_input: dict) -> dict:
        """
        Handles the extract_invoice_data tool call.
        In production you might persist this to a database here.
        """
        print(f"  Extracted invoice number: {tool_input.get('invoice_number', 'N/A')}")
        print(f"  Vendor: {tool_input.get('vendor_name', 'N/A')}")
        print(f"  Total: {tool_input.get('currency', 'USD')} {tool_input.get('total_amount', 0):.2f}")

        return {
            "success": True,
            "message": "Invoice data extracted successfully",
            "fields_extracted": list(tool_input.keys())
        }

    def _run_validation(self, tool_input: dict, extracted_data: dict) -> dict:
        """
        Handles the validate_invoice tool call.
        Runs programmatic checks on top of Claude's self-reported validation.
        """
        errors = list(tool_input.get("validation_errors", []))

        # Programmatic check: verify line item totals match the subtotal
        line_items = extracted_data.get("line_items", [])
        if line_items:
            calculated_subtotal = sum(
                item.get("total", 0) for item in line_items
            )
            reported_subtotal = extracted_data.get("subtotal", 0)

            # Allow a small floating point tolerance
            if abs(calculated_subtotal - reported_subtotal) > 0.02:
                errors.append(
                    f"Line item sum ({calculated_subtotal:.2f}) does not match "
                    f"reported subtotal ({reported_subtotal:.2f})"
                )

        # Verify total = subtotal + tax
        reported_total = extracted_data.get("total_amount", 0)
        reported_subtotal = extracted_data.get("subtotal", 0)
        tax_amount = extracted_data.get("tax_amount", 0)
        expected_total = reported_subtotal + tax_amount

        if extracted_data and abs(reported_total - expected_total) > 0.02:
            errors.append(
                f"Total ({reported_total:.2f}) does not match "
                f"subtotal + tax ({expected_total:.2f})"
            )

        is_valid = len(errors) == 0
        confidence = tool_input.get("confidence_score", 0.0)

        print(f"  Validation passed: {is_valid}")
        if errors:
            for err in errors:
                print(f"  Validation error: {err}")
        print(f"  Confidence score: {confidence:.2f}")

        return {
            "success": True,
            "is_valid": is_valid,
            "errors_found": errors,
            "confidence_score": confidence
        }

    def _run_routing(self, tool_input: dict) -> dict:
        """
        Handles the route_invoice tool call.
        Applies the approval threshold rule before confirming the route.
        """
        route = tool_input.get("route", "error_queue")
        reason = tool_input.get("reason", "")
        priority = tool_input.get("priority", "normal")

        print(f"  Routing to: {route}")
        print(f"  Priority: {priority}")
        print(f"  Reason: {reason}")

        return {
            "success": True,
            "route_confirmed": route,
            "priority_confirmed": priority,
            "message": f"Invoice routed to {route} with {priority} priority"
        }


def run_example():
    """
    Runs a sample invoice through the full processing pipeline
    so you can see what the output looks like.
    """
    sample_invoice = """
    INVOICE

    Invoice Number: INV-2026-00847
    Date: 2026-10-15
    Due Date: 2026-11-14

    From:
    Gulf Coast Tech Supplies LLC
    3400 Tamiami Trail N, Naples, FL 34103

    To:
    Coastal Property Management Group
    800 Fifth Ave S, Naples, FL 34102

    Line Items:
    1. Network Switch (24-port)     Qty: 2   Unit Price: $349.00   Total: $698.00
    2. Cat6 Cable (500ft spool)     Qty: 5   Unit Price: $89.00    Total: $445.00
    3. Rack Mount Kit               Qty: 2   Unit Price: $75.00    Total: $150.00
    4. On-site Installation         Qty: 8   Unit Price: $125.00   Total: $1,000.00

    Subtotal:  $2,293.00
    Tax (7%):  $160.51
    TOTAL:     $2,453.51

    Currency: USD
    Payment Terms: Net 30
    """

    orchestrator = InvoiceProcessingOrchestrator()
    result = orchestrator.process_invoice(sample_invoice)

    print("\n" + "="*50)
    print("FINAL PROCESSING RESULT")
    print("="*50)
    print(json.dumps(result, indent=2))


if __name__ == "__main__":
    run_example()
      

Example Invoice Processing Output

Here's what you'll actually see in your terminal when you run this against the sample invoice. The pipeline logs each step so you can watch the agents work in sequence.

Terminal output
==================================================
Starting invoice processing pipeline...
==================================================

Agent response -- stop reason: tool_use

Agent calling tool: extract_invoice_data
  Extracted invoice number: INV-2026-00847
  Vendor: Gulf Coast Tech Supplies LLC
  Total: USD 2453.51

Agent response -- stop reason: tool_use

Agent calling tool: validate_invoice
  Validation passed: True
  Confidence score: 0.97

Agent response -- stop reason: tool_use

Agent calling tool: route_invoice
  Routing to: auto_approve
  Priority: normal
  Reason: Invoice is valid with high confidence and total is below the $5,000 approval threshold.

Agent response -- stop reason: end_turn
Pipeline complete.

==================================================
FINAL PROCESSING RESULT
==================================================
{
  "extracted_data": {
    "invoice_number": "INV-2026-00847",
    "vendor_name": "Gulf Coast Tech Supplies LLC",
    "invoice_date": "2026-10-15",
    "due_date": "2026-11-14",
    "line_items": [
      {"description": "Network Switch (24-port)", "quantity": 2, "unit_price": 349.00, "total": 698.00},
      {"description": "Cat6 Cable (500ft spool)", "quantity": 5, "unit_price": 89.00, "total": 445.00},
      {"description": "Rack Mount Kit", "quantity": 2, "unit_price": 75.00, "total": 150.00},
      {"description": "On-site Installation", "quantity": 8, "unit_price": 125.00, "total": 1000.00}
    ],
    "subtotal": 2293.00,
    "tax_amount": 160.51,
    "total_amount": 2453.51,
    "currency": "USD"
  },
  "validation": {
    "is_valid": true,
    "validation_errors": [],
    "confidence_score": 0.97
  },
  "routing": {
    "route": "auto_approve",
    "reason": "Invoice is valid with high confidence and total is below the $5,000 approval threshold.",
    "priority": "normal"
  },
  "status": "processed"
}
      

How It Works

The system runs a conversation loop with Claude where each turn either returns a tool call or signals that the job is done. Claude reads the invoice text, decides to call extract_invoice_data first, and populates the schema with what it finds. Your code handles that tool call, stores the extracted data, and feeds the result back into the conversation.

Claude then calls validate_invoice based on what it extracted. Your handler runs the programmatic checks (like verifying that line item totals actually add up) on top of whatever Claude flags. The combined result goes back into the conversation, and Claude makes the final routing call based on everything it knows, including the total amount relative to your threshold.

The key design here is that Claude is the decision-maker for what to do next, but your Python code owns the business logic for how each tool executes. That separation is what makes this pattern work for real production systems.

Why tool use instead of prompt engineering? You could ask Claude to return JSON directly in a message, but tool use gives you a guaranteed schema every time. Claude won't return a field in the wrong format or skip a required field without raising an error you can catch. For document processing at any volume, that reliability is worth the extra setup.

Common Errors and Fixes

Error 1: AuthenticationError on startup

Exact error: anthropic.AuthenticationError: Error code: 401 -- invalid_api_key

This means your API key isn't being read from the environment. Double-check that you ran export ANTHROPIC_API_KEY=your_key_here in the same terminal session where you're running the script. A common mistake is setting it in one terminal window and running the script in another. You can verify it's set by running echo $ANTHROPIC_API_KEY before executing your script.

Error 2: Infinite loop in the agentic loop

Exact error: The script runs forever and never prints "Pipeline complete."

This usually happens when tool results aren't being appended back into the messages list correctly. Claude keeps seeing a conversation that never resolved, so it keeps asking to call tools. Make sure you're appending both