Recipe: Chatbot slot filling
Collect N slots in any order with a self-transition. Move on when an allSlotsFilled guard passes, and nudge on silence with an after timer. The engine is pure and tested on both interpreters.
A booking bot needs a date, a party size and a name, and users give them in any order, several at once, or not at all. The conversation logic is small, and a chart can own all of it. Your NLU, whether an LLM, a regex or Rasa, only extracts {"slot": value} pairs from a turn and sends them as USER_SAID.
Files: examples/recipes/slot_filling/.
---
config:
state:
nodeSpacing: 80
rankSpacing: 120
---
stateDiagram-v2
direction LR
[*] --> collecting
collecting --> collecting: USER_SAID / fillSlots (re-enter)
collecting --> confirming: always [allSlotsFilled]
collecting --> silent: after 30s
silent --> collecting: always [canNudge] / nudge
silent --> abandoned: always
confirming --> booked: YES
confirming --> collecting: NO / clearSlots
USER_SAIDis a re-entering self-transition ("reenter": true). It stores whatever slots arrived, and re-entry restarts the 30 s silence timer. Every answer buys the user another 30 s.always+allSlotsFilled: the moment the last slot lands, in whichever turn, the machine moves toconfirming. The code never checks “are we done?” by hand.- Silence fires the
aftertimer into the transientsilentstate. Itsalwayscandidates nudge and return tocollecting, which restarts the timer (canNudge: fewer than 2 nudges so far), or abandon the conversation.
xsm simulate examples/recipes/slot_filling/machine.json --events USER_SAID,YES
# -> booking.booked (stub guards: allSlotsFilled is True)
xsm simulate examples/recipes/slot_filling/machine.json --events +30000 --guards-false allSlotsFilled,canNudge
# -> booking.abandoned
The code
from xstate_statemachine import (
MachineLogic,
SimulatedClock,
SyncInterpreter,
create_machine,
)
chart = {
"id": "booking",
"initial": "collecting",
"context": {
"slots": {
"date": None,
"party_size": None,
"name": None,
},
"nudges": 0,
},
"states": {
"collecting": {
"always": {
"target": "confirming",
"guard": "allSlotsFilled",
},
"on": {
"USER_SAID": {
"target": "collecting",
"reenter": True,
"actions": "fillSlots",
},
},
"after": {
"30000": "silent",
},
},
"silent": {
"always": [
{
"target": "collecting",
"guard": "canNudge",
"actions": "nudge",
},
{
"target": "abandoned",
},
],
},
"confirming": {
"on": {
"YES": "booked",
},
},
"booked": {
"type": "final",
},
"abandoned": {
"type": "final",
},
},
}
said = []
def missing(ctx):
return [k for k, v in ctx["slots"].items() if v is None]
def fill_slots(i, ctx, e, a):
ctx["slots"].update(
{k: v for k, v in e.payload.items() if k in ctx["slots"] and v}
)
ctx["nudges"] = 0
def nudge(i, ctx, e, a):
ctx["nudges"] += 1
said.append(f"Still there? I need: {', '.join(missing(ctx))}")
machine = create_machine(
chart,
logic=MachineLogic(
actions={
"fillSlots": fill_slots,
"nudge": nudge,
},
guards={
"allSlotsFilled": lambda ctx, e: not missing(ctx),
"canNudge": lambda ctx, e: ctx["nudges"] < 2,
},
),
)
clock = SimulatedClock()
bot = SyncInterpreter(machine, clock=clock).start()
bot.send("USER_SAID", name="Ann")
clock.increment(30_000) # silence -> nudge
assert said == ["Still there? I need: date, party_size"]
bot.send("USER_SAID", date="Fri", party_size=4) # two slots in one turn
assert bot.value == "confirming" # `always` moved on
bot.send("YES")
assert bot.value == "booked"
The example module adds per-slot prompts (“For how many people?”), a confirmation read-back, and NO → clear and restart. Its tests run the same script on SyncInterpreter and Interpreter on a SimulatedClock, so a conversation lasting minutes is tested in milliseconds.
Troubleshooting
| You see | Why | Fix |
|---|---|---|
| A value the user gave never lands in its slot | fillSlots keeps only short scalars: a non-empty string of at most MAX_SLOT_CHARS (200) characters, or a finite number. A dict, a list, True/None, an empty string or a longer string is dropped, and the bot asks again. The extractor (often an LLM) is the untrusted side. |
Have the extractor return plain strings or numbers. Raise MAX_SLOT_CHARS deliberately if a slot needs it. |
| A key the user gave is ignored | Only the slots declared in context.slots are filled; any other key is dropped. |
Add the slot to machine.json’s context and to PROMPTS. |
Variations
- Persist the conversation between turns with
persisted(store, f"chat:{session_id}", machine), and the silence timer becomes a durable deadline (see the APScheduler recipe). - Validate a slot by making
fillSlotsreject bad values and re-prompt. Or add aslotInvalidguarded self-transition before the storing one. - LLM extraction: see LLM agents. The model proposes the slot values, and the chart decides what happens next.
Related: Guards, Delayed transitions, all recipes.