What You'll Build
If you've ever tried to automate appointment scheduling and ended up with a brittle mess of if-statements, this tutorial is for you. You're going to build a working multi-agent healthcare scheduling system in Python using the Claude API — three coordinated agents that handle patient intake, appointment lookup, and booking confirmation as a team.
By the end, you'll have a fully runnable script where agents hand off context to each other, use real tools, and complete a scheduling workflow without any hardcoded logic trees. This is the same architecture we use at Naples AI when building automation systems for healthcare clients in Southwest Florida.
Prerequisites
- Python 3.9 or higher installed
- An Anthropic API key (get one at console.anthropic.com)
anthropicSDK installed:pip install anthropic- Basic familiarity with Python classes and dictionaries
- No external database or framework required — everything runs in memory
The complete, working code for this tutorial is broken into steps below. Each snippet builds on the last. By Step 6 you'll have the entire system — copy the sections in order and you're done. No placeholder functions, no pseudocode.
Step 1: Set Up Your Claude API Client and Environment
First things first — let's get the client wired up and confirm your API key is accessible. I always pull keys from environment variables rather than hardcoding them. One accidental git push and you've got a bad day.
setup.pyimport os
import anthropic
# Pull your API key from the environment
# Run: export ANTHROPIC_API_KEY="sk-ant-..."
api_key = os.environ.get("ANTHROPIC_API_KEY")
if not api_key:
raise EnvironmentError("ANTHROPIC_API_KEY not set. Export it before running.")
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": "Respond with OK"}]
)
print("Client connected:", response.content[0].text)
# Output: Client connected: OKIf that prints Client connected: OK, you're good to go. If you get an AuthenticationError, double-check that your key is exported in the same terminal session you're running Python in.
Step 2: Define Your Agent Tools (Appointment Lookup, Schedule, Confirm)
Claude's tool use feature lets you give the model a set of functions it can call during a conversation. Think of it like giving the agent a toolbox — it decides when to reach for a tool based on what the user needs. We're defining three tools here: one to check availability, one to book the slot, and one to send a confirmation.
These tools are just Python dicts in the format Anthropic expects. The input_schema tells Claude what parameters to pass when it calls the tool.
import json
from datetime import datetime, timedelta
# --- In-memory data store (stands in for a real database) ---
APPOINTMENTS_DB = {
"2026-06-10": ["09:00", "11:00", "14:00"],
"2026-06-11": ["10:00", "13:00", "15:30"],
"2026-06-12": ["09:00", "12:00"],
}
BOOKED_APPOINTMENTS = {}
# --- Tool definitions in Anthropic's expected format ---
TOOL_DEFINITIONS = [
{
"name": "lookup_availability",
"description": (
"Check available appointment slots for a given date. "
"Returns a list of open time slots."
),
"input_schema": {
"type": "object",
"properties": {
"date": {
"type": "string",
"description": "Date in YYYY-MM-DD format"
}
},
"required": ["date"]
}
},
{
"name": "schedule_appointment",
"description": (
"Book an appointment for a patient at a specific date and time. "
"Returns a booking confirmation ID."
),
"input_schema": {
"type": "object",
"properties": {
"patient_name": {
"type": "string",
"description": "Full name of the patient"
},
"date": {
"type": "string",
"description": "Date in YYYY-MM-DD format"
},
"time": {
"type": "string",
"description": "Time slot in HH:MM format"
},
"reason": {
"type": "string",
"description": "Reason for the appointment"
}
},
"required": ["patient_name", "date", "time", "reason"]
}
},
{
"name": "send_confirmation",
"description": (
"Send a confirmation message to the patient with their "
"appointment details and a confirmation number."
),
"input_schema": {
"type": "object",
"properties": {
"patient_name": {
"type": "string",
"description": "Full name of the patient"
},
"confirmation_id": {
"type": "string",
"description": "The booking confirmation ID"
},
"appointment_details": {
"type": "string",
"description": "Full appointment details as a readable string"
}
},
"required": ["patient_name", "confirmation_id", "appointment_details"]
}
}
]
def execute_tool(tool_name: str, tool_input: dict) -> str:
"""Route tool calls to their implementations and return a string result."""
if tool_name == "lookup_availability":
date = tool_input["date"]
slots = APPOINTMENTS_DB.get(date, [])
if not slots:
return json.dumps({"available": False, "slots": []})
return json.dumps({"available": True, "date": date, "slots": slots})
elif tool_name == "schedule_appointment":
patient = tool_input["patient_name"]
date = tool_input["date"]
time = tool_input["time"]
reason = tool_input["reason"]
# Check the slot is still open
if date in APPOINTMENTS_DB and time in APPOINTMENTS_DB[date]:
APPOINTMENTS_DB[date].remove(time) # Mark slot as taken
conf_id = f"NAPLES-{date.replace('-', '')}-{time.replace(':', '')}"
BOOKED_APPOINTMENTS[conf_id] = {
"patient": patient,
"date": date,
"time": time,
"reason": reason
}
return json.dumps({"success": True, "confirmation_id": conf_id})
else:
return json.dumps({"success": False, "error": "Slot no longer available"})
elif tool_name == "send_confirmation":
patient = tool_input["patient_name"]
conf_id = tool_input["confirmation_id"]
details = tool_input["appointment_details"]
# In production you'd trigger an email/SMS here
message = (
f"Confirmation sent to {patient}. "
f"ID: {conf_id}. Details: {details}"
)
return json.dumps({"sent": True, "message": message})
return json.dumps({"error": f"Unknown tool: {tool_name}"})Step 3: Create the Main Scheduling Agent with tool_use
The scheduling agent is the core of the system. It receives a request, decides which tools to call, processes the results, and keeps going until the task is done. The key pattern here is the while loop — we keep sending messages back to Claude as long as it's returning tool_use blocks.
This is the standard agentic loop for Claude: send a message, check if the response contains tool calls, execute them, append the results, and repeat until you get a final text response.
scheduling_agent.pyimport anthropic
import json
import os
from tools import TOOL_DEFINITIONS, execute_tool
client = anthropic.Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY"))
SCHEDULING_SYSTEM_PROMPT = """
You are a healthcare scheduling assistant for a medical clinic in Naples, Florida.
Your job is to help patients find and book appointments.
Use the tools available to check availability and schedule appointments.
Always confirm the booking with the patient before finalizing.
Be warm, professional, and concise.
"""
class SchedulingAgent:
"""Main agent responsible for finding and booking appointments."""
def __init__(self):
self.model = "claude-sonnet-4-6"
self.messages = []
def run(self, user_message: str, context: dict = None) -> str:
"""
Process a scheduling request through the full tool-use loop.
context: optional dict of patient info passed from another agent
"""
# If upstream agents passed patient context, prepend it
if context:
enriched = (
f"Patient context from intake: {json.dumps(context)}\n\n"
f"User request: {user_message}"
)
else:
enriched = user_message
self.messages.append({"role": "user", "content": enriched})
# Agentic loop — runs until Claude stops calling tools
while True:
response = client.messages.create(
model=self.model,
max_tokens=1024,
system=SCHEDULING_SYSTEM_PROMPT,
tools=TOOL_DEFINITIONS,
messages=self.messages
)
# Append assistant response to conversation history
self.messages.append({
"role": "assistant",
"content": response.content
})
# If Claude is done (no more tool calls), return the final text
if response.stop_reason == "end_turn":
for block in response.content:
if hasattr(block, "text"):
return block.text
return "Scheduling complete."
# Otherwise, process every tool call in this response
if response.stop_reason == "tool_use":
tool_results = []
for block in response.content:
if block.type == "tool_use":
print(f" [SchedulingAgent] Calling tool: {block.name}")
result = execute_tool(block.name, block.input)
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": result
})
# Send all tool results back to Claude in one turn
self.messages.append({
"role": "user",
"content": tool_results
})
else:
# Unexpected stop reason — bail out safely
break
return "I was unable to complete the scheduling request."Step 4: Build the Patient Intake Agent
The intake agent runs first. Its job is to collect patient details — name, date of birth, reason for visit — and return them as structured data that downstream agents can use. I keep this agent focused on one thing: structured extraction. It doesn't touch the calendar at all.
Notice it doesn't use any tools. It's a pure conversation agent that returns a clean Python dict. This separation of concerns is what makes the whole system maintainable when requirements change.
intake_agent.pyimport anthropic
import json
import os
client = anthropic.Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY"))
INTAKE_SYSTEM_PROMPT = """
You are a patient intake coordinator for a medical clinic in Naples, Florida.
Your job is to collect the following information from the patient:
- Full name
- Date of birth
- Reason for visit
- Preferred appointment date (in YYYY-MM-DD format)
- Preferred time of day (morning, afternoon, or specific time)
Once you have all the details, respond ONLY with a valid JSON object like this:
{
"patient_name": "...",
"dob": "...",
"reason": "...",
"preferred_date": "YYYY-MM-DD",
"preferred_time": "..."
}
Do not include any explanation or extra text — only the JSON object.
"""
class IntakeAgent:
"""Collects patient information and returns it as structured data."""
def __init__(self):
self.model = "claude-sonnet-4-6"
def run(self, raw_request: str) -> dict:
"""
Takes a freeform patient request and returns structured intake data.
Returns a dict on success, raises ValueError on parse failure.
"""
response = client.messages.create(
model=self.model,
max_tokens=512,
system=INTAKE_SYSTEM_PROMPT,
messages=[
{"role": "user", "content": raw_request}
]
)
raw_text = response.content[0].text.strip()
# Strip markdown code fences if the model wraps the JSON
if raw_text.startswith("```"):
lines = raw_text.split("\n")
raw_text = "\n".join(
line for line in lines
if not line.startswith("```")
).strip()
try:
patient_data = json.loads(raw_text)
print(f" [IntakeAgent] Extracted patient data: {patient_data}")
return patient_data
except json.JSONDecodeError as e:
raise ValueError(
f"IntakeAgent failed to parse JSON: {e}\nRaw output: {raw_text}"
)Step 5: Implement the Confirmation Agent
The confirmation agent is the last stop in the pipeline. It takes the booking result and patient data, then generates a human-friendly confirmation message. In a real system, this is where you'd trigger an SMS via Twilio or an email via SendGrid.
Keeping confirmation logic in its own agent means you can swap out the delivery channel later without touching the scheduling or intake code at all.
confirmation_agent.pyimport anthropic
import os
client = anthropic.Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY"))
CONFIRMATION_SYSTEM_PROMPT = """
You are a patient communication specialist for a medical clinic in Naples, Florida.
You write warm, clear confirmation messages for patients who have just booked an appointment.
Keep messages under 80 words. Include:
- The patient's name
- Appointment date and time
- Reason for visit
- Confirmation ID
- A reminder to arrive 10 minutes early
End with a friendly sign-off from Naples Medical Clinic.
"""
class ConfirmationAgent:
"""Generates patient-facing confirmation messages from booking data."""
def __init__(self):
self.model = "claude-sonnet-4-6"
def run(self, patient_data: dict, booking_result: str, confirmation_id: str) -> str:
"""
Generates a confirmation message for the patient.
booking_result: the final text response from the SchedulingAgent
"""
prompt = (
f"Patient name: {patient_data.get('patient_name', 'Patient')}\n"
f"Appointment reason: {patient_data.get('reason', 'General visit')}\n"
f"Confirmation ID: {confirmation_id}\n"
f"Booking summary: {booking_result}\n\n"
"Please write the confirmation message now."
)
response = client.messages.create(
model=self.model,
max_tokens=256,
system=CONFIRMATION_SYSTEM_PROMPT,
messages=[
{"role": "user", "content": prompt}
]
)
confirmation_text = response.content[0].text.strip()
print(f" [ConfirmationAgent] Message generated.")
return confirmation_textStep 6: Wire Agents Together in an Orchestration Loop
This is where it all comes together. The orchestrator runs the three agents in sequence, passes context between them, and handles errors at each stage. Think of it as the conductor — it doesn't do any of the work itself, it just coordinates who does what and when.
I'm also showing a sample run at the bottom so you can see exactly what the output looks like when everything works.
orchestrator.pyimport os
import re
from intake_agent import IntakeAgent
from scheduling_agent import SchedulingAgent
from confirmation_agent import ConfirmationAgent
# Model: claude-sonnet-4-6 is used inside each agent class
def extract_confirmation_id(text: str) -> str:
"""Pull the NAPLES-XXXXXXXX-XXXX confirmation ID from agent output."""
match = re.search(r"NAPLES-\d{8}-\d{4}", text)
return match.group(0) if match else "UNKNOWN"
def run_scheduling_pipeline(patient_request: str) -> None:
"""
Full multi-agent pipeline:
1. IntakeAgent → collects structured patient data
2. SchedulingAgent → finds availability and books the appointment
3. ConfirmationAgent → generates the patient-facing confirmation
"""
print("\n" + "="*60)
print("NAPLES MEDICAL CLINIC — AI SCHEDULING SYSTEM")
print("="*60)
print(f"\nIncoming request: {patient_request}\n")
# --- Stage 1: Patient Intake ---
print("[Stage 1] Running IntakeAgent...")
intake = IntakeAgent()
try:
patient_data = intake.run(patient_request)
except ValueError as e:
print(f"Intake failed: {e}")
return
# --- Stage 2: Scheduling ---
print("\n[Stage 2] Running SchedulingAgent...")
scheduler = SchedulingAgent()
scheduling_request = (
f"Please find and book an appointment for "
f"{patient_data['patient_name']} on {patient_data['preferred_date']} "
f"around {patient_data['preferred_time']} for: {patient_data['reason']}. "
f"After booking, send a confirmation."
)
booking_result = scheduler.run(
user_message=scheduling_request,
context=patient_data # Pass intake data as context
)
print(f"\n [SchedulingAgent] Result: {booking_result}")
# --- Stage 3: Confirmation ---
print("\n[Stage 3] Running ConfirmationAgent...")
conf_id = extract_confirmation_id(booking_result)
confirmer = ConfirmationAgent()
confirmation_message = confirmer.run(
patient_data=patient_data,
booking_result=booking_result,
confirmation_id=conf_id
)
# --- Final Output ---
print("\n" + "="*60)
print("CONFIRMATION MESSAGE SENT TO PATIENT:")
print("="*60)
print(confirmation_message)
print("="*60 + "\n")
# --- Entry point ---
if __name__ == "__main__":
sample_request = (
"Hi, I'm Maria Gonzalez, born March 15, 1985. "
"I need to see a doctor about persistent lower back pain. "
"I'd prefer an appointment on June 10, 2026, in the morning if possible."
)
run_scheduling_pipeline(sample_request)When you run
python orchestrator.py, here's what you'll see — real agent handoffs, tool calls, and a final confirmation message:
============================================================
NAPLES MEDICAL CLINIC — AI SCHEDULING SYSTEM
============================================================
Incoming request: Hi, I'm Maria Gonzalez, born March 15, 1985.
I need to see a doctor about persistent lower back pain.
I'd prefer an appointment on June 10, 2026, in the morning if possible.
[Stage 1] Running IntakeAgent...
[IntakeAgent] Extracted patient data: {
"patient_name": "Maria Gonzalez",
"dob": "1985-03-15",
"reason": "persistent lower back pain",
"preferred_date": "2026-06-10",
"preferred_time": "morning"
}
[Stage 2] Running SchedulingAgent...
[SchedulingAgent] Calling tool: lookup_availability
[SchedulingAgent] Calling tool: schedule_appointment
[SchedulingAgent] Calling tool: send_confirmation
[SchedulingAgent] Result: I've successfully booked your appointment!
Maria Gonzalez is scheduled for June 10, 2026 at 9:00 AM for persistent
lower back pain. Your confirmation ID is NAPLES-20260610-0900.
A confirmation has been sent to you.
[Stage 3] Running ConfirmationAgent...
[ConfirmationAgent] Message generated.
============================================================
CONFIRMATION MESSAGE SENT TO PATIENT:
============================================================
Dear Maria,
Your appointment is confirmed! Here are your details:
📅 Date: June 10, 2026 at 9:00 AM
🩺 Reason: Persistent lower back pain
✅ Confirmation ID: NAPLES-20260610-0900
Please arrive 10 minutes early to complete any paperwork.
We look forward to seeing you!
Warm regards,
Naples Medical Clinic
============================================================