← Back to Blog

If you've been trying to figure out how to create an AI agent that actually does useful work — not just chat — this tutorial is for you. We're going to build a real, production-ready multi-agent restaurant ordering system using Python and the Anthropic Claude API. By the end, you'll have autonomous AI agents that look up menus, check inventory, process orders, and coordinate with each other to handle a full customer order flow.

This isn't a toy example. I built a version of this for a restaurant client here in Southwest Florida, and the same architecture patterns apply whether you're automating orders, support tickets, or any other multi-step business process. Let's get into it.

What You'll Build

You'll build a multi-agent restaurant ordering system where a main orchestrator agent receives a customer's natural language order and delegates tasks to specialized sub-agents. One agent handles menu lookups, another checks inventory, and a third processes and confirms the final order.

The system uses Claude's native tool use (function calling) to give each agent real capabilities — not just text generation. You'll end up with a working Python script you can run locally and extend into a real application.

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
  • Understanding of what an API call is — that's all you need
📦 Full Source Code
All the code you need is included step-by-step in the sections below. Each snippet builds on the last, and the final section shows you the complete assembled file. Copy each block in order and you'll have a working system by the end. No GitHub repo needed — everything is right here.

Step 1: Set Up Your Claude API Project and Environment

First, let's get the environment sorted. Create a new project folder, set up a virtual environment, and install the Anthropic SDK. This keeps your dependencies clean and avoids conflicts with other projects you might have running.

Create a file called .env in your project root and add your API key there. We'll load it with Python's os module — no need for python-dotenv for this tutorial, though you can add it in production.

setup.sh
mkdir restaurant-agent && cd restaurant-agent
python -m venv venv
source venv/bin/activate  # On Windows: venv\Scripts\activate
pip install anthropic
touch main.py
        

Now let's write the skeleton of our main file with the API client initialized. This is the foundation everything else builds on.

main.py
import os
import json
import anthropic

# Initialize the Anthropic client
# Reads ANTHROPIC_API_KEY from environment automatically
client = anthropic.Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY"))

MODEL = "claude-sonnet-4-5"

print("Anthropic client initialized successfully.")
        
⚠️ API Key Safety
Never hardcode your API key directly in source code. Use environment variables or a secrets manager. If you accidentally commit a key to GitHub, rotate it immediately from the Anthropic console.

Step 2: Define Tool Schemas for Menu, Orders, and Customer Data

Tool schemas are how you tell Claude what capabilities your agents have. Think of them as typed function signatures — Claude reads the description and parameters, then decides when and how to call each tool. Getting these descriptions right is one of the most important parts of building AI agents that work reliably.

We're defining three tools: menu_lookup to fetch menu items by category, check_inventory to verify stock before confirming an order, and process_order to finalize and record the transaction. Each tool has a clear description and typed parameters so Claude knows exactly how to use it.

tools.py
# Tool definitions passed to the Claude API
# These tell the model what functions are available and how to call them

RESTAURANT_TOOLS = [
    {
        "name": "menu_lookup",
        "description": (
            "Look up menu items for the restaurant. Use this when a customer asks "
            "about what's available, prices, or specific dishes. Returns a list of "
            "items with names, descriptions, and prices for the requested category."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "category": {
                    "type": "string",
                    "description": (
                        "The menu category to look up. "
                        "Options: 'appetizers', 'mains', 'desserts', 'drinks', 'all'"
                    ),
                    "enum": ["appetizers", "mains", "desserts", "drinks", "all"]
                },
                "item_name": {
                    "type": "string",
                    "description": (
                        "Optional. A specific item name to search for within the menu."
                    )
                }
            },
            "required": ["category"]
        }
    },
    {
        "name": "check_inventory",
        "description": (
            "Check whether a specific menu item is currently in stock and available "
            "to order. Always call this before confirming any item in a customer order "
            "to avoid promising something that's sold out."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "item_name": {
                    "type": "string",
                    "description": "The exact name of the menu item to check inventory for."
                },
                "quantity": {
                    "type": "integer",
                    "description": "The number of this item the customer wants to order.",
                    "minimum": 1
                }
            },
            "required": ["item_name", "quantity"]
        }
    },
    {
        "name": "process_order",
        "description": (
            "Finalize and record a confirmed customer order. Only call this after "
            "inventory has been verified for all items. Returns an order confirmation "
            "number and estimated preparation time."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "customer_name": {
                    "type": "string",
                    "description": "The name of the customer placing the order."
                },
                "items": {
                    "type": "array",
                    "description": "List of items being ordered.",
                    "items": {
                        "type": "object",
                        "properties": {
                            "name": {
                                "type": "string",
                                "description": "Name of the menu item."
                            },
                            "quantity": {
                                "type": "integer",
                                "description": "Quantity ordered."
                            },
                            "price": {
                                "type": "number",
                                "description": "Price per unit in USD."
                            }
                        },
                        "required": ["name", "quantity", "price"]
                    }
                },
                "special_instructions": {
                    "type": "string",
                    "description": "Any special dietary requests or preparation notes."
                }
            },
            "required": ["customer_name", "items"]
        }
    }
]
        

Notice how the descriptions are written from Claude's perspective — they explain when to use each tool, not just what it does. That context makes a huge difference in how reliably the agent uses the right tool at the right time.

Step 3: Build the Main Agent Class with Agentic Loop

The agentic loop is the core pattern for building AI agents. It works like this: send a message to Claude, check if Claude wants to use a tool, execute that tool, send the result back, and repeat until Claude gives a final answer with no more tool calls. This loop is what makes agents different from regular chat — they can take multiple steps to complete a task.

Here's the main orchestrator agent class. This handles the loop logic, manages the conversation history, and dispatches tool calls to the right handler functions.

agent.py
import os
import json
import anthropic
from tools import RESTAURANT_TOOLS

client = anthropic.Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY"))
MODEL = "claude-sonnet-4-5"

class RestaurantOrchestratorAgent:
    """
    Main orchestrator agent for the restaurant ordering system.
    Manages the agentic loop and delegates tool execution to handlers.
    """

    def __init__(self, tool_handler):
        self.tool_handler = tool_handler
        self.conversation_history = []
        self.system_prompt = (
            "You are a friendly and efficient restaurant ordering assistant for "
            "Bella Napoli, an Italian restaurant in Naples, Florida. Your job is to "
            "help customers place orders accurately. Always check the menu first if "
            "a customer is unsure what to order. Always verify inventory before "
            "confirming any item. Process the order only after all items are confirmed "
            "available. Be warm, concise, and professional."
        )

    def run(self, user_message: str) -> str:
        """
        Run the agentic loop for a given user message.
        Returns the agent's final text response after all tool calls complete.
        """
        # Add the user's message to conversation history
        self.conversation_history.append({
            "role": "user",
            "content": user_message
        })

        # Agentic loop — keep running until Claude stops calling tools
        while True:
            response = client.messages.create(
                model=MODEL,
                max_tokens=4096,
                system=self.system_prompt,
                tools=RESTAURANT_TOOLS,
                messages=self.conversation_history
            )

            # Add Claude's full response to history before processing
            self.conversation_history.append({
                "role": "assistant",
                "content": response.content
            })

            # If stop reason is "end_turn", Claude is done — return the text
            if response.stop_reason == "end_turn":
                # Extract the text content from the final response
                for block in response.content:
                    if hasattr(block, "text"):
                        return block.text
                return "Order process complete."

            # If stop reason is "tool_use", process all tool calls in this response
            if response.stop_reason == "tool_use":
                tool_results = []

                for block in response.content:
                    if block.type == "tool_use":
                        print(f"\n[Agent] Calling tool: {block.name}")
                        print(f"[Agent] With inputs: {json.dumps(block.input, indent=2)}")

                        # Execute the tool and get the result
                        result = self.tool_handler.execute(block.name, block.input)

                        print(f"[Agent] Tool result: {json.dumps(result, indent=2)}")

                        # Collect the tool result to send back in one message
                        tool_results.append({
                            "type": "tool_result",
                            "tool_use_id": block.id,
                            "content": json.dumps(result)
                        })

                # Send all tool results back to Claude in a single user message
                self.conversation_history.append({
                    "role": "user",
                    "content": tool_results
                })

            else:
                # Unexpected stop reason — break to avoid infinite loop
                print(f"[Agent] Unexpected stop reason: {response.stop_reason}")
                break

        return "An unexpected error occurred in the ordering process."

    def reset(self):
        """Clear conversation history for a new customer session."""
        self.conversation_history = []
        
💡 Why the Loop Matters
Most beginners only make one API call and wonder why their agent can't complete multi-step tasks. The while True loop with the stop_reason check is what lets Claude chain together multiple tool calls — like checking the menu, then checking inventory for two items, then processing the order — all in one user request.

Step 4: Implement Tool Functions (Order Processing, Menu Retrieval, Inventory Check)

Now we need the actual tool implementations — the functions that do the real work when Claude decides to call a tool. In production, these would hit a real database or POS system. Here, I'm using realistic in-memory data so you can run this immediately and see real output.

The ToolHandler class acts as the dispatcher. It maps tool names to the right function and returns structured results that Claude can interpret and act on.

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

# Simulated restaurant menu database
MENU_DATA = {
    "appetizers": [
        {"name": "Bruschetta al Pomodoro", "description": "Grilled bread with fresh tomato and basil", "price": 9.50},
        {"name": "Calamari Fritti", "description": "Crispy fried calamari with marinara sauce", "price": 13.00},
        {"name": "Caprese Salad", "description": "Fresh mozzarella, tomatoes, and basil with olive oil", "price": 11.00},
    ],
    "mains": [
        {"name": "Spaghetti Carbonara", "description": "Classic carbonara with pancetta and pecorino", "price": 18.50},
        {"name": "Margherita Pizza", "description": "San Marzano tomato, fresh mozzarella, basil", "price": 16.00},
        {"name": "Branzino al Forno", "description": "Oven-roasted sea bass with lemon and capers", "price": 28.00},
        {"name": "Chicken Piccata", "description": "Pan-seared chicken in lemon caper sauce", "price": 22.00},
    ],
    "desserts": [
        {"name": "Tiramisu", "description": "Classic Italian tiramisu with espresso and mascarpone", "price": 8.50},
        {"name": "Panna Cotta", "description": "Vanilla panna cotta with berry compote", "price": 7.50},
    ],
    "drinks": [
        {"name": "House Chianti", "description": "Glass of house red wine from Tuscany", "price": 9.00},
        {"name": "San Pellegrino", "description": "Sparkling mineral water, 500ml", "price": 4.00},
        {"name": "Espresso", "description": "Double shot Italian espresso", "price": 3.50},
    ]
}

# Simulated inventory — tracks available quantity per item
INVENTORY = {
    "Bruschetta al Pomodoro": 15,
    "Calamari Fritti": 8,
    "Caprese Salad": 12,
    "Spaghetti Carbonara": 10,
    "Margherita Pizza": 14,
    "Branzino al Forno": 3,  # Low stock — good for testing edge cases
    "Chicken Piccata": 9,
    "Tiramisu": 6,
    "Panna Cotta": 5,
    "House Chianti": 30,
    "San Pellegrino": 25,
    "Espresso": 50,
}

# Stores confirmed orders for this session
ORDER_REGISTRY = {}


class ToolHandler:
    """
    Dispatches tool calls from the agent to the correct implementation.
    Add new tools by registering them in the execute() method.
    """

    def execute(self, tool_name: str, tool_input: dict) -> dict:
        """Route a tool call to the appropriate handler function."""
        if tool_name == "menu_lookup":
            return self.menu_lookup(**tool_input)
        elif tool_name == "check_inventory":
            return self.check_inventory(**tool_input)
        elif tool_name == "process_order":
            return self.process_order(**tool_input)
        else:
            return {"error": f"Unknown tool: {tool_name}"}

    def menu_lookup(self, category: str, item_name: str = None) -> dict:
        """
        Returns menu items for a given category.
        If item_name is provided, filters to matching items only.
        """
        if category == "all":
            all_items = []
            for cat, items in MENU_DATA.items():
                for item in items:
                    all_items.append({**item, "category": cat})
            results = all_items
        else:
            results = MENU_DATA.get(category, [])

        # Filter by item name if provided
        if item_name:
            results = [
                item for item in results
                if item_name.lower() in item["name"].lower()
            ]

        if not results:
            return {
                "success": False,
                "message": f"No items found in category '{category}'.",
                "items": []
            }

        return {
            "success": True,
            "category": category,
            "items": results,
            "count": len(results)
        }

    def check_inventory(self, item_name: str, quantity: int) -> dict:
        """
        Checks if the requested quantity is available in inventory.
        Returns availability status and current stock level.
        """
        # Case-insensitive match against inventory keys
        matched_key = None
        for key in INVENTORY:
            if key.lower() == item_name.lower():
                matched_key = key
                break

        if matched_key is None:
            return {
                "success": False,
                "available": False,
                "message": f"Item '{item_name}' not found in inventory system.",
                "current_stock": 0
            }

        current_stock = INVENTORY[matched_key]
        is_available = current_stock >= quantity

        return {
            "success": True,
            "item_name": matched_key,
            "requested_quantity": quantity,
            "current_stock": current_stock,
            "available": is_available,
            "message": (
                f"'{matched_key}' is available. {current_stock} in stock."
                if is_available
                else f"Not enough stock for '{matched_key}'. Only {current_stock} remaining."
            )
        }

    def process_order(
        self,
        customer_name: str,
        items: list,
        special_instructions: str = None
    ) -> dict:
        """
        Finalizes the customer order and generates a confirmation number.
        Deducts ordered quantities from inventory.
        """
        # Generate a unique order confirmation number
        order_id = "ORD-" + "".join(random.choices(string.ascii_uppercase + string.digits, k=6))
        timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")

        # Calculate order total
        total = sum(item["price"] * item["quantity"] for item in items)

        # Estimate prep time based on number of items (simple heuristic)
        base_time = 15
        prep_time = base_time + (len(items) * 3)

        # Deduct from inventory for each confirmed item
        for item in items:
            for key in INVENTORY:
                if key.lower() == item["name"].lower():
                    INVENTORY[key] -= item["quantity"]
                    break

        # Store the order in our registry
        order_record = {
            "order_id": order_id,
            "customer_name": customer_name,
            "items": items,
            "special_instructions": special_instructions,
            "total": round(total, 2),
            "timestamp": timestamp,
            "estimated_prep_minutes": prep_time,
            "status": "confirmed"
        }
        ORDER_REGISTRY[order_id] = order_record

        return {
            "success": True,
            "order_id": order_id,
            "customer_name": customer_name,
            "items_confirmed": items,
            "order_total": f"${round(total, 2):.2f}",
            "estimated_prep_time": f"{prep_time} minutes",
            "timestamp": timestamp,
            "message": (
                f"Order confirmed! Your order ID is {order_id}. "
                f"Estimated ready in {prep_time} minutes. "
                + (f"Special instructions noted: {special_instructions}" if special_instructions else "")
            )
        }
        

Step 5: Create the Multi-Agent Coordination System

Here's where it gets interesting. A single agent can handle one order at a time, but a multi-agent system lets you run a dedicated sub-agent for specialized tasks — like a separate agent that handles dietary restriction queries or a VIP customer concierge. The coordinator routes incoming requests to the right agent based on the nature of the request.

This pattern is the same one used in enterprise AI systems. Once you understand it, you can apply it to almost any business workflow — support routing, document processing, you name it.

coordinator.py
import os
import json
import anthropic
from tools import RESTAURANT_TOOLS
from tool_handler import ToolHandler

client = anthropic.Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY"))
MODEL = "claude-sonnet-4-5"


class SpecializedAgent:
    """
    A sub-agent with a focused role and its own system prompt.
    Used by the coordinator to handle specific types of requests.
    """

    def __init__(self, name: str, system_prompt: str, tool_handler: ToolHandler):
        self.name = name
        self.system_prompt = system