ClinicBot · First principles

How the bot thinks

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.

// 00 — The one idea to hold on to

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

Put a label on it

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

Answer from a fact sheet

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.

Everything else — which slot is free, whose appointment is on Tuesday, what a booking costs, whether to show a button — is decided by ordinary code reading an ordinary database. A model is never asked a question the system already knows the answer to.

// 01 — Watch a message travel

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.

// 02 — The front door

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.

Then every message in the batch runs in its own 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.

// 03 — Twenty-four gates before the model

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.

// 04 — What the model is actually sent

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.

The second job: answering, on a leash

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.

This is why the bot cannot invent a doctor. Not because it was asked nicely — because inventing one would require it to produce a name that is not on a sheet it was told is the only truth, and because the reply is checked for URLs and length before it is ever relayed.

Two models, one queue

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.

// 05 — "Template" means two different things

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 hoursOutside 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"],
})
When the client said "it just throws the booking template", nothing template-shaped was involved. A branch was firing that appended the same button and the same sentence to unrelated questions. The fix was routing, not copy.

// 06 — And back out again

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.

// 07 — The shape of one turn

Put together, a single patient message costs somewhere between zero and two model calls — and the common cases cost zero.

Patient saysModel callsAnswered by
hi0A closed-vocabulary greeting matcher
okay bye0A closed-vocabulary farewell matcher
chest pain0Escalation regex → hotline
20Whatever flow is mid-conversation
when is my appointment?1Classify, then read the bookings table
is Dr Biju Govind free?1Classify, then the availability engine
do you have parking?2Classify, 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".

// 08 — Why it is built this way

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.