n4nAI

Building a voice AI agent for appointment scheduling

Hands-on tutorial to build a voice AI agent for appointment scheduling using Twilio, SQLite, and an OpenAI-compatible LLM with function calling.

n4n Team3 min read676 words

Audio narration

Coming soon — every post will get a voice note here.

Building a production-grade voice AI agent appointment scheduling system is less about ML wizardry and more about wiring speech I/O to a reliable tool-calling loop. This tutorial walks through a working implementation using Twilio for telephony, a SQLite backend, and an OpenAI-compatible LLM endpoint with function calling. By the end you’ll have a phone number that can book medical appointments through natural speech.

Prerequisites

  • Python 3.11 or newer
  • A Twilio account with a provisioned phone number
  • ngrok for exposing your local Flask app to the internet
  • An API key for an OpenAI-compatible LLM endpoint (we use n4n.ai’s gateway)
  • Install dependencies:
pip install flask twilio openai

You should also have a basic understanding of REST webhooks and TwiML. No prior speech model training is required; Twilio handles transcription and text-to-speech.

Architecture

The flow is simple:

  1. Twilio receives a call and sends a webhook to /voice.
  2. Your server responds with TwiML that uses <Gather input="speech"> to capture the caller’s utterance.
  3. The transcribed text posts to /process, where an LLM interprets intent and emits tool calls.
  4. Local functions book or query appointments in SQLite.
  5. The LLM synthesizes a spoken confirmation, and Twilio reads it back.

This keeps the voice AI agent appointment scheduling logic stateless except for the database writes.

1. Database layer

We start with a minimal SQLite store. It is enough for a clinic demo and easy to swap for Postgres later.

import sqlite3

def init_db():
    conn = sqlite3.connect("appointments.db")
    conn.execute("""
        CREATE TABLE IF NOT EXISTS appointments (
            id INTEGER PRIMARY KEY,
            patient_name TEXT,
            phone TEXT,
            start_time TEXT,
            reason TEXT
        )
    """)
    conn.commit()
    conn.close()

def book_appointment(patient_name: str, phone: str, start_time: str, reason: str):
    conn = sqlite3.connect("appointments.db")
    cur = conn.execute(
        "INSERT INTO appointments (patient_name, phone, start_time, reason) VALUES (?, ?, ?, ?)",
        (patient_name, phone, start_time, reason),
    )
    conn.commit()
    conn.close()
    return {"status": "booked", "id": cur.lastrowid}

def get_available_slots(date_str: str):
    # Stub: real systems check practitioner calendars
    return [
        f"{date_str}T09:00:00",
        f"{date_str}T10:00:00",
        f"{date_str}T11:00:00",
    ]

Run init_db() once at startup.

2. LLM client and tools

We define two tools the model can call. The base URL points to the gateway, which fronts 240+ models and provides automatic fallback if a provider is degraded.

import json
from openai import OpenAI

client = OpenAI(
    base_url="https://api.n4n.ai/v1",
    api_key="YOUR_N4N_KEY",
)

tools = [
    {
        "type": "function",
        "function": {
            "name": "book_appointment",
            "description": "Book a medical appointment for a patient",
            "parameters": {
                "type": "object",
                "properties": {
                    "patient_name": {"type": "string"},
                    "start_time": {"type": "string", "description": "ISO 8601 datetime"},
                    "reason": {"type": "string"},
                },
                "required": ["patient_name", "start_time", "reason"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "get_available_slots",
            "description": "List available appointment slots for a date",
            "parameters": {
                "type": "object",
                "properties": {"date_str": {"type": "string", "description": "YYYY-MM-DD"}},
                "required": ["date_str"],
            },
        },
    },
]

def llm_turn(messages):
    resp = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=messages,
        tools=tools,
        tool_choice="auto",
    )
    return resp.choices[0].message

Note we omit phone from the tool schema; we inject the caller ID server-side to prevent spoofing.

3. Twilio webhook handlers

Create a Flask app with two routes. The first greets and gathers speech; the second processes it.

from flask import Flask, request, Response
from twilio.twiml.voice_response import VoiceResponse, Gather

app = Flask(__name__)

@app.route("/voice", methods=["POST"])
def voice():
    resp = VoiceResponse()
    gather = Gather(input="speech", action="/process", timeout=5, speech_timeout="auto")
    gather.say("Hello, this is the scheduling assistant. What date and time would you like to come in?")
    resp.append(gather)
    resp.say("We didn't hear you. Goodbye.")
    return Response(str(resp), mimetype="application/xml")

Expected TwiML output when curling locally:

<Response>
  <Gather input="speech" action="/process" timeout="5" speechTimeout="auto">
    <Say>Hello, this is the scheduling assistant. What date and time would you like to come in?</Say>
  </Gather>
  <Say>We didn't hear you. Goodbye.</Say>
</Response>

Now the processing route:

@app.route("/process", methods=["POST"])
def process():
    speech = request.form.get("SpeechResult", "")
    caller = request.form.get("From", "")
    messages = [
        {"role": "system", "content": "You are a clinic scheduling agent. Use tools to book or check slots."},
        {"role": "user", "content": speech},
    ]
    msg = llm_turn(messages)
    if msg.tool_calls:
        for call in msg.tool_calls:
            if call.function.name == "book_appointment":
                args = json.loads(call.function.arguments)
                args["phone"] = caller
                result = book_appointment(**args)
                messages.append(msg)
                messages.append({"role": "tool", "tool_call_id": call.id, "content": json.dumps(result)})
        final = llm_turn(messages)
        reply = final.content
    else:
        reply = msg.content
    resp = VoiceResponse()
    resp.say(reply)
    resp.redirect("/voice")  # allow follow-up turns
    return Response(str(resp), mimetype="application/xml")

4. Local run and tunnel

Start the server:

python app.py

Expose it:

ngrok http 5000

Point your Twilio number’s voice webhook to https://<your-ngrok>.ngrok-free.app/voice.

Sample call flow

Caller: “I need to see Dr. Smith on tomorrow at 10 AM for a checkup.” Twilio transcribes and posts to /process. LLM emits book_appointment with patient_name inferred from conversation or prompted earlier, start_time = next day 10:00, reason = checkup. Database row inserted. LLM replies: “You’re booked for tomorrow at 10:00 AM for a checkup. Anything else?” TwiML loops back to /voice.

Checkpoint: query the DB.

sqlite3 appointments.db "SELECT * FROM appointments;"

Output similar to:

1|Jane Doe|+15551234567|2025-02-21T10:00:00|checkup

5. Hardening the voice AI agent appointment scheduling

The naive loop works, but production needs guardrails.

Timezone handling. Store UTC and convert using the caller’s area code or explicit ask. Don’t let the LLM emit local naive times without qualification.

Confirmation step. Require the model to read back the exact ISO time and ask “Should I confirm?” before writing. This reduces double-bookings.

Fallback. The n4n.ai endpoint automatically falls back when a provider is rate-limited or degraded, so the voice AI agent appointment scheduling stays responsive during spikes. Per-token metering lets you attribute cost per call.

Concurrency. SQLite will lock under parallel writes. Use a connection pool or move to Postgres before handling more than one call per second.

Privacy. Healthcare contexts imply HIPAA. Twilio has a HIPAA-eligible product; ensure your LLM processor signs a BAA or use a self-hosted model through the same OpenAI-compatible interface.

6. Extending to multi-step dialogues

For a real clinic, you’ll want to collect patient name, DOB, and insurance before booking. Implement a small state machine in the session (Twilio passes CallSid; store intermediate slots in Redis). Feed the accumulated slots back to the LLM as a system note:

messages.append({
    "role": "system",
    "content": f"Current collected data: {json.dumps(state)}"
})

The model will only call book_appointment once all required fields are present. This keeps the voice AI agent appointment scheduling robust without custom intent parsers.

Wrap-up

You now have a callable phone number that talks to an LLM, books into a database, and confirms via speech. The pattern—speech capture, tool-calling loop, server-side side-effects—generalizes to any voice AI agent appointment scheduling use case beyond healthcare. Swap the SQLite layer for your EHR API and you’re most of the way to production.

Tagsschedulingvoice-agentstutorialhealthcare

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All voice ai agents posts →