WritingAI

A customer's three quick messages are one turn, not three

Treat everything a customer sends before they stop typing as one turn, run NLU once over it, and abort a reply that a message landing mid turn has made wrong.

At 14:02:10 a customer on WhatsApp sends "hi i need to cancel". At 14:02:13 they send "order 48812". At 14:02:15 they send "actually only the lamp". A chatbot that runs intent detection on each inbound message as it lands produces three replies: a question asking which order, an offer to cancel the whole of order 48812, and a fallback message for the third because "only the lamp" matches no intent on its own. The customer made one request. The bot answered the first message before the second had been stored.

The unit of work for a chatbot is the turn, not the message. Everything a customer sends before they stop typing is one input, the bot should run intent and slot extraction once over all of it, and a conversation should admit one turn at a time. Processing per message is the default every webhook handler falls into, and it is wrong on every channel where people type in fragments, which is all of them.

Store the message, then wait for quiet

The webhook handler does two things and nothing else: it inserts the message row with its conversation id, received_at and the state unread, and it pushes the conversation's turn deadline to now plus a quiet window. The deadline lives in one key per conversation, so a second message in the window moves the deadline rather than scheduling a second job. Two seconds suits WhatsApp, Messenger and Telegram, where the only signal is the next message. Web chat can end the window early on a typing stopped event, with the same two seconds as the cap.

When the deadline fires, the worker takes the conversation lock, reads every unread message in received_at order, joins the texts with newlines and makes one NLU call. I led an omni-channel chatbot across Messenger, WhatsApp, Telegram and web chat, and the rule I apply is that the planner never sees a message, only a turn. The three fragments above become "hi i need to cancel\norder 48812\nactually only the lamp", which resolves to one intent, one order number and one line item in a single pass.

flowchart TD
  M[Inbound message] --> S[Store as unread]
  S --> D[Push quiet deadline]
  D --> W{Deadline reached}
  W -->|new message| D
  W -->|quiet| L[Take conversation lock]
  L --> R[Read all unread messages]:::accent
  R --> N[One NLU call]
  N --> U{Unread arrived}
  U -->|yes| B[Abort the turn]
  B --> W
  U -->|no| A[Act and reply]
One turn per conversation, built from every unread message after a quiet window

What a message that lands mid turn does

The quiet window does not close the gap, it narrows it. Suppose only the first message had arrived by 14:02:12, so a turn starts then, and its order lookup takes a second and a half. At 14:02:13 "order 48812" lands while that turn is running. The handler stores it as unread and pushes the deadline as usual. The running turn has already planned its reply, "Which order would you like to cancel?", and that reply is now wrong, because the answer is sitting in the table.

So the turn checks for unread messages newer than its input before it does anything visible. If any exist, it aborts: its own messages stay unread, the abort count on the conversation goes up by one, the lock is released, and the pending deadline job reads all of them together. The order lookup for 48812 runs twice, which is the cost of one coherent reply instead of a question whose answer was already given. The check must sit before side effects as well as before the reply, because a turn that has cancelled an order cannot be aborted, only apologised for.

TypeScript
// simplified: one turn per conversation, its input is every unread message
async function runTurn(conversationId: string) {
  const key = `turn:lock:${conversationId}`;
  const held = await redis.set(key, '1', 'NX', 'PX', 15000);
  if (!held) return requeue(conversationId, 500);
  try {
    const msgs = await db.messages.unread(conversationId); // received_at asc
    if (msgs.length === 0) return;
    const text = msgs.map((m) => m.text).join('\n');
    const plan = await nlu.plan(conversationId, text); // intent, slots, actions
    const late = await db.messages.unreadAfter(conversationId, msgs.at(-1)!.id);
    const aborts = await db.conversations.abortCount(conversationId);
    if (late > 0 && aborts < 2) {
      await db.conversations.recordAbort(conversationId); // msgs stay unread
      return; // the deadline job will read them all together
    }
    await db.messages.markProcessed(msgs, plan.turnId);
    await actions.run(plan); // side effects only after the unread check
    await channel.send(conversationId, plan.reply);
  } finally {
    await redis.del(key);
  }
}

The abort cap matters. A customer who sends a message every second would otherwise never get an answer, because every turn would find a newer message and give way. After two aborts the third turn sends whatever it has, and the next fragments start a fresh turn. What the customer sees at 14:02:17 is one message: "I can cancel the lamp on order 48812 and refund 24.00 GBP. Shall I go ahead?" What the transcript shows is one turn with three inbound rows and one outbound row, which is also what the intent accuracy report should count.

Build the unread state and the per-conversation deadline first, since the lock and the abort check mean nothing until messages stop being processed on arrival. Then add the unread check in front of every side effect. Once it is live, measure the share of turns with more than one inbound message per channel: that number is how often the old handler was answering half a request.

Written by Md Nasim Anjum, senior full-stack engineer in Manchester. He builds payment orchestration, KYC and KYB compliance platforms and conversational AI.

Get in touchAll writingRSS

More writing