← Back to Blog

What You'll Build

If you've ever watched a busy restaurant kitchen fall apart because an order got lost, duplicated, or miscommunicated, you already know why this matters. In this AI agent tutorial, you'll build a multi-agent system using Python and the Claude API that automatically validates incoming orders, sends structured alerts to the kitchen, and confirms preparation times — with zero human in the loop.

By the end, you'll have a working orchestration loop where two agents hand off tasks to each other, use custom tools, and handle errors gracefully. This is the same pattern we use at Naples AI when building restaurant automation for clients in Southwest Florida.

📦 Full Source Code
All the code in this tutorial is production-ready and syntactically complete. Each step below builds on the last, and the final section shows the full example output. You can copy each snippet in order and run it end-to-end.

Prerequisites

  • Python 3.10 or higher installed
  • An Anthropic API key (get one at console.anthropic.com)
  • anthropic Python SDK installed: pip install anthropic
  • Basic familiarity with Python classes and dictionaries
  • A terminal you're comfortable running scripts from

Step 1: Set Up the Claude API Client and Define Your Tools

The first thing we need is a solid foundation — the Anthropic client and a set of tools that our agents can actually call. Tools in Claude's API are just JSON schema definitions that tell the model what functions are available and what arguments they expect.

We're defining three tools here: validate_order, send_kitchen_alert, and confirm_prep_time. These map directly to real restaurant operations — checking if an order is valid, alerting the kitchen crew, and getting an estimated prep time back.

tools.py
import anthropic
import json
from typing import Any

# Initialize the Anthropic client — make sure ANTHROPIC_API_KEY is set in your environment
client = anthropic.Anthropic()

# Tool definitions tell Claude what it can call and what each argument means
TOOLS = [
    {
        "name": "validate_order",
        "description": (
            "Validates a restaurant order to confirm all required fields are present, "
            "items are on the menu, and quantities are within acceptable limits. "
            "Returns a validation result with any errors found."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "order_id": {
                    "type": "string",
                    "description": "Unique identifier for the order"
                },
                "items": {
                    "type": "array",
                    "description": "List of ordered items, each with name and quantity",
                    "items": {
                        "type": "object",
                        "properties": {
                            "name": {"type": "string"},
                            "quantity": {"type": "integer"}
                        },
                        "required": ["name", "quantity"]
                    }
                },
                "table_number": {
                    "type": "integer",
                    "description": "The table number placing the order"
                }
            },
            "required": ["order_id", "items", "table_number"]
        }
    },
    {
        "name": "send_kitchen_alert",
        "description": (
            "Sends a validated order to the kitchen display system. "
            "Should only be called after validate_order confirms the order is valid. "
            "Returns a confirmation with ticket number."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "order_id": {
                    "type": "string",
                    "description": "The validated order ID to send to the kitchen"
                },
                "items": {
                    "type": "array",
                    "description": "Validated list of items to prepare",
                    "items": {
                        "type": "object",
                        "properties": {
                            "name": {"type": "string"},
                            "quantity": {"type": "integer"}
                        },
                        "required": ["name", "quantity"]
                    }
                },
                "table_number": {
                    "type": "integer",
                    "description": "Table number to display on kitchen ticket"
                },
                "priority": {
                    "type": "string",
                    "enum": ["normal", "rush", "vip"],
                    "description": "Priority level for the kitchen ticket"
                }
            },
            "required": ["order_id", "items", "table_number", "priority"]
        }
    },
    {
        "name": "confirm_prep_time",
        "description": (
            "Retrieves the estimated preparation time for a kitchen ticket. "
            "Call this after send_kitchen_alert to get the expected ready time "
            "so it can be relayed to the server."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "ticket_number": {
                    "type": "string",
                    "description": "Kitchen ticket number returned by send_kitchen_alert"
                }
            },
            "required": ["ticket_number"]
        }
    }
]

Notice we're not doing anything fancy yet — just clean definitions. The schema descriptions matter a lot here. Claude reads them to decide when and how to call each tool, so be specific with your language.

Step 2: Create the Order Validation Agent

The validation agent is the gatekeeper. It takes raw order data, calls the validate_order tool, and decides whether the order is clean enough to pass downstream. Think of it as the expediter at the pass — nothing gets to the kitchen until it checks out.

We're also writing the actual Python functions that execute when Claude calls those tools. This is where your business logic lives — checking against a real menu, enforcing quantity limits, whatever your restaurant needs.

validation_agent.py
import anthropic
import json
from typing import Any

client = anthropic.Anthropic()

# Simulated menu — in production this would query your POS database
VALID_MENU_ITEMS = {
    "Margherita Pizza", "Pepperoni Pizza", "Caesar Salad",
    "Grilled Salmon", "Ribeye Steak", "Pasta Carbonara",
    "Tiramisu", "Cheesecake", "Sparkling Water", "House Wine"
}

MAX_QUANTITY_PER_ITEM = 20


def validate_order(order_id: str, items: list[dict], table_number: int) -> dict[str, Any]:
    """
    Executes the actual order validation logic.
    Returns a dict with 'valid' bool and 'errors' list.
    """
    errors = []

    if not order_id or not order_id.strip():
        errors.append("Order ID cannot be empty.")

    if table_number < 1 or table_number > 100:
        errors.append(f"Table number {table_number} is out of range (1-100).")

    if not items:
        errors.append("Order must contain at least one item.")

    for item in items:
        name = item.get("name", "")
        quantity = item.get("quantity", 0)

        if name not in VALID_MENU_ITEMS:
            errors.append(f"'{name}' is not on the menu.")

        if quantity < 1 or quantity > MAX_QUANTITY_PER_ITEM:
            errors.append(f"Quantity {quantity} for '{name}' is invalid (must be 1-{MAX_QUANTITY_PER_ITEM}).")

    return {
        "order_id": order_id,
        "valid": len(errors) == 0,
        "errors": errors,
        "item_count": len(items)
    }


def run_validation_agent(order: dict[str, Any]) -> dict[str, Any]:
    """
    Runs the validation agent loop. Sends the order to Claude,
    processes any tool calls, and returns the final validation result.
    """
    system_prompt = (
        "You are an order validation agent for a restaurant. "
        "When given an order, use the validate_order tool to check it. "
        "Report the result clearly — whether it passed or failed, and why."
    )

    messages = [
        {
            "role": "user",
            "content": (
                f"Please validate this incoming order: {json.dumps(order)}"
            )
        }
    ]

    validation_result = None

    # Agent loop — keep going until Claude stops calling tools
    while True:
        response = client.messages.create(
            model="claude-sonnet-4-6",
            max_tokens=1024,
            system=system_prompt,
            tools=tools_for_agent(["validate_order"]),
            messages=messages
        )

        # Append Claude's response to the conversation history
        messages.append({"role": "assistant", "content": response.content})

        if response.stop_reason == "end_turn":
            break

        if response.stop_reason == "tool_use":
            tool_results = []

            for block in response.content:
                if block.type == "tool_use":
                    if block.name == "validate_order":
                        result = validate_order(
                            order_id=block.input["order_id"],
                            items=block.input["items"],
                            table_number=block.input["table_number"]
                        )
                        validation_result = result

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

            # Feed tool results back to Claude to continue the conversation
            messages.append({"role": "user", "content": tool_results})

    return validation_result or {"valid": False, "errors": ["Validation agent did not complete."]}


def tools_for_agent(tool_names: list[str]) -> list[dict]:
    """Helper to filter the global TOOLS list by name."""
    from tools import TOOLS
    return [t for t in TOOLS if t["name"] in tool_names]

Step 3: Create the Kitchen Notification Agent

Once an order clears validation, the kitchen notification agent takes over. It calls send_kitchen_alert to push the order to the kitchen display, then immediately calls confirm_prep_time to get an ETA back for the server. Two tool calls, one agent, one clean handoff.

This is where multi-agent design really pays off. The kitchen agent doesn't know anything about validation — it just trusts that what it receives is already clean. That separation keeps each agent simple and easy to debug.

kitchen_agent.py
import anthropic
import json
import random
import string
from typing import Any
from datetime import datetime, timedelta

client = anthropic.Anthropic()


def send_kitchen_alert(
    order_id: str,
    items: list[dict],
    table_number: int,
    priority: str
) -> dict[str, Any]:
    """
    Simulates sending an alert to the kitchen display system.
    In production, this would POST to your KDS API or fire a webhook.
    """
    # Generate a realistic kitchen ticket number
    ticket_number = "KT-" + "".join(random.choices(string.digits, k=5))

    return {
        "success": True,
        "ticket_number": ticket_number,
        "order_id": order_id,
        "table_number": table_number,
        "priority": priority,
        "sent_at": datetime.now().strftime("%H:%M:%S"),
        "message": f"Order {order_id} sent to kitchen with ticket {ticket_number}."
    }


def confirm_prep_time(ticket_number: str) -> dict[str, Any]:
    """
    Simulates querying the kitchen for an estimated prep time.
    In production, this would pull from your KDS or queue management system.
    """
    # Simulate variable prep times between 8 and 25 minutes
    prep_minutes = random.randint(8, 25)
    ready_at = (datetime.now() + timedelta(minutes=prep_minutes)).strftime("%H:%M")

    return {
        "ticket_number": ticket_number,
        "estimated_prep_minutes": prep_minutes,
        "ready_at": ready_at,
        "kitchen_status": "accepted"
    }


def run_kitchen_agent(validated_order: dict[str, Any]) -> dict[str, Any]:
    """
    Runs the kitchen notification agent. Sends the alert and
    confirms prep time using two sequential tool calls.
    """
    system_prompt = (
        "You are a kitchen notification agent for a restaurant. "
        "When given a validated order, first use send_kitchen_alert to notify the kitchen, "
        "then use confirm_prep_time with the returned ticket number to get the ETA. "
        "Report both the ticket number and estimated prep time clearly."
    )

    messages = [
        {
            "role": "user",
            "content": (
                f"Send this validated order to the kitchen and confirm the prep time: "
                f"{json.dumps(validated_order)}"
            )
        }
    ]

    kitchen_result = {}
    prep_result = {}

    # Agent loop — Claude will call both tools in sequence
    while True:
        response = client.messages.create(
            model="claude-sonnet-4-6",
            max_tokens=1024,
            system=system_prompt,
            tools=tools_for_agent(["send_kitchen_alert", "confirm_prep_time"]),
            messages=messages
        )

        messages.append({"role": "assistant", "content": response.content})

        if response.stop_reason == "end_turn":
            break

        if response.stop_reason == "tool_use":
            tool_results = []

            for block in response.content:
                if block.type == "tool_use":
                    if block.name == "send_kitchen_alert":
                        result = send_kitchen_alert(
                            order_id=block.input["order_id"],
                            items=block.input["items"],
                            table_number=block.input["table_number"],
                            priority=block.input["priority"]
                        )
                        kitchen_result = result

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

                    elif block.name == "confirm_prep_time":
                        result = confirm_prep_time(
                            ticket_number=block.input["ticket_number"]
                        )
                        prep_result = result

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

            messages.append({"role": "user", "content": tool_results})

    return {**kitchen_result, **prep_result}


def tools_for_agent(tool_names: list[str]) -> list[dict]:
    """Helper to filter tools by name."""
    from tools import TOOLS
    return [t for t in TOOLS if t["name"] in tool_names]

Step 4: Build the Orchestration Loop

This is the part that ties everything together. The orchestrator is the traffic controller — it receives raw orders, hands them to the validation agent, checks the result, and only passes clean orders to the kitchen agent. If validation fails, it stops and reports why.

I also added a process_batch method so you can run multiple orders in one shot, which is how you'd use this in a real lunch rush. The orchestrator handles errors at every step so one bad order doesn't crash the whole pipeline.

orchestrator.py
import anthropic
import json
from typing import Any
from validation_agent import run_validation_agent
from kitchen_agent import run_kitchen_agent

client = anthropic.Anthropic()


class RestaurantOrderOrchestrator:
    """
    Coordinates the validation and kitchen notification agents.
    Receives raw orders, routes them through each agent in sequence,
    and returns a full processing report.
    """

    def __init__(self):
        self.processed_orders = []
        self.failed_orders = []

    def process_order(self, order: dict[str, Any]) -> dict[str, Any]:
        """
        Full pipeline for a single order:
        1. Validate with the validation agent
        2. If valid, notify the kitchen agent
        3. Return the complete result
        """
        print(f"\n{'='*50}")
        print(f"Processing Order ID: {order.get('order_id', 'UNKNOWN')}")
        print(f"Table: {order.get('table_number')} | Items: {len(order.get('items', []))}")
        print(f"{'='*50}")

        # Step 1: Run order through the validation agent
        print("\n[VALIDATION AGENT] Running order validation...")
        validation_result = run_validation_agent(order)

        if not validation_result.get("valid"):
            errors = validation_result.get("errors", [])
            print(f"[VALIDATION AGENT] ❌ Order FAILED validation.")
            for err in errors:
                print(f"  → {err}")

            failed_record = {
                "order_id": order.get("order_id"),
                "status": "validation_failed",
                "errors": errors
            }
            self.failed_orders.append(failed_record)
            return failed_record

        print(f"[VALIDATION AGENT] ✅ Order passed validation. {validation_result.get('item_count')} items confirmed.")

        # Step 2: Pass validated order to the kitchen notification agent
        print("\n[KITCHEN AGENT] Sending order to kitchen...")

        # Build a clean payload for the kitchen agent
        kitchen_payload = {
            "order_id": order["order_id"],
            "items": order["items"],
            "table_number": order["table_number"],
            "priority": order.get("priority", "normal")
        }

        kitchen_result = run_kitchen_agent(kitchen_payload)

        print(f"[KITCHEN AGENT] ✅ Kitchen notified.")
        print(f"  → Ticket: {kitchen_result.get('ticket_number')}")
        print(f"  → ETA: {kitchen_result.get('estimated_prep_minutes')} minutes (ready at {kitchen_result.get('ready_at')})")

        # Compile final result
        final_result = {
            "order_id": order["order_id"],
            "table_number": order["table_number"],
            "status": "sent_to_kitchen",
            "ticket_number": kitchen_result.get("ticket_number"),
            "estimated_prep_minutes": kitchen_result.get("estimated_prep_minutes"),
            "ready_at": kitchen_result.get("ready_at"),
            "kitchen_status": kitchen_result.get("kitchen_status")
        }

        self.processed_orders.append(final_result)
        return final_result

    def process_batch(self, orders: list[dict[str, Any]]) -> dict[str, Any]:
        """
        Processes a list of orders and returns a summary report.
        Continues processing even if individual orders fail.
        """
        results = []

        for order in orders:
            try:
                result = self.process_order(order)
                results.append(result)
            except Exception as e:
                # Catch unexpected errors so one bad order doesn't stop the batch
                error_record = {
                    "order_id": order.get("order_id", "UNKNOWN"),
                    "status": "processing_error",
                    "error": str(e)
                }
                results.append(error_record)
                self.failed_orders.append(error_record)
                print(f"[ORCHESTRATOR] ⚠️  Unexpected error on order {order.get('order_id')}: {e}")

        return {
            "total_orders": len(orders),
            "successful": len(self.processed_orders),
            "failed": len(self.failed_orders),
            "results": results
        }


# ── Entry point ─────────────────────────────────────────────
if __name__ == "__main__":

    # Sample orders — one valid, one with a bad menu item, one with invalid table
    sample_orders = [
        {
            "order_id": "ORD-1001",
            "table_number": 7,
            "priority": "normal",
            "items": [
                {"name": "Margherita Pizza", "quantity": 2},
                {"name": "Caesar Salad", "quantity": 1},
                {"name": "House Wine", "quantity": 2}
            ]
        },
        {
            "order_id": "ORD-1002",
            "table_number": 12,
            "priority": "rush",
            "items": [
                {"name": "Ribeye Steak", "quantity": 1},
                {"name": "Deep Dish Pizza", "quantity": 1}  # Not on the menu
            ]
        },
        {
            "order_id": "ORD-1003",
            "table_number": 999,  # Invalid table number
            "priority": "normal",
            "items": [
                {"name": "Tiramisu", "quantity": 3}
            ]
        }
    ]

    orchestrator = RestaurantOrderOrchestrator()
    summary = orchestrator.process_batch(sample_orders)

    print(f"\n{'='*50}")
    print("BATCH PROCESSING COMPLETE")
    print(f"{'='*50}")
    print(json.dumps(summary, indent=2))

Full Example Order Processing Output

Here's what you'll actually see in your terminal when you run python orchestrator.py. I've trimmed a few timestamps for readability, but this reflects real output.

terminal output