AI receptionist

A text conversation that finds the caller, offers open times and books when they say yes. Python, one file.

What it does

  1. The caller's phone number comes in with the conversation. The receptionist looks them up with GET /patients by phone_number.
  2. It reads their upcoming visits with GET /appointments, filtered by patient_ids[].
  3. It offers open times from GET /available_slots for one day.
  4. After the caller says yes, it books with POST /appointments and says the practice's system will confirm.

A language model runs the conversation. The model never talks to the API. It calls four functions, and the functions make the calls.

Run it

pip install requests

export NEX_API_KEY="your-sandbox-api-key"
export NEX_SUBDOMAIN="your-subdomain"
export NEX_LOCATION_ID="your-location-id"
export NEX_PROVIDER_ID="a-provider-id"   # from GET /providers
export ANTHROPIC_API_KEY="your-model-key"
export ANTHROPIC_MODEL="claude-sonnet-4-5"  # set a current model name

python receptionist.py +15165550100

Type messages as the caller. An empty line ends the conversation.

The code

"""AI receptionist: a text conversation that finds the patient, offers open times and books.

Run: python receptionist.py +15165550100
Needs: pip install requests, plus these environment variables:
  NEX_API_KEY, NEX_SUBDOMAIN, NEX_LOCATION_ID, NEX_PROVIDER_ID   the practice, from the Developer Portal
  ANTHROPIC_API_KEY                                            the model that runs the conversation
The conversation runs in the terminal. Wire the same loop to your SMS provider to answer texts.
"""
import datetime as dt
import json
import os
import sys

import requests

BASE = os.environ.get("NEX_BASE", "https://nexhealth.info")
LLM = os.environ.get("ANTHROPIC_BASE_URL", "https://api.anthropic.com")
MODEL = os.environ.get("ANTHROPIC_MODEL", "claude-sonnet-4-5")
SCOPE = {"subdomain": os.environ["NEX_SUBDOMAIN"], "location_id": os.environ["NEX_LOCATION_ID"]}
PROVIDER_ID = int(os.environ["NEX_PROVIDER_ID"])
SLOT_MINUTES = int(os.environ.get("SLOT_MINUTES", "30"))


# --- NexHealth API ----------------------------------------------------------

_token = {"value": None, "expires": dt.datetime.min}


def token():
    """Trade the API key for a bearer token. Refresh before the hour is up."""
    if _token["value"] and dt.datetime.now() < _token["expires"]:
        return _token["value"]
    res = requests.post(BASE + "/authenticates", headers={"Nex-Api-Version": "v3.0.0", "Authorization": os.environ["NEX_API_KEY"]})
    data = res.json()
    if not res.ok or data.get("code") is False:
        raise RuntimeError(f"Token exchange failed: {data.get('error')}")
    _token.update(value=data["data"]["token"], expires=dt.datetime.now() + dt.timedelta(minutes=50))
    return _token["value"]


def api(method, path, params=None, body=None, accept=None):
    headers = {"Nex-Api-Version": "v3.0.0", "Authorization": "Bearer " + token()}
    if accept:
        headers["accept"] = accept
    res = requests.request(method, BASE + path, params=params, json=body, headers=headers)
    data = res.json() if res.content else {}
    if not res.ok or data.get("code") is False:
        raise RuntimeError("; ".join(data.get("error") or [f"{method} {path} failed with {res.status_code}"]))
    return data.get("data")


# --- The tools the model can call -------------------------------------------

def find_patient(phone_number):
    patients = api("GET", "/patients", {**SCOPE, "phone_number": phone_number, "location_strict": "true", "inactive": "false", "non_patient": "false"})
    return [{"id": p["id"], "name": p["name"], "date_of_birth": p.get("bio", {}).get("date_of_birth")} for p in patients]


def upcoming_appointments(patient_id):
    now = dt.datetime.now(dt.timezone.utc)
    appts = api("GET", "/appointments", {**SCOPE, "patient_ids[]": patient_id, "cancelled": "false",
                                          "start": now.strftime("%Y-%m-%dT%H:%M:%SZ"),
                                          "end": (now + dt.timedelta(days=90)).strftime("%Y-%m-%dT%H:%M:%SZ"), "per_page": 20})
    return [{"id": a["id"], "start_time": a["start_time"], "provider_name": a.get("provider_name")} for a in appts]


def open_times(date):
    groups = api("GET", "/available_slots", {"subdomain": SCOPE["subdomain"], "start_date": date, "days": 1,
                                             "lids[]": SCOPE["location_id"], "pids[]": PROVIDER_ID, "slot_length": SLOT_MINUTES,
                                             "overlapping_operatory_slots": "false", "appointments_per_timeslot": 1},
                 accept="application/vnd.Nexhealth+json;version=2")
    slots = [{"time": s["time"], "end_time": s.get("end_time"), "provider_id": s.get("provider_id", g.get("pid")),
              "operatory_id": s.get("operatory_id", g.get("operatory_id"))} for g in groups for s in g["slots"]]
    return slots[:6]


def book(patient_id, time, end_time, provider_id, operatory_id, note=""):
    appt = {"patient_id": patient_id, "provider_id": provider_id, "operatory_id": operatory_id,
            "start_time": time, "end_time": end_time, "note": (note or "Booked by the receptionist")[:128]}
    data = api("POST", "/appointments", SCOPE, {"appt": appt})
    # A 201 means NexHealth accepted the booking. The appointment_insertion webhook confirms the write.
    return {"appointment_id": data["appt"]["id"], "start_time": data["appt"]["start_time"], "status": "pending"}


TOOLS = [
    {"name": "find_patient", "description": "Find the caller's patient record by phone number.",
     "input_schema": {"type": "object", "properties": {"phone_number": {"type": "string"}}, "required": ["phone_number"]}},
    {"name": "upcoming_appointments", "description": "List the patient's upcoming appointments.",
     "input_schema": {"type": "object", "properties": {"patient_id": {"type": "integer"}}, "required": ["patient_id"]}},
    {"name": "open_times", "description": "Open appointment times on one day, YYYY-MM-DD.",
     "input_schema": {"type": "object", "properties": {"date": {"type": "string"}}, "required": ["date"]}},
    {"name": "book", "description": "Book one of the open times for the patient. Confirm with the caller first.",
     "input_schema": {"type": "object", "properties": {
         "patient_id": {"type": "integer"}, "time": {"type": "string"}, "end_time": {"type": "string"},
         "provider_id": {"type": "integer"}, "operatory_id": {"type": "integer"}, "note": {"type": "string"}},
         "required": ["patient_id", "time", "end_time", "provider_id", "operatory_id"]}},
]
FUNCTIONS = {"find_patient": find_patient, "upcoming_appointments": upcoming_appointments, "open_times": open_times, "book": book}


# --- The conversation -------------------------------------------------------

def system_prompt(phone):
    today = dt.date.today().isoformat()
    return (f"You are the front desk receptionist for a medical practice. Today is {today}. "
            f"The caller is texting from {phone}. Look them up first. If there is no record, ask for their name "
            "and say the office will set them up. Offer at most three open times. Book only after the caller says yes. "
            "After booking, say the practice's system will confirm the time. Keep every message to two sentences.")


def ask_model(system, messages):
    res = requests.post(LLM + "/v1/messages",
                        headers={"x-api-key": os.environ["ANTHROPIC_API_KEY"], "anthropic-version": "2023-06-01"},
                        json={"model": MODEL, "max_tokens": 600, "system": system, "tools": TOOLS, "messages": messages})
    res.raise_for_status()
    return res.json()


def run_tools(blocks):
    results = []
    for block in blocks:
        if block["type"] != "tool_use":
            continue
        try:
            output = FUNCTIONS[block["name"]](**block["input"])
        except Exception as exc:  # the model sees the API's message and can explain or try again
            output = {"error": str(exc)}
        results.append({"type": "tool_result", "tool_use_id": block["id"], "content": json.dumps(output)})
    return results


def reply(system, messages, text):
    messages.append({"role": "user", "content": text})
    while True:
        answer = ask_model(system, messages)
        messages.append({"role": "assistant", "content": answer["content"]})
        if answer["stop_reason"] != "tool_use":
            return "".join(b["text"] for b in answer["content"] if b["type"] == "text")
        messages.append({"role": "user", "content": run_tools(answer["content"])})


if __name__ == "__main__":
    phone = sys.argv[1] if len(sys.argv) > 1 else input("Caller's phone number: ")
    system, messages = system_prompt(phone), []
    print("Receptionist ready. Type a message, or an empty line to stop.")
    for line in sys.stdin:
        line = line.strip()
        if not line:
            break
        print("Receptionist:", reply(system, messages, line))

How it works

  • Four tools. find_patient, upcoming_appointments, open_times and book are plain functions. The TOOLS list describes them to the model. The model picks one, the loop runs it and sends the result back, until the model answers in words.
  • Errors go to the model. When the API refuses a call, the function returns the API's message as {"error": ...} instead of crashing. The model can explain it to the caller or try another time.
  • The booking is pending until the webhook. book returns status: pending. In a real deployment, subscribe to appointment_insertion and text the caller when it fires. See How a write reaches the health record system.
  • The system prompt sets the rules. Look the caller up first, offer at most three times, book only after a yes, keep messages short. Change it to match the practice.

Change it for production

  • Replace the terminal loop with your SMS provider's inbound webhook, and keep one messages list per conversation.
  • Send messages through your own SMS provider. The API does not send texts.
  • Ask for the caller's name and date of birth when find_patient returns nothing, and create the record with POST /patients as in the Patients configuration guide.
  • Never let the model see the API key. It is only in the environment of the process that runs the functions.

Did this page help you?