All articles
Build Guides

Build a WhatsApp Customer-Support Agent Backed by Your Docs Using Free AI

FDE Coach EditorialJuly 18, 202610 min read

What We're Building

A WhatsApp bot that acts as a first-line customer-support agent. Your users send a question on WhatsApp and get an answer derived directly from your documentation—no hallucinations, no stale FAQ pages. The entire pipeline runs on free-tier services: Cloudflare Workers AI for the LLM and embeddings, Pinecone for vector search, and the WhatsApp Cloud API (free for up to 1,000 conversations/month).

Feature list:

  • Ingests markdown, HTML, or plain-text docs into a vector store
  • Receives WhatsApp messages via webhook
  • Retrieves the most relevant doc chunks with semantic search
  • Generates a concise, grounded answer using Llama 3.1 8B on Workers AI
  • Responds within WhatsApp’s 30-second window
  • Zero infrastructure to manage

Architecture Overview

The Worker does three things: verifies the webhook signature, embeds the user’s question, fetches relevant doc chunks from Pinecone, then calls the LLM with those chunks as grounding context. The reply is sent back via the WhatsApp Cloud API.

Prerequisites

All free-tier, no credit card tricks:

  1. Cloudflare accountdash.cloudflare.com (Workers free tier: 100k requests/day, Workers AI included)
  2. Pinecone accountpinecone.io (free tier: 1 index, 100k vectors)
  3. Meta Developer accountdevelopers.facebook.com (WhatsApp Cloud API: 1k free conversations/month)
  4. Node.js 20+ and Wrangler CLI (npm i -g wrangler)

You’ll also need a phone number to test with and a small set of docs (markdown files work great).

Project Initialization

mkdir whatsapp-doc-bot && cd whatsapp-doc-bot
npm create cloudflare@latest . -- --type=hello-world
yes | npm install @pinecone-database/pinecone hono

We use Hono because it’s lighter than Express and first-class on Workers. Replace src/index.ts with the scaffold below.

// src/index.ts
import { Hono } from 'hono';
import { Pinecone } from '@pinecone-database/pinecone';

const app = new Hono();

// Bindings injected by wrangler.toml
type Bindings = {
  PINECONE_API_KEY: string;
  PINECONE_INDEX_HOST: string;
  WHATSAPP_TOKEN: string;
  WHATSAPP_VERIFY_TOKEN: string;
  AI: Ai;
};

app.get('/webhook', async (c) => {
  const { query } = c.req;
  const mode = query['hub.mode'];
  const token = query['hub.verify_token'];
  const challenge = query['hub.challenge'];
  const env: Bindings = c.env as any;

  if (mode === 'subscribe' && token === env.WHATSAPP_VERIFY_TOKEN) {
    return c.text(challenge ?? '', 200);
  }
  return c.text('Forbidden', 403);
});

app.post('/webhook', async (c) => {
  // We'll fill this in Step 4
  return c.text('ok', 200);
});

export default app;

wrangler.toml:

name = "whatsapp-doc-bot"
main = "src/index.ts"
compatibility_date = "2024-12-01"

[vars]
PINECONE_INDEX_HOST = "your-index-host.pinecone.io"

[ai]
binding = "AI"

Set secrets:

wrangler secret put PINECONE_API_KEY
wrangler secret put WHATSAPP_TOKEN
wrangler secret put WHATSAPP_VERIFY_TOKEN

Document Indexing Pipeline

Before the bot can answer questions, your docs need to be chunked and embedded. We’ll build a one-off script that runs locally, reads a folder of markdown files, splits them, embeds with Workers AI, and upserts into Pinecone.

Create scripts/ingest.ts:

// scripts/ingest.ts
import { Pinecone } from '@pinecone-database/pinecone';
import fs from 'fs';
import path from 'path';

const PINECONE_API_KEY = process.env.PINECONE_API_KEY!;
const INDEX_HOST = process.env.PINECONE_INDEX_HOST!;
const CLOUDFLARE_ACCOUNT_ID = process.env.CLOUDFLARE_ACCOUNT_ID!;
const CLOUDFLARE_API_TOKEN = process.env.CLOUDFLARE_API_TOKEN!;

const EMBED_MODEL = '@cf/baai/bge-base-en-v1.5'; // 768-dim, free on Workers AI

async function embed(texts: string[]): Promise<number[][]> {
  const res = await fetch(
    `https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/run/${EMBED_MODEL}`,
    {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${CLOUDFLARE_API_TOKEN}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ text: texts }),
    }
  );
  const json: any = await res.json();
  return json.result.data;
}

function chunkText(text: string, maxLen = 512): string[] {
  const paragraphs = text.split(/\n\n+/);
  const chunks: string[] = [];
  let current = '';
  for (const p of paragraphs) {
    if ((current + p).length > maxLen && current.length > 0) {
      chunks.push(current.trim());
      current = p;
    } else {
      current += (current ? '\n\n' : '') + p;
    }
  }
  if (current.trim()) chunks.push(current.trim());
  return chunks;
}

async function main() {
  const pc = new Pinecone({ apiKey: PINECONE_API_KEY });
  const index = pc.index(INDEX_HOST);

  const docsDir = path.resolve(__dirname, '../docs');
  const files = fs.readdirSync(docsDir).filter(f => f.endsWith('.md'));

  for (const file of files) {
    const content = fs.readFileSync(path.join(docsDir, file), 'utf-8');
    const chunks = chunkText(content);
    const vectors = await embed(chunks);

    const records = chunks.map((chunk, i) => ({
      id: `${file}#${i}`,
      values: vectors[i],
      metadata: { file, chunkIndex: i, text: chunk.substring(0, 2048) },
    }));

    await index.upsert(records);
    console.log(`Indexed ${chunks.length} chunks from ${file}`);
  }
}

main().catch(console.error);

Run it:

npx tsx scripts/ingest.ts

Pinecone index setup: Create an index with dimension 768 (matches bge-base-en-v1.5) and metric cosine. The free tier gives you one index—perfect.

WhatsApp Webhook Handler

Back in src/index.ts, flesh out the POST handler. This receives the inbound message, extracts text, runs the RAG pipeline, and replies.

app.post('/webhook', async (c) => {
  const env: Bindings = c.env as any;
  const body: any = await c.req.json();

  // WhatsApp sends an array of entry objects
  const entry = body?.entry?.[0];
  const change = entry?.changes?.[0];
  const message = change?.value?.messages?.[0];
  if (!message || message.type !== 'text') return c.text('ok', 200);

  const userPhone = message.from;
  const userText: string = message.text.body;

  // Step 1: Embed the query
  const embedRes = await env.AI.run('@cf/baai/bge-base-en-v1.5', {
    text: [userText],
  });
  const queryVector = (embedRes as any).data[0];

  // Step 2: Query Pinecone
  const pc = new Pinecone({ apiKey: env.PINECONE_API_KEY });
  const index = pc.index(env.PINECONE_INDEX_HOST);
  const queryRes = await index.query({
    vector: queryVector,
    topK: 3,
    includeMetadata: true,
  });

  const contextChunks = queryRes.matches
    ?.map((m: any) => m.metadata?.text)
    .filter(Boolean)
    .join('\n\n---\n\n') ?? 'No relevant docs found.';

  // Step 3: Generate answer with grounding
  const systemPrompt = `You are a helpful support agent. Answer the user's question using ONLY the provided documentation context. If the answer isn't in the context, say "I don't have that information in my docs." Never make up information.`;

  const userPrompt = `Context:\n${contextChunks}\n\nUser question: ${userText}\n\nAnswer:`;

  const llmRes = await env.AI.run('@cf/meta/llama-3.1-8b-instruct', {
    messages: [
      { role: 'system', content: systemPrompt },
      { role: 'user', content: userPrompt },
    ],
    max_tokens: 512,
    temperature: 0.3,
  });

  const answer = (llmRes as any).response ?? 'Sorry, I could not generate a response.';

  // Step 4: Send reply via WhatsApp Cloud API
  const phoneNumberId = change.value.metadata.phone_number_id;
  await fetch(`https://graph.facebook.com/v21.0/${phoneNumberId}/messages`, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${env.WHATSAPP_TOKEN}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      messaging_product: 'whatsapp',
      to: userPhone,
      text: { body: answer },
    }),
  });

  return c.text('ok', 200);
});

RAG Query Logic

The retrieval-augmented generation flow above is deliberately simple. Three things make it work well in practice:

  1. Chunk overlap matters. The chunkText function splits on double-newlines to keep semantic units intact. If your docs are dense, add a sliding-window overlap.
  2. Top-K tuning. 3 chunks is a safe default for Llama 3.1 8B’s context window. If your chunks are small (256 tokens), bump to 5.
  3. Prompt engineering. The system prompt forces grounding—without it, the model will happily confabulate. The temperature: 0.3 keeps answers factual.

Deploying to Cloudflare Workers

wrangler deploy

After deployment, you’ll get a URL like https://whatsapp-doc-bot.<your-subdomain>.workers.dev. This is your webhook endpoint.

WhatsApp configuration:

  1. In the Meta Developer dashboard, create a WhatsApp app (or use an existing one).
  2. Add a phone number (the test number is free).
  3. Under Configuration, set the Callback URL to https://<your-worker>/webhook and the Verify token to the value you stored in WHATSAPP_VERIFY_TOKEN.
  4. Subscribe to the messages webhook field.

When you save, Meta hits your GET /webhook to verify. If it returns 403, double-check the verify token.

Testing End-to-End

Send a WhatsApp message to your test number. The bot should reply within a few seconds. Check wrangler tail for live logs:

wrangler tail

Common first-run issues:

  • "I don't have that information" for every query: Your docs aren’t indexed or the Pinecone index host is wrong.
  • Timeout: Workers have a 30-second CPU limit. If embedding + Pinecone query + LLM call exceeds that, consider pre-warming or using smaller chunks.
  • 401 from WhatsApp API: The token doesn’t have the whatsapp_business_messaging permission.

Extensions

Once the basic loop works, here’s where you can take it:

  1. Multi-turn conversations. Store the last N messages in a simple KV namespace and include them in the prompt. Cloudflare KV has a generous free tier.
  2. Source citations. Append (source: docs/getting-started.md) to the reply so users can verify. The metadata is already in Pinecone.
  3. Slash commands. Detect /status or /agent in the message text and route to human handoff logic.
  4. Periodic re-indexing. Set up a Cron Trigger in wrangler.toml to re-run the ingest script when your docs change.
  5. Multi-language support. The bge-base-en-v1.5 embedding model is English-only, but Workers AI has multilingual embedders. Swap the model and Pinecone dimension accordingly.

For a deeper dive into grounding techniques, read our piece on Deploy a RAG Chatbot Over Your PDFs and Notes Using Qdrant Free Tier and Groq. If you want to extend this bot to handle inbound email triage, check out Build a Gmail AI Triage Agent That Drafts Replies with Gemini and Groq Free Tiers.

Common Pitfalls

Pinecone dimension mismatch. The embedding model outputs 768-dimensional vectors. If your Pinecone index was created with a different dimension (e.g., 1536 for OpenAI), upserts will fail silently or return garbage. Delete and recreate the index with the correct dimension.

WhatsApp 30-second timeout. The Cloud API expects a 200 OK within ~20 seconds, but the actual reply can be sent asynchronously. If your pipeline is slow, acknowledge the webhook immediately and process in a background waitUntil:

c.executionCtx.waitUntil(processAndReply(env, message));
return c.text('ok', 200);

Cold starts on Workers. The free tier has cold-start latency on infrequently hit Workers. The first message after deployment may take 2-3 extra seconds. Subsequent calls are fast. Set up a health-check cron to keep it warm.

Token limits in free-tier LLMs. Llama 3.1 8B on Workers AI has a generous context window, but stuffing 10+ chunks plus conversation history can hit limits. Trim context aggressively.

FAQ

Q: Can I use my own OpenAI API key instead of Workers AI? A: Yes, but then you’re paying per token. Workers AI is free for up to 10k requests/day on the LLM and embedding models—more than enough for a support bot handling hundreds of conversations.

Q: How do I handle media messages (images, voice notes)? A: The webhook handler currently ignores non-text messages. You can add a branch that calls a transcription model (Workers AI has Whisper) for voice notes, or a vision model for images.

Q: What if my docs are 10,000+ pages? A: Pinecone’s free tier caps at 100k vectors. With 512-token chunks, that’s roughly 50k pages. If you exceed that, you’ll need a paid Pinecone plan or shard across multiple free-tier accounts.

Q: Is the WhatsApp Business API really free? A: Meta gives 1,000 free conversations per month per WABA (WhatsApp Business Account). A conversation is a 24-hour window of messaging with a user. Beyond that, pricing is per-conversation and varies by region.

Q: How do I move this to production with a real phone number? A: The code doesn’t change. You’ll need to verify your business on Meta (requires legal business info) and go through the phone number registration process. The API calls are identical.

Q: Can I A/B test different prompt strategies? A: Absolutely. Store the prompt template in a Cloudflare KV namespace and update it without redeploying. Track which version yields higher satisfaction by adding a thumbs-up/thumbs-down quick-reply button.


Building agents that actually ship is what FDEs do. If you want to get hands-on with more patterns like this—grounding, retrieval, and shipping demos that close deals—FDE Coach builds the muscle memory you need.

#customer-support#whatsapp#chatbot#rag#cloudflare

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

More build guides

August 15 · 0d left
Enroll Now