Moosa Memon
← Index of work

A WhatsApp booking agent that cannot double-book

ConverseIQ: a multi-channel agent for a booking-heavy business that answers from the business's own records, books appointments, captures leads, and routes phone calls into the same brain.

Type
Agents
Stack
FastAPI / Gemini / Qdrant / Supabase / WhatsApp Cloud API / Twilio
Status
Complete; 145 offline tests; voice path implemented but not yet exercised against a live number
Measured
Eval suite with task completion, booking accuracy, faithfulness and handoff precision/recall; any wrong-date booking fails the run outright. Numbers not published yet: the eval needs a live Gemini key

Problem

A dental clinic in Lahore (the reference business; swapping vertical is editing one YAML file) gets the same questions on WhatsApp all day, and every booking still goes through a receptionist. A chatbot that answers questions is easy. One that books appointments has to be right about times, and messaging platforms have operational rules that produce visible failures when you get them wrong: duplicate appointments, messages that silently never arrive, webhooks that never activate.

Constraints

System

A FastAPI webhook verifies the HMAC signature over the raw body, parses, enqueues, and returns 200 in about 5 ms. A worker pool claims each message ID by inserting it as a primary key (insert-and-catch-409 is an atomic claim with no read-then-write race), loads the session, runs the agent, and sends the reply.

agent/core.py knows nothing about WhatsApp, Twilio, or TwiML. It takes text plus a session and returns an AgentReply; transports render that into WhatsApp buttons and lists, or into spoken options on a call. That’s what makes “multi-channel” a fact about the code. The agent retrieves from Qdrant (BM25 fallback), reasons, and calls one of four tools: check_availability, book_slot, capture_lead, handoff_to_human.

Decisions

A booking can only land on a slot the system itself offered. check_availability writes the slots it returned into session state; book_slot rejects any start time outside that set and tells the model to re-check. Offers expire after 30 minutes. This is the mechanism behind the zero-wrong-date guarantee. It’s not a prompt instruction and doesn’t depend on the model behaving.

Double-booking is prevented by the database. A partial unique index on bookings(starts_at) where status = 'confirmed' means the loser of a race gets a 409, which the agent turns into “that slot just went, here are the next ones.”

Tools own the truth; the model owns the wording. Hours, prices, and slot arithmetic are computed in Python from one business.yaml that is also rendered into the RAG documents. The agent physically can’t quote a price the booking tool disagrees with, because there’s only one number.

Claim the message ID before doing any work. If the process dies mid-turn, Meta’s retry is suppressed and the customer gets nothing, so the processor guarantees an apology on failure. A customer with an apology is recoverable; a customer with two appointments is a phone call to the clinic.

Twilio’s built-in speech recognizer over self-hosted Whisper for voice. One HTTP round trip instead of record → download → transcribe saves 2–4 seconds per turn, and turn latency is what makes a voice bot feel broken. The Whisper path is kept for when code-switched Urdu/English accuracy matters more.

Outcome

145 tests run with no network. The eval is multi-turn by design, because the failures that matter are multi-turn: booking a slot that was never offered, re-asking for a name already given, escalating on turn three something answered on turn one. The clock is pinned so “tomorrow” means the same thing every run.

Known limitations, stated in the repo rather than hidden: rescheduling and cancellation escalate to a human, the double-booking guard assumes one practitioner, reminders exist but have no scheduler yet, and voice is unit-tested but not exercised against a live Twilio number.

Have a workflow that looks like this?

Most of these start as a messy, manual process someone got tired of. Twenty minutes is usually enough to sketch how I'd approach yours.