Skip to main content
Every flow starts with a state declaration: a map of field names to Zod schemas. State serves two purposes.
  1. Type inference. The runtime state type is inferred from the Zod map, so ctx.state is fully typed inside every node. No generics to pass.
  2. Model protocol. Field names, types, and .describe() strings are embedded into the compiled tool description. The model uses this to know what data to collect and which values it can pre-fill from the user’s message.

Shape

State is a flat object where each value is a Zod schema.
import { createFlow } from "@waniwani/sdk/mcp";
import { z } from "zod";

const flow = createFlow({
  id: "quote",
  title: "Quote",
  description: "Collect quote inputs.",
  state: {
    postalCode: z.string().describe("ZIP or postal code of the property"),
    squareMeters: z.number().describe("Home size in square meters"),
    heating: z
      .enum(["gas", "electric", "oil", "heat_pump"])
      .describe("Primary heating source"),
  },
});
Inside a node, the type is inferred:
.addNode("summarize", ({ state }) => {
  // state is Partial<{
  //   postalCode: string;
  //   squareMeters: number;
  //   heating: "gas" | "electric" | "oil" | "heat_pump";
  // }>
  return { summary: `${state.postalCode}, ${state.squareMeters}m2` };
})
Always add .describe() to every field. The description is what the model reads when phrasing questions and deciding which values it can pre-fill. It is the most load-bearing piece of documentation your flow ships with.

State is always partial

At any point during execution, only the fields filled by earlier nodes are populated. The engine types ctx.state as Partial<TState>, so always guard when reading fields.
.addNode("review", ({ state }) => {
  if (!state.email || !state.company) {
    return {};
  }
  return { reviewed: true };
})

Field types

Prefer constrained types. The more constrained a field, the better the model fills it.
Field shapeUse
Fixed choicesz.enum([...])
Free-form textz.string()
Bounded numbersz.number().min(0).max(100)
Booleansz.boolean()
Listsz.array(z.string())
Nested group (1 deep)z.object({ ... })

Enums pair well with suggestions

state: {
  level: z
    .enum(["beginner", "intermediate", "advanced"])
    .describe("The user's ski level"),
}
An interrupt that lists the same values as suggestions will lead the model to offer exactly those and validate the answer against the enum.

Nested state

Use z.object() when a sub-entity has multiple fields. Only one level of nesting is supported.
state: {
  driver: z
    .object({
      name: z.string().describe("Driver's full name"),
      licenseNumber: z.string().describe("Driving license number"),
    })
    .describe("Driver details"),
  pickupDate: z.string().describe("Pickup date (YYYY-MM-DD)"),
}
Interrupts target nested fields with dot-paths:
.addNode("ask_driver", ({ interrupt }) =>
  interrupt({
    "driver.name": { question: "What's the driver's name?" },
    "driver.licenseNumber": { question: "What's their license number?" },
  }),
)
Action nodes that return nested objects are deep-merged, so returning { driver: { name: "John" } } does not wipe driver.licenseNumber.
driver.address.city will not work. Flatten deeper hierarchies or handle the sub-object in one question that fills the whole z.object() at once.

Pre-filling from the user’s message

When the user’s opening message already contains answers (for example, “I’m in 75011, quote me a half-day lesson”), the model can pass them in stateUpdates on the very first call:
{ "action": "start", "intent": "...", "stateUpdates": { "postalCode": "75011" } }
The engine auto-skips any node whose target field is already filled (values of undefined, null, or "" do not count as filled). No extra configuration is needed. Just make sure field descriptions are clear enough for the model to map message content to field names.

How state updates happen

Nodes update state in three places:
  1. Action nodes return a plain object that is deep-merged into state.
  2. Interrupts store the user’s answer at the field key used in interrupt({ field: { question } }).
  3. Interrupt validators (see Interrupts) can return additional state updates after a user answer passes validation.
All three paths are typed against the state schema.