What You'll Build
By the end of this tutorial, you'll have a working multi-agent restaurant system that handles customer orders and table reservations using Claude's API — two specialized agents coordinated by a routing agent that decides who handles what. This is the same kind of architecture we build for actual restaurant clients at Naples AI, just stripped down so you can follow every line. You'll walk away with production-ready Python code you can extend into a real deployment.
Prerequisites
- Python 3.10 or higher installed
- An Anthropic API key (get one at console.anthropic.com)
- Basic familiarity with Python classes and functions
anthropicSDK installed:pip install anthropicpython-dotenvfor managing your API key:pip install python-dotenv
All the code you need is written out step-by-step in the sections below. Each snippet builds on the last, so by the time you reach Step 6, you'll have a fully working system. Copy each section into a single file called
restaurant_agents.py as you go, or jump to the end and grab it all at once.
Step 1: Set Up Claude API Authentication and Environment
First, let's wire up authentication and make sure your environment is configured correctly. I always use a .env file to keep the API key out of the source code — it's a small habit that saves you from accidentally committing secrets to GitHub.
ANTHROPIC_API_KEY=your_api_key_here
import os
import json
from datetime import datetime
from typing import Any
import anthropic
from dotenv import load_dotenv
load_dotenv()
# Initialize the Anthropic client — it reads ANTHROPIC_API_KEY from the environment automatically
client = anthropic.Anthropic()
MODEL = "claude-sonnet-4-6"
print("Claude client initialized successfully.")Run this first to confirm your key is loading correctly before you build anything on top of it. If you get an AuthenticationError, double-check that your .env file is in the same directory you're running the script from.
Step 2: Define Tool Schemas for Order Taking and Reservation Management
Tools are how you give Claude structured ways to take action. Think of them as function signatures Claude can call — you describe what each function does and what parameters it needs, and Claude decides when to invoke them based on the conversation. Here we're defining tools for both agents separately so each one stays focused on its job.
restaurant_agents.py (continued)# ── ORDER AGENT TOOLS ──────────────────────────────────────────────────────────
order_tools = [
{
"name": "add_item_to_order",
"description": "Adds a menu item to the customer's current order. Use this when a customer requests a specific food or drink item.",
"input_schema": {
"type": "object",
"properties": {
"item_name": {
"type": "string",
"description": "The name of the menu item to add, e.g. 'Margherita Pizza'"
},
"quantity": {
"type": "integer",
"description": "How many of this item the customer wants"
},
"special_instructions": {
"type": "string",
"description": "Any modifications or allergies, e.g. 'no onions, extra cheese'"
}
},
"required": ["item_name", "quantity"]
}
},
{
"name": "confirm_order",
"description": "Finalizes and submits the customer's order to the kitchen. Call this only after the customer explicitly confirms they are done ordering.",
"input_schema": {
"type": "object",
"properties": {
"order_items": {
"type": "array",
"items": {"type": "string"},
"description": "A list of all items in the final order"
},
"table_number": {
"type": "integer",
"description": "The table number to deliver the order to"
}
},
"required": ["order_items", "table_number"]
}
}
]
# ── RESERVATION AGENT TOOLS ────────────────────────────────────────────────────
reservation_tools = [
{
"name": "check_availability",
"description": "Checks whether a table is available for a given date, time, and party size.",
"input_schema": {
"type": "object",
"properties": {
"date": {
"type": "string",
"description": "Reservation date in YYYY-MM-DD format"
},
"time": {
"type": "string",
"description": "Reservation time in HH:MM format (24-hour)"
},
"party_size": {
"type": "integer",
"description": "Number of guests in the party"
}
},
"required": ["date", "time", "party_size"]
}
},
{
"name": "book_reservation",
"description": "Books a table reservation for the customer. Only call this after confirming availability.",
"input_schema": {
"type": "object",
"properties": {
"customer_name": {
"type": "string",
"description": "Full name of the person making the reservation"
},
"date": {
"type": "string",
"description": "Reservation date in YYYY-MM-DD format"
},
"time": {
"type": "string",
"description": "Reservation time in HH:MM format (24-hour)"
},
"party_size": {
"type": "integer",
"description": "Number of guests"
},
"phone_number": {
"type": "string",
"description": "Customer's contact phone number"
}
},
"required": ["customer_name", "date", "time", "party_size", "phone_number"]
}
}
]Step 3: Build the Order-Taking Agent
The order agent handles everything food and drink related. It holds an in-memory cart, processes tool calls from Claude, and returns a structured summary when the order is confirmed. The key pattern here is the tool-use loop — Claude returns a tool call, you execute the function, then you feed the result back to Claude so it can respond naturally to the customer.
restaurant_agents.py (continued)class OrderAgent:
def __init__(self):
self.current_order: list[dict] = []
self.system_prompt = """You are a friendly order-taking assistant at Bella Napoli, an Italian restaurant in Naples, Florida.
Help customers place their food and drink orders. Use the add_item_to_order tool each time a customer requests an item.
When the customer says they are done, summarize their order and use confirm_order to submit it.
Be warm, helpful, and concise."""
def process_tool_call(self, tool_name: str, tool_input: dict) -> str:
"""Execute the tool logic and return a result string Claude can read."""
if tool_name == "add_item_to_order":
item = {
"name": tool_input["item_name"],
"quantity": tool_input["quantity"],
"instructions": tool_input.get("special_instructions", "none")
}
self.current_order.append(item)
return f"Added {tool_input['quantity']}x {tool_input['item_name']} to the order."
elif tool_name == "confirm_order":
order_summary = {
"status": "confirmed",
"table": tool_input["table_number"],
"items": tool_input["order_items"],
"timestamp": datetime.now().isoformat()
}
# In production, this is where you'd POST to your POS system
return f"Order confirmed for table {tool_input['table_number']}. Ticket sent to kitchen: {json.dumps(order_summary)}"
return "Unknown tool called."
def run(self, user_message: str, table_number: int = 1) -> str:
"""Run a single turn of the order conversation and return Claude's response."""
messages = [{"role": "user", "content": user_message}]
while True:
response = client.messages.create(
model=MODEL,
max_tokens=1024,
system=self.system_prompt + f"\nThe customer is seated at table {table_number}.",
tools=order_tools,
messages=messages
)
# If Claude is done (no tool calls), return its final text response
if response.stop_reason == "end_turn":
for block in response.content:
if hasattr(block, "text"):
return block.text
return "Order agent finished."
# Process any tool calls Claude made
if response.stop_reason == "tool_use":
# Add Claude's response (including tool calls) to message history
messages.append({"role": "assistant", "content": response.content})
tool_results = []
for block in response.content:
if block.type == "tool_use":
result = self.process_tool_call(block.name, block.input)
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": result
})
# Feed tool results back to Claude
messages.append({"role": "user", "content": tool_results})
else:
break
return "Order agent reached an unexpected state."Step 4: Build the Reservation Scheduling Agent
The reservation agent follows the exact same tool-use loop pattern — that's intentional. Once you understand this pattern, every agent you build works the same way, which makes the system easy to extend. Here I'm mocking the availability check with simple logic, but in a real deployment you'd replace that with a database query.
restaurant_agents.py (continued)class ReservationAgent:
def __init__(self):
# Simulated bookings database — replace with a real DB in production
self.reservations: list[dict] = []
self.system_prompt = """You are a reservation specialist at Bella Napoli restaurant in Naples, Florida.
Help customers check table availability and book reservations. Always check availability before booking.
Confirm all details with the customer before finalizing. Be professional and friendly."""
def process_tool_call(self, tool_name: str, tool_input: dict) -> str:
if tool_name == "check_availability":
# Simplified availability logic — real version queries your reservation DB
date = tool_input["date"]
time = tool_input["time"]
party_size = tool_input["party_size"]
# Check if we already have a reservation at this exact time slot
conflict = any(
r["date"] == date and r["time"] == time
for r in self.reservations
)
if conflict:
return f"No availability on {date} at {time} for {party_size} guests. Please try a different time."
elif party_size > 12:
return f"Party sizes over 12 require a private event booking. Please call us directly."
else:
return f"Table available on {date} at {time} for {party_size} guests."
elif tool_name == "book_reservation":
reservation = {
"id": f"RES-{len(self.reservations) + 1001}",
"customer_name": tool_input["customer_name"],
"date": tool_input["date"],
"time": tool_input["time"],
"party_size": tool_input["party_size"],
"phone": tool_input["phone_number"],
"created_at": datetime.now().isoformat()
}
self.reservations.append(reservation)
return f"Reservation confirmed! ID: {reservation['id']} for {tool_input['customer_name']} on {tool_input['date']} at {tool_input['time']} for {tool_input['party_size']} guests."
return "Unknown tool called."
def run(self, user_message: str) -> str:
messages = [{"role": "user", "content": user_message}]
while True:
response = client.messages.create(
model=MODEL,
max_tokens=1024,
system=self.system_prompt,
tools=reservation_tools,
messages=messages
)
if response.stop_reason == "end_turn":
for block in response.content:
if hasattr(block, "text"):
return block.text
return "Reservation agent finished."
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 = self.process_tool_call(block.name, block.input)
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": result
})
messages.append({"role": "user", "content": tool_results})
else:
break
return "Reservation agent reached an unexpected state."Step 5: Create the Coordinator Agent That Routes Between Agents
This is the part that makes it a multi-agent system instead of just a chatbot. The coordinator reads the customer's message, decides which specialist agent should handle it, and hands off control. It uses Claude itself to make the routing decision — no hardcoded keyword matching, no brittle if-else chains.
restaurant_agents.py (continued)class CoordinatorAgent:
def __init__(self):
self.order_agent = OrderAgent()
self.reservation_agent = ReservationAgent()
self.conversation_history: list[dict] = []
self.system_prompt = """You are the front-of-house coordinator for Bella Napoli restaurant.
Your only job is to classify customer requests and route them to the correct department.
Respond with ONLY a JSON object in this exact format (no other text):
{
"intent": "order" | "reservation" | "general",
"message": "the customer's original message, cleaned up if needed"
}
Use "order" for any food or drink orders.
Use "reservation" for table bookings, availability checks, or scheduling.
Use "general" for anything else like hours, directions, or menu questions."""
def route(self, user_message: str) -> dict:
"""Ask Claude to classify the intent and return a routing decision."""
response = client.messages.create(
model=MODEL,
max_tokens=256,
system=self.system_prompt,
messages=[{"role": "user", "content": user_message}]
)
# Extract the text from Claude's response
response_text = ""
for block in response.content:
if hasattr(block, "text"):
response_text = block.text.strip()
break
try:
routing = json.loads(response_text)
except json.JSONDecodeError:
# Fall back to general if Claude's response isn't valid JSON
routing = {"intent": "general", "message": user_message}
return routing
def handle(self, user_message: str, table_number: int = 1) -> str:
"""Route the message to the appropriate agent and return its response."""
print(f"\n[COORDINATOR] Received: '{user_message}'")
routing = self.route(user_message)
intent = routing.get("intent", "general")
cleaned_message = routing.get("message", user_message)
print(f"[COORDINATOR] Routed to: {intent.upper()} AGENT")
if intent == "order":
response = self.order_agent.run(cleaned_message, table_number=table_number)
print(f"[ORDER AGENT] Response: {response}")
return response
elif intent == "reservation":
response = self.reservation_agent.run(cleaned_message)
print(f"[RESERVATION AGENT] Response: {response}")
return response
else:
# Handle general questions directly in the coordinator
general_response = client.messages.create(
model=MODEL,
max_tokens=512,
system="You are a helpful assistant for Bella Napoli, an Italian restaurant in Naples, Florida. Answer general questions about the restaurant politely and concisely.",
messages=[{"role": "user", "content": cleaned_message}]
)
response_text = ""
for block in general_response.content:
if hasattr(block, "text"):
response_text = block.text
break
print(f"[COORDINATOR] General response delivered.")
return response_textStep 6: Implement the Main Execution Loop and Error Handling
Now we tie everything together with a simple interactive loop. I've wrapped the coordinator calls in a try-except block so one bad API response doesn't crash the whole session — in a restaurant environment, uptime matters. The loop keeps running until the customer types quit.
def main():
print("=" * 60)
print(" Bella Napoli — AI Restaurant Assistant")
print(" Powered by Claude + Naples AI")
print("=" * 60)
print("Type your message below. Type 'quit' to exit.\n")
coordinator = CoordinatorAgent()
table_number = 5 # In a real app, this comes from the POS or QR code scan
while True:
try:
user_input = input("Customer: ").strip()
if not user_input:
continue
if user_input.lower() in ("quit", "exit", "bye"):
print("\nAssistant: Thank you for dining at Bella Napoli! Have a wonderful evening.")
break
response = coordinator.handle(user_input, table_number=table_number)
print(f"\nAssistant: {response}\n")
except anthropic.APIConnectionError:
print("\n[ERROR] Could not reach the Anthropic API. Check your internet connection and try again.\n")
except anthropic.RateLimitError:
print("\n[ERROR] Rate limit hit. Wait a few seconds before sending another message.\n")
except anthropic.APIStatusError as e:
print(f"\n[ERROR] API returned an error: {e.status_code} — {e.message}\n")
except KeyboardInterrupt:
print("\n\nSession ended by user.")
break
if __name__ == "__main__":
main()Here's what a real session looks like when you run this:
Example output============================================================
Bella Napoli — AI Restaurant Assistant
Powered by Claude + Naples AI
============================================================
Type your message below. Type 'quit' to exit.
Customer: I'd like to order a margherita pizza and a caesar salad, no croutons
[COORDINATOR] Received: 'I'd like to order a margherita pizza and a caesar salad, no croutons'
[COORDINATOR] Routed to: ORDER AGENT
[ORDER AGENT] Response: Got it! I've added 1x Margherita Pizza and 1x Caesar Salad
(no croutons) to your order for table 5. Are you ready to submit,
or would you like to add anything else?