Home / Articles

Clinic Booking Chatbot With LangGraph, SQLite and Streamlit, Step by Step

Build an appointment-booking assistant for a clinic: SQLite tables, service functions, a nine-node LangGraph workflow with gpt-4o-mini, a Streamlit chat UI and a notebook mode.

Publisher: REACTAPP.TOP17 min read3855 words

This article is published in English.

Sources: Agentic AI Project: Build a Customer Service Chatbot for a Clinic

Running a clinic involves a lot of tedious coordination: matching patients to doctors, tracking who is free when, and avoiding double bookings. A conversational assistant can take over the appointment part of that. This project builds one from four pieces: LangGraph to drive a multi-step conversation as a state machine, OpenAI's gpt-4o-mini to understand what people type, SQLite to hold the data, and Streamlit for the chat window.

The problem and the plan

Many clinics still lean on manual processes or a patchwork of tools. The consequences are long waits, scheduling conflicts, uneven staff workload and a frustrating experience for patients, who can struggle just to find an available doctor or the right specialty. What a clinic needs is an automated system that books through natural conversation while keeping accurate records of doctors, patients and bookings.

The chatbot guides the user through a short script:

  1. It greets the user and asks whether they want an appointment.
  2. It lists the available medical specialties.
  3. It shows the doctor and the open time slots.
  4. It collects the patient's name and phone number.
  5. It confirms the booking and saves it in the database.

The payoff is less administrative load, faster booking, no clashing appointments and a smoother patient journey. The finished code is organized into folders for data, services, the agent and the UI. Each piece has a narrow job. LangGraph decides which step of the conversation comes next, the model interprets free text, SQLite remembers who and when, and Streamlit shows it all to the user in a browser. Keeping those jobs apart is what lets you change one part, say the interface, without touching the rest.

Setup

Create a virtual environment. The command is the same on Mac, Linux and Windows:

python -m venv .venv

Activate it on Mac or Linux:

source .venv/bin/activate

On Windows with PowerShell:

.venv\Scripts\Activate.ps1

On the Windows Command Prompt:

.venv\Scripts\activate

Once active, the terminal prompt starts with the environment name, like this:

(.venv) your-folder-name %

Next come the dependencies. The requirements.txt file pins Streamlit, python-dotenv, pydantic, pandas and the other libraries:

streamlit>=1.50.0
python-dotenv==1.0.0
pydantic==2.12.5
pandas==2.3.3
python-dateutil==2.8.2
langgraph>=1.0.7
openai>=2.16.0
pygraphviz==1.14

Install them from the project folder:

cd clinic-agent
pip install -r requirements.txt

Finally, create a .env file in the clinic-agent directory holding your OpenAI key:

OPENAI_API_KEY=your_openai_api_key_here

Part 1: the database (data/db.py)

A booking system stands on its data layer. The clinic uses SQLite with three tables for doctors, customers and bookings. The schema is relational and easy to query during a conversation: a booking points to one doctor and one customer, so a single lookup tells you who is seeing whom and when. Phone numbers act as the practical identity for a patient, since a returning caller shouldn't end up with a duplicate record.

Doctors. Each row is a doctor with a specialty and working hours, which lets the chatbot suggest the right person once a specialty is chosen. The fields are doctor_id (unique identifier), doctor_name, speciality (such as Dermatologist or Pediatrician) and office_timing (the hours the doctor works). The demo covers five specialties: Dermatologist, Orthopedic, General Physician, Pediatrician and ENT Specialist, with made-up names and hours for each. Adding more is straightforward.

Customers. This table holds what is needed to book. Every time someone books, the system looks up the customer by phone number and creates a new record if there isn't one. The fields are customer_id, name and phone.

Bookings. The central table. Each row ties a doctor and a customer to a particular date and time. Its fields are booking_id, doctor_id and customer_id (references to the other two tables), appointment_date, appointment_time and status, such as Confirmed or Cancelled.

The script defines these tables and creates the clinic.db file:

# data/db.py - Database initialization and operations

import sqlite3
import os
from datetime import datetime, timedelta

DB_PATH = os.path.join(os.path.dirname(__file__), "clinic.db")

def get_connection():
    """Get a database connection."""
    return sqlite3.connect(DB_PATH)

def init_db():
    """Initialize the database with tables and sample data."""
    conn = get_connection()
    cursor = conn.cursor()

    # Doctors table
    cursor.execute("""
        CREATE TABLE IF NOT EXISTS doctors (
            doctor_id TEXT PRIMARY KEY,
            doctor_name TEXT NOT NULL,
            speciality TEXT NOT NULL,
            office_timing TEXT NOT NULL
        )
    """)

    # Customers table
    cursor.execute("""
        CREATE TABLE IF NOT EXISTS customers (
            customer_id TEXT PRIMARY KEY,
            name TEXT NOT NULL,
            phone TEXT NOT NULL
        )
    """)

    # Bookings table
    cursor.execute("""
        CREATE TABLE IF NOT EXISTS bookings (
            booking_id TEXT PRIMARY KEY,
            doctor_id TEXT NOT NULL,
            customer_id TEXT NOT NULL,
            appointment_date TEXT NOT NULL,
            appointment_time TEXT NOT NULL,
            status TEXT NOT NULL,
            FOREIGN KEY (doctor_id) REFERENCES doctors (doctor_id),
            FOREIGN KEY (customer_id) REFERENCES customers (customer_id)
        )
    """)

    # Insert sample doctors
    doctors = [
        ("D1", "Dr. Anil Sharma", "General Physician", "10:00-14:00"),
        ("D2", "Dr. Neha Verma", "Dermatologist", "11:00-16:00"),
        ("D3", "Dr. Rohit Mehta", "Orthopedic", "09:00-13:00"),
        ("D4", "Dr. Kavita Rao", "Pediatrician", "10:00-15:00"),
        ("D5", "Dr. Sanjay Iyer", "ENT Specialist", "12:00-17:00"),
    ]

    for doctor in doctors:
        cursor.execute(
            "INSERT OR IGNORE INTO doctors (doctor_id, doctor_name, speciality, office_timing) VALUES (?, ?, ?, ?)",
            doctor
        )

    conn.commit()
    conn.close()

if __name__ == "__main__":
    print("Initializing database...")
    init_db()
    print("Database initialized successfully.")

Run it from the project folder:

cd clinic-agent
python data/db.py

That produces clinic.db with the three tables. Rerunning the script is a quick way to reset the sample data while experimenting.

Part 2: the service layer

Services hold the business logic between the agent and the database. The LangGraph agent doesn't query tables directly. It calls service functions to fetch doctor information, generate time slots and confirm bookings. In an agent design these functions act as tools, leaving the agent free to concentrate on the flow of the conversation. This split also makes the logic testable on its own. You can check slot generation and booking without ever calling the language model, which is exactly what the next small script does.

The doctor service (services/doctor_service.py) deals with specialties, doctors and time slots. It returns the list of specialties from the database, fetches doctors for a chosen specialty, builds time slots from a doctor's office hours, and converts display times such as 2:00 PM into 24-hour format for storage.

# services/doctor_service.py - Doctor operations

from data.db import get_all_doctors, get_doctor_by_speciality, get_doctor_by_id

def get_specialities_list():
    """Get list of all specialities."""
    doctors = get_all_doctors()
    # Return unique specialities
    return list(dict.fromkeys([doc[2] for doc in doctors]))

def get_doctor_info(speciality):
    """Get doctor information by speciality."""
    doctor = get_doctor_by_speciality(speciality)
    if doctor:
        return {
            "doctor_id": doctor[0],
            "doctor_name": doctor[1],
            "speciality": doctor[2],
            "office_timing": doctor[3]
        }
    return None

def generate_time_slots(office_timing):
    """Generate hourly time slots from office timing string.

    Args:
        office_timing: String like "11:00-16:00"

    Returns:
        List of time slots like ["11:00 AM", "12:00 PM", ...]
    """
    start_time, end_time = office_timing.split("-")
    start_hour = int(start_time.split(":")[0])
    end_hour = int(end_time.split(":")[0])

    slots = []
    for hour in range(start_hour, end_hour):
        if hour < 12:
            suffix = "AM"
            display_hour = hour if hour > 0 else 12
        elif hour == 12:
            suffix = "PM"
            display_hour = 12
        else:
            suffix = "PM"
            display_hour = hour - 12
        slots.append(f"{display_hour}:00 {suffix}")

    return slots

def parse_time_slot(slot_str):
    """Parse time slot string to 24-hour format.

    Args:
        slot_str: String like "1:00 PM"

    Returns:
        String like "13:00"
    """
    time_part, suffix = slot_str.split(" ")
    hour, minute = time_part.split(":")
    hour = int(hour)

    if suffix == "PM" and hour != 12:
        hour += 12
    elif suffix == "AM" and hour == 12:
        hour = 0

    return f"{hour:02d}:{minute}"

The booking service (services/booking_service.py) handles customers and bookings. It finds an existing customer by phone number or creates a new one, looks up the slots already taken for a doctor on a date, filters those out to leave the free slots, generates a unique booking ID and saves the final appointment details of doctor, patient, date and time.

# services/booking_service.py - Booking operations

import uuid
from datetime import datetime
from data.db import (
    create_customer,
    create_booking,
    get_customer_by_phone,
    get_bookings_by_doctor_and_date,
    get_booking_by_id
)
from services.doctor_service import parse_time_slot

def get_or_create_customer(name, phone):
    """Get existing customer or create new one."""
    customer = get_customer_by_phone(phone)
    if customer:
        return customer[0]  # Return customer_id

    customer_id = f"CUST-{uuid.uuid4().hex[:6].upper()}"
    create_customer(customer_id, name, phone)
    return customer_id

def get_available_slots(doctor_id, office_timing):
    """Get available time slots for a doctor for today.

    Args:
        doctor_id: Doctor ID
        office_timing: Office timing string like "11:00-16:00"

    Returns:
        List of available time slots
    """
    from services.doctor_service import generate_time_slots

    today = datetime.now().strftime("%Y-%m-%d")
    all_slots = generate_time_slots(office_timing)

    # Get booked slots
    booked_times = get_bookings_by_doctor_and_date(doctor_id, today)

    # Filter out booked slots
    available = []
    for slot in all_slots:
        slot_24h = parse_time_slot(slot)
        if slot_24h not in booked_times:
            available.append(slot)

    return available

def confirm_booking(doctor_id, customer_name, customer_phone, time_slot, appointment_date=None):
    """Confirm a booking.

    Args:
        doctor_id: Doctor ID
        customer_name: Customer name
        customer_phone: Customer phone
        time_slot: Time slot like "1:00 PM"
        appointment_date: Optional date in YYYY-MM-DD format. Defaults to today.

    Returns:
        Booking ID
    """
    # Get or create customer
    customer_id = get_or_create_customer(customer_name, customer_phone)

    # Generate booking ID
    booking_id = f"BKG-{uuid.uuid4().hex[:6].upper()}"

    # Format appointment time
    if not appointment_date:
        appointment_date = datetime.now().strftime("%Y-%m-%d")
    appointment_time = parse_time_slot(time_slot)

    # Create booking
    create_booking(booking_id, doctor_id, customer_id, appointment_date, appointment_time)

    return booking_id

Checking the services

A small script exercises both services:

# test/test_service.py - to test the services created

from pathlib import Path
import sys

# Allow running this file directly: `python test/test_service.py`.
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
    sys.path.insert(0, str(PROJECT_ROOT))

from services.doctor_service import get_specialities_list, generate_time_slots
from services.booking_service import confirm_booking

# Get all specialities
specialities = get_specialities_list()
print("Available specialities:", specialities)

# Generate time slots for a doctor (11:00 AM - 4:00 PM)
slots = generate_time_slots("11:00-16:00")
print("Available slots:", slots)

# Confirm a booking
booking_id = confirm_booking(
    doctor_id="D1",
    customer_name="John Doe",
    customer_phone="9876543210",
    time_slot="2:00 PM"
)
print(f"Booking confirmed: {booking_id}")

Run it:

cd clinic-agent
python test_service.py

The terminal prints the list of specialties and the slots for a doctor, among other things:

(.venv) (base) my-mac clinic-agent % python test_service.py
Available specialities: ['General Physician', 'Dermatologist', 'Orthopedic', 'Pediatrician', 'ENT Specialist']
Available slots: ['11:00 AM', '12:00 PM', '1:00 PM', '2:00 PM', '3:00 PM']
Booking confirmed: BKG-C4F60A

If those look right, the services work, and the interesting part can begin. Seeing real specialties and real slots in the output confirms that the database, the doctor logic and the booking logic all agree with each other before any conversation logic is layered on top.

Part 3: the agent

The heart of the chatbot is a LangGraph agent that moves a booking through several stages.

Booking state

The state object is structured memory for the conversation. It records the current stage, such as greeting or choosing a doctor, remembers selections like specialty, doctor and slot, and keeps customer and booking details so the agent can finish the appointment.

# agents/booking_agent.py - LangGraph agent implementation

from typing import TypedDict, Annotated, List, Optional
from langgraph.graph import StateGraph, END
from openai import OpenAI
import os
from dotenv import load_dotenv

load_dotenv()
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))

class BookingState(TypedDict):
    """State for the booking conversation."""
    messages: List[dict]                    # Chat history
    stage: str                               # greeting, select_speciality, select_doctor, etc.
    selected_speciality: Optional[str]      # Chosen medical specialty
    selected_doctor: Optional[dict]         # Selected doctor details
    selected_date: Optional[str]            # Appointment date
    selected_slot: Optional[str]            # Time slot
    customer_name: Optional[str]            # Customer name
    customer_phone: Optional[str]           # Customer phone
    booking_id: Optional[str]               # Confirmation ID
    available_options: List[str]            # UI options

def create_initial_state():
    """Create initial state for the conversation."""
    return {
        "messages": [],
        "stage": "greeting",
        "selected_speciality": None,
        "selected_doctor": None,
        "selected_date": None,
        "selected_slot": None,
        "customer_name": None,
        "customer_phone": None,
        "booking_id": None,
        "available_options": []
    }

The LLM helper

A single call_llm function centralizes communication with the OpenAI model. It sends system and user prompts, returns the generated text and keeps every model call consistent and reusable.

# agents/booking_agent.py - LangGraph agent implementation
def call_llm(
    system_prompt: str,
    user_prompt: str,
    *,
    model: str = "gpt-4o-mini",
    temperature: float = 0,
    max_tokens: int = 50,
) -> str:
    """
    Centralized helper for all LLM calls.
    Returns the assistant's response
    """
    try:
        response = client.chat.completions.create(
            model=model,
            messages=[
                {"role": "system", "content": system_prompt},
                {"role": "user", "content": user_prompt},
            ],
            temperature=temperature,
            max_tokens=max_tokens,
        )
        return response
    except Exception as e:
        print(f"LLM call error: {e}")
        return ""

Nodes

Each node is one step in the booking workflow. Nodes handle the stage they represent, read the user's input and update the state, which keeps the workflow modular. There are nine of them:

  • greeting_node opens the chat and asks if the user wants an appointment.
  • select_speciality_node lists the specialties on offer.
  • select_doctor_node presents the doctors in the specialty that was picked.
  • select_date_node asks which day suits the user.
  • select_slot_node offers the free times that day.
  • confirm_node asks the user to approve the choice.
  • collect_details_node asks for a name and a phone number.
  • completed_node closes out a successful booking.
  • cancelled_node deals with a user who backs out.

Building the graph

The build_booking_graph function adds those nodes, defines the conditional transitions between them and so sets the overall path of the conversation.

# agents/booking_agent.py - LangGraph agent implementation
from langgraph.checkpoint.memory import MemorySaver

def build_booking_graph():
    """Build the LangGraph workflow."""
    workflow = StateGraph(BookingState)

    # Add all nodes
    workflow.add_node("greeting", greeting_node)
    workflow.add_node("select_speciality", select_speciality_node)
    workflow.add_node("select_doctor", select_doctor_node)
    workflow.add_node("select_date", select_date_node)
    workflow.add_node("select_slot", select_slot_node)
    workflow.add_node("confirm", confirm_node)
    workflow.add_node("collect_details", collect_details_node)
    workflow.add_node("completed", completed_node)
    workflow.add_node("cancelled", cancelled_node)

    # Set entry point
    workflow.set_entry_point("greeting")

    # Add conditional edges based on routing
    workflow.add_conditional_edges(
        "greeting",
        llm_router,
        {
            "greeting": "greeting",
            "select_speciality": "select_speciality",
            "cancelled": "cancelled"
        }
    )

    # Similar conditional edges for other nodes...

    # Final edges to END
    workflow.add_edge("completed", END)
    workflow.add_edge("cancelled", END)

    # Compile with checkpointer for session management
    return workflow.compile(checkpointer=MemorySaver())

# Create the compiled graph
booking_graph = build_booking_graph()

A short script renders the graph as an image:

#agents/save_langgraph_flow.py
"""Save the clinic booking LangGraph flow to png format in this folder."""


from pathlib import Path
import sys


# Ensure imports work whether the script is run from project root or this folder.
AGENTS_DIR = Path(__file__).resolve().parent
PROJECT_ROOT = AGENTS_DIR.parent
if str(PROJECT_ROOT) not in sys.path:
    sys.path.insert(0, str(PROJECT_ROOT))

from agents.booking_agent import booking_graph  # noqa: E402


def save_graph_files() -> None:
    """Export graph as PNG."""
    graph = booking_graph.get_graph()

    png_path = AGENTS_DIR / "langgraph_flow.png"
    png_data = graph.draw_mermaid_png()
    png_path.write_bytes(png_data)
    print(f"Saved PNG flow to: {png_path}")


if __name__ == "__main__":
    save_graph_files()

Run it:

cd clinic-agent
python save_langgraph_flow.py

It saves the diagram into the agents folder, which is a handy way to see the stages and their connections at a glance before trusting them with real users.

Processing messages

process_message takes input from the interface, runs it through the graph, and returns the updated state along with the assistant's reply. It accepts a thread ID, with a default session name, so that each browser session or notebook run gets its own saved progress through the booking flow.

# agents/booking_agent.py - LangGraph agent implementation
def process_message(state: BookingState, user_message: str, thread_id: str = "default_session") -> BookingState:
    """Process a user message through the booking graph."""
    config = {"configurable": {"thread_id": thread_id}}

    # Check if the graph is currently interrupted
    current_state = booking_graph.get_state(config)

    if current_state.tasks and current_state.tasks[0].interrupts:
        # Resume the graph with the user's message
        result = booking_graph.invoke(Command(resume=user_message), config=config)
    else:
        # No interrupt, so start/continue normally
        # Add user message to state (unless it's an initial trigger)
        if user_message.lower() != "hi" or state["messages"]:
            # Avoid duplicate user messages if already added
            if not state["messages"] or state["messages"][-1].get("content") != user_message:
                state["messages"].append({
                    "role": "user",
                    "content": user_message
                })
        # Run the graph
        result = booking_graph.invoke(state, config=config)

    # Update available_options and ensure message is in history
    snapshot = booking_graph.get_state(config)
    if snapshot.tasks and snapshot.tasks[0].interrupts:
        interrupt_value = snapshot.tasks[0].interrupts[0].value

        # Handle both dict and string interrupt values
        msg_content = ""
        options = []
        if isinstance(interrupt_value, dict):
            msg_content = interrupt_value.get("content", "")
            options = interrupt_value.get("available_options", [])
        else:
            msg_content = str(interrupt_value)

        # Ensure the interrupt message is in the chat history
        if msg_content:
            # Check if it was already added by the node
            last_msg_content = result["messages"][-1].get("content", "") if result["messages"] else ""
            if last_msg_content != msg_content:
                result["messages"].append({
                    "role": "assistant",
                    "content": msg_content,
                    "options": options
                })
            else:
                # If already added, just update it with options if missing
                result["messages"][-1]["options"] = options

        result["available_options"] = options
    else:
        # If not interrupted, use whatever set in state, or default to empty
        if "available_options" not in result:
            result["available_options"] = []

    return result

Testing the agent

A test script drives the agent from the terminal:

# test/test_agent.py - test agent
# Initialize state

from pathlib import Path
import sys

# Allow running this file directly: `python test/test_agent.py`.
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
 sys.path.insert(0, str(PROJECT_ROOT))

from agents.booking_agent import create_initial_state, process_message


state = create_initial_state()

# Process messages
state = process_message(state, "Hi", thread_id="session_1")
print(state["messages"][-1]["content"])

state = process_message(state, "I want to book", thread_id="session_1")
print(state["available_options"])

Run it:

cd clinic-agent
python test/test_agent.py

It should print the available options, which shows the graph is responding. At this point the whole conversation works from the terminal, and only the presentation is missing.

Part 4: the Streamlit interface

The UI offers a real-time chat window, clickable option buttons and sessions identified by unique IDs. Buttons matter in a flow like this, because choosing a specialty or a time slot with one click is quicker and less error-prone than typing it out.

# ui/chat_ui.py - Streamlit chatbot interface

"""Streamlit UI for the clinic booking chatbot."""

import streamlit as st
from agents.booking_agent import create_initial_state, process_message
from data.db import init_db


def initialize_session():
    """Initialize session state."""
    if "state" not in st.session_state:
        st.session_state.state = create_initial_state()
    if "initialized" not in st.session_state:
        st.session_state.initialized = False
    if "session_id" not in st.session_state:
        import uuid
        st.session_state.session_id = str(uuid.uuid4())


def display_chat_history():
    """Display the chat history with persistent options and styling."""
    messages = st.session_state.state.get("messages", [])
    for i, message in enumerate(messages):
        if message["role"] == "assistant":
            with st.chat_message("assistant"):
                st.markdown(message["content"])

                # Show options if they exist
                options = message.get("options", [])
                if options:
                    # If this is the last message in history, show as clickable buttons
                    if i == len(messages) - 1 and st.session_state.state["stage"] not in ["completed", "cancelled"]:
                        st.markdown("---")
                        # Create columns for buttons
                        cols = st.columns(min(len(options), 3))
                        for idx, option in enumerate(options):
                            col_idx = idx % 3
                            with cols[col_idx]:
                                if st.button(option, key=f"btn_{i}_{idx}", use_container_width=True):
                                    handle_user_input(option)
                    else:
                        # For older messages, show options as pills/text to keep history
                        options_str = "  ".join([f"`{opt}`" for opt in options])
                        st.markdown(f"**Available options:** {options_str}")
        else:
            with st.chat_message("user"):
                st.markdown(message["content"])


def handle_user_input(user_input: str):
    """Handle user input and process through agent."""
    # Process the message
    st.session_state.state = process_message(
        st.session_state.state,
        user_input,
        thread_id=st.session_state.session_id
    )

    # Rerun to update UI
    st.rerun()


def run_chat_ui():
    """Run the chat UI."""
    # Page config
    st.set_page_config(
        page_title="CarePlus Clinic - Book Appointment",
        page_icon="🏥",
        layout="centered"
    )

    # Custom CSS for distinction between messages
    st.markdown("""
        <style>
        [data-testid="stChatMessageUser"] {
            flex-direction: row-reverse;
            text-align: right;
            background-color: #e0f2f1;
            border-radius: 15px 15px 0px 15px;
        }
        [data-testid="stChatMessageAssistant"] {
            background-color: #f5f5f5;
            border-radius: 15px 15px 15px 0px;
        }
        </style>
    """, unsafe_allow_html=True)

    # Initialize database
    init_db()

    # Initialize session
    initialize_session()

    # Header
    st.title("🏥 CarePlus Clinic")
    st.markdown("*Book your doctor appointment easily*")
    st.markdown("---")

    # Send initial greeting if not initialized
    if not st.session_state.initialized:
        st.session_state.state = process_message(
            st.session_state.state,
            "Hi",
            thread_id=st.session_state.session_id
        )
        st.session_state.initialized = True
        st.rerun()

    # Display chat history
    display_chat_history()

    # Chat input (only show if not completed)
    if st.session_state.state["stage"] not in ["completed", "cancelled"]:
        if prompt := st.chat_input("Type your message here..."):
            handle_user_input(prompt)
    else:
        # Show restart button after completion
        st.markdown("---")
        if st.button("🔄 Start New Booking", use_container_width=True):
            st.session_state.state = create_initial_state()
            st.session_state.initialized = False
            st.rerun()

Part 5: the entry point

app.py is the starting point that launches the whole application:

# app.py - Main entry point for the application

from ui.chat_ui import run_chat_ui

if __name__ == "__main__":
    run_chat_ui()

Two ways to run it

As a Streamlit web app, which is the easiest route:

streamlit run app.py

It opens at http://localhost:8501.

In a Jupyter notebook, which suits development and experiments. Import the functions first:

# clinic-agent.ipynb
from services.doctor_service import get_specialities_list, get_doctor_info, generate_time_slots
from services.booking_service import confirm_booking
from agents.booking_agent import (
    BookingState,
    create_initial_state,
    build_booking_graph,
    process_message
)

Build the graph and display its structure:

# clinic-agent.ipynb
# Initialize the booking graph
booking_graph = build_booking_graph()

## Visualize the booking graph structure
from IPython.display import Image, display

png_bytes = booking_graph.get_graph().draw_mermaid_png()
display(Image(png_bytes))

Then run an interactive chat session inside the notebook:

# clinic-agent.ipynb
from langgraph.types import Command

def run_booking_session(graph, thread_id="notebook_session", reset=False):
    config = {"configurable": {"thread_id": thread_id}}

    # 1. Start or Reset logic
    current_state = graph.get_state(config)
    if reset or not current_state.values:
        print(f"--- {'🔄 Resetting' if reset else '🆕 Initializing'} Session ---")
        # Using invoke() here kicks off the 'greeting' node immediately
        graph.invoke(create_initial_state(), config=config)

    print("---⚕⚕ Starting CarePlus Booking Session ---")

    last_displayed_message_idx = -1  # Track which messages have been displayed

    while True:
        state = graph.get_state(config)

        # Display any new assistant messages that haven't been shown yet
        # (This handles guardrail/off-topic responses)
        if state.values and state.values.get('messages'):
            messages = state.values['messages']
            for idx in range(last_displayed_message_idx + 1, len(messages)):
                msg = messages[idx]
                if msg.get("role") == "assistant":
                    print(f"\n[AI]: {msg['content']}")
            last_displayed_message_idx = len(messages) - 1

        # 2. Check for Interrupts
        if state.tasks and state.tasks[0].interrupts:
            interrupt_info = state.tasks[0].interrupts[0].value

            # --- FIX: Safely handle both String and Dict interrupts ---
            if isinstance(interrupt_info, dict):
                ai_message = interrupt_info.get('content', 'No message content')
                options = interrupt_info.get('available_options', [])
            else:
                ai_message = interrupt_info
                options = []

            print(f"\n[AI]: {ai_message}")
            if options:
                print(f"Options: {', '.join(options)}")
            # -------------------------------------------------------

            user_input = input("\n[YOU]: ")
            print(f"[YOU]: {user_input}")

            # Resume the graph with the user's input
            graph.invoke(Command(resume=user_input), config=config)

        # 3. Check if the graph has finished
        elif not state.next:
            # Before ending, check if there's a final assistant message to print
            if state.values and state.values.get('messages') and state.values['messages'][-1]["role"] == "assistant":
                if last_displayed_message_idx < len(state.values['messages']) - 1:
                    print(f"\n[AI]: {state.values['messages'][-1]['content']}")
            print("\n--- ⚑⚑ Session Ended ---")
            break

        # 4. If nodes are pending but no interrupt, let them run (the gas pedal)
        else:
            graph.invoke(None, config=config)

# IMPORTANT: Set reset=True only when you want to wipe the history.
# Set it to False to actually continue the conversation!
run_booking_session(booking_graph, reset=True)

The notebook helper runs a booking session on a named thread and can be reset to start over. In the notebook the input box appears at the top, above the cells. The repository includes a clinic-agent.ipynb notebook that runs the full flow.

What the finished bot gives you

The result is a booking chatbot built from LangGraph, OpenAI and SQLite with a Streamlit front end. A state-driven workflow carries users from picking a specialty to confirming a slot, and the layers (database, services, agent logic and UI) stay separate, so the application is modular and easy to extend. Because the layers are cleanly separated, each improvement touches only one of them. Natural next steps include calendar integration, notifications, multilingual support and messaging platforms. It is a practical example of combining modern AI frameworks and simple web tools into a useful healthcare application.