If you've searched for a real AI agent tutorial that actually runs — not a toy demo with fake functions — you're in the right place. Most examples online stub out the hard parts or skip the orchestration logic entirely. This one doesn't.
By the end of this tutorial, you'll have a fully working multi-agent scheduling system built in Python using the Claude API. It handles real scheduling scenarios: checking availability, booking appointments, and sending confirmations — all coordinated between specialized agents.
What You'll Build
You're building a multi-agent scheduling assistant that uses two cooperating Claude-powered agents: an Orchestrator that manages the conversation flow, and a Scheduling Agent that executes calendar tools. The system accepts natural language requests like "Book me a meeting Tuesday at 2pm" and handles everything from availability checks to booking confirmations.
The final system produces real structured output, handles errors gracefully, and is architected the way production AI systems actually work — not the watered-down version you usually see in tutorials.
Prerequisites
- Python 3.10 or higher installed
- An Anthropic API key (get one at console.anthropic.com)
- Basic familiarity with Python functions and classes
anthropicSDK installed (pip install anthropic)- Basic understanding of what an API call is
The complete working code for this tutorial is built up step by step in the sections below. Every snippet connects to the next. By Step 6 you'll have the full system running. Copy each block in order and you'll be good to go.
Step 1: Setting Up Your Claude API Environment
First, let's get your environment wired up correctly. You need the Anthropic SDK installed and your API key accessible. Don't hardcode the key — use an environment variable. That's not optional.
Here's the setup and a quick sanity-check call to confirm everything works before we build anything complex.
setup.pyimport os
import anthropic
# Pull the API key from your environment — never hardcode this
api_key = os.environ.get("ANTHROPIC_API_KEY")
if not api_key:
raise ValueError("ANTHROPIC_API_KEY environment variable not set.")
# Initialize the client — you'll reuse this across both agents
client = anthropic.Anthropic(api_key=api_key)
# Quick connectivity test
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=64,
messages=[{"role": "user", "content": "Say 'API connected' and nothing else."}]
)
print(response.content[0].text)
Expected output:
terminal outputAPI connected
If you see that, you're ready to build. If you get an AuthenticationError, double-check that your environment variable is exported correctly in your shell (export ANTHROPIC_API_KEY=your_key_here).
Step 2: Creating the Scheduling Agent Class
Now let's build the Scheduling Agent. This is the worker agent — it knows how to execute calendar operations and it talks to Claude to decide which tool to use. The Orchestrator (coming in Step 4) will delegate tasks to this agent.
The class holds its own conversation history, its own system prompt, and its own tool list. That's what makes it a proper agent and not just a function wrapper.
scheduling_agent.pyimport os
import json
import anthropic
from datetime import datetime, timedelta
from typing import Any
client = anthropic.Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY"))
class SchedulingAgent:
"""
A specialized agent responsible for calendar operations.
It processes tool calls from Claude and executes them against
our simulated calendar backend.
"""
def __init__(self):
self.model = "claude-sonnet-4-6"
self.conversation_history = []
self.system_prompt = (
"You are a scheduling assistant. Your job is to check calendar availability, "
"book appointments, and send confirmations. Always use the tools provided to "
"complete scheduling tasks. Be concise and confirm actions clearly."
)
# Simulated in-memory calendar — in production this would be Google Calendar, etc.
self.calendar = {}
def run(self, user_message: str) -> str:
"""Process a scheduling request and return the result."""
self.conversation_history.append({
"role": "user",
"content": user_message
})
# Initial Claude call with tools available
response = client.messages.create(
model=self.model,
max_tokens=1024,
system=self.system_prompt,
tools=self._get_tools(),
messages=self.conversation_history
)
# Run the agentic loop until Claude stops calling tools
while response.stop_reason == "tool_use":
tool_results = []
for block in response.content:
if block.type == "tool_use":
result = self._execute_tool(block.name, block.input)
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": json.dumps(result)
})
# Append assistant message and tool results to history
self.conversation_history.append({
"role": "assistant",
"content": response.content
})
self.conversation_history.append({
"role": "user",
"content": tool_results
})
# Call Claude again with the tool results
response = client.messages.create(
model=self.model,
max_tokens=1024,
system=self.system_prompt,
tools=self._get_tools(),
messages=self.conversation_history
)
# Extract the final text response
final_text = ""
for block in response.content:
if hasattr(block, "text"):
final_text += block.text
self.conversation_history.append({
"role": "assistant",
"content": final_text
})
return final_text
def _execute_tool(self, tool_name: str, tool_input: dict) -> Any:
"""Route tool calls to the correct handler method."""
if tool_name == "check_availability":
return self._check_availability(
tool_input["date"],
tool_input["time_slot"]
)
elif tool_name == "book_appointment":
return self._book_appointment(
tool_input["date"],
tool_input["time_slot"],
tool_input["attendee_name"],
tool_input["meeting_purpose"]
)
elif tool_name == "send_confirmation":
return self._send_confirmation(
tool_input["attendee_name"],
tool_input["attendee_email"],
tool_input["date"],
tool_input["time_slot"],
tool_input["meeting_purpose"]
)
else:
return {"error": f"Unknown tool: {tool_name}"}
def _check_availability(self, date: str, time_slot: str) -> dict:
"""Check if a time slot is open on the calendar."""
slot_key = f"{date}_{time_slot}"
is_available = slot_key not in self.calendar
return {
"available": is_available,
"date": date,
"time_slot": time_slot,
"message": f"{'Available' if is_available else 'Already booked'}: {date} at {time_slot}"
}
def _book_appointment(
self,
date: str,
time_slot: str,
attendee_name: str,
meeting_purpose: str
) -> dict:
"""Book an appointment if the slot is open."""
slot_key = f"{date}_{time_slot}"
if slot_key in self.calendar:
return {
"success": False,
"message": f"Slot {date} at {time_slot} is already booked."
}
# Write the booking to our in-memory calendar
self.calendar[slot_key] = {
"attendee": attendee_name,
"purpose": meeting_purpose,
"booked_at": datetime.now().isoformat()
}
return {
"success": True,
"booking_id": f"BK-{abs(hash(slot_key)) % 100000:05d}",
"date": date,
"time_slot": time_slot,
"attendee": attendee_name,
"message": f"Appointment booked for {attendee_name} on {date} at {time_slot}."
}
def _send_confirmation(
self,
attendee_name: str,
attendee_email: str,
date: str,
time_slot: str,
meeting_purpose: str
) -> dict:
"""Simulate sending a confirmation email."""
# In production, this calls SendGrid, Mailgun, etc.
confirmation_number = f"CONF-{abs(hash(attendee_email + date)) % 99999:05d}"
print(f"\n[EMAIL SENT] To: {attendee_email}")
print(f" Subject: Your appointment on {date} at {time_slot} is confirmed")
print(f" Confirmation #: {confirmation_number}\n")
return {
"sent": True,
"confirmation_number": confirmation_number,
"recipient": attendee_email,
"message": f"Confirmation email sent to {attendee_email}."
}
def _get_tools(self) -> list:
"""Return the tool definitions for this agent."""
return _get_scheduling_tools()
Step 3: Defining Tools (Calendar, Availability, Confirmation)
Tools are how you give Claude the ability to actually do things. Each tool definition tells Claude what the function does, what parameters it expects, and which ones are required. Get this right and Claude knows exactly when and how to call each tool.
I'm defining these as a standalone function so both the SchedulingAgent and any future agents can import them without creating circular dependencies.
tools.pydef _get_scheduling_tools() -> list:
"""
Define the tools available to the scheduling agent.
These follow the Anthropic tool use specification exactly.
"""
return [
{
"name": "check_availability",
"description": (
"Check whether a specific date and time slot is available on the calendar. "
"Always call this before attempting to book an appointment."
),
"input_schema": {
"type": "object",
"properties": {
"date": {
"type": "string",
"description": "The date to check in YYYY-MM-DD format (e.g., 2026-09-15)"
},
"time_slot": {
"type": "string",
"description": "The time slot to check (e.g., '2:00 PM', '10:30 AM')"
}
},
"required": ["date", "time_slot"]
}
},
{
"name": "book_appointment",
"description": (
"Book an appointment on the calendar after confirming availability. "
"Returns a booking ID on success."
),
"input_schema": {
"type": "object",
"properties": {
"date": {
"type": "string",
"description": "The appointment date in YYYY-MM-DD format"
},
"time_slot": {
"type": "string",
"description": "The time slot for the appointment (e.g., '2:00 PM')"
},
"attendee_name": {
"type": "string",
"description": "Full name of the person booking the appointment"
},
"meeting_purpose": {
"type": "string",
"description": "Brief description of what the meeting is about"
}
},
"required": ["date", "time_slot", "attendee_name", "meeting_purpose"]
}
},
{
"name": "send_confirmation",
"description": (
"Send a confirmation email to the attendee after successfully booking. "
"Always call this after a successful book_appointment."
),
"input_schema": {
"type": "object",
"properties": {
"attendee_name": {
"type": "string",
"description": "Full name of the attendee"
},
"attendee_email": {
"type": "string",
"description": "Email address to send the confirmation to"
},
"date": {
"type": "string",
"description": "The booked date in YYYY-MM-DD format"
},
"time_slot": {
"type": "string",
"description": "The booked time slot"
},
"meeting_purpose": {
"type": "string",
"description": "What the meeting is about"
}
},
"required": [
"attendee_name",
"attendee_email",
"date",
"time_slot",
"meeting_purpose"
]
}
}
]
description field in each tool definition is not just documentation — Claude reads it at inference time to decide when to use the tool. Write it like you're giving instructions to a smart intern. Specific descriptions produce dramatically better tool selection.
Step 4: Building the Orchestrator Agent
The Orchestrator is the brain of the system. It receives the raw user request, interprets intent, and delegates structured tasks to the SchedulingAgent. This separation matters — it means you can swap out either agent without breaking the other one.
The Orchestrator uses Claude to parse natural language and extract structured scheduling parameters before handing them off. That's the pattern that makes multi-agent systems actually work in production.
orchestrator.pyimport os
import json
import anthropic
client = anthropic.Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY"))
class OrchestratorAgent:
"""
The top-level agent that interprets user intent and delegates
scheduling tasks to the SchedulingAgent worker.
"""
def __init__(self, scheduling_agent):
self.model = "claude-sonnet-4-6"
# Hold a reference to the worker agent
self.scheduling_agent = scheduling_agent
self.system_prompt = (
"You are a scheduling orchestrator. Your job is to understand what the user "
"wants to schedule, extract all necessary details, and produce a clear, structured "
"task description for the scheduling agent. "
"If any required information is missing (date, time, name, email, or purpose), "
"ask the user for it before proceeding. "
"When you have everything you need, respond with a JSON object in this exact format:\n"
"{\n"
' "action": "schedule",\n'
' "date": "YYYY-MM-DD",\n'
' "time_slot": "H:MM AM/PM",\n'
' "attendee_name": "Full Name",\n'
' "attendee_email": "[email protected]",\n'
' "meeting_purpose": "Brief description"\n'
"}\n"
"If the user's request is unclear or off-topic, respond normally in plain English."
)
self.conversation_history = []
def process(self, user_input: str) -> str:
"""
Take a raw user message, interpret it, and either ask
a clarifying question or delegate to the SchedulingAgent.
"""
self.conversation_history.append({
"role": "user",
"content": user_input
})
response = client.messages.create(
model=self.model,
max_tokens=512,
system=self.system_prompt,
messages=self.conversation_history
)
orchestrator_reply = response.content[0].text.strip()
self.conversation_history.append({
"role": "assistant",
"content": orchestrator_reply
})
# Try to detect whether the orchestrator produced a scheduling JSON
parsed = self._try_parse_scheduling_json(orchestrator_reply)
if parsed:
# Delegate to the scheduling agent with a structured task
task = (
f"Please complete the following scheduling task:\n"
f"- Check availability for {parsed['date']} at {parsed['time_slot']}\n"
f"- If available, book an appointment for {parsed['attendee_name']} "
f"for: {parsed['meeting_purpose']}\n"
f"- Then send a confirmation to {parsed['attendee_email']}"
)
result = self.scheduling_agent.run(task)
return result
else:
# Orchestrator is asking a follow-up question
return orchestrator_reply
def _try_parse_scheduling_json(self, text: str) -> dict | None:
"""
Attempt to extract a JSON scheduling object from the orchestrator's reply.
Returns None if the text isn't a scheduling instruction.
"""
try:
# Handle cases where Claude wraps JSON in markdown code fences
if "```" in text:
start = text.find("{")
end = text.rfind("}") + 1
if start != -1 and end > start:
text = text[start:end]
data = json.loads(text)
# Validate the required fields are present
required = [
"action", "date", "time_slot",
"attendee_name", "attendee_email", "meeting_purpose"
]
if all(k in data for k in required) and data.get("action") == "schedule":
return data
except (json.JSONDecodeError, ValueError):
pass
return None
Step 5: Implementing the Main Run Loop
This is where everything comes together. The main loop ties the Orchestrator and SchedulingAgent into a single interactive program. It handles the conversation back-and-forth, catches errors cleanly, and keeps running until the user exits.
I've added proper exception handling for the most common failure points: API errors, rate limits, and unexpected tool failures. In a production system you'd log these to something like Datadog or CloudWatch, but for this tutorial we'll print them clearly.
main.pyimport os
import anthropic
from scheduling_agent import SchedulingAgent
from orchestrator import OrchestratorAgent
def run_scheduler():
"""
Main entry point. Initializes both agents and runs
an interactive scheduling session in the terminal.
"""
print("=" * 55)
print(" Naples AI — Multi-Agent Scheduling Assistant")
print(" Powered by Claude claude-sonnet-4-6")
print("=" * 55)
print("Type your scheduling request below.")
print("Type 'quit' or 'exit' to stop.\n")
# Instantiate agents — SchedulingAgent is injected into Orchestrator
scheduling_agent = SchedulingAgent()
orchestrator = OrchestratorAgent(scheduling_agent)
while True:
try:
user_input = input("You: ").strip()
if not user_input:
continue
if user_input.lower() in ["quit", "exit", "q"]:
print("\nScheduler session ended. Goodbye!")
break
print("\nAssistant: ", end="", flush=True)
try:
response = orchestrator.process(user_input)
print(response)
except anthropic.APIStatusError as e:
# Handles 4xx/5xx errors from the Anthropic API
print(f"\n[API Error {e.status_code}]: {e.message}")
print("Please try again or check your API key.")
except anthropic.RateLimitError:
print("\n[Rate Limit]: Too many requests. Wait a moment and try again.")
except anthropic.APIConnectionError:
print("\n[Connection Error]: Can't reach Anthropic API. Check your internet.")
except Exception as e:
# Catch-all for unexpected errors — log in production
print(f"\n[Unexpected Error]: {type(e).__name__}: {e}")
print() # Blank line between exchanges
except KeyboardInterrupt:
print("\n\nInterrupted. Goodbye!")
break
if __name__ == "__main__":
run_scheduler()
Step 6: Testing with Real Scheduling Scenarios
Let's run the system against three realistic scenarios. This is the part most tutorials skip — but seeing real output is how you confirm the agents are cooperating correctly.
Run python main.py in your terminal and try these inputs.
Scenario 1: Complete booking request
terminal sessionYou: Book a meeting for Maria Gonzalez on September 15 2026 at 2pm.
Her email is [email protected]. It's for a project kickoff.