WritingAI

Slot filling that survives corrections and bounded retries

How to collect the details a chatbot needs over several turns so a customer can correct an earlier answer and never gets asked the same question forever.

A customer on WhatsApp writes "need to move my delivery to thursday" at 09:12 on Monday 5 October. To reschedule, the bot needs three things: the order number, the new date and a time window (morning or afternoon). Two turns later the customer adds "actually friday, afternoon please, order 48312 not 48213". A flow that reads each reply as the answer to the last question will book Thursday, reject "friday" as a time window and ask again. This playbook builds a flow where one message can fill several slots, a later message can correct an earlier one, and a slot that keeps failing gets a different question and then a person, within a bounded number of turns. The rule it defends: every message is first tried as a correction to every filled slot, and only then as an answer to the open question, with the retry budget kept on the slot rather than on the conversation.

Define each slot with a parser, a validator and a retry budget

A slot that is only a name and a prompt cannot tell a wrong answer from a missing one, so the flow ends up with a single "sorry, I didn't get that" reply that fires for every problem. Splitting parsing (turn the text into a candidate value) from validation (decide whether that candidate is acceptable for this customer) produces two different failures with two different replies: "I couldn't find a date in that" and "Sunday 11 October is not a delivery day for your postcode" call for different next questions. The retry budget belongs in the same definition, because how many times it is worth asking depends on the slot: an order number the customer has to look up deserves fewer attempts than a date.

SlotParserValidatorRetriesOn exhaustion
order_idWG plus five digits, with or without the hyphenexists and belongs to this phone number2hand over, reason order_not_found
delivery_dateweekday name, or day and month, resolved from todaywithin 14 days and a delivery day for the postcode3offer the next three available dates as buttons
time_windowmorning or afternoon and synonyms such as am, pm, before noonwindow still open on the chosen date2offer the one window that is open
contact_phonedigits with an optional country codevalid length for the country1default to the WhatsApp sender number

Every rejection carries a reason code (no_candidate, not_found, not_a_delivery_day, window_full) and the reply template names the reason. You know this step worked when no reply in the flow says "I didn't understand" without saying what it was looking for.

Extract from every message against all slots, not only the open one

The open question tells the bot what it is waiting for; it does not tell the customer what to say. People answer two questions in one sentence, answer a question that has not been asked yet, and change an earlier answer while doing so. I led an omni-channel chatbot across Messenger, WhatsApp, Telegram and web chat, and the rule I apply is that the set of parsers run on a message never depends on which question was asked last. Running every parser costs a handful of pattern matches or one extraction call; asking a question the customer already answered costs a turn and some goodwill.

TypeScript
// simplified: run every slot parser, then decide what each candidate means
type Candidate = { slot: string; value: string; source: string };

function apply(form: Form, message: string): Event[] {
  const candidates: Candidate[] = form.slots.flatMap((s) =>
    s.parse(message).map((value) => ({ slot: s.name, value, source: message })),
  );

  const events: Event[] = [];
  for (const c of candidates) {
    const current = form.values[c.slot];
    if (current === undefined) events.push({ type: "filled", ...c });
    else if (current !== c.value) {
      events.push({ type: "corrected", from: current, ...c });
    }
    // an identical value is a repeat, not an event
  }
  return events;
}

When one fragment matches two parsers (a bare "2" could be a day of the month or nothing at all), the open slot wins the tie and the other parser is ignored for that fragment. You know this step worked when "friday afternoon, order WG48312" sent as the first message fills three slots and the next thing the customer sees is the confirmation.

Treat a later value for a filled slot as a correction, and say so

Overwriting a slot silently is how a flow books the wrong day while the customer believes it heard them; ignoring the new value is how it books the wrong day while the customer knows it did not. The transition is Filled to Filled with the previous value retained and the reply acknowledging the change: "Changed the date from Thursday 8 October to Friday 9 October." The old value is kept for two reasons. The customer may say "no, keep the first one", and the handover summary should show that the date was changed once rather than present the final value as if it had been given once.

stateDiagram-v2
  [*] --> Empty
  Empty --> Filled : candidate accepted
  Empty --> Empty : rejected with budget left
  Empty --> Exhausted : rejected on last attempt
  Filled --> Filled:::accent : corrected with old value kept
  Filled --> Confirmed : customer confirms the form
  Exhausted --> Handover
  Confirmed --> [*]
One slot's lifecycle; a correction stays in Filled and keeps the old value

Here is the failure the step prevents, turn by turn, in a flow without it. Turn one: "move my delivery to thursday"; the bot parses Thursday 8 October, fills delivery_date and asks for the order number. Turn two: "WG-48213"; the bot fills order_id and asks "Morning or afternoon?". Turn three: "actually friday, afternoon please, order 48312 not 48213". The bot reads the reply as an answer to the open time_window question, finds "afternoon", fills it, and the form is complete: order WG-48213, Thursday 8 October, afternoon. The customer sees "Done, we'll deliver on Thursday 8 October in the afternoon" and replies "no, FRIDAY, and it's 48312". The flow has no open slot, so the message matches nothing and the bot answers with its fallback. With correction handling, turn three raises two corrected events and one filled event, and the reply reads back all three values before anything is booked.

You know this step worked when the conversation log records a corrected event with both values, and the confirmation message shows the later one.

Bound retries per slot and change the question after a miss

A conversation-level counter ("three failures, then hand over") punishes a customer who answered two slots perfectly and is struggling with one. A budget on the slot, from the table in step one, does not. Repeating the same wording after a miss changes nothing, so each miss moves down a ladder of question types: from free text to a short choice, from a choice to a yes or no. For delivery_date the ladder is "Which day would you like?", then three dates as buttons, then "Is Friday 9 October right?". For order_id it is "What is the order number?", then "Is it WG-48213, the order placed on 1 October?", then handover.

TypeScript
// simplified: a miss spends one attempt and moves one step down the ladder
function reject(form: Form, slot: Slot, reason: string): Event {
  const attempts = form.attempts[slot.name] + 1;
  form.attempts[slot.name] = attempts;
  if (attempts >= slot.retries) {
    return { type: "exhausted", slot: slot.name, reason, next: slot.onExhaustion };
  }
  const question = slot.ladder[Math.min(attempts, slot.ladder.length - 1)];
  return { type: "ask", slot: slot.name, reason, question };
}

An exhausted slot does not always mean a handover: contact_phone defaults to the sender number and delivery_date turns into buttons, and only order_id ends the bot's part of the conversation. You know this step worked when no slot in the logs ever shows more attempts than its budget, and the exhausted count is reported per slot rather than per conversation.

Confirm the whole form once, then act

Confirming each slot as it is filled doubles the number of turns and still leaves a mismatch undetected until the end. Confirm once, with every value in one sentence: "Move order WG-48312 to Friday 9 October, afternoon, and text the driver on 07700 900123?". A "yes" (or a tap on the button) triggers the action. Anything else goes back through step two as a message, so "no, morning" becomes a corrected event on time_window, the form re-confirms with the new value, and no slot is asked about from scratch.

flowchart TD
  M[Inbound message] --> P[Run every parser]
  P --> C{Any candidates}
  C -->|no| R[Reject open slot]
  R --> L{Budget left}
  L -->|yes| Q[Ask next ladder step]
  L -->|no| X[Apply exhaustion rule]
  C -->|yes| F[Fill or correct slots]:::accent
  F --> D{All slots filled}
  D -->|no| Q
  D -->|yes| K[Confirm the form]
  K --> A[Act on yes]
How one inbound message moves through the form

The action itself must be idempotent on the confirmation: a reschedule request carries the id of the message that confirmed it, and a second "yes" (every chat channel redelivers a message now and then) finds the request already recorded and replies with the same result. You know this step worked when every reschedule in the system points at exactly one confirmation message id.

Hand over with the form, not the transcript

When order_id exhausts its budget, the agent who receives the conversation should not have to reread eight messages to learn that the customer wants Friday afternoon. The handover payload is the form state: the filled values with their corrections and old values, the exhausted slot with every rejected candidate and its reason code (48213 not_found, 48312 not_found), the channel, and the customer's identifier on that channel. The agent's first message can then be specific: "I can see you want Friday afternoon. I couldn't find order 48312 under this number; is there a confirmation email you can check?" The transcript travels too, as an attachment rather than the thing the agent reads first.

You know this step worked when the agent's opening message never asks for a value that is already in the form.

Before the flow goes live

  • Every slot has a parser, a validator, a retry budget and an exhaustion rule in one definition
  • Every rejection carries a reason code and the reply names what was being looked for
  • Every inbound message runs through every slot's parser, whatever the open question is
  • A new value for a filled slot raises a corrected event, keeps the old value and is read back
  • No slot can exceed its retry budget, and each miss changes the question type
  • The form is confirmed once, and any reply other than yes is treated as a correction
  • Every action stores the id of the confirmation message that triggered it
  • The handover payload contains the form state and the exhausted slot's rejected candidates

Start with the slot table: write down the parser, validator, budget and exhaustion rule for each slot before touching the dialogue code, because every other step reads from it. Then replace the "answer to the open question" branch with a run of every parser, and watch how many of the retries you were about to build stop being needed.

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