← Back to Blog

What You'll Build

If you've been searching for a real, working example of how to build a multi-agent chatbot with the Claude API, you're in the right place. By the end of this tutorial, you'll have a fully functional restaurant order system where an orchestrator agent delegates tasks — menu lookups, order processing, and payment handling — to specialized sub-agents, all powered by Claude's tool use feature. The entire thing runs in under 200 lines of Python and is production-ready enough to drop into a real business.

This isn't a toy demo. It's the same architecture we use at Naples AI when we build restaurant automation systems for clients here in Southwest Florida.

📦 Full Source Code Note: The complete, working code is built step-by-step in the sections below. Every snippet connects to the next — by Step 5 you'll have the entire system assembled. You can copy each block in order and run it directly.

Prerequisites

  • Python 3.10 or higher installed
  • An Anthropic API key (console.anthropic.com)
  • Basic familiarity with Python classes and functions
  • anthropic SDK installed (pip install anthropic)
  • A .env file or environment variable set for ANTHROPIC_API_KEY

Step 1: Set Up Your Claude API Project and Dependencies

First, let's get the project scaffolded. Create a new directory, set up a virtual environment, and install the one dependency you actually need.

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 python-dotenv

Now create a .env file in your project root with your API key:

.env
ANTHROPIC_API_KEY=your_api_key_here

Next, create your main file. This first block handles all imports and sets up the Anthropic client. Everything else in the system builds on this foundation.

restaurant_agent.py
import os
import json
from anthropic import Anthropic
from dotenv import load_dotenv

load_dotenv()

client = Anthropic()

# Our in-memory "database" for this demo
MENU = {
    "margherita_pizza": {"name": "Margherita Pizza", "price": 14.99, "available": True},
    "caesar_salad": {"name": "Caesar Salad", "price": 9.99, "available": True},
    "spaghetti_bolognese": {"name": "Spaghetti Bolognese", "price": 16.99, "available": True},
    "tiramisu": {"name": "Tiramisu", "price": 7.99, "available": False},
    "sparkling_water": {"name": "Sparkling Water", "price": 2.99, "available": True},
}

orders = {}  # Stores active orders keyed by order_id
order_counter = 1000
💡 Why claude-sonnet-4-5? Claude Sonnet 4 gives you the best balance of reasoning ability and speed for agentic tasks. It handles multi-step tool calls reliably without the latency of Opus, which matters when customers are waiting for order confirmations.

Step 2: Define Tool Functions (Order Processing, Menu Lookup, Payment)

This is where the real work happens. We define three Python functions — check_menu, process_order, and handle_payment — plus the JSON schemas that tell Claude how and when to call them.

The schemas are what Claude reads to understand each tool's purpose. The Python functions are what actually execute when Claude decides to use a tool.

restaurant_agent.py (continued)
def check_menu(item_name: str = None) -> dict:
    """Look up a specific item or return the full menu."""
    if item_name:
        # Normalize the search key
        key = item_name.lower().replace(" ", "_")
        if key in MENU:
            item = MENU[key]
            return {
                "found": True,
                "item": item["name"],
                "price": item["price"],
                "available": item["available"]
            }
        return {"found": False, "message": f"'{item_name}' is not on our menu."}
    # Return full menu if no specific item requested
    return {
        "menu": [
            {"item": v["name"], "price": v["price"], "available": v["available"]}
            for v in MENU.values()
        ]
    }


def process_order(customer_name: str, items: list) -> dict:
    """Create a new order and return an order ID with the total."""
    global order_counter
    order_id = f"ORD-{order_counter}"
    order_counter += 1

    ordered_items = []
    total = 0.0
    errors = []

    for item_name in items:
        key = item_name.lower().replace(" ", "_")
        if key not in MENU:
            errors.append(f"'{item_name}' not found on menu")
            continue
        if not MENU[key]["available"]:
            errors.append(f"'{item_name}' is currently unavailable")
            continue
        ordered_items.append({"name": MENU[key]["name"], "price": MENU[key]["price"]})
        total += MENU[key]["price"]

    if not ordered_items:
        return {"success": False, "errors": errors}

    # Store the order in our in-memory database
    orders[order_id] = {
        "customer": customer_name,
        "items": ordered_items,
        "total": round(total, 2),
        "status": "pending_payment",
        "errors": errors
    }

    return {
        "success": True,
        "order_id": order_id,
        "customer": customer_name,
        "items": ordered_items,
        "total": round(total, 2),
        "errors": errors,
        "message": f"Order {order_id} created successfully."
    }


def handle_payment(order_id: str, payment_method: str, amount: float) -> dict:
    """Process payment for a given order."""
    if order_id not in orders:
        return {"success": False, "message": f"Order {order_id} not found."}

    order = orders[order_id]

    if order["status"] == "paid":
        return {"success": False, "message": f"Order {order_id} has already been paid."}

    # Validate payment covers the total
    if round(amount, 2) < order["total"]:
        return {
            "success": False,
            "message": f"Insufficient payment. Order total is ${order['total']:.2f}, received ${amount:.2f}."
        }

    valid_methods = ["credit_card", "debit_card", "cash", "apple_pay"]
    if payment_method.lower() not in valid_methods:
        return {"success": False, "message": f"Invalid payment method. Use: {', '.join(valid_methods)}"}

    # Mark the order as paid
    orders[order_id]["status"] = "paid"
    change = round(amount - order["total"], 2)

    return {
        "success": True,
        "order_id": order_id,
        "amount_charged": order["total"],
        "change": change,
        "payment_method": payment_method,
        "message": f"Payment successful! Order {order_id} is confirmed."
    }

Now define the tool schemas. These are the JSON objects you pass directly to the Claude API so it knows what each tool does and what parameters to pass.

restaurant_agent.py (continued)
TOOLS = [
    {
        "name": "check_menu",
        "description": "Look up menu items and their availability. Call with no arguments to get the full menu, or pass an item name to check a specific dish.",
        "input_schema": {
            "type": "object",
            "properties": {
                "item_name": {
                    "type": "string",
                    "description": "The name of the menu item to look up. Optional — omit to return full menu."
                }
            },
            "required": []
        }
    },
    {
        "name": "process_order",
        "description": "Create a new order for a customer. Requires the customer's name and a list of items they want to order.",
        "input_schema": {
            "type": "object",
            "properties": {
                "customer_name": {
                    "type": "string",
                    "description": "The full name of the customer placing the order."
                },
                "items": {
                    "type": "array",
                    "items": {"type": "string"},
                    "description": "List of menu item names the customer wants to order."
                }
            },
            "required": ["customer_name", "items"]
        }
    },
    {
        "name": "handle_payment",
        "description": "Process payment for an existing order. Requires a valid order ID, payment method, and the amount being paid.",
        "input_schema": {
            "type": "object",
            "properties": {
                "order_id": {
                    "type": "string",
                    "description": "The order ID returned by process_order (e.g. 'ORD-1000')."
                },
                "payment_method": {
                    "type": "string",
                    "description": "Payment method: credit_card, debit_card, cash, or apple_pay."
                },
                "amount": {
                    "type": "number",
                    "description": "The amount the customer is paying in USD."
                }
            },
            "required": ["order_id", "payment_method", "amount"]
        }
    }
]

Step 3: Build the Orchestrator Agent

The orchestrator is the brain of the whole system. It receives the customer's message, decides which tools or sub-agents are needed, and coordinates the response. Think of it as the restaurant manager — it doesn't cook the food, but it makes sure everything happens in the right order.

Here's the orchestrator class initialization with its system prompt and core dispatch logic:

restaurant_agent.py (continued)
class OrchestratorAgent:
    """
    The main coordinating agent. Receives customer input, routes to tools
    or sub-agents, and synthesizes the final response.
    """

    def __init__(self):
        self.client = client
        self.model = "claude-sonnet-4-5"
        self.conversation_history = []
        self.system_prompt = """You are the AI assistant for Bella Napoli, an Italian restaurant.
You help customers check the menu, place orders, and process payments.

Your workflow:
1. Greet the customer and understand what they need.
2. Use check_menu to look up items or show the full menu when asked.
3. Use process_order once the customer has decided what they want.
4. Use handle_payment after the order is confirmed and the customer is ready to pay.

Always confirm order details before processing payment. Be friendly and conversational.
If an item is unavailable, suggest alternatives from the menu."""

    def dispatch_tool(self, tool_name: str, tool_input: dict) -> str:
        """Route tool calls to the appropriate Python function and return the result as a string."""
        if tool_name == "check_menu":
            result = check_menu(**tool_input)
        elif tool_name == "process_order":
            result = process_order(**tool_input)
        elif tool_name == "handle_payment":
            result = handle_payment(**tool_input)
        else:
            result = {"error": f"Unknown tool: {tool_name}"}

        # Claude expects tool results as strings
        return json.dumps(result)

Step 4: Create Specialized Sub-Agents

Sub-agents handle focused tasks with their own scoped context and system prompts. In this system, we have a MenuAgent that specializes in menu recommendations and a PaymentAgent that handles payment confirmations. The orchestrator calls them when it needs domain-specific reasoning.

restaurant_agent.py (continued)
class MenuAgent:
    """
    Specialized sub-agent for menu recommendations and dietary questions.
    Called by the orchestrator when detailed menu guidance is needed.
    """

    def __init__(self):
        self.model = "claude-sonnet-4-5"
        self.system_prompt = """You are a knowledgeable menu specialist for Bella Napoli Italian restaurant.
Help customers understand menu options, suggest pairings, and handle dietary questions.
Use the check_menu tool to provide accurate pricing and availability."""

    def get_recommendation(self, customer_query: str) -> str:
        """Generate a menu recommendation based on the customer's query."""
        response = client.messages.create(
            model=self.model,
            max_tokens=512,
            system=self.system_prompt,
            tools=TOOLS,
            messages=[{"role": "user", "content": customer_query}]
        )

        # If Claude called a tool, execute it and get the final answer
        if response.stop_reason == "tool_use":
            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)
                    })

            # Send the tool result back for a final natural-language response
            final_response = client.messages.create(
                model=self.model,
                max_tokens=512,
                system=self.system_prompt,
                tools=TOOLS,
                messages=[
                    {"role": "user", "content": customer_query},
                    {"role": "assistant", "content": response.content},
                    {"role": "user", "content": tool_results}
                ]
            )
            return final_response.content[0].text

        return response.content[0].text


class PaymentAgent:
    """
    Specialized sub-agent for payment confirmation and receipt generation.
    """

    def __init__(self):
        self.model = "claude-sonnet-4-5"
        self.system_prompt = """You are the payment specialist for Bella Napoli restaurant.
Confirm order totals, process payments using handle_payment, and issue friendly receipts.
Always verify the order total with the customer before charging."""

    def process_checkout(self, order_id: str, order_details: dict, payment_info: dict) -> str:
        """Handle the checkout flow for a confirmed order."""
        checkout_message = (
            f"Process payment for order {order_id}. "
            f"Order total: ${order_details.get('total', 0):.2f}. "
            f"Payment method: {payment_info.get('method', 'credit_card')}. "
            f"Amount tendered: ${payment_info.get('amount', 0):.2f}."
        )

        response = client.messages.create(
            model=self.model,
            max_tokens=512,
            system=self.system_prompt,
            tools=TOOLS,
            messages=[{"role": "user", "content": checkout_message}]
        )

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

            final_response = client.messages.create(
                model=self.model,
                max_tokens=512,
                system=self.system_prompt,
                tools=TOOLS,
                messages=[
                    {"role": "user", "content": checkout_message},
                    {"role": "assistant", "content": response.content},
                    {"role": "user", "content": tool_results}
                ]
            )
            return final_response.content[0].text

        return response.content[0].text


# Instantiate sub-agents once at module level so they're ready to use
menu_agent = MenuAgent()
payment_agent = PaymentAgent()

Step 5: Implement the Agent Run Loop with Tool Use

This is the core of the multi-agent system — the agentic loop. Claude returns a response, we check if it wants to use a tool, execute the tool, feed the result back, and repeat until Claude is done. This loop handles everything from a simple "what's on the menu?" to a full order-and-pay flow.

restaurant_agent.py (continued)
    def run(self, user_message: str) -> str:
        """
        Main agentic loop. Sends user message to Claude, handles tool calls
        iteratively until Claude returns a final text response.
        """
        # Append the new user message to conversation history
        self.conversation_history.append({
            "role": "user",
            "content": user_message
        })

        while True:
            response = self.client.messages.create(
                model=self.model,
                max_tokens=1024,
                system=self.system_prompt,
                tools=TOOLS,
                messages=self.conversation_history
            )

            # Append Claude's response to history so it has context going forward
            self.conversation_history.append({
                "role": "assistant",
                "content": response.content
            })

            # If Claude is done thinking and just wants to reply, return the text
            if response.stop_reason == "end_turn":
                for block in response.content:
                    if hasattr(block, "text"):
                        return block.text
                return "I'm not sure how to respond to that. Could you rephrase?"

            # If Claude wants to use tools, execute them and loop back
            if response.stop_reason == "tool_use":
                tool_results = []

                for block in response.content:
                    if block.type == "tool_use":
                        print(f"  [Tool call] {block.name}({json.dumps(block.input)})")
                        result = self.dispatch_tool(block.name, block.input)
                        print(f"  [Tool result] {result}")

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

                # Feed tool results back into the conversation and loop again
                self.conversation_history.append({
                    "role": "user",
                    "content": tool_results
                })
                continue  # Go back to the top of the while loop

            # Fallback: unexpected stop reason
            return f"Unexpected stop reason: {response.stop_reason}"


def main():
    """Run an interactive restaurant ordering session."""
    print("=" * 50)
    print("Welcome to Bella Napoli AI Ordering System")
    print("Type 'quit' to exit.")
    print("=" * 50)

    orchestrator = OrchestratorAgent()

    while True:
        user_input = input("\nYou: ").strip()
        if user_input.lower() in ("quit", "exit", "q"):
            print("Thanks for visiting Bella Napoli. Ciao!")
            break
        if not user_input:
            continue

        response = orchestrator.run(user_input)
        print(f"\nBella Napoli AI: {response}")


if __name__ == "__main__":
    main()

Here's what a real session looks like when you run this:

sample output
==================================================
Welcome to Bella Napoli AI Ordering System
Type 'quit' to exit.
==================================================

You: Hi, what's on the menu?
  [Tool call] check_menu({})
  [Tool result] {"menu": [{"item": "Margherita Pizza", "price": 14.99, "available": true}, ...]}

Bella Napoli AI: Welcome to Bella Napoli! Here's what we have tonight:

🍕 Margherita Pizza — $14.99
🥗 Caesar Salad — $9.99
🍝 Spaghetti Bolognese — $16.99
🍰 Tiramisu — $7.99 (currently unavailable)
💧 Sparkling Water — $2.99

What can I get started for you?

You: I'd like the pizza and a Caesar salad. Name is Marco.
  [Tool call] process_order({"customer_name": "Marco", "items": ["margherita pizza", "caesar salad"]})
  [Tool result] {"success": true, "order_id": "ORD-1000", "total": 24.98, ...}

Bella Napoli AI: Great choice, Marco! Here's your order summary:

- Margherita Pizza — $14.99
- Caesar Salad — $9.99
**Total: $24.98**

Order ID: ORD-1000. Ready to pay?