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.
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.
.addNode("ask_email", ({ interrupt }) =>
  interrupt({
    email: { question: "What's your work email?" },
  }),
)
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.
.addNode("ask_level", ({ interrupt }) =>
  interrupt({
    level: {
      question: "What's your ski level?",
      suggestions: ["beginner", "intermediate", "advanced"],
    },
  }),
)

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({
  email: {
    question: "What's your work email?",
    context: "Politely reject generic domains like gmail.com.",
  },
});
Interrupt-wide context lives in the second argument:
interrupt(
  {
    date: { question: "When would you like to book?" },
  },
  {
    context: "Help the user commit to a relative date. Never suggest past dates.",
  },
);

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.
.addNode("ask_contact", ({ interrupt }) =>
  interrupt(
    {
      name: { question: "What's your name?" },
      email: { question: "What's your email?" },
    },
    { context: "Ask both in one natural message." },
  ),
)
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.
interrupt({
  email: {
    question: "What's your work email?",
    validate: (value) => {
      if (!value.includes("@")) {
        throw new Error("That doesn't look like an email.");
      }
      return { email: value.trim().toLowerCase() };
    },
  },
});
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.
state: {
  driver: z.object({
    name: z.string().describe("Driver name"),
    license: z.string().describe("License number"),
  }),
}

// In a node:
interrupt({
  "driver.name": { question: "What's the driver's name?" },
  "driver.license": { question: "What's their license number?" },
})
Only one level of nesting is supported. See State.

Auto-skip when a field is already filled

If the user’s opening message provides an answer (passed via stateUpdates on action: "start"), the engine skips any interrupt whose fields are already populated. 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 skipped entirely. Values of undefined, null, and "" do not count as filled.

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.