Ship a WhatsApp Support Bot Backed by Your Docs Using Twilio & Groq
What We’re Building
A WhatsApp bot that reads your product docs and answers customer questions in real time. When a user sends a message to your Twilio WhatsApp Sandbox number, the bot searches a local Qdrant vector database for the most relevant chunks of your documentation, feeds them to Groq’s Llama model, and returns a concise, sourced answer — all on free infrastructure.
Feature list:
- WhatsApp-native interface via Twilio’s free Sandbox (no production approval needed).
- Semantic search over your own markdown, PDF, or text docs using Qdrant’s vector store.
- Fast, free LLM inference with Groq’s Llama 3.3 70B or 8B endpoints.
- Context-grounded answers that cite the exact document sections used.
- Stateless conversation loop — each message triggers a fresh retrieval + generation cycle.
- Zero-cost deployment (runs on your laptop or a free-tier cloud VM).
Architecture Overview
This diagram shows the data flow from WhatsApp message to AI response. The entire pipeline runs on a single machine, with Twilio and Groq as the only external services.
Flow breakdown:
- A user sends a WhatsApp message to your Twilio Sandbox number.
- Twilio fires an HTTP POST to your Flask webhook with the message body.
- The webhook embeds the query using a free embedding model and searches Qdrant for the top 3 most similar doc chunks.
- Those chunks, plus the original question, are packed into a prompt and sent to Groq’s Llama endpoint.
- Groq returns a grounded answer, which Flask formats as a TwiML response.
- Twilio delivers the answer back to WhatsApp.
Prerequisites (All Free-Tier)
You need exactly four things. Every one has a free tier:
| Tool | Purpose | Free Tier Limit | Sign-Up Link |
|---|---|---|---|
| Twilio | WhatsApp Sandbox + webhook relay | 1 Sandbox number, unlimited test messages to verified numbers | twilio.com/try-twilio |
| Groq | Fast LLM inference (Llama 3.3 70B) | 30 requests/minute, 14,400 tokens/minute on free tier | console.groq.com |
| Qdrant | Vector database (local Docker) | Unlimited local storage, free Cloud tier also available | qdrant.tech |
| Sentence Transformers | Free local embedding model | Runs entirely on your machine | sbert.net |
You’ll also need:
- Python 3.10+
- Docker (for Qdrant)
- ngrok (free tier) to expose your local webhook to the internet
Step 1: Spin Up a Local Qdrant Vector Store
Pull the Qdrant Docker image and run it with a persistent volume:
docker pull qdrant/qdrant
docker run -p 6333:6333 -p 6334:6334 \
-v "$(pwd)/qdrant_storage:/qdrant/storage:z" \
qdrant/qdrant
Verify it’s alive:
curl http://localhost:6333/health
# {"title":"qdrant - vector search engine","version":"1.9.0"}
Why local? Qdrant Cloud’s free tier is also fine, but running locally eliminates network latency between embedding and retrieval. If you later move to production, swap the connection string to a managed instance.
Step 2: Ingest Your Docs into Qdrant
Create ingest.py. This script reads a directory of markdown files, splits them into chunks, embeds each chunk with all-MiniLM-L6-v2 (a lightweight, free Sentence Transformer model), and upserts them into Qdrant.
# ingest.py
import os
from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams, PointStruct
from sentence_transformers import SentenceTransformer
COLLECTION_NAME = "support_docs"
DOCS_DIR = "./docs"
CHUNK_SIZE = 500 # characters
client = QdrantClient(host="localhost", port=6333)
model = SentenceTransformer("all-MiniLM-L6-v2")
# Delete and recreate collection for idempotent runs
if client.collection_exists(COLLECTION_NAME):
client.delete_collection(COLLECTION_NAME)
client.create_collection(
collection_name=COLLECTION_NAME,
vectors_config=VectorParams(size=384, distance=Distance.COSINE),
)
points = []
point_id = 0
for filename in os.listdir(DOCS_DIR):
if not filename.endswith(".md"):
continue
with open(os.path.join(DOCS_DIR, filename), "r") as f:
content = f.read()
# Naive chunking: split by characters; production would use a smarter splitter
chunks = [content[i:i+CHUNK_SIZE] for i in range(0, len(content), CHUNK_SIZE)]
for chunk in chunks:
embedding = model.encode(chunk).tolist()
points.append(PointStruct(
id=point_id,
vector=embedding,
payload={"text": chunk, "source": filename}
))
point_id += 1
client.upsert(collection_name=COLLECTION_NAME, points=points)
print(f"Ingested {point_id} chunks from {len(os.listdir(DOCS_DIR))} files.")
Run it:
mkdir docs
echo "# FAQ\nQ: How do I reset my password?\nA: Go to Settings > Security > Reset Password." > docs/faq.md
python ingest.py
You now have a searchable vector database of your docs.
Step 3: Create the Groq-Powered RAG Engine
Create rag.py — the retrieval-augmented generation module. It takes a user query, embeds it, fetches the top 3 chunks from Qdrant, and calls Groq.
# rag.py
import os
from groq import Groq
from qdrant_client import QdrantClient
from sentence_transformers import SentenceTransformer
COLLECTION_NAME = "support_docs"
GROQ_API_KEY = os.environ["GROQ_API_KEY"]
qdrant = QdrantClient(host="localhost", port=6333)
embedder = SentenceTransformer("all-MiniLM-L6-v2")
groq = Groq(api_key=GROQ_API_KEY)
def answer_query(user_message: str) -> str:
# 1. Embed the query
query_vec = embedder.encode(user_message).tolist()
# 2. Search Qdrant
results = qdrant.search(
collection_name=COLLECTION_NAME,
query_vector=query_vec,
limit=3
)
# 3. Build context from retrieved chunks
context = "\n\n---\n\n".join([hit.payload["text"] for hit in results])
sources = list(set([hit.payload["source"] for hit in results]))
# 4. Call Groq
system_prompt = (
"You are a helpful customer support assistant. "
"Answer the user's question using ONLY the provided context. "
"If the answer isn't in the context, say you don't know. "
"Cite the source documents when possible."
)
response = groq.chat.completions.create(
model="llama-3.3-70b-versatile",
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": f"Context:\n{context}\n\nQuestion: {user_message}"}
],
max_tokens=500,
temperature=0.3,
)
answer = response.choices[0].message.content
return f"{answer}\n\n📚 Sources: {', '.join(sources)}"
Test it standalone:
# test_rag.py
from rag import answer_query
print(answer_query("How do I reset my password?"))
Step 4: Wire Up the Twilio WhatsApp Sandbox
4.1 Activate the Sandbox
- Go to Twilio Console > Messaging > Try it out > Send a WhatsApp message.
- Note the Sandbox number and the join code (e.g.,
join <code>). - Send that join code from your WhatsApp to the Sandbox number. You’re now connected.
4.2 Create the Flask Webhook
Create app.py. This is the server that receives Twilio’s POST and returns TwiML.
# app.py
from flask import Flask, request
from twilio.twiml.messaging_response import MessagingResponse
from rag import answer_query
app = Flask(__name__)
@app.route("/whatsapp", methods=["POST"])
def whatsapp_webhook():
incoming_msg = request.values.get("Body", "").strip()
print(f"Received: {incoming_msg}")
resp = MessagingResponse()
msg = resp.message()
if not incoming_msg:
msg.body("Hi! Ask me anything about our product.")
return str(resp)
try:
answer = answer_query(incoming_msg)
msg.body(answer[:1600]) # WhatsApp has a 1600-char limit
except Exception as e:
print(f"Error: {e}")
msg.body("Sorry, I ran into an issue. Try again in a moment.")
return str(resp)
if __name__ == "__main__":
app.run(port=5000, debug=True)
4.3 Expose Your Local Server with ngrok
ngrok http 5000
Copy the HTTPS forwarding URL (e.g., https://abc123.ngrok.io).
4.4 Configure the Twilio Webhook
- In Twilio Console, go to Messaging > Try it out > WhatsApp Sandbox Settings.
- Paste your ngrok URL +
/whatsappinto the When a message comes in field. - Set method to HTTP POST.
- Save.
Step 5: Run the Full Pipeline Locally
Open three terminal windows:
Terminal 1 — Qdrant:
docker run -p 6333:6333 qdrant/qdrant
Terminal 2 — Flask app:
export GROQ_API_KEY="gsk_your_key_here"
python app.py
Terminal 3 — ngrok:
ngrok http 5000
Now send a WhatsApp message to your Sandbox number. The bot should respond with a grounded answer drawn from your docs.
Test message: “How do I reset my password?”
Expected response: “Go to Settings > Security > Reset Password. 📚 Sources: faq.md”
Sensible Extensions
Once the core loop works, here’s what to add next:
-
Conversation memory. Store the last N messages per user in a lightweight dict keyed by
Fromnumber. Pass them as prior messages to Groq so the bot can handle follow-ups like “what about on mobile?” -
Smarter chunking. Replace the naive 500-character split with a recursive text splitter that respects markdown headings. LangChain’s
RecursiveCharacterTextSplitteror LlamaIndex’sSentenceSplitterboth work well and are free. -
Multi-format ingestion. Add PDF support with
PyMuPDF(free) orpdfplumber. For HTML docs, useBeautifulSoup. The pipeline stays the same — just extract text and chunk it. -
Hybrid search. Qdrant supports keyword + vector hybrid search out of the box. Enable it by adding a
keywordindex on the text payload and usingsearchwithquery_filterfor exact term matches alongside semantic similarity. -
Multi-turn escalation. If the bot detects a question it can’t answer (low confidence score or explicit “I don’t know”), forward the conversation to a human agent via Twilio’s Flex or a simple email alert.
-
Deploy for free. Push the Flask app to Render (free tier) or Fly.io (free allowance), and swap ngrok for a real domain. Qdrant Cloud’s free tier gives you a managed vector store so you don’t need Docker in production.
Common Pitfalls & How to Avoid Them
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Twilio Sandbox messages not arriving | You haven’t sent the join code from your WhatsApp to the Sandbox number | Send join <code> exactly as shown in Twilio Console |
| ngrok URL changes every restart | Free ngrok generates a new random subdomain | Keep ngrok running, or use ngrok http 5000 --subdomain=yourname (requires free account) |
| Groq rate limit 429 errors | Free tier caps at 30 RPM / 14.4k TPM | Add exponential backoff in rag.py, or switch to llama-3.1-8b-instant which has higher free limits |
| Qdrant “collection not found” | You forgot to run ingest.py | Always run ingestion before starting the webhook |
| Embedding dimension mismatch | all-MiniLM-L6-v2 outputs 384-dim vectors; Qdrant expects 384 | Stick with the same model for ingestion and query. If you change models, re-ingest |
| WhatsApp 1600-char truncation | Twilio’s WhatsApp Sandbox enforces a message length limit | Truncate in app.py and add a “Read more” link if needed |
FAQ
Q: Do I need a Meta Business account for the WhatsApp Sandbox? No. Twilio’s Sandbox lets you test with up to 5 verified phone numbers without any Meta approval. Perfect for prototyping. When you’re ready for production, you’ll need a WhatsApp Business Profile.
Q: Can I use a different LLM? Absolutely. Swap the Groq client for OpenAI, Anthropic, or a local Ollama model. The RAG pipeline is model-agnostic. Groq is recommended here because its free tier is generous and latency is sub-200ms.
Q: How many docs can I ingest?
With all-MiniLM-L6-v2, each 500-char chunk is a 384-dim vector. A million chunks would occupy roughly 1.5 GB of RAM in Qdrant. For most support doc sets (a few thousand chunks), you’re well within free-tier limits on any laptop.
Q: How do I update the docs without downtime?
Re-run ingest.py — it drops and recreates the collection. For zero-downtime, use Qdrant’s collection aliases: create a new collection, ingest there, then atomically swap the alias.
Q: Is this production-ready? The core architecture is solid, but you’d want to add authentication on the webhook, persistent conversation state, and proper error monitoring before going live. For a deeper dive on shipping prototypes fast, check out From Messy Enterprise Problem to Shipped Prototype in 5 Days: An FDE Playbook.
Q: Can I extend this to index a codebase instead of docs? Yes. The same pipeline works for code. Chunk by function or class, embed, and query. For a full walkthrough on codebase Q&A, see Build a Codebase Q&A Tool That Indexes a Repo and Answers Questions in Natural Language.
Q: What if the bot gives wrong answers? Grounding is only as good as your retrieval. If the wrong chunks are fetched, the LLM hallucinates. Improve retrieval by experimenting with chunk size, adding metadata filters, or using a reranker. For a deeper discussion on agent guardrails, read GPT‑5.6 Lost $447 Running a Business: How to Structure Agent Guardrails That Actually Work.
Q: How do I handle multiple languages?
Replace all-MiniLM-L6-v2 with a multilingual embedding model like paraphrase-multilingual-MiniLM-L12-v2 (also free). Groq’s Llama models handle dozens of languages natively.
Q: What does a Forward Deployed Engineer actually ship in a week? Prototypes exactly like this one — a working end-to-end integration that solves a real customer problem. For a realistic week-in-the-life breakdown, see What a Forward Deployed Engineer Actually Does in a Week: From Standup to Shipped Prototype.
Want to build like a Forward Deployed Engineer?
FDE Coach is a cohort-based program in frontend, backend, AWS, and AI. Build real products and get referred to 200+ hiring partners.
Explore the program