▲ Case 04 · Automated customer support in production
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.
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.
Real WhatsApp screens, August 2026. Customer name and photo pixelated. The screens are in Portuguese.
{"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.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.
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.
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.
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.
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.
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.


Count taken in the repository on September 22, 2026.