Build notes · conversational AI

The WhatsApp auto-reply that answered a hello with a price list

13 August 2026 · 46 messages in the thread, the model was shown 21 through 40 · Founder, Klaros

Someone we had been talking to for five weeks opened WhatsApp and typed “Hello”. Nine seconds later our software replied: “Klaros offers a one-time or annual license depending on the plan.” Nobody had asked about licensing. Nobody had asked anything at all.

The obvious diagnosis is that the model hallucinated, or that the prompt was weak, or that a small model is simply not up to the job. All three are wrong, and chasing any of them would have wasted a week. The model was behaving correctly. It had been handed the wrong conversation.

TL;DR

The thread query was ORDER BY created_at ASC LIMIT 40, which selects the oldest 40 rows. On a 46-message conversation the model saw messages 1 to 40, then a later slice cut that to 21 to 40. It never received the greeting, and the newest message it could see was part of a pricing discussion from three weeks earlier. Every timestamp had also been stripped, so it could not tell that three weeks had passed. Investigating that one reply surfaced seven further ways the same assistant was failing to listen, ending with a customer who asked to speak to a person and was ignored twice.

What the model was actually given

The router that decides what an unclaimed inbound message deserves fetches the conversation so the model has context. That fetch read direction, type and body, ordered ascending, limited to 40. Then the prompt builder took the last 20 of those.

On a short thread this is invisible. On a real relationship it inverts: the longer somebody has been a customer, the further back the window slides, until the model is answering today’s question with last month’s conversation. The better the customer, the less the system knew about them. That is the exact opposite of what a product built on remembering is supposed to do, and it had been true since the feature shipped.

The second half was the clock. The transcript was passed as direction and text only, with created_at discarded. So even with the right messages, the model would have read a pricing exchange from 31 July and a greeting on 12 August as consecutive turns of one conversation. A person who says “hi” after three weeks is starting something. A person who says “hi” ninety seconds after you quoted them a price is continuing something. We had removed the only information that distinguishes those two.

The fix is unglamorous: fetch descending, reverse for display, carry created_at, and render a marker between messages when real time has passed, so the transcript contains lines like — 12 days passed —. The prompt now says that anything after a gap of a day or more is a new conversation and not the next turn of the old one.

Then the deterministic layer, which was switched off

A model should never have been the first thing to see “Hello”. There is a greeting rule, a keyword rule and an away rule, all of which run before the model and all of which are free. On this deployment all three were empty, so every inbound message from every stranger fell through to the model. It was not a fallback. It was the front door.

Worse, the greeting could not have fired even if it had been switched on. It was gated on isFirstInbound, derived from whether the conversation row already existed, which means a greeting could fire at most once per contact, ever. Somebody returning after three weeks was, by that definition, mid-conversation.

We replaced it with a session boundary. The first attempt used the 24-hour service window, because that mapped neatly onto something WhatsApp already defines. That was tidy reasoning and wrong reasoning, and it took one more real message to find out: a contact wrote “What’s up ?” at 05:40 after eleven and a half hours of overnight silence, and 11.5 is less than 24, so the system treated the next morning as the same conversation. No human alive agrees with that. The service window is a billing construct, not a conversational one. The boundary is now six hours.

The lazier the message, the worse the answer

People type the smallest thing that will do. They send voice notes. They send a sticker and wait. The matcher in front of the model began by checking that the message text was non-empty:

if (!String(text || '').trim()) return false;

A voice note has no text. Neither does a sticker, or a photo without a caption. So the model never ran, the greeting rule required text that looked like a greeting, and the result was total silence in response to the laziest thing a person can send, which in the markets this product serves is also one of the most common. A session-opening message with no text at all is now answered like a greeting. Mid-conversation it still stays quiet, because a person is presumably already engaged.

The spelling problem is the same shape. A lookup table handles “hi”, “hello” and “hey” for free and correctly. It will never handle “hlo bhai”, “vanakkam anna”, “gm sir” or whatever gets invented next, and enumerating them is a losing game. Recognising them is exactly what a model is good at. Writing the reply to a stranger is exactly what it should not be trusted with.

So the model got a new outcome that carries no words of its own. It can classify a message as a greeting; the sentence that sends is the operator’s. Any prose the model attaches to that verdict is discarded. Non-deterministic recognition, deterministic emission. That split is the whole design, and it is the same one that lets the model choose which of three pre-written buttons a person sees without ever composing what happens when they tap one.

It needed a second pass. “What’s up ?” is interrogative in form and contentless in substance, and the model classified it as an unanswerable question and handed off. The instruction now names question-shaped greetings explicitly, and just as explicitly excludes the ones that only look casual: “any update?” is a status request, and answering it with “how can I help?” would be the same deafness pointing the other way.

The compliance bug underneath

While tracing which code claims an inbound message, we found one that is not about tone at all. The opt-out keyword list contains cancel and remove, and short messages are matched on whole words. So:

Customer: “cancel my order” → three words, matches cancel → consent status flipped to opted out, suppression row written, order untouched.

The customer believed they had cancelled a purchase. The business believed they had refused marketing. Both were wrong, and neither would find out until something else went strange.

The instinct is to remove cancel from the list. That trades one silent failure for another, because plenty of people do mean the subscription. The actual answer is that the word is genuinely ambiguous and only live state can resolve it. When the contact has an order in flight, Klaros now asks: two buttons, My order and Stop messages. A tap is unambiguous, and because a button payload is read before any keyword matching, the same word cannot be re-misread on the way back in. stop, unsubscribe and optout are untouched and still absolute, because those mean the subscription in every context and weakening them would trade a real compliance guarantee for a hypothetical one.

This has history. In July a contact typed “extend” and was unsubscribed, because opt-out matching was substring-based and ext-END contains end. That was fixed the same day with word boundaries. The word-boundary fix treated the symptom; this one treats the disease, which is a lexicon making a decision that needs context.

Whose business is this, anyway

Every automatic reply was being generated under the instruction “You are answering a WhatsApp message on behalf of…” followed by the founder’s personal name. The prompt read a display_name column, which holds whatever free text was typed at signup.

The number itself had four different identities in circulation at once: a Meta verified_name that had passed review, a business profile pointing at this site, a signup column holding a person’s name, and an inbound advertising referral claiming something else entirely. The rule we settled on is that the identity of record is the one that survived review. A Meta-reviewed name outranks a local free-text column, which outranks an assertion made by something outside the system. It is read on a schedule and cached rather than copied once, because a name under review moves.

That last category deserves its own paragraph, because it produced the strangest message of the week. A Facebook post for an unrelated business, a saree shop, points its wa.me link at this number. So a shopper arrived asking about cotton sarees, was offered our product funnel by a sales matcher that could not see where they had come from, and then received a perfectly accurate description of a WhatsApp CRM.

The referral data explaining all of this had been captured on the message since a migration in July and read by nobody in the chain. It now reaches the model, but deliberately as provenance and never as fact: the prompt states that it describes where the person came from, that it is not a description of this business, and that it must not be adopted as an identity. An advertisement can explain why somebody is confused. It can never redefine who you are.

The one that actually matters

At 18:12 the assistant correctly reached the limit of what it could ground and offered a menu. At 18:13 the contact tapped “Talk to a person”. The handoff was recorded as a milestone. The thread was flagged as needing a reply.

Then nothing happened, because nothing consumed either signal.

Eleven and a half hours later the same person wrote “What’s up ?” and got silence again. Every individual decision in that sequence was defensible. The model was right that it could not answer. The handoff was right to be recorded. The greeting was right not to fire mid-conversation. The sum of all those correct decisions was a business ignoring somebody twice.

Two things came out of it. The first is a sweep that asks the only question that matters: is somebody waiting who has not been answered? Not “did a handoff fire”, because there are already four ways a thread ends up needing a human and there will be more, and a detector that enumerates causes is guaranteed to miss the fifth. How long counts as too long is derived rather than configured: three times that business’s own median reply time, floored and capped, because a fixed threshold is either noise for a business that replies in four hours or useless for one that replies in five minutes. An explicit request for a person gets a fixed fifteen minutes instead, because that is a stated request rather than an inference.

It found something on its first run. A different contact, from that same saree advertisement, had asked for a human and been waiting 62 hours. Nobody knew.

The second is a rule we would now defend in any code review:

Silence is a reasonable answer to a message nobody can parse. It is never a reasonable answer to somebody asking why they were ignored.

When nothing claims a message and that thread is already flagged as waiting, Klaros now says so. It admits fault, promises nothing it cannot keep, and is debounced so that a frustrated person writing five times does not get five apologies. It deliberately does not say “shortly”, because the last promise made on that thread went unkept for eleven hours.

What we are not claiming

This note describes a system that listens better than it did last week. It does not describe one that remembers you.

Everything above is about a single conversation: the right messages, in the right order, with the clock attached, answered by the right identity, or honestly handed to a person. That is a floor, not a ceiling. A window is still a truncation, and on a long relationship the model is still structurally incapable of knowing how it began. Fixing that properly means compaction at session boundaries and a durable record of what was said and promised, which is designed and not built. When it exists it will be described here, with the same amount of detail about what it fails to do.

We would rather publish the eight ways our own assistant was not listening than a page about relationship intelligence we have not shipped. If you are evaluating any vendor in this category, including this one, the useful question is not what the AI can do. It is what happens when it cannot, and who finds out.

Klaros is a self-deployed WhatsApp Business platform: you run it on your own Cloudflare account, connect your own Meta WhatsApp Cloud API number, and pay Meta directly with no markup added. More build notes · What is next · Start free