Most small businesses get thier enquiries at the worst time. A student finishes work at night, opens WhatsApp, and asks a study-abroad consultancy, "What documents do I need for Canada?" Nobody replies until 11am the next day. By then the student has messaged other consultancies.
I built a WhatsApp assistant for exactly this case, as a demo for a study-visa consultancy in the Chandigarh area. It answers after-hours enquiries using only the consultancy's own material, collects the student's details for a counsellor, and hands the conversation to a human when it should. It runs on n8n, self-hosted on an AWS EC2 instance.
This post walks through how it works, the design decisions behind it, and what broke along the way.
What the assistant does (and doesn't)
The goal was not a chatbot that "knows everything." It was narrower:
- Answer from the consultancy's own material only: services, countries, documents needed, intakes, office addresses. If the answer isn't in that material, it says a counsellor will confirm.
- Collect lead details: name, what they need (study, tourist, spouse, PR or coaching), preferred country and best time to call.
- Hand off to a human when the student is upset, has an urgent visa problem, or asks for a person.
It never promises a visa, never invents fees or band scores, and never mentions tools or systems to the student.
Architecture
The architecture is simple:
The stack:
- WAHA, a self-hosted WhatsApp Web bridge, running in Docker next to n8n
- n8n: a Webhook node, a Filter, a Set node and an AI Agent node
- The model, the agent's memory, and a
save_leadtool that writes to an n8n Data Table - AWS EC2, Docker, Caddy for HTTPS and a free DuckDNS domain
Step 1: Connect WhatsApp to n8n
The production route for a business is Meta's WhatsApp Cloud API. My Meta business account got restricted before I could use it, so the demo runs on WAHA. It logs into a WhatsApp number the way WhatsApp Web does, and forwards every incoming message to a webhook. It's quick to set up, but it breaks WhatsApp's terms, so I only use it on a spare number for short demos.
WAHA runs as one container on the same Docker network as n8n:
docker run -d --name waha --restart unless-stopped \ --network app-net \ -p 127.0.0.1:3000:3000 \ -e WAHA_API_KEY=<your-key> \ -e WHATSAPP_HOOK_URL=http://n8n:5678/webhook/<your-path> \ -e WHATSAPP_HOOK_EVENTS=message \ -v waha_sessions:/app/.sessions \ devlikeapro/waha:latest Two things I like about this setup:
- WAHA listens only on
127.0.0.1, so it isn't exposed to the internet. I open its dashboard through an SSH tunnel to scan the QR code. - WAHA reaches n8n by container name (
http://n8n:5678), so messages never leave the server.
In n8n, a Webhook node receives the message and a Filter node decides whether the bot should answer. For the demo it only replies to my test phone, and it always ignores groups, its own messages and empty messages:
event equals "message" payload.fromMe is false payload.from does not end with "@g.us" (no groups) payload.from is the test phone's ID (demo allowlist) payload.body is not empty A Set node then pulls out the four things the rest of the workflow needs: the message text, the chat ID, the sender's WhatsApp name and the WAHA session.
Step 2: Ground the answers in the consultancy's own material
This is the part that decides whether the bot is useful or dangerous. A consultancy cannot have a bot inventing fees or visa rules.
I didn't use a vector database. The consultancy's services, document lists, intakes and office details fit in a couple of pages. At that size, putting the material straight into the system prompt is simpler, cheaper to run and easier to debug than embeddings and retrieval. If the material grows to dozens of documents, retrieval becomes worth it.
The system prompt has two parts, the knowledge and the rules. A shortened version of the rules:
Answer ONLY from the knowledge above. 1. Never invent fees, exact band scores, specific intake dates, visa approval chances or which university a student will get. For these say: "Our counsellor will give you exact details for your profile." 2. Never promise or guarantee a visa. 3. Keep replies short like WhatsApp: 1-5 short lines, at most one emoji. Use *bold* WhatsApp style, never markdown headings. 4. Reply in the same language and script as the student's latest message. 5. After answering their first question, collect ONE at a time: name, what they need, preferred country, best time to call. Never ask for the phone number; you already have it. 6. Once you have name and what they need, call save_lead ONCE. Only after it succeeds, confirm their details and say a counsellor will call them. 7. If someone is angry, has an urgent visa problem, or asks for a person, get their name, call save_lead with URGENT in the note, and give the office number. 8. Never mention tools, systems or saving data; just talk naturally. Rule 6 matters more than it looks. Without "only after it succeeds", the model happily tells the student "your details have been passed on" even when the save failed.
Step 3: Remember the conversation per student
Without memory, the bot asks "What's your name?" on every message. The AI Agent has a chat memory attached, keyed by the student's chat ID so each student gets their own history:
Session key: {{ $('Read Message').item.json.chatId }} Context window: last 12 messages Memory is stored in Postgres using n8n's Postgres Chat Memory node. I started with n8n's Simple Memory, but that lives inside the n8n process, so every conversation was forgotten whenever n8n restarted. Postgres keeps each student's history across restarts and redeploys.
Step 4: Save leads with a tool, not a parser
A common approach is to make the model return JSON with the reply and the lead details, then parse it in a Code node. I didn't need that. n8n's AI Agent can call tools, so lead saving is a tool the model calls itself, and the reply stays plain text.
save_lead is an n8n Data Table tool. Each column is filled with $fromAI(), which tells the model what to put there:
student_name: $fromAI('student_name', 'Student full name', 'string') service: $fromAI('service', 'What they need: study, tourist, spouse, PR or coaching', 'string') country: $fromAI('country', 'Preferred country, or unknown', 'string') best_time_to_call: $fromAI('best_time_to_call', 'When to call them, or unknown', 'string') question_for_counsellor: $fromAI('question_for_counsellor', 'What the counsellor should follow up on', 'string') phone: (taken from the incoming message, never from the model) channel: "WhatsApp" status: "New - call back" Two details I'd keep in any version:
- The phone number never comes from the model. It's mapped from the incoming message, so the model can't get it wrong or make one up.
- The
question_for_counsellorcolumn carries whatever the bot wasn't allowed to answer, such as fees or visa chances. The counsellor opens the table in the morning and sees each student's details plus the exact question to answer.
Handoff in the demo is simple: an urgent case is saved with URGENT in the note, and the student gets the office number straight away. Sending the counsellor a WhatsApp alert is the next step for the real version.
Step 5: Reply on WhatsApp
The agent's text goes back through WAHA's sendText endpoint with an HTTP Request node:
{ session: $('Read Message').item.json.wahaSession, chatId: $('Read Message').item.json.chatId, text: $json.output || "Thanks for your message! A counsellor will get back to you shortly." } The || fallback is there on purpose. If the model fails, the student still gets a polite reply instead of silence.
Two small branches run in parallel with the agent: one marks the message as seen and one shows typing… while the model works. They cost nothing and make the 5–15 second wait feel like a person replying.
Where it runs
n8n, Postgres and WAHA run in Docker on an AWS EC2 t3.medium instance, all on one Docker network, with a free DuckDNS subdomain pointing at it. HTTPS comes from Caddy, configured in a Caddyfile at /etc/caddy/Caddyfile, which reverse-proxies the domain to n8n and handles the TLS certificate automatically.
WAHA talks to n8n over the Docker network, so it doesn't need the public URL at all. The public HTTPS address still matters for the n8n editor and for any external service that calls your webhooks, such as Twilio or Meta. For those, these two settings are easy to miss:
WEBHOOK_URL=https://<your-subdomain>.duckdns.org/ N8N_PROTOCOL=https Without WEBHOOK_URL, n8n can show webhook URLs with localhost or the wrong port, and the external service fails to reach them with no useful error.
Problems I hit (and how I fixed them)
1. Meta restricted my business account before I could send a message. The Cloud API returned error 131031 (account locked), and verification wasn't going to clear in time for the demo. I tried Twilio's WhatsApp trial next, then ran the demo on WAHA on a spare number.
2. Twilio received my message but the reply never arrived. n8n finished in about 6 seconds and handed back a valid reply, yet nothing showed up on the phone the trial number only accepted pre-approved templates, not free-text replies. Lesson: check what a sandbox allows before building on it.
3. The bot went silent because the model disappeared. Google had retired the Gemini model I'd picked, and the failure looked like the bot simply ignoring the student. After switching models I hit a different error: 503: This model is currently experiencing high demand. The fix was three settings: retry on fail on the AI Agent (3 tries, 500 ms apart), "continue on error", and the fallback text in the send step. Now a busy model costs a few seconds, and in the worst case the student gets "a counsellor will get back to you" instead of silence. Switching models also left old messages in memory that confused the new one, so I reset the session key.
4. My allowlist blocked my own test phone. My "Hii" reached n8n, but the filter dropped it. WhatsApp no longer shows a sender's phone number to linked devices. The message came from a private ID ending in @lid, not 91XXXXXXXXXX@c.us. I added the LID to the allowlist.
5. Every message arrived twice. I'd set the webhook URL both in WAHA's WHATSAPP_HOOK_URL environment variable and in the session settings in WAHA's dashboard, so n8n got two copies of each message and would have sent two replies. Configure the webhook in one place only.
What I will do differently
Start Meta business verification days before writing a single node Also put memory in Postgres from the beginning instead of finding out what Simple Memory forgets on a restart. And before touching the prompt prepare a test conversation and write down 15–20 real questions students ask, including the awkward ones about fees and visa chances, and run every prompt change against that list instead of testing with "Hi" over and over.
Wrapping up
The useful part of this project wasn't the LLM. It was the constraints around it: answer only from known material, collect details one question at a time, never claim something was saved unless it was, and hand off to a human the moment it's out of its depth. That's what makes it something a real business can trust after hours.
On cost: the first version ran in production for a week on a free-tier t3.micro with Gemini 3.5 Flash Lite on Google's free tier, so it cost nothing. A free setup is enough to prove the idea. I've since moved to a t3.medium for better performance and switched the model to Claude Sonnet 4.5 for higher-quality answers, so the running cost is now the instance plus per-token API usage.
If you're building something similar, I'm happy to answer questions in the comments.

