All articles

What You'll Build

If you've ever tried to automate a restaurant's order flow and realized a single chatbot can't handle the complexity, this tutorial is for you. You're going to build a working multi-agent system in Python using the Claude API, where a main orchestrator agent routes customer requests to three specialized sub-agents: one for the menu, one for orders, and one for payments. By the end, you'll have a fully functional conversational ordering system you can actually run, test, and extend.

This isn't a toy demo. The architecture mirrors what we build at Naples AI for real restaurant clients in Southwest Florida, scaled down into a tutorial you can finish in an afternoon.

Prerequisites

  • Python 3.10 or higher installed
  • An Anthropic API key (get one at console.anthropic.com)
  • Basic familiarity with Python functions and dictionaries
  • The anthropic Python package installed (pip install anthropic)
  • Roughly 60 minutes and a coffee
Full Source Code Note: The complete, working code for this system is spread across the numbered steps below. Each step builds on the previous one, and Step 3 shows you how to wire everything together into a single runnable file. If you want to copy the entire thing at once, you can assemble the snippets in order. Every block here is syntactically correct and tested against the Anthropic SDK with model claude-sonnet-4-6.

Step 1: Set Up Your Environment and Anthropic SDK

First, get your environment ready. Create a fresh project folder, set up a virtual environment, and install the Anthropic SDK. This keeps your dependencies clean and avoids version conflicts with other projects.

terminal
mkdir restaurant-agent-system
cd restaurant-agent-system
python -m venv venv
source venv/bin/activate   # On Windows: venv\Scripts\activate
pip install anthropic

Next, store your API key as an environment variable. Never hardcode it in your script. If you're on Mac or Linux, add the export line to your .bashrc or .zshrc so it persists across sessions.

terminal
export ANTHROPIC_API_KEY="sk-ant-your-key-here"

Now create the main project file and verify the SDK is working with a quick smoke test before you build anything else.

smoke_test.py
import os
import anthropic

client = anthropic.Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY"))

response = client.messages.create(
    model="claude-sonnet-4-6",
    max_tokens=64,
    messages=[{"role": "user", "content": "Say 'SDK is working' and nothing else."}]
)

print(response.content[0].text)

Run it with python smoke_test.py. If you see "SDK is working" printed back, you're good to go.

expected output
SDK is working

Step 2: Define Tool Functions for Order Processing and Payment

In Claude's tool use system, you define what tools the model can call, and then you write the Python functions that actually execute when Claude decides to call them. Think of tool definitions as a contract: you tell Claude what the function does, what arguments it takes, and what it returns.

Here are the three core tools this system uses: check_menu, process_order, and process_payment.

tools.py
import json
import random
import string
from datetime import datetime

# Simulated in-memory state for the session
ORDER_STATE = {
    "items": [],
    "total": 0.0,
    "order_id": None,
    "paid": False
}

# Static menu data
MENU = {
    "margherita_pizza": {"name": "Margherita Pizza", "price": 14.99, "category": "entree"},
    "pepperoni_pizza": {"name": "Pepperoni Pizza", "price": 16.99, "category": "entree"},
    "caesar_salad": {"name": "Caesar Salad", "price": 9.99, "category": "starter"},
    "tiramisu": {"name": "Tiramisu", "price": 7.99, "category": "dessert"},
    "sparkling_water": {"name": "Sparkling Water", "price": 2.99, "category": "drink"},
    "house_red_wine": {"name": "House Red Wine", "price": 8.99, "category": "drink"},
}


def check_menu(category: str = "all") -> dict:
    """Return menu items, optionally filtered by category."""
    if category == "all":
        items = MENU
    else:
        items = {
            k: v for k, v in MENU.items()
            if v["category"].lower() == category.lower()
        }

    if not items:
        return {"status": "error", "message": f"No items found in category: {category}"}

    formatted = []
    for key, item in items.items():
        formatted.append({
            "id": key,
            "name": item["name"],
            "price": f"${item['price']:.2f}",
            "category": item["category"]
        })

    return {"status": "success", "items": formatted, "count": len(formatted)}


def process_order(item_id: str, quantity: int = 1) -> dict:
    """Add an item to the current order by its menu ID."""
    if item_id not in MENU:
        return {
            "status": "error",
            "message": f"Item '{item_id}' not found. Use check_menu to see valid item IDs."
        }

    if quantity < 1 or quantity > 20:
        return {"status": "error", "message": "Quantity must be between 1 and 20."}

    item = MENU[item_id]
    line_total = item["price"] * quantity

    ORDER_STATE["items"].append({
        "item_id": item_id,
        "name": item["name"],
        "quantity": quantity,
        "unit_price": item["price"],
        "line_total": line_total
    })
    ORDER_STATE["total"] += line_total

    # Generate an order ID if this is the first item
    if ORDER_STATE["order_id"] is None:
        ORDER_STATE["order_id"] = "ORD-" + "".join(
            random.choices(string.ascii_uppercase + string.digits, k=6)
        )

    return {
        "status": "success",
        "message": f"Added {quantity}x {item['name']} to your order.",
        "line_total": f"${line_total:.2f}",
        "order_total_so_far": f"${ORDER_STATE['total']:.2f}",
        "order_id": ORDER_STATE["order_id"]
    }


def process_payment(payment_method: str, tip_percent: float = 0.0) -> dict:
    """Process payment for the current order."""
    valid_methods = ["credit_card", "debit_card", "cash", "apple_pay"]

    if payment_method not in valid_methods:
        return {
            "status": "error",
            "message": f"Invalid payment method. Choose from: {', '.join(valid_methods)}"
        }

    if not ORDER_STATE["items"]:
        return {"status": "error", "message": "No items in order. Add items before paying."}

    if ORDER_STATE["paid"]:
        return {"status": "error", "message": "This order has already been paid."}

    tip_amount = ORDER_STATE["total"] * (tip_percent / 100)
    grand_total = ORDER_STATE["total"] + tip_amount
    ORDER_STATE["paid"] = True

    receipt = {
        "status": "success",
        "message": "Payment processed successfully.",
        "order_id": ORDER_STATE["order_id"],
        "items": ORDER_STATE["items"],
        "subtotal": f"${ORDER_STATE['total']:.2f}",
        "tip": f"${tip_amount:.2f}",
        "grand_total": f"${grand_total:.2f}",
        "payment_method": payment_method,
        "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    }

    return receipt

Now define the tool schemas that Claude will read. These tell the model what each function expects as input. Get the descriptions right here because Claude uses them to decide which tool to call.

tool_schemas.py
# Tool definitions in the format Anthropic's API expects

TOOL_DEFINITIONS = [
    {
        "name": "check_menu",
        "description": (
            "Retrieve the restaurant menu. Can return all items or filter by category. "
            "Use this when a customer asks what's available, asks for the menu, "
            "or asks about a specific food category."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "category": {
                    "type": "string",
                    "description": (
                        "Optional category filter. Valid values: 'all', 'entree', "
                        "'starter', 'dessert', 'drink'. Defaults to 'all'."
                    ),
                    "enum": ["all", "entree", "starter", "dessert", "drink"]
                }
            },
            "required": []
        }
    },
    {
        "name": "process_order",
        "description": (
            "Add a menu item to the customer's current order. "
            "Use the item_id from the menu, not the display name. "
            "Call check_menu first if you don't have the item_id."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "item_id": {
                    "type": "string",
                    "description": "The unique menu item ID (e.g., 'margherita_pizza')."
                },
                "quantity": {
                    "type": "integer",
                    "description": "How many of this item to add. Defaults to 1.",
                    "minimum": 1,
                    "maximum": 20
                }
            },
            "required": ["item_id"]
        }
    },
    {
        "name": "process_payment",
        "description": (
            "Process payment for the customer's current order. "
            "Only call this when the customer explicitly confirms they want to pay. "
            "Do not call this speculatively."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "payment_method": {
                    "type": "string",
                    "description": "Payment method chosen by the customer.",
                    "enum": ["credit_card", "debit_card", "cash", "apple_pay"]
                },
                "tip_percent": {
                    "type": "number",
                    "description": "Tip percentage as a number (e.g., 20 for 20%). Defaults to 0."
                }
            },
            "required": ["payment_method"]
        }
    }
]

Step 3: Create the Main Orchestrator Agent

The orchestrator is the brain of the whole system. It receives every customer message, decides which tool or sub-agent to invoke, and coordinates the response. In this architecture, the orchestrator holds the conversation history and passes it forward on every turn so Claude maintains context.

orchestrator.py
import os
import json
import anthropic
from tools import check_menu, process_order, process_payment
from tool_schemas import TOOL_DEFINITIONS

client = anthropic.Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY"))

ORCHESTRATOR_SYSTEM_PROMPT = """
You are the AI ordering assistant for Bella Napoli, a casual Italian restaurant.
Your job is to help customers browse the menu, place orders, and pay for their meal.

You have three tools available:
- check_menu: Show the customer what's on the menu
- process_order: Add an item to their order
- process_payment: Charge the customer when they're ready to pay

Guidelines:
- Always confirm an item exists on the menu before adding it to an order.
- Summarize the order total after each item is added.
- Ask for payment method preference before calling process_payment.
- Be friendly, concise, and helpful. This is a restaurant, not a tech support line.
- If the customer asks something outside your scope, politely redirect them.
"""


def run_tool(tool_name: str, tool_input: dict) -> str:
    """Execute the appropriate tool function and return the result as a JSON string."""
    if tool_name == "check_menu":
        result = check_menu(**tool_input)
    elif tool_name == "process_order":
        result = process_order(**tool_input)
    elif tool_name == "process_payment":
        result = process_payment(**tool_input)
    else:
        result = {"status": "error", "message": f"Unknown tool: {tool_name}"}

    return json.dumps(result)


def orchestrator_turn(conversation_history: list, user_message: str) -> tuple[str, list]:
    """
    Process one customer message through the orchestrator.
    Returns the final text response and the updated conversation history.
    """
    # Append the new user message to history
    conversation_history.append({
        "role": "user",
        "content": user_message
    })

    # Keep looping until Claude returns a final text response (no more tool calls)
    while True:
        response = client.messages.create(
            model="claude-sonnet-4-6",
            max_tokens=1024,
            system=ORCHESTRATOR_SYSTEM_PROMPT,
            tools=TOOL_DEFINITIONS,
            messages=conversation_history
        )

        # If Claude is done (no tool calls), extract and return the text
        if response.stop_reason == "end_turn":
            final_text = ""
            for block in response.content:
                if hasattr(block, "text"):
                    final_text += block.text
            # Add Claude's final response to history
            conversation_history.append({
                "role": "assistant",
                "content": response.content
            })
            return final_text, conversation_history

        # Claude wants to use a tool
        if response.stop_reason == "tool_use":
            # Add Claude's tool-calling message to history first
            conversation_history.append({
                "role": "assistant",
                "content": response.content
            })

            # Process each tool call and collect results
            tool_results = []
            for block in response.content:
                if block.type == "tool_use":
                    print(f"  [Orchestrator] Calling tool: {block.name} with {block.input}")
                    result = run_tool(block.name, block.input)
                    print(f"  [Orchestrator] Tool result: {result}")

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

            # Feed tool results back to Claude so it can continue
            conversation_history.append({
                "role": "user",
                "content": tool_results
            })
            # Loop again so Claude can process the tool results
Why the while loop? Claude might call multiple tools in sequence before it has enough information to give a final answer. For example, it might call check_menu to confirm an item exists, then call process_order to add it. The loop keeps running until stop_reason is "end_turn", which means Claude has finished thinking and is ready to respond to the customer.

Step 4: Build the Specialized Sub-Agents

For more complex production systems, you'd run separate Claude calls for each domain. Here I'm showing you how to structure those sub-agents as dedicated functions with their own system prompts and tool subsets. This makes them easy to swap out, test independently, or move to separate services later.

sub_agents.py
import os
import json
import anthropic
from tools import check_menu, process_order, process_payment
from tool_schemas import TOOL_DEFINITIONS

client = anthropic.Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY"))


def menu_agent(query: str) -> str:
    """
    Specialized sub-agent for menu inquiries.
    Only has access to check_menu tool.
    """
    menu_tool = [t for t in TOOL_DEFINITIONS if t["name"] == "check_menu"]

    system = (
        "You are the menu specialist for Bella Napoli restaurant. "
        "Answer questions about menu items, prices, and categories. "
        "Always use the check_menu tool to get accurate, up-to-date information. "
        "Keep your response brief and easy to read."
    )

    messages = [{"role": "user", "content": query}]

    while True:
        response = client.messages.create(
            model="claude-sonnet-4-6",
            max_tokens=512,
            system=system,
            tools=menu_tool,
            messages=messages
        )

        if response.stop_reason == "end_turn":
            return "".join(
                block.text for block in response.content if hasattr(block, "text")
            )

        if response.stop_reason == "tool_use":
            messages.append({"role": "assistant", "content": response.content})
            tool_results = []
            for block in response.content:
                if block.type == "tool_use":
                    result = check_menu(**block.input)
                    tool_results.append({
                        "type": "tool_result",
                        "tool_use_id": block.id,
                        "content": json.dumps(result)
                    })
            messages.append({"role": "user", "content": tool_results})


def order_agent(item_request: str) -> str:
    """
    Specialized sub-agent for adding items to an order.
    Has access to check_menu and process_order tools.
    """
    order_tools = [
        t for t in TOOL_DEFINITIONS
        if t["name"] in ("check_menu", "process_order")
    ]

    system = (
        "You are the order-taking specialist for Bella Napoli restaurant. "
        "Your only job is to add items to the customer's order. "
        "Always verify an item exists using check_menu before calling process_order. "
        "Confirm what was added and state the running total."
    )

    messages = [{"role": "user", "content": item_request}]

    while True:
        response = client.messages.create(
            model="claude-sonnet-4-6",
            max_tokens=512,
            system=system,
            tools=order_tools,
            messages=messages
        )

        if response.stop_reason == "end_turn":
            return "".join(
                block.text for block in response.content if hasattr(block, "text")
            )

        if response.stop_reason == "tool_use":
            messages.append({"role": "assistant", "content": response.content})
            tool_results = []
            for block in response.content:
                if block.type == "tool_use":
                    if block.name == "check_menu":
                        result = check_menu(**block.input)
                    elif block.name == "process_order":
                        result = process_order(**block.input)
                    else:
                        result = {"status": "error", "message": "Unknown tool"}
                    tool_results.append({
                        "type": "tool_result",
                        "tool_use_id": block.id,
                        "content": json.dumps(result)
                    })
            messages.append({"role": "user", "content": tool_results})


def payment_agent(payment_request: str) -> str:
    """
    Specialized sub-agent for handling payment.
    Only has access to the process_payment tool.
    """
    payment_tool = [t for t in TOOL_DEFINITIONS if t["name"] == "process_payment"]

    system = (
        "You are the payment specialist for Bella Napoli restaurant. "
        "Process the customer's payment using their preferred method. "
        "If no tip is specified, default to 0. "
        "After payment, read back the receipt clearly."
    )

    messages = [{"role": "user", "content": payment_request}]

    while True:
        response = client.messages.create(
            model="claude-sonnet-4-6",
            max_tokens=512,
            system=system,
            tools=payment_tool,
            messages=messages
        )

        if response.stop_reason == "end_turn":
            return "".join(
                block.text for block in response.content if hasattr(block, "text")
            )

        if response.stop_reason == "tool_use":
            messages.append({"role": "assistant", "content": response.content})
            tool_results = []
            for block in response.content:
                if block.type == "tool_use":
                    result = process_payment(**block.input)
                    tool_results.append({
                        "type": "tool_result",
                        "tool_use_id": block.id,
                        "content": json.dumps(result)
                    })
            messages.append({"role": "user", "content": tool_results})


def route_to_sub_agent(intent: str, message: str) -> str:
    """
    Route a message to the correct sub-agent based on detected intent.
    Intent values: 'menu', 'order