Skip to main content
An interrupt is the mechanism by which a flow collects information from the user. When a node returns interrupt(...), the engine returns status: "interrupt" to the model, persists the current step, and waits. On the next action: "continue" call, the user’s answers arrive in stateUpdates, validators run, and the engine advances along the node’s outgoing edge.

What gets persisted

Between an interrupt and the matching continue call, the flow store holds:
  • The current step name (which node is paused).
  • The full state snapshot at the time of pause.
  • For single-question interrupts, the field being asked.
  • Every field the run has asked so far (asked). An answer to one of these merges as sent, and a field marked neverPrefilled() takes a value only once it is listed here.
Everything else (handler closures, validator functions) lives in the compiled flow in memory. Validators are registered when a handler first returns an interrupt and are keyed by "nodeName:fieldName". They are not serialized. This has one operational consequence: if the server process restarts between the interrupt and the continue call, persisted state is reloaded but validator functions are rebuilt from the handler’s return value as the interrupt re-executes. The interrupt handler must therefore be deterministic enough to produce the same validator for a given state.

Single question

The smallest interrupt asks one question that writes its answer to one field.
The key (email) must be a field in the flow’s state schema. TypeScript enforces this.

Suggestions

Pass suggestions to give the model a concrete list to offer. Pairs well with z.enum() state fields so the model validates the user’s choice against a fixed set.
Suggestions serve two audiences at once. The assistant receives them as candidate answers on every host, regardless of any configuration. In the Waniwani chat widget they can also render as clickable pills above the input — clicking one sends it as the visitor’s answer — but only when the host chat surface opts the "flow" suggestion origin in:
  • <WaniwaniChat>: overrides={{ suggestionOrigins: ["channel", "page", "flow", "followup"] }}
  • <script> embed: data-suggestion-origins="channel,page,flow,followup"
  • <ChatEmbed> (bare primitive): suggestions={{ origins: ["flow"] }}
Pills appear only when the step has exactly one question still open. That is evaluated at runtime, not from how you wrote the call: a four-question interrupt where three fields are already filled has one open question, so it gets pills. A step with two or more open questions gets none, because no single pill could answer them all. Other MCP hosts (ChatGPT, Claude) are unaffected either way: they have no pill row, and what they receive is unchanged.

Hidden context

Sometimes the question you show the user differs from the guidance you want to give the model. Put the guidance in context. It is passed through the protocol marked as hidden, so the model uses it to shape phrasing without showing it verbatim. Per-question context lives inside the question config:
Interrupt-wide context lives in the second argument:

Multi-question interrupts

A single interrupt can ask several questions at once. The model asks them in one conversational message and the user’s answers arrive together.
If the user only answers some of the questions, the engine re-presents the interrupt with the unanswered ones filtered in. Validators run only once every field in the interrupt is filled.

Validation and re-ask

Add a validate function to reject bad answers or enrich state.
value is typed from the Zod schema for that field. The function can:
  • Return void to accept the answer as-is.
  • Return a Partial<TState> object to merge extra updates (for example, an enriched lookup result).
  • Throw an Error (not return a string) to reject the answer.
On a thrown error, the engine:
  1. Clears the offending field from state.
  2. Re-presents the same interrupt.
  3. Prepends ERROR: <message> to that specific question’s context so the model can relay the reason naturally.
Other questions in a multi-question interrupt are not cleared. Only the failing field is re-asked. Validators run after all fields for an interrupt are filled, and before the engine advances along the outgoing edge. Any updates they return are visible in the next node.

Nested fields

If state uses z.object() for one level of nesting, target sub-fields with dot-paths.
Only one level of nesting is supported. See State.

Auto-skip when a field is already filled

The engine skips any interrupt whose fields are already populated, whether an earlier answer, a value the model sent before the question came up, a validator or an action node filled them. For multi-question interrupts, already-answered questions are filtered out and only the unanswered ones are presented. An interrupt whose fields are all filled is never shown: its validators run on those values, and the flow moves on unless one of them throws. Leave that filtering to the engine. A node that drops a question once its field is set (...(!state.breed ? { breed: { validate } } : {})) also drops its validator, so a value the model sent early never gets checked. Values of undefined, null, and "" do not count as filled.

Answers the user already gave

When the user’s opening message already answers a question (“I’m in 75011, quote me a half-day lesson”), the model doesn’t ask the user again. The session’s first interrupt or widget lists every field the conversation may fill under fields, so the model sends each value the user has stated on its next call, even for a question the flow has not reached yet. If every question on the table is answered that way, the model resumes the flow right away. A value sent ahead of its question is written when it passes the field’s Zod schema, and the flow skips that question when it gets there. A field marked neverPrefilled() waits for its own question. See What a call can write.

Gotchas

  • Throw, do not return strings. A validator that returns a string is treated as void, so the answer is accepted. Throw new Error("...") to reject.
  • Validators run on all-filled, not per-question. In a multi-question interrupt, a validator for question A will not fire until question B is also answered. Design validations accordingly.
  • Validator closures can reference state from the handler. That state is the snapshot at the time the handler ran, not at the time the user answered. If you need the freshest state, do the lookup inside the validator using the value argument plus your own fetch, not the closed-over state.
  • The field is cleared on error, not the whole interrupt. After a failed validation, only the failing field is empty. The user’s other answers remain.