
A WhatsApp agent that knows when to call a human
The problem wasn't answering, it was answering in time
Someone messages a business on WhatsApp on a Saturday at eleven at night. The sales team replies Monday mid-morning. By then the prospect has already messaged three competitors.
That's the scenario I set out to solve. The project is my own, built as practice: no client, no production traffic, just an excuse to wrestle properly with a channel I wanted to understand. The problem, though, is as real as it gets — it happens to any business that sells over chat.
No magic chatbot was needed. What was needed was something that would reply within seconds, understand what the person needs, and pick up the phone only when it's worth it.
How it works under the hood
A message takes a short trip:
- Someone messages the business's WhatsApp number.
- The message comes in through Meta's WhatsApp Cloud API via Kapso, which handles the number, the session and the inbox.
- Kapso fires a webhook at a NestJS API of my own.
- The API assembles the context for that conversation and asks Claude what to reply.
- If a qualified lead comes out of the conversation, two things happen: a summary to the team's Telegram, and a Twilio call to the prospect that connects them with whoever is available.
The interesting part isn't any single step — they're all HTTP calls — it's the rules around them.
First: one number, two providers
I wanted the call to come from the same number the person already had in their chat. Getting rung up by a stranger right after chatting with a business on WhatsApp is exactly what makes nobody pick up.
That's where the platform limit hit me: a number's WhatsApp Business Account lives with one provider at a time. If Kapso holds the WhatsApp side of that number, you can't also register the same number as a WABA in Twilio; it isn't shared, and it isn't a configuration problem you can fix by trying harder.
The way out was to stop splitting providers and split channels instead: Kapso runs the chat, Twilio runs the voice, over the same number as far as the person is concerned. Two integrations instead of one, but from the outside it's a single contact: the number that answered the message is the number that calls.
Second: the 24-hour window
WhatsApp isn't a channel where you write whenever you feel like it. If the user's last reply is more than 24 hours old, you can't send them free-form text: only templates pre-approved by Meta.
That changes the whole design. The flow has to know where each conversation stands, because replying inside the window is not the same as reopening a dormant one. Ignore it and the symptom is nasty to diagnose: the bot "works" in testing — where you're always inside the window — and goes silent with exactly the leads that went cold.
The rule I landed on: inside the window, normal conversation; outside it, a template or nothing.
Third: Meta's webhooks arrive more than once
It's in the docs and it still bites you. A webhook can arrive duplicated, and sometimes out of order. Process every notification as it lands and the same user message ends up producing two bot replies, two Telegram alerts and — this is the expensive one — two phone calls to the same prospect, seconds apart.
The fix isn't clever, but it has to be there from day one: every event carries an id, and that id is processed exactly once.
@Post('webhook')
async handle(@Body() payload: WhatsAppWebhookDto) {
// Answer fast: Meta retries if you take too long
for (const event of extractMessages(payload)) {
const isNew = await this.events.claim(event.id); // INSERT ... ON CONFLICT DO NOTHING
if (!isNew) continue; // already handled: retry or duplicate
await this.queue.add('reply', event);
}
return { received: true };
}Two things I learned here:
- Answer the webhook fast. If you take the time to call the model before responding, Meta assumes you failed and retries. Receive, enqueue, respond.
- Deduplication belongs in the database, not in memory. The moment you run more than one instance, an in-memory
Setstops working.
Fourth: when you're allowed to call someone
This was the hardest product decision, and it has nothing to do with code.
A Telegram notification is cheap: if too many arrive, they get ignored. A phone call isn't. Someone who wrote on WhatsApp picked chat precisely so they wouldn't have to talk on the phone; calling them because they said "hi" isn't fast service, it's an intrusion.
So the call has conditions: it only fires once the agent has gathered the minimum — what they need, by when, how to reach them — once the person has agreed to be called, within reasonable hours, and with someone on the team available to take it. At midnight, or with nobody on the other end, there's no call: the Telegram alert waits instead. Worse than not calling is calling and dropping the person into a phone nobody answers.
Teaching the bot to shut up
The last piece is the one that buys the most peace of mind: the human handoff.
If the prospect asks to talk to a person, or the conversation drifts outside what the agent knows, the bot stops replying and the conversation sits in the inbox for the team. Nothing is worse than someone asking for a human while a bot keeps pushing its script.
Along the same lines, the agent's instructions are explicit: answer from what you know about the business and, when you don't know, say so and offer to hand over. I'd rather have a "let me put you through to someone" than an invented price somebody has to walk back later.
What I took away
- The channel is in charge. WhatsApp's rules (24-hour window, templates, retries, one provider per number) shape the architecture far more than the choice of model does.
- Platform limits show up late. A WABA living with a single provider isn't visible while you design: it shows up once you've already decided the call comes from the same number.
- Idempotency from day one. In a system that fires outward actions — messages, alerts, calls — a duplicate isn't an extra log line: it's a phone ringing twice.
- The AI is the easy part. The hard part is deciding when to act, when to wait and when to step aside.
If you're building something similar, start from that last question, not from the prompt.