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
anthropicPython package installed (pip install anthropic) - Roughly 60 minutes and a coffee
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.
terminalmkdir 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.
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.pyimport 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.
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.
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 receiptNow 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.pyimport 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 resultscheck_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.pyimport 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
Naples AI