Renan Oliveira
← all systems

▲ Case 04 · Automated customer support in production

Three layers of bot, and the cheapest one handles most of it

Sending every message to a language model costs per token, takes time, and returns different answers to the same question. The architecture flips that: the expensive model only steps in where real judgment is needed.

Period
2026
My role
concept, architecture and code
Where it runs
Cloudflare Workers and GitHub Actions
Status
in production, 4 pages live

The conversation, as it happened

Sunday, 7:16 p.m. An electrician asked for an NR 10 (the Brazilian electrical safety standard) checklist in Excel. The bot answered within the same minute, explained what it had and what it did not have, and offered the Kit. It remembered that he had already downloaded the wire gauge card. When he asked whether there was a photo, the bot sent two videos of the material and the payment link.

At 7:27 p.m. he replied that he would pay the following weekend, and the bot closed the conversation without pressuring him. Twelve minutes of conversation, with nobody from the team in the middle.

The customer asks for an NR 10 checklist and the bot answers within the same minute, offering the Kit The bot remembers that the customer downloaded the wire gauge card, and the customer asks whether there is a photo The bot sends the video of the quote spreadsheet and the payment link The customer says he will pay on the weekend and the bot closes without pressuring him

Real WhatsApp screens, August 2026. Customer name and photo pixelated. The screens are in Portuguese.

How it is built

Intake
A 7,859-line Cloudflare Worker receives each message in real time through the WhatsApp integration and replies within the same request.
Layer 1
Deterministic classifier in JavaScript, with intents declared in a table. Zero cost per message and an identical answer to the same question.
Layer 2
A fast model that returns {"acao": "enviar_pdf"} as JSON when it recognizes a request for material, instead of returning text. The parse degrades gracefully: if the JSON does not come back, the raw text is still used.
Layer 3
A strong model, called only for what is left over from the first two.
Knowledge base
A retrieval layer feeds the model with the actual content of the materials. When it fails, the bot carries on without it instead of dropping the answer.
Checkout
Payment platform webhook with its own secret, and a mirror that replicates the funnel events for later auditing.
Lead state
Cloudflare D1 for the history and KV for fast state, with the lead's canonical key as the single identity.
Contact sequence
Python on GitHub Actions for the scheduled sequence, with a business-hours guard.
Sending pace
A randomized interval between messages, with the wait function injected as a parameter, so the test suite exercises the pacing without actually sleeping.
Pages
Four static landing pages live, each one going after a specific pain point of people who work on electrical installations.

The decisions, and what I ruled out in each one

Decision 01

The webhook answers, and the scheduled process does not

The Worker receives the message and answers right away. The GitHub Actions process only handles the scheduled re-engagement sequence, which is outbound and not conversation.

Why: a scheduled process has no way to capture a reply in real time. Whoever writes in expects an answer in seconds, not in the next cycle.

Decision 02

The model returns a structured action, not prose

When someone writes "manda aê" (roughly "send it over"), no word list covers every informal variation. The model understands the intent, but it used to only know how to answer with sales text, when what the person wanted was the file.

Layer 2 started returning an action field in JSON. The caller checks the action before looking at any text.

What I ruled out: continuing to enumerate ways of asking in the deterministic layer. It is a list that never closes.

Decision 03

The phone number has one canonical form, and it holds in every component

Every component that matches messages by phone number normalizes the number before comparing. The lead key is that canonical form, and it is the person's only identity in the database and in the state.

What this fixed, measured: the base had 350 records for 252 people, with 98 duplicates. And the process that watched the line compared the raw identifier: it reported zero replies while the bot was answering normally.

An identifier that arrives in more than one format needs a canonical form before it becomes a key. Without it, the system lies with authority.

Decision 04

The human pause expires on its own after twelve hours

When someone on the team takes over the conversation, the bot goes quiet. That pause stops applying twelve hours later, automatically.

Why twelve: a human support session fits in one working day. With no deadline, the pause stayed until someone remembered to release it, and a lead paused by mistake never received anything again.

What I ruled out: a permanent pause until manual release. It turns a thirty-second lapse of memory into a lead lost for good.

Decision 05

When all three layers fail, the bot admits it

The bot says it did not understand, offers three concrete paths for the person to choose from, and alerts a person on Telegram.

Why: an honest answer costs less than a wrong answer given with confidence. The model always has something to say, and that is exactly the risk.

The bug that taught more than the success

At one point the bot stopped answering. No error, no alarm, nothing in the monitoring: the request returned normally and the message simply did not go out.

The cause was a platform rule I did not know. Asynchronous processing in Cloudflare Workers has a deadline to finish after the HTTP response is returned. I had put a fifteen-second wait before processing, to group messages that people send in pieces. That wait used up the entire budget before any call happened.

The diagnosis did not come from the code or from the application log. It came from opening the platform's live stream and watching where the process was cut off.

A system that fails silently is worse than a system that breaks loudly. The one returning 200 was delivering nothing.

Map of the support bot, step by step
The bot's behavior documented step by step. Documenting it is what makes it possible to discuss whether it is right.
Price table landing page
One of the four entry pages.
3support layers
7,859lines in the worker
4pages live

Count taken in the repository on September 22, 2026.