ClinicBot · First principles
A patient types four words into WhatsApp. Somewhere between their thumb and the reply is a webhook, a signature check, twenty-four gates, and — sometimes, but far less often than you would guess — a language model. This is that journey, traced against the code that actually runs.
The model never chooses what to say.
This is the thing most people get backwards, so it is worth being blunt about it up front. The bot does not hand a patient's message to an LLM and send back whatever comes out. The model is given exactly two jobs, both narrow, and neither of them is "write the reply":
Job one
Read the message, return one of nine words. book_appointment,
check_availability, farewell… That is the entire output.
The code decides what happens next.
Job two
Given the clinic's own facts — and only those — phrase an answer. It cannot reach the database, cannot browse, cannot look anything up. If the answer is not on the sheet, it must say so.
Pick a message. Four real ones, each taking a different route through the same loop. Watch where the model appears — and where it doesn't.
Meta does not hold a connection open waiting for us. It fires a
webhook — an HTTP POST to a URL we registered — and
expects 200 quickly. If it doesn't get one, it redelivers,
which is how a patient ends up with the same reply three times.
POST /api/webhooks/whatsapp
X-Hub-Signature-256: sha256=…
{ "entry": [{ "changes": [{ "value": {
"metadata": { "phone_number_id": "1158459207350477" },
"messages": [{ "from": "9198…", "type": "text",
"text": { "body": "is Dr Biju Govind free?" } }] }}]}] }
Two things happen before a single character of that JSON is trusted.
The signature is checked against the raw body. Anyone
can POST to a public URL. An HMAC over the exact bytes, using the app
secret, is what separates a real patient from someone spoofing one.
Parse first and you have already trusted the attacker's JSON — so the
order is load-bearing, not stylistic. Bad signature →
401, nothing else runs.
The tenant is derived, never accepted. That
phone_number_id is the only thing that decides which
hospital this message belongs to. It comes from Meta, not from the
message body, and every database read afterwards is scoped to the clinic
it resolves to. A patient cannot name a clinic and be believed.
try/catch,
and the route answers 200 regardless. One malformed message
must never take down the other four, and a crash must never become an
infinite redelivery loop.
Once a message is authentic and its tenant is known, it enters the core loop. What it meets is not an AI — it is a long ladder of cheap, deterministic checks, each asking "are you obviously this?"
Only a message that survives all of them is worth paying a model to understand.
24
checks that can answer a patient without any model call at all.
The ordering is not arbitrary — it is a priority list, and one entry matters more than the rest. Escalation runs fourth, above payments, above every conversation state, above the model.
if (welcomeRequest) …greet, no model
else if (unsupportedMedia) …ask for text
else if (flowCompletion) …a submitted form
else if (escalation === "emergency") …call 108 + the hospital hotline ← here
else if (escalation === "urgent") …hand to a human, never answer
else if (paymentChoice) …pay now / pay at hospital
…eighteen more…
else …NOW ask the model what this is
Someone typing "chest pain" or "Emergency" gets a phone number in milliseconds. No model, no latency, no chance of a clever paraphrase. A rule you can read in one line is worth more than a probability here.
A message that reaches the bottom of the ladder gets classified. This is the first of the two jobs, and it is deliberately joyless work.
The reply is not requested as prose and hoped to be JSON. It is constrained with Structured Outputs — a strict JSON schema handed to the API, so the model is only permitted to emit tokens that fit. "Return valid JSON, I promise" is a wish. A schema is a fence.
{ "intent": "book_appointment" | "cancel_appointment"
| "reschedule_appointment" | "check_appointment"
| "check_availability" | "ask_faq"
| "greeting" | "farewell" | "unknown",
"language": "en" | "te" | "hi" | "hinglish",
"entities": { "date": "2026-08-11", "doctor": "Biju Govind" },
"confidence": 0.0 – 1.0 }
Temperature is 0. Classification is a lookup, not a
creative act: the same sentence must produce the same label on Tuesday
that it produced on Monday.
Today's date is injected into that prompt, which sounds fussy until you realise the alternative. Ask a model to resolve "tomorrow" without telling it what day it is and it resolves against its training cutoff — confidently booking a patient into a date that has already passed.
If — and only if — the label is ask_faq (or the classifier
gave up entirely), the message goes to the model a second time. Now it
gets facts:
You are a clinic's WhatsApp receptionist. Answer using ONLY the facts below.
If the answer is not in the facts, say plainly that you don't have that detail.
NEVER invent clinic details, prices, services, doctors, or hours.
--- CLINIC FACTS ---
Clinic: Sri Sri Holistic Hospitals
Timings: Emergency 24/7 · OP Mon–Sat, 9 AM – 8 PM
Address: Nizampet Road, Kukatpally, Hyderabad – 500072
Consultation fee: ₹500
Services: Cardiology, Orthopaedics, Neurology, … (28)
Doctors currently practising here, by department:
Cardiology: Dr Biju Govind (Cardiologist, Kukatpally); Dr Barani Velan S …
Obesity: Dr Sample One (Obesity & Metabolic Medicine, Nizampet); …
--- END FACTS ---
Patient question: """do you have a cardiologist in kukatpally?"""
That fact sheet is assembled fresh from the database on every question — 142 doctors, 29 departments, 6 branches, read live. The receptionist edits a doctor's branch in the dashboard and the very next patient question is grounded in the new answer.
OpenAI is asked first, Gemini stands behind it, and behind them both is a plain keyword matcher that needs no credentials at all. A rate-limit or an outage on one provider degrades to the next instead of dead-ending a real patient.
The distinction that makes that chain safe is subtle and worth naming: "I failed" and "the answer is genuinely unknown" are different facts. An adapter throws when it could not answer, and returns a sentinel when it answered "I don't know". Collapse the two and a rate-limit looks exactly like a correct handoff — so the router either burns money retrying good answers, or refuses to retry real outages and strands the patient.
In front of both sits a cache keyed on the question plus a fingerprint of the clinic's facts. Ask "what are your timings?" twice and the model is paid once. Edit the timings and the fingerprint changes, so the old answer stops matching — an hour's TTL is only a safety net.
This is where the vocabulary trips people, so let us separate them properly. WhatsApp has a hard rule, and it is about time, not content.
| Inside 24 hours | Outside 24 hours | |
|---|---|---|
| What triggers it | The patient messaged us. A window opens. | Silence. We want to start the conversation. |
| What we may send | Free-form text, buttons, lists, native forms — anything. | Only a pre-approved template. Meta rejects free text outright. |
| Approval | None. Write what you like. | Submitted to Meta, reviewed, approved before first use. |
| Used for | Every reply in this document. | Reminders, no-show follow-ups, review requests. |
So the "templates" a patient sees during a conversation are not WhatsApp templates at all. They are ordinary strings in our own code, chosen by the branch that ran, with buttons attached:
flow = {
replyEn: `Hello! This is ${clinic.clinicName}. How can I help?`,
buttons: [{ id: "book", title: "📅 Book appointment" }],
}
A real WhatsApp template is a different object entirely — a registered name, a language, and positional holes to fill:
sendTemplate({
templateName: "smart_reminder", // approved in WhatsApp Manager
languageCode: "en",
bodyParams: ["Ravi", "Dr Biju Govind", "Mon, 11 Aug, 9:30 am"],
})
Whatever branch ran produced one object: some English, an optional list of buttons, and the conversation state to save. The last stretch is the same for all of them.
Note the ordering at the end. The conversation state is written after the send succeeds, not before. If Meta is down and every retry fails, the error propagates and the conversation stays exactly where it was — so the patient can send the message again rather than finding themselves stranded halfway through a booking that the database thinks already advanced.
Put together, a single patient message costs somewhere between zero and two model calls — and the common cases cost zero.
| Patient says | Model calls | Answered by |
|---|---|---|
| hi | 0 | A closed-vocabulary greeting matcher |
| okay bye | 0 | A closed-vocabulary farewell matcher |
| chest pain | 0 | Escalation regex → hotline |
| 2 | 0 | Whatever flow is mid-conversation |
| when is my appointment? | 1 | Classify, then read the bookings table |
| is Dr Biju Govind free? | 1 | Classify, then the availability engine |
| do you have parking? | 2 | Classify, then answer from clinic facts |
That distribution is the design, not an accident. Every path that can be made deterministic has been, because deterministic paths are testable, instant, free, and incapable of surprising a patient. The model is reserved for the one thing code is genuinely bad at: working out what a human meant by "Doctor availablity".
Ports & adapters
The loop depends on interfaces, never on OpenAI or Meta. Swapping a provider is one new file plus one line at the composition root — the core loop never learns about it.
Zero-secret by default
Every vendor has a credential-free fake. The whole product builds, tests and runs the core loop with no keys at all — which is what makes 1,092 tests fast and offline.
Facts beat fluency
Anything the database knows is read, not generated. A hospital can survive an awkward sentence; it cannot survive a confidently wrong appointment time.
Traced against handle-inbound-message.ts,
whatsapp-webhook.ts, gemini.prompt.ts,
chain-llm.adapter.ts and classify.ts.