AI receptionist
A text conversation that finds the caller, offers open times and books when they say yes. Python, one file.
What it does
- The caller's phone number comes in with the conversation. The receptionist looks them up with
GET /patientsbyphone_number. - It reads their upcoming visits with
GET /appointments, filtered bypatient_ids[]. - It offers open times from
GET /available_slotsfor one day. - After the caller says yes, it books with
POST /appointmentsand 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 +15165550100Type 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_timesandbookare plain functions. TheTOOLSlist 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.
bookreturnsstatus: pending. In a real deployment, subscribe toappointment_insertionand 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
messageslist 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_patientreturns nothing, and create the record withPOST /patientsas 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.
Updated about 1 hour ago
Did this page help you?