← Back to Blog

What You'll Build

If you've searched for how to build AI agents tutorial, you've probably hit a wall of vague examples that don't actually run. This tutorial builds something real: a production-ready multi-agent healthcare scheduling system in Python that handles appointment booking, patient qualification, and automated reminders using the Claude API.

By the end, you'll have a working orchestrator that routes patients through two specialized agents — one that qualifies them based on symptoms and insurance, and one that manages calendar slots. The whole system handles edge cases, retries gracefully, and logs what it's doing at every step.

📦 Full Source Code Note: The complete working code is built step by step in the sections below. Each snippet is copy-paste ready and syntactically correct. By Step 5, you'll have everything assembled into one cohesive system you can run locally or deploy to a server.

Prerequisites

  • Python 3.10 or higher installed
  • An Anthropic API key — get one at console.anthropic.com
  • Basic familiarity with Python classes and functions
  • The anthropic and python-dotenv packages installed (pip install anthropic python-dotenv)
  • A .env file in your project root with ANTHROPIC_API_KEY=your_key_here

Step 1: Set Up Your Claude API Environment

Start by creating your project folder and environment file. This keeps your API key out of your source code — a habit worth building from day one.

.env
ANTHROPIC_API_KEY=your_anthropic_api_key_here

Now install your dependencies and verify the connection works before writing a single line of agent logic.

setup_check.py
import os
import anthropic
from dotenv import load_dotenv

load_dotenv()

client = anthropic.Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY"))

# Quick sanity check — if this prints a response, you're connected
message = client.messages.create(
    model="claude-sonnet-4-5",
    max_tokens=64,
    messages=[{"role": "user", "content": "Reply with: API connection successful."}]
)

print(message.content[0].text)

Expected output:

API connection successful.

If you see that, you're ready to build. If you get an AuthenticationError, double-check that your .env file is in the same directory you're running the script from.

Step 2: Create the Scheduling Agent Class

This is the core of how to build AI agents tutorial content — a real agent class that knows its role, uses tools, and keeps a conversation history. The scheduling agent's only job is managing calendar state: checking availability, booking slots, and sending confirmations.

scheduling_agent.py
import os
import json
import anthropic
from dotenv import load_dotenv
from datetime import datetime, timedelta
from typing import Any

load_dotenv()


class SchedulingAgent:
    """Handles appointment slot management using Claude API tool use."""

    def __init__(self):
        self.client = anthropic.Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY"))
        self.model = "claude-sonnet-4-5"
        self.conversation_history = []
        # Simulated calendar store — in production, connect to Google Calendar or similar
        self.calendar = self._initialize_calendar()

    def _initialize_calendar(self) -> dict:
        """Creates a mock calendar with available slots for the next 5 business days."""
        calendar = {}
        base = datetime(2026, 9, 15, 9, 0)  # Start from tutorial publish date
        for day_offset in range(5):
            date = base + timedelta(days=day_offset)
            date_str = date.strftime("%Y-%m-%d")
            calendar[date_str] = {
                "09:00": "available",
                "10:00": "available",
                "11:00": "booked",
                "14:00": "available",
                "15:00": "available",
                "16:00": "booked",
            }
        return calendar

    def get_tools(self) -> list[dict]:
        """Returns the tool definitions Claude uses to interact with the calendar."""
        return [
            {
                "name": "check_availability",
                "description": "Check available appointment slots for a given date.",
                "input_schema": {
                    "type": "object",
                    "properties": {
                        "date": {
                            "type": "string",
                            "description": "Date in YYYY-MM-DD format"
                        }
                    },
                    "required": ["date"]
                }
            },
            {
                "name": "book_appointment",
                "description": "Book an appointment slot for a patient.",
                "input_schema": {
                    "type": "object",
                    "properties": {
                        "date": {"type": "string", "description": "Date in YYYY-MM-DD format"},
                        "time": {"type": "string", "description": "Time in HH:MM format"},
                        "patient_name": {"type": "string", "description": "Full name of the patient"},
                        "reason": {"type": "string", "description": "Brief reason for the visit"}
                    },
                    "required": ["date", "time", "patient_name", "reason"]
                }
            },
            {
                "name": "cancel_appointment",
                "description": "Cancel an existing booked appointment.",
                "input_schema": {
                    "type": "object",
                    "properties": {
                        "date": {"type": "string"},
                        "time": {"type": "string"}
                    },
                    "required": ["date", "time"]
                }
            }
        ]

    def process_request(self, user_message: str) -> str:
        """Sends a message to Claude and handles tool use in a loop."""
        self.conversation_history.append({"role": "user", "content": user_message})

        system_prompt = """You are a healthcare scheduling assistant. Use the available tools 
        to check calendars, book appointments, and manage cancellations. Always confirm 
        the booking details clearly before finalizing."""

        while True:
            response = self.client.messages.create(
                model=self.model,
                max_tokens=1024,
                system=system_prompt,
                tools=self.get_tools(),
                messages=self.conversation_history
            )

            # If Claude is done thinking and responding with text, return it
            if response.stop_reason == "end_turn":
                assistant_text = response.content[0].text
                self.conversation_history.append({"role": "assistant", "content": response.content})
                return assistant_text

            # Claude wants to use a tool — execute it and feed results back
            if response.stop_reason == "tool_use":
                self.conversation_history.append({"role": "assistant", "content": response.content})
                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)
                        })

                self.conversation_history.append({"role": "user", "content": tool_results})

    def _execute_tool(self, tool_name: str, tool_input: dict) -> Any:
        """Routes tool calls to the right calendar function."""
        if tool_name == "check_availability":
            return self._check_availability(tool_input["date"])
        elif tool_name == "book_appointment":
            return self._book_appointment(
                tool_input["date"],
                tool_input["time"],
                tool_input["patient_name"],
                tool_input["reason"]
            )
        elif tool_name == "cancel_appointment":
            return self._cancel_appointment(tool_input["date"], tool_input["time"])
        return {"error": f"Unknown tool: {tool_name}"}

    def _check_availability(self, date: str) -> dict:
        slots = self.calendar.get(date, {})
        available = [time for time, status in slots.items() if status == "available"]
        return {"date": date, "available_slots": available}

    def _book_appointment(self, date: str, time: str, patient_name: str, reason: str) -> dict:
        if date not in self.calendar:
            return {"success": False, "error": "Date not found in calendar"}
        if self.calendar[date].get(time) != "available":
            return {"success": False, "error": f"Slot {time} is not available"}
        self.calendar[date][time] = f"booked:{patient_name}"
        return {
            "success": True,
            "confirmation": f"Appointment booked for {patient_name} on {date} at {time} — Reason: {reason}"
        }

    def _cancel_appointment(self, date: str, time: str) -> dict:
        if date in self.calendar and time in self.calendar[date]:
            self.calendar[date][time] = "available"
            return {"success": True, "message": f"Slot {date} at {time} is now available"}
        return {"success": False, "error": "No appointment found at that date and time"}

Step 3: Define Tool Functions for Calendar Management

The tool definitions in Step 2 tell Claude what it can do. Now let's verify the calendar integration actually works end-to-end before wiring up the second agent. Run this quick test to see tool use in action.

test_scheduling_agent.py
from scheduling_agent import SchedulingAgent

agent = SchedulingAgent()

# Test checking availability
response = agent.process_request("What slots are available on 2026-09-15?")
print("--- Availability Check ---")
print(response)

# Test booking an appointment
response = agent.process_request(
    "Book the 9:00 AM slot on 2026-09-15 for Maria Santos. She needs a flu vaccine."
)
print("\n--- Booking Confirmation ---")
print(response)

Expected output:

--- Availability Check ---
On September 15, 2026, the following slots are available: 9:00 AM, 10:00 AM, 2:00 PM, and 3:00 PM.

--- Booking Confirmation ---
I've booked your appointment! Here are the details:
- Patient: Maria Santos
- Date: September 15, 2026
- Time: 9:00 AM
- Reason: Flu vaccine

Is there anything else you need?

Step 4: Build the Patient Qualification Agent

A scheduling system that books anyone who asks isn't very useful in healthcare. This agent screens patients first — checking symptoms, urgency level, and whether the visit type is appropriate for the clinic. If someone describes chest pain, it flags them for urgent routing instead of a standard booking.

qualification_agent.py
import os
import json
import anthropic
from dotenv import load_dotenv
from typing import TypedDict

load_dotenv()


class QualificationResult(TypedDict):
    approved: bool
    urgency: str          # "routine", "priority", "emergency"
    visit_type: str       # "general", "specialist", "urgent_care"
    reason: str
    patient_name: str
    chief_complaint: str


class PatientQualificationAgent:
    """Screens patients and determines appropriate care routing before scheduling."""

    def __init__(self):
        self.client = anthropic.Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY"))
        self.model = "claude-sonnet-4-5"

    def get_qualification_tools(self) -> list[dict]:
        return [
            {
                "name": "assess_urgency",
                "description": "Evaluates patient symptoms and determines urgency level.",
                "input_schema": {
                    "type": "object",
                    "properties": {
                        "symptoms": {
                            "type": "array",
                            "items": {"type": "string"},
                            "description": "List of symptoms the patient reported"
                        },
                        "duration": {
                            "type": "string",
                            "description": "How long the patient has had these symptoms"
                        },
                        "severity": {
                            "type": "integer",
                            "description": "Patient-reported severity on a scale of 1 to 10"
                        }
                    },
                    "required": ["symptoms", "duration", "severity"]
                }
            },
            {
                "name": "determine_visit_type",
                "description": "Decides what type of appointment the patient needs.",
                "input_schema": {
                    "type": "object",
                    "properties": {
                        "chief_complaint": {"type": "string"},
                        "urgency_level": {"type": "string"}
                    },
                    "required": ["chief_complaint", "urgency_level"]
                }
            },
            {
                "name": "submit_qualification",
                "description": "Submits the final qualification decision for a patient.",
                "input_schema": {
                    "type": "object",
                    "properties": {
                        "patient_name": {"type": "string"},
                        "approved": {"type": "boolean"},
                        "urgency": {"type": "string"},
                        "visit_type": {"type": "string"},
                        "chief_complaint": {"type": "string"},
                        "reason": {"type": "string"}
                    },
                    "required": ["patient_name", "approved", "urgency", "visit_type", "chief_complaint", "reason"]
                }
            }
        ]

    def _execute_qualification_tool(self, tool_name: str, tool_input: dict) -> dict:
        """Runs the qualification logic — in production, these could call external APIs."""
        if tool_name == "assess_urgency":
            symptoms = tool_input.get("symptoms", [])
            severity = tool_input.get("severity", 0)
            # Flag high-risk symptoms immediately
            emergency_symptoms = ["chest pain", "difficulty breathing", "stroke symptoms", "severe bleeding"]
            is_emergency = any(s.lower() in emergency_symptoms for s in symptoms) or severity >= 9
            is_priority = severity >= 6 or len(symptoms) > 3

            if is_emergency:
                return {"urgency_level": "emergency", "recommendation": "Direct to ER immediately"}
            elif is_priority:
                return {"urgency_level": "priority", "recommendation": "Same-day or next-day appointment"}
            else:
                return {"urgency_level": "routine", "recommendation": "Standard scheduling within 1 week"}

        elif tool_name == "determine_visit_type":
            complaint = tool_input.get("chief_complaint", "").lower()
            urgency = tool_input.get("urgency_level", "routine")

            if urgency == "emergency":
                return {"visit_type": "urgent_care", "notes": "Redirect to ER or urgent care facility"}
            elif any(word in complaint for word in ["specialist", "cardio", "ortho", "neuro"]):
                return {"visit_type": "specialist", "notes": "Requires specialist referral"}
            else:
                return {"visit_type": "general", "notes": "General practitioner appointment"}

        elif tool_name == "submit_qualification":
            # Store or forward the result — here we just return it
            return {"status": "submitted", "qualification": tool_input}

        return {"error": f"Unknown tool: {tool_name}"}

    def qualify_patient(self, patient_info: str) -> QualificationResult:
        """Runs the patient through qualification and returns a structured result."""
        system_prompt = """You are a medical triage assistant. Assess the patient's information 
        using the available tools. Always call assess_urgency first, then determine_visit_type, 
        then submit_qualification. Never approve emergency patients for standard scheduling."""

        messages = [{"role": "user", "content": patient_info}]
        final_result = None

        while True:
            response = self.client.messages.create(
                model=self.model,
                max_tokens=1024,
                system=system_prompt,
                tools=self.get_qualification_tools(),
                messages=messages
            )

            if response.stop_reason == "end_turn":
                break

            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._execute_qualification_tool(block.name, block.input)
                        # Capture the final qualification submission
                        if block.name == "submit_qualification":
                            q = block.input
                            final_result = QualificationResult(
                                approved=q["approved"],
                                urgency=q["urgency"],
                                visit_type=q["visit_type"],
                                reason=q["reason"],
                                patient_name=q["patient_name"],
                                chief_complaint=q["chief_complaint"]
                            )
                        tool_results.append({
                            "type": "tool_result",
                            "tool_use_id": block.id,
                            "content": json.dumps(result)
                        })

                messages.append({"role": "user", "content": tool_results})

        # Safe fallback if submit_qualification was never called
        if final_result is None:
            final_result = QualificationResult(
                approved=False,
                urgency="unknown",
                visit_type="general",
                reason="Qualification process did not complete",
                patient_name="Unknown",
                chief_complaint="Unknown"
            )

        return final_result
⚠️ Important: The emergency symptom list in _execute_qualification_tool is for demo purposes only. In a real healthcare deployment, triage logic must be reviewed and approved by licensed medical professionals. Never ship clinical decision logic without clinical review.

Step 5: Implement the Coordination Loop

This is where everything connects. The orchestrator takes a patient request, runs it through the qualification agent, and then — if approved — hands it off to the scheduling agent. It also handles the error fallback patterns you'll need in production.

orchestrator.py
import os
import logging
import anthropic
from dotenv import load_dotenv
from scheduling_agent import SchedulingAgent
from qualification_agent import PatientQualificationAgent, QualificationResult

load_dotenv()

# Set up logging so you can see what each agent is doing
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s"
)
logger = logging.getLogger(__name__)


class HealthcareSchedulingOrchestrator:
    """
    Multi-agent orchestrator that coordinates patient qualification
    and appointment scheduling using two specialized Claude agents.
    """

    def __init__(self):
        self.qualification_agent = PatientQualificationAgent()
        self.scheduling_agent = SchedulingAgent()
        self.max_retries = 3

    def handle_patient_request(self, patient_info: str, scheduling_request: str) -> dict:
        """
        Main entry point. Takes raw patient info and a scheduling request,
        runs qualification, then schedules if approved.
        """
        logger.info("Starting patient intake process")
        result = {
            "status": "pending",
            "qualification": None,
            "scheduling": None,
            "error": None
        }

        # --- Phase 1: Qualify the patient ---
        try:
            logger.info("Running patient qualification...")
            qualification = self._run_with_retry(
                lambda: self.qualification_agent.qualify_patient(patient_info),
                step="qualification"
            )
            result["qualification"] = qualification
            logger.info(f"Qualification complete — urgency: {qualification['urgency']}, approved: {qualification['approved']}")

        except Exception as e:
            logger.error(f"Qualification failed: {e}")
            result["status"] = "failed"
            result["error"] = f"Qualification error: {str(e)}"
            return result

        # --- Phase 2: Route based on urgency ---
        if qualification["urgency"] == "emergency":
            result["status"] = "emergency_redirect"
            result["scheduling"] = {
                "message": "Patient flagged as emergency. Please call 911 or proceed to the nearest ER immediately.",
                "booked": False
            }
            logger.warning(f"Emergency patient detected: {qualification['patient_name']}")
            return result

        if not qualification["approved"]:
            result["status"] = "not_approved"
            result["scheduling"] = {
                "message": f"Patient not approved for scheduling. Reason: {qualification['reason']}",
                "booked": False
            }
            return result

        # --- Phase 3: Schedule the approved patient ---
        try:
            logger.info("Handing off to scheduling agent...")
            # Build a rich scheduling prompt from qualification data
            enriched_request = (
                f"{scheduling_request} "
                f"Patient name: {qualification['patient_name']}. "
                f"Visit type: {qualification['visit_type']}. "
                f"Reason for visit: {qualification['chief_complaint']}. "