Skip to content
Rocky G
Go back

Building Caira: a WhatsApp AI Receptionist a Hospital Can Trust

Updated:

On paper, Caira is a WhatsApp receptionist for hospitals and clinics. In practice she is the most patient colleague I have ever had: answers in English, Hindi, Telugu or Hinglish, at 3 am, to a hundred people at once, and she has never once sighed.

This is the story of how I built her, told the way I actually experienced it. Less “here is my architecture”, more “here is the thing that scared me, and here is the trick that made it boring”. The hard part was never getting a bot to answer. Any modern model answers anything, instantly and beautifully. The hard part was making it answer like an employee who knows the rules, admits what she does not know, and never, ever invents a consultation fee.

Before any theory, watch one real message go through the machine. A patient asks about migraine and timings. About 1.6 seconds later they have an answer the AI was never allowed to make up.

inbound → grounded → sent  ·  one message, decoded estimate
do you treat migraine? and what are your timings 11:42
Yes, migraine is handled by our Neurology dept. We're open Mon to Sat, 9 am to 7 pm. Want me to book you in?11:42
40 minutes later, another patient
what're the clinic timings?12:24
We're open Mon to Sat, 9 am to 7 pm.12:24
route phone_number_id 1084…41 clinic resolved · 1 indexed lookup
intent → faq question · lang=en · conf 0.92 ~190ms
retrieve the 6 most relevant of the clinic's FAQs
grounded answer from those 6 facts · answered=true ~640ms $0.0005
cache answer saved for the next patient who asks
cache HIT · normalized question matched 0ms $0.00
WhatsApp domain (chat) system / AI path cost & latency failure / handoff

One request before we start: everything below is animated and every playground actually works, so press things. Break things. That is what they are for. If you are an engineer, the “For engineers” boxes carry the deep cuts. If you are not, skip every one of them and you will still get the whole story.

Meet Caira

Every hospital and clinic has the same problem. The phone rings all day, someone has to answer “what are your timings?” for the fortieth time, write names in a diary, and call people so they actually show up. That person is expensive, goes home at 7 pm, and can hold exactly one conversation at a time.

So I built that person as software, living inside WhatsApp. She answers questions, books appointments, sends reminders, offers freed slots to the waitlist, and checks on you after your visit.

And her first client is not a small shop. It is a big hospital: departments, many doctors, a front desk that never goes quiet. That scale is baked into every decision below.

The decision that shaped everything came right at the start: one copy of Caira serves every hospital and clinic. One apartment building with many locked flats, instead of a new house for every family. It looks like a cost decision on a slide. In reality it is the decision that caused almost every hard problem in this story, and honestly, most of the fun ones too.

21
modules, one reason to change each
24
ports · 43 adapters · 16 fakes
$0.0005
typical cost of one grounded answer
~1.6s
from patient text to confirmed reply
▸ For engineers: the stack behind the numbers

TypeScript end to end, thin serverless HTTP routes in front, Postgres with row-level security underneath, a Redis cache, and Gemini as the current model behind a swappable port. Around them, rented services for messaging (WhatsApp Cloud API), Indic translation, payments, and per-call LLM cost and latency traces. Everything patient-facing flows through a handful of thin routes: an inbound webhook, a booking form endpoint, a payment webhook, and a cron drain.

How a message flows through the system

You type “do you treat migraine? and what are your timings” and hit send. Here is everything that happens before the reply lands, and I promise none of it is magic.

  1. WhatsApp delivers your message to us. Not to a phone on a desk. To a small program on a server.
  2. We check it is really from WhatsApp. The message carries a cryptographic seal and we verify it. Broken seal: thrown away, no exceptions, no hurt feelings.
  3. We work out which clinic you texted. We look at which of our numbers your message arrived on. We never believe anything inside the message about which clinic it is, because a stranger could lie.
  4. We ask the AI one narrow question: what does this person want? Not "answer them." Just "sort this into a box." The AI is a receptionist's ears here, not her mouth.
  5. If they want to book, the AI steps aside. A form opens inside WhatsApp: branch, department, doctor, time. Boring, predictable code reads the real appointment book. No AI touches your booking.
  6. If it is a question, we fetch the facts first. The clinic wrote down its real answers. We pick the handful most likely to be relevant and hand the AI only those.
  7. The AI must answer from those facts alone. If the answer is not in what we handed over, she says "let me check with the clinic" instead of guessing.
  8. We check her homework before sending. The reply is inspected, length-capped, and stripped of any link she tried to sneak in. Then translated into your language and sent.
  9. We remember the answer. The next patient asking about timings gets the same reply instantly and for free. The same few questions are most of the traffic, so most of the traffic is free.
The thing to notice The AI appears exactly twice, and both times it is doing something small: sorting a message into a box, and writing one paragraph from facts it was handed. It never decides whether a slot is free, never writes to the appointment book, and never has the last word on what gets sent.

The system map: who talks to what

When I sat down to draw the system, the first map had three kinds of humans on it: patients (WhatsApp only, they never see a website), clinic staff (a dashboard), and doctors (their own day view). Everything on the right side is a company I rent something from.

The rule I refused to break: every rented service sits behind a plug socket of my own design. If Google doubles Gemini’s price tomorrow, I change the plug, not the house.

HUMANS THE SYSTEM RENTED SERVICES Patient WhatsApp only · te/hi/en Clinic staff web dashboard · authenticated Doctor own day · availability · wellness Cron timer wakes the queue drain Caira multi-tenant · stateless one serverless app 21 modules · 24 ports Meta WhatsApp Cloud Graph v21 · messages, Flows Google Gemini 2.5 Flash intent + grounded answers Indic translation API Indic translation Postgres tenant rows · RLS · auth Redis shared answer cache Payments provider upfront-fee payment links LLM trace store per-call cost + latency traces sends / receives model port translation port repositories cache port payments port metrics port
Green: the WhatsApp path a patient experiences. Teal: calls we make outward, each labelled with its port. Amber dashed: things that call us back in.
▸ For engineers: why this map is load-bearing

A patient has no direct edge to the system: every inbound byte arrives via Meta, signed, which is what makes the tenant router trustworthy. And every outward edge is labelled with a port name (a model provider, a translation provider, a cache, a payments provider, a metrics sink, a stack of repositories), not a vendor name. The vendor is resolved in one place, the composition root.

How I structured the entry points

Zoom in one level and Caira’s building has only three doors that matter: one where WhatsApp messages arrive, one where the booking form talks to us, and one where a timer knocks to say “send today’s reminders”. Plus a small payment door round the back.

I kept the doors deliberately dumb. They check ID and hand you to the rooms inside. All the thinking happens in rooms that have no idea they are inside a building, which is why I can test every room without ever building a door.

INBOUND EDGE · thin HTTP shells DOMAIN · pure, port-backed, zero-secret STATE Meta webhook patient message Meta Flow RSA data_exchange Cron timer Bearer CRON_SECRET Payments paid event Browser staff / doctor /webhooks/whatsapp HMAC · 88 lines …/whatsapp/flow decrypt · 31 lines /cron/reminders 401 without secret /webhooks/ razorpay RSC pages + /api/doctor/* bot-brain · the loop verify → sort intent → branch → reply one function, no vendor import drainDueJobs claim 100 · SKIP LOCKED 5 job types · dead-letter at 5 FlowExchangeService 5 screens · 7-day horizon max 20 slot options language prompt · schema sanitize · cache retrieval top-K availability slot-math (pure) holds · 5 min TTL schedule + overrides booking idempotency key status machine 23505 backstop reviews wellness waitlist post-visit funnels dashboard doctor read models + mutations Postgres: the single source of truth every tenant row carries its clinic id · RLS as the backstop Redis cache faq: answers · 1 h TTL stdout / LLM traces one JSON line per call cache read/write
Amber boxes are HTTP shells, 31 to 88 lines each, no business logic. Teal boxes are the domain: pure TypeScript that runs with zero secrets. Dashed edges carry state.
▸ For engineers: what every route does before it delegates

An HTTP route can only be exercised by constructing a request, faking a signature, and standing up an environment. A pure function can be exercised by calling it. So every route in the edge layer does exactly four things, then hands off:

  1. Read the raw body before any JSON parsing: the HMAC runs over raw bytes.
  2. Verify identity: HMAC signature, RSA decryption, or a Bearer secret.
  3. Parse and validate the payload shape against a strict schema.
  4. Hand the validated object to a domain function, and translate its result into a status code.

How I keep clinics isolated from each other

Here is the bug that actually kept me careful: clinic A seeing clinic B’s patients. In healthcare that is not a bug report, it is a regulatory incident.

So how do I know which clinic a message is for? The tempting answer is “the message says so”. Wrong answer. Whoever sends a message chooses what it says. Instead I use the one thing a sender cannot choose: which of our phone numbers the message physically arrived on.

It is the difference between a visitor telling you which flat they live in, and you noticing which letterbox their post came out of. Only one of those can be faked.

inbound payload metadata.phone_number_id = 1084…41 ← physical anything claiming "clinic_id": "<other>" in the body → IGNORED clinic resolver one receiving number maps to exactly one clinic 1 indexed lookup · no scan clinic_id (server-derived) the only tenant identity that exists tenant wrapper pins the clinic onto the transaction → transaction-scoped, never leaks RLS policy on every tenant table rows visible only to their own clinic enforced by the database itself unknown number → null → message dropped physical arrival forged claim
Green solid: identity derived from the letterbox, trusted. Red dashed: whatever the visitor claims, ignored.
▸ For engineers: the three backstops behind the letterbox

Resolving the receiving number to a clinic is one indexed lookup against a unique index, no table scan. From there every tenant row in the database carries its own clinic id, and every repository call takes that clinic id explicitly. Row-level security is the designed backstop: a small wrapper pins the current clinic onto the database transaction, and policies on the rows themselves refuse to serve another clinic's data, in case a developer ever forgets a WHERE clause.

Every AI agent is secretly just a loop

Now the AI concepts, and I will let you in on the industry’s best-kept boring secret. Strip away the hype and every agent does four things, forever: read what arrived (perceive), decide what is needed (think), do it (act), and confirm it worked (check). The loop, the rules and the checks around the model are called the harness.

The model is one part in the machine, and not even the biggest part. Caira’s harness is the receptionist’s training, checklist and manager. The model is just her vocabulary.

PERCEIVE read what arrived THINK decide what's needed LLM ACT do it CHECK confirm it worked
The whole trick of AI engineering: everyone gets the same models. The harness is where products differ.
▸ For engineers: the loop, in real code

One function IS the loop: resolve tenant, detect intent, run the matched flow, and persist state only after a successful send. That last detail is a check most builders forget. Not a single vendor import inside it: every dependency arrives through a port, and the LLM is called from exactly one narrow place inside think.

How I stopped the model from making things up

Language models are fluent guessers. In most products a wrong guess is a quirk. Here, if Caira invents a consultation fee, a real patient brings 500 rupees to a 900 rupee visit, under a hospital’s name. So my central question was never “which model”. It was: how do I make her structurally incapable of answering outside the facts she was handed?

Step one, she only ever sees the right page, never the whole library:

▶ Playground: the librarian
🕐 Open Mon to Sat, 9 am to 8 pm. Sunday closed
🩺 Dr. Rao: pediatrics, fever and child care
🅿️ Basement parking, free for patients
💳 UPI, cards and cash. No insurance yet

A librarian does not read the whole library to answer you. She finds the one right page. Caira scores every clinic fact against your question and hands the AI only the winners.

Step two, I built three walls around whatever she writes:

Wall 1: the briefing

The AI is handed the clinic's real facts inside clear markers and told: answer using only these; if it is not here, say you will check with the clinic; never invent a price, service or time.

Cheap, and it works most of the time. But it is a request.

Wall 2: the inspection

Whatever comes back is treated as untrusted input from a stranger. It must be the exact shape we asked for, under a length limit, and any web link it tried to sneak in is deleted.

This is the wall that catches a model going off-script.

Wall 3: the fallback

If the AI is slow, broken, out of quota, or returns nonsense, we do not retry into a guess. We send "let me check with the clinic and get back to you."

Every failure becomes a human handoff, never a made-up number.

The subtle one

The clinic's map link is not written by the AI. The AI can only raise a flag that says "they want the location." Our code attaches the real link.

Which is why any link the AI produces is treated as an anomaly and deleted.

And because the internet is the internet, people will absolutely try to talk her out of the rules. Go ahead, you try:

▶ Playground: try to trick her
🛡️

Also try a normal question. The bouncer lets guests in. He only stops troublemakers.

How I designed memory: state vs recall

Memory sounds warm and fuzzy until you have to design it. Remember too little and “book me with the same doctor as last time” gets a blank stare. Remember too much and every message costs more and leaks privacy. There are two kinds of memory here, and most builders mix them up.

Your school keeps two things about you: a timetable (exact, which class you are in right now) and a report card (a short summary of who you are). Caira needs both. Timetable = state. Report card = recall.

📋 State (exact)

"We are on step 3 of booking." Stored in the database, never guessed, and saved only after a successful send.

🧠 Recall (summary)

"Last visit was Dr. Rao, prefers Hindi, evenings." A few summarized lines handed to the model so conversations feel human.

▶ Playground: give her a memory
Patient: book me with the same doctor as last time
Caira:

How I cut the LLM bill with caching

Two hundred patients ask “what are your timings?” and a naive bot pays the model two hundred times for one identical sentence. If ten friends ask you the same question, you do not re-think the answer ten times. You remember it. So does she.

▶ Playground: ask twice
▶ Playground: the monthly AI bill estimate
Without cache
With cache

▸ For engineers: the detail
answers cached per clinic, keyed on the normalized question, and dropped the moment the clinic edits its facts; a cache hit is 0 ms of model time and $0.00; the trace window at the top of this post shows one happening

How I handle vendor outages

Some day Gemini will have a bad minute. Every vendor does. In most products the AI feature just dies and takes the user experience with it. I decided early that a vendor outage is not allowed to become Caira’s outage: she downgrades to a simple keyword matcher and keeps answering. Simpler replies, zero cost, nobody left on read.

If the head chef is sick, the restaurant does not close. The sous-chef cooks a simpler menu.

▶ Playground: break the AI
message ROUTER fallback-llm Gemini 2.5 smart · costs money Keyword bot simple · free · alive

The second half of surviving bad days is the letterbox. A phone call needs both people free at the same moment. A letter does not: drop it in the box, get your receipt instantly, the postman delivers when ready and retries if you are not home. All slow work goes through the letterbox, so WhatsApp never waits on a slow model.

webhook "got it" in 50ms QUEUE 📬 worker retried safely
▶ Playground: the model has a 25 second day
▸ For engineers: the detail
fallback is a decorator on the model port (429/timeout → keyword matcher); jobs are Postgres rows drained by cron with SKIP LOCKED, claim 100, dead-letter after 5 attempts; message-id idempotency makes Meta retries harmless

How I test the bot: an exam before every deploy

Change one word in a prompt and a bot can silently get worse. Without a safety net you find out from an angry hospital. So Caira sits an exam before every deploy: fixed questions, known right answers, a score. The alarm below is the strict parent who notices the moment a 90 becomes a 60.

▶ Playground: run the exam
·"What time do you open?"
·"Book me with Dr. Rao tomorrow"
·"Mera appointment cancel karo"
·"Do you take insurance?" (not in the facts)
·"My chest hurts badly" (must escalate)

How I model conversations: a state machine, not vibes

My favorite piece of low-level design. Caira never “remembers the vibe” of a conversation. She knows exactly which square you are standing on: browsing, booking, confirming, cancelled. Every message either moves you along a printed arrow or does nothing. There is no square called “improvise”.

idle every turn can return here BOOKING · intent book_appointment booking_location booking_department booking_doctor booking_slot booking_name awaiting_confirm booked booking_flow: native WhatsApp form replaces 1-5 RESCHEDULE reschedule_select reschedule_slot reschedule_flow REVIEW FUNNEL · triggered by a queue job, not by the patient review_open review_rating review_dimension review_comment rating ≥ 4 → skip straight to Google link wellness_reply good / better / bad every terminal state → idle STORED AS: TWO SMALL COLUMNS ON THE CONVERSATION ROW, NOTHING ELSE NO IN-MEMORY SESSION · A REDEPLOY MID-BOOKING LOSES NOTHING ONE LIVE CONVERSATION PER PATIENT PER CLINIC, ENFORCED BY THE DATABASE ITSELF
Solid = the forward path, amber (dashed) = shortcuts, escapes and returns to idle. The Flow variant collapses a five-state chain into one native form. The teal box is the one you are standing in right now.

And every AI answer rides a conveyor belt: facts go in, the model writes, the shape gets checked, links get stripped, length gets capped. Anything weird falls off the belt into the safest sentence in the product: “let me check with the clinic and get back to you.”

The worst thing Caira can do is ask a human. She is never able to be confident and wrong. That guarantee is what most of the engineering is for.

facts in fenced markers model writes JSON only shape check strict schema sanitizer links stripped length capped send to the patient "let me check with the clinic"
Teal: the happy path, five stations, one direction. Red: anything shape check or the sanitizer rejects falls straight to the same safe sentence, never back into the model for a retry.
▸ For engineers: the three walls, in code

The prompt fences the clinic's facts between explicit markers and asks for JSON only, a request the model can ignore. The response is checked against a strict schema, and the sanitizer strips every link and caps the reply length, an actual guarantee. The provider stack itself is a decorator chain, cache then fallback then metering then vendor, so a slow or dead model degrades instead of taking the feature down. Feed her a prompt injection and the classifier tags it, grounding still checks it against the clinic's real facts, finds nothing there, and the whole thing falls back to the same polite handoff a real failure would produce.

What actually costs money at scale

“Does it scale?” almost never means what people think it means. Computers are cheap and Caira is stateless, so more traffic just means more copies of her. The real question I kept asking myself: as this grows from one big hospital to a thousand clinics, which single thing bends first, and did I leave myself a lever?

Here is where the 1.6 seconds actually goes:

Meta → our function~50 ms
verify + parse~2 ms
tenant resolve~15 ms
patient + conversation~35 ms
intent sort (LLM)~190 ms
retrieve facts (pure)<1 ms
grounded answer (LLM)~640 ms
translate (if needed)~120 ms
WhatsApp send~180 ms
persist message log~20 ms
cache MISS, translated · estimate ≈ 1.25 s compute + ~0.35 s network = ≈ 1.6 s
Where 1.6 seconds goes. The model is the big slice, which is why the cache exists.

And here is the honest ladder, rung by rung, of what bends on the way up:

1 clinicpilot
Nothing technical bends. Free tiers are ample, the in-memory cache is fine, one cron pass drains everything with room to spare. What actually costs you: onboarding. Writing 40 good FAQs, getting templates approved by Meta, and mapping a real doctor roster is human work, and it is the genuine bottleneck at this rung, not the software.
10 clinicsearly revenue
The first real technical change, and it is a two-line one. Multiple function instances mean the per-instance cache stops hitting: the same question paid for repeatedly across instances. One config flip and a shared Redis cache takes over behind the same port. Bends: cache hit rate → fix is config, not code. Also: template approval becomes recurring ops work.
100 clinicsthe real test
Three things converge. Gemini requests reach ~145 RPM at peak, so throttling becomes routine and the fallback path starts earning its keep for real. Connection count makes transaction-mode pooling mandatory rather than merely correct. And the largest tenants' FAQ bases outgrow keyword retrieval, degrading answer quality silently. Bends: model quota · DB connections · retrieval quality. Levers: cache + fallback (built) · pooler (built) · pgvector adapter (one file).
1000 clinicsthe hard rung
Now the architecture is genuinely tested. One Postgres holding every tenant is the ceiling. Read replicas for dashboard queries buy time, then sharding by clinic is the real answer, and it is possible only because every table already carries that column and no query crosses tenants. The 100-job batch needs raising or more concurrent drainers. Slot recompute becomes a hot path. Noisy-neighbor isolation and per-tenant cost attribution stop being nice-to-haves and become billing requirements. Bends: the shared database. Real work, weeks not hours. Everything else is a dial.

The punchline most people get backwards: at realistic volumes, sending WhatsApp reminders costs more than running the AI. The robot brain is cheaper than the postage.

The demo is easy. The product is everything else.

If you remember one thing from this whole page, make it this. A weekend and a model API gets anyone a chatbot that books appointments. The distance between that demo and something a hospital trusts with real patients is everything you just scrolled through: grounding so she cannot lie, isolation so clinics cannot leak into each other, metering so costs cannot surprise anyone, and graceful degradation so the product outlives the vendor.

The AI is the easy, swappable part. The building around her is the product.

21
modules · 24 ports · 43 adapters
118
test files, zero secrets needed
22
tables · every row carries clinic_id
3
patient-facing routes, 31 to 88 lines each

Thanks for scrolling this far. If you pressed at least one button on the way down, we are officially friends.

Client details anonymized. The architecture is real but deliberately simplified: names, counts and several implementation details are changed or left out on purpose. Cost figures use published Gemini rates; token counts and traffic volumes are modeled estimates and are labelled estimate wherever they appear.


Share this post on:

Next Post
The Project Where I Banned AI