On this page
A guard written in code reads every WhatsApp message before the AI agent ever sees it. On 2026-09-22, under n8n execution 31388, that guard handed off 10 of 10 messages about symptoms, medication, diagnosis, price or an emergency to a human, and the agent that was left booked 4 of 12 simulated patients into a Postgres slot table with zero double bookings, at a median 0.16 seconds from webhook to first reply.
A clinic's WhatsApp inbox mixes three kinds of message: someone booking a cleaning, someone asking what a filling costs, and someone describing pain. A model can be told, in a system prompt, to defer the second and third kind to a person. It can also be talked out of that instruction by a patient who rephrases, or simply miss one on a bad day. Episode 05 of the n8n Workflows series moves that decision out of the prompt and into a branch on the canvas, so the refusal does not depend on the model at all.
It refuses by code, not by prompt. A deterministic regex guard, in Spanish and English, runs on every inbound message first. Anything about symptoms, medication, diagnosis, price or an emergency is intercepted before the AI Agent's turn, written to a handoff table with a fixed bilingual reply, and never reaches the model. Everything left, scheduling and general FAQ, goes to a Tools Agent with Postgres-backed chat memory per patient and two Postgres tools to check and book a slot.
If iframes are blocked where you are reading this, the video is at youtu.be/hbWm05jjwYU.
Specifications
| Item | Value |
|---|---|
| Workflow name |
05 Clinic WhatsApp Booking Agent (Postgres memory, code-side
handoff)
|
| Nodes | 25 (22 working nodes plus 3 sticky notes), 3 of the 22 shipped disabled |
| Trigger |
Three doors: a manual trigger, a webhook at
/webhook/ep05-wa-in, and a disabled WhatsApp Trigger
|
| Data source |
samples/patients-es.json, 26 synthetic WhatsApp messages
from 12 fictional patients in the public repo
|
| Safety guard | Code node, deterministic ES/EN regex, five categories in a fixed order: emergency, symptoms, medication, diagnosis, price |
| Model |
Any OpenAI-compatible chat model with tool calling; tested with
openai/gpt-oss-20b via OpenRouter
|
| Memory |
Postgres Chat Memory, session key = wa_id, context window 6
|
| Booking tool |
UPDATE ep05_slots SET status='booked' ... WHERE status='free' ...
RETURNING, so a slot can only be booked once
|
| Storage | Postgres, 7 tables: messages, sessions, slots, bookings, outbox, handoffs, run_summary |
| Idempotency |
INSERT ... ON CONFLICT (message_id) DO NOTHING RETURNING. A
redelivered message emits no downstream item.
|
| Caps | 30 messages per run, 5 agent iterations, 900-second workflow timeout |
| Output | One summary item: patients simulated, bookings confirmed, median seconds to first reply, handoffs fired, double bookings, duplicate webhooks ignored, languages |
| Public JSON includes |
All 25 nodes, the safety guard, both Postgres tools, the system prompt,
schema.sql and the 26 sample messages. Credentials stripped
to REPLACE_ME.
|
| Licence | MIT |
How does the n8n clinic WhatsApp booking agent work?
In six moves: normalise whatever arrived into one item per message, dedupe on the message id, run the safety guard in code, branch to a human or to the AI Agent, write every reply to an outbox, and emit one summary item off the execution panel.
Normalize message flattens any of the three doors into
{message_id, wa_id, name, lang_hint, text, received_at} and
guesses lang_hint from accented characters and a Spanish word
list, built entirely from ASCII source so nothing corrupts across a storage
round trip. Dedupe on message.id then does an
INSERT ... ON CONFLICT (message_id) DO NOTHING RETURNING, which
is the point rather than decoration: Meta webhooks redeliver, and the sample
set ships one deliberate duplicate message id to prove the workflow drops it.
Safety guard runs the five-category regex in a fixed order,
emergency first, then symptoms, medication, diagnosis, price. First match
wins, and a match sets handoff=true. The IF node that follows
either writes a handoff row plus a fixed bilingual notice to the outbox in one
Postgres statement, or hands the item to the AI Agent.
The AI Agent is a Tools Agent with Postgres Chat Memory keyed on
wa_id and two Postgres tools.
check_availability takes no arguments and returns one morning and
one earliest-afternoon slot per day for the next 7 days, using a window
function partitioned on the day and the morning or afternoon half, not a flat
LIMIT that lets one busy day crowd out every later day.
book_slot takes a model-chosen slot id and runs the atomic
update; 0 rows back means the slot is already taken and the model has to offer
another one. The system prompt's first line hard-locks the reply language from
lang_hint, and separately instructs the model never to repeat an
internal slot id back to the patient.
| Node | Type | What it does |
|---|---|---|
When clicking Execute |
Manual trigger | The demo door. Fetches the 26 sample messages from the repo. |
ep05-wa-in |
Webhook | POST a Meta Cloud API-shaped payload. The door a real WhatsApp Business webhook points at. |
WhatsApp Trigger |
WhatsApp (disabled) | Swap point. Enable it and add a credential to poll inbound messages directly. |
Normalize message |
Code | One item per message from any door, language guess, 30-message cap. |
Dedupe on message.id |
Postgres | ON CONFLICT DO NOTHING RETURNING; a redelivered message emits nothing. |
Safety guard |
Code, per item | Five-category ES/EN regex, first match wins, sets handoff + reason. |
handoff? |
IF | True routes to a human, false routes to the AI Agent. |
Handoff: insert handoff + outbox |
Postgres | One data-modifying CTE writes the handoff row and the fixed notice. |
AI Agent |
Tools Agent | Any OpenAI-compatible model with tool calling, Postgres Chat Memory, two Postgres tools. |
check_availability |
Postgres tool | No arguments; one morning and one earliest-afternoon slot per day, next 7 days. |
book_slot |
Postgres tool | UPDATE ... WHERE status='free' ... RETURNING. 0 rows means already taken. |
Parse agent output |
Code | Reads the reply text and whether book_slot actually returned a row. |
Insert outbox (agent reply) |
Postgres | Every reply into ep05_outbox, kind='agent_reply'. |
Google Calendar create event |
Google Calendar (disabled) | Swap point. Enabled, a confirmed booking also writes a real calendar event. |
Insert bookings (conditional) |
Postgres | INSERT ... SELECT ... WHERE booked = true, a no-op when the turn did not book. |
WhatsApp Send message |
WhatsApp (disabled) | Swap point. Enabled, the same reply text sends to the real number. |
Run summary |
Code | The item you read off the execution panel: counts and the median reply time. |
What did the measured run produce?
On 2026-09-22, under n8n execution 31388, the workflow processed 25 of the 26 sent messages, dropping the one deliberate duplicate id, across 12 simulated patients reached over a webhook. The safety guard handed off 10 of 10 messages that should have gone to a human, before the model saw any of them. The AI Agent confirmed 4 bookings, with 0 double bookings across every slot, at a median 0.16 seconds from webhook to first reply.
| Metric | Result |
|---|---|
| Messages processed | 25 of 26 sent, 1 exact duplicate id correctly ignored |
| Patients simulated | 12, via webhook, no live WhatsApp number involved |
| Handoffs fired | 10 of 10 medical, dosage, diagnosis, price and emergency messages |
| Bookings confirmed | 4 |
| Double bookings | 0, across every confirmed slot |
| Median time to first reply | 0.16 seconds, webhook to reply |
| One control message | Did not book. Its exact requested slot was never offered. |
Read 4 of 12 as a count of complete booking conversations, not an accuracy
score. Several patients' sample messages were single-turn handoffs by design,
with no follow-up that could ever end in a confirmation, and one control
patient's exact requested slot was never among the slots the availability tool
returned, so that conversation correctly ended without a booking rather than
forcing one. The number the video quotes is the number
ep05_run_summary holds, not a rounded claim about how the agent
behaves in general.
- Scope lives in code, not the prompt.
- Medical and price questions go to a human.
-
One slot, one booking:
UPDATE ... WHERE status = free ... RETURNING. - Every handoff is logged.
Where does this stop, on purpose?
Scheduling and FAQ only. No symptoms, no diagnosis. No health data stored. n8n
offers no BAA. This is not a clinical tool, and the safety guard exists so the
model never has to be trusted with a health question in the first place:
anything that could be one is handed to a human before it reaches the agent.
schema.sql carries no PHI columns; message bodies are stored
because the dedupe check and the guard both need the raw text, and nothing
else about a patient is recorded.
The 12 patients and 26 messages are fictional, with masked phone numbers, built to include 8 Spanish speakers and 4 English speakers plus the one duplicate delivery. The demo feeds these samples through a webhook or the manual trigger; it does not read a live WhatsApp number. Do not point a real clinic's inbox at this without a legal review of your own — WhatsApp Business plus n8n is not a covered, BAA-backed channel for protected health information in a jurisdiction that requires one.
How do I import and run the clinic WhatsApp booking agent?
Import the free workflow JSON (MIT) from
github.com/waseemnasir2k26/n8n-workflows/tree/main/workflows/05-clinic-whatsapp-booking, run schema.sql against a Postgres database, create the two
credentials, and press the manual trigger's own execute button. It fetches the
26 samples and seeds a 7-day slot grid.
What you have to supply:
-
A Postgres credential, with
schema.sqlapplied. It seedsep05_slotswith a Monday-Saturday 09:00-17:00 grid, 30-minute slots, starting tomorrow. -
An OpenAI-compatible chat credential with tool calling. Type
openAiApiwith theurloverridden to your provider's base URL. This build was tested with OpenRouter'sopenai/gpt-oss-20band with Gemini Flash's OpenAI-compatible endpoint. Any Anthropic-compatible gateway with tool calling swaps in the same way. - Optionally a WhatsApp Cloud API credential and a Google Calendar OAuth2 credential, to enable the three disabled nodes and point the workflow at a real number and a real calendar.
Groq's free tier is not a drop-in swap here: its per-model token-per-minute ceiling is too small for a 12-patient batch, and three earlier passes on this build measured 13 to 15 successful Groq calls before hitting that ceiling mid-run, short of the roughly 22 calls a full run needs. Anything with a larger free or paid throughput ceiling and tool calling works the same way, by swapping the node's URL and key.
Which build gotchas cost a failed run?
A flat LIMIT is not the same as N per day
Sorting free slots by start time and taking the first 8 lets one nearby busy
day exhaust the limit before a later day's slots ever appear, so a patient can
explicitly confirm a day the model can never see. The fix is a window
function, row_number() OVER (PARTITION BY day ...), capped per
day rather than globally, then partitioned again on the morning or afternoon
half so an afternoon-specific request is not hidden behind two morning slots
on a 9-to-5 grid.
A soft language instruction is not reliable
A mid-prompt line telling the model to reply in the patient's language
measured wrong twice in eleven replies during an earlier pass. Passing the
deterministic lang_hint field in as data and hard-locking it as
the literal first line of the system prompt closed the gap, because a
suggestion buried in a longer instruction competes with everything else in
that instruction.
A tool node does not always bind to the current item
$('NodeName').item inside a Postgres node used as an AI Agent
tool does not bind to the currently-iterating item the way it does in a normal
main-flow node; it can resolve to the tool's own first invocation context and
read the same field across an entire batch. The fix here writes the
patient-identifying field from a verified main-flow node instead of from
inside the tool, through a single data-modifying statement that repairs both
tables at once. Verify identity-critical writes with a real execution, not by
reading the expression.
Never let the model see an internal id it might repeat back
The numeric slot id has to reach the booking tool as an argument, because that is the only path the tool call has. It does not have to reach the patient. The system prompt explicitly forbids quoting any internal id back, which is the only thing standing between a clean confirmation and a reply that leaks "(id 1)" next to the offered time.
Frequently asked questions
Can an n8n WhatsApp bot tell a medical question from a booking request?
Yes, and it does it before the AI model is involved. A deterministic regex guard checks every incoming message in Spanish and English for symptoms, medication, diagnosis, price and emergency language, in that order, and the first match routes the message to a human handoff instead of the agent. On 2026-09-22, execution 31388 handed off 10 of 10 messages that should have gone to a human.
How does this workflow stop a WhatsApp slot from being double-booked?
The booking tool is a single Postgres statement,
UPDATE ep05_slots SET status='booked' ... WHERE status='free' ...
RETURNING. If two requests race for the same slot, only one update can match the
WHERE status='free' condition; the second gets 0 rows back and
the agent offers a different slot. The measured run recorded 0 double bookings
across every confirmed booking.
Does this workflow store or read real patient health data?
No. The 12 patients and 26 messages are fictional, delivered through a webhook
or a manual trigger, and no live WhatsApp number is read.
schema.sql has no PHI columns beyond the raw message text the
dedupe check and the safety guard both need. n8n offers no BAA, so the
workflow is scoped to scheduling and FAQ only, never symptoms or diagnosis.
What do I need to run the clinic WhatsApp booking agent myself?
A Postgres database with schema.sql applied, and an
OpenAI-compatible chat credential with tool calling, such as OpenRouter's
openai/gpt-oss-20b or Gemini Flash's OpenAI-compatible endpoint.
A WhatsApp Cloud API credential and a Google Calendar OAuth2 credential are
optional, only needed to enable the three disabled swap-point nodes. All
credentials ship as REPLACE_ME in the public JSON.
The rest of the series
One workflow per video, the JSON given away under MIT each time, built by Waseem Nasir at SkynetLabs. The four other published episodes:
- Episode 01, n8n Shorts Factory: one topic string in, a captioned 9:16 Short out, with the publish step deliberately switched off.
- Episode 02, n8n speed-to-lead: a form fill gets an AI reply in about two seconds, a second model qualifies the follow-up and proposes a visit window.
- Episode 03, n8n Maps lead harvest: a trade and a city in, a deduped lead table out, keyed on place id, written to Postgres.
- Episode 04, n8n freight quote parser: one quote email in, eight validated fields out with Claude Haiku 4.5 under a forced tool schema.
WhatsApp +92 300 1001957 · Waseem Nasir, SkynetLabs
Hire SkynetLabs, our Top Rated agency on Fiverr: https://www.fiverr.com/agencies/skynetjoellc