SkynetLabs
Back to journal
AutomationSeptember 23, 202612 min read

The n8n Clinic WhatsApp Booking Agent: It Refuses by Code, Not by Prompt

By , Founder, SkynetLabs

An n8n workflow handed off 10 of 10 medical and price questions by code before the model on 2026-09-22, then booked 4 of 12 simulated WhatsApp patients with 0 double bookings.

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.

Where the patients come from. The demo feeds 26 synthetic WhatsApp-shaped messages from 12 fictional patients through a webhook, and no live WhatsApp number and no real patient sent anything. The WhatsApp Trigger, WhatsApp Send message, and Google Calendar create event nodes sit on the canvas, disabled, as the swap points for a real inbox and a real calendar. Phone numbers in the sample set are masked.

Specifications

One WhatsApp-shaped message in. A deterministic code guard decides, before the model, whether it goes to a human or to an AI agent that checks and books a Postgres slot with an atomic update. 25 nodes, of which 22 do work and 3 are sticky notes. Three of the 22 ship disabled on purpose.
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.

The four rules
  1. Scope lives in code, not the prompt.
  2. Medical and price questions go to a human.
  3. One slot, one booking: UPDATE ... WHERE status = free ... RETURNING.
  4. 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:

  1. A Postgres credential, with schema.sql applied. It seeds ep05_slots with a Monday-Saturday 09:00-17:00 grid, 30-minute slots, starting tomorrow.
  2. An OpenAI-compatible chat credential with tool calling. Type openAiApi with the url overridden to your provider's base URL. This build was tested with OpenRouter's openai/gpt-oss-20b and with Gemini Flash's OpenAI-compatible endpoint. Any Anthropic-compatible gateway with tool calling swaps in the same way.
  3. 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:

WhatsApp +92 300 1001957 · Waseem Nasir, SkynetLabs

Hire SkynetLabs, our Top Rated agency on Fiverr: https://www.fiverr.com/agencies/skynetjoellc