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.
Prerequisites
- Python 3.10 or higher installed
- An Anthropic API key (console.anthropic.com)
- Basic familiarity with Python classes and functions
anthropicSDK installed (pip install anthropic)- A
.envfile or environment variable set forANTHROPIC_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.
terminalmkdir 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:
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.pyimport 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
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.
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?