> ## Documentation Index
> Fetch the complete documentation index at: https://docs.waniwani.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# State & Persistence

> Typed fields collected by the flow, persisted via the pluggable KvStore interface.

This page covers the state schema: the typed fields a flow collects. A flow also needs a place to persist that state between MCP calls. See [Persistence](/sdk/flows/kv-store), which is required for any flow to work.

## State schema

Every flow declares `state` as a map of field names to Zod schemas. The runtime state type is inferred from this map, so `ctx.state` is fully typed inside every node. Field descriptions also feed the model's tool description, so it knows what to collect.

```ts theme={null}
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:

```ts theme={null}
.addNode({
  id: "summarize",
  run: ({ state }) => {
    // state is Partial<{
    //   postalCode: string;
    //   squareMeters: number;
    //   heating: "gas" | "electric" | "oil" | "heat_pump";
    // }>
    return { summary: `${state.postalCode}, ${state.squareMeters}m2` };
  },
})
```

<Tip>
  Always add `.describe()` to every field. It is what the model reads when phrasing questions and deciding which values it can pre-fill. The most load-bearing piece of documentation your flow ships with.
</Tip>

## 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.

```ts theme={null}
.addNode({
  id: "review",
  run: ({ 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 shape           | Use                          |
| --------------------- | ---------------------------- |
| Fixed choices         | `z.enum([...])`              |
| Free-form text        | `z.string()`                 |
| Bounded numbers       | `z.number().min(0).max(100)` |
| Booleans              | `z.boolean()`                |
| Lists                 | `z.array(z.string())`        |
| Nested group (1 deep) | `z.object({ ... })`          |

### Enums pair well with suggestions

```ts theme={null}
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.

```ts theme={null}
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:

```ts theme={null}
.addNode({
  id: "ask_driver",
  run: ({ 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`.

<Warning>
  `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.
</Warning>

## 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:

```json theme={null}
{ "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](/sdk/flows/interrupts)) can return additional state updates after a user answer passes validation.

All three paths are typed against the state schema.
