Flow options
defineFlow({ ... }) returns a factory. Call the factory (defineFlow({ ... })()) to get a registerable instance. The definition is the contract; the instance is what you pass to createFlowState.
By default a definition yields one instance, whose id is its kind; the factory takes no id, or the kind spelled out. A definition declared cardinality: "collection" yields as many instances as you register, and each call must supply an id. See Flows for when you want more than one copy.
import { defineFlow, generator } from "@flow-state-dev/core";
import { z } from "zod";
const inputSchema = z.object({ message: z.string() });
export default defineFlow({
kind: "support",
requireUser: true,
actions: {
chat: {
inputSchema,
block: generator({
name: "chat",
model: "intent/chat",
prompt: "You answer support questions.",
inputSchema,
history: true,
user: (input) => input.message,
itemVisibility: { client: true, history: true },
}),
userMessage: (input) => input.message,
},
},
session: {
stateSchema: z.object({ ticketId: z.string().nullable().default(null) }),
client: { expose: ["ticketId"] },
historyWindow: { turns: 50 },
},
})();
Narrative: Flows, Actions, State and scopes.
defineFlow fields
| Field | Type | Default | What it does |
|---|---|---|---|
kind | string | required | Flow type id. For a singleton flow it is also the instance id and the URL segment /api/flows/:flowId. |
cardinality | "singleton" | "collection" | "singleton" | How many instances the definition can register. A singleton's instance id is its kind and a custom id is refused. A collection instance must be given an id, and only that id addresses it. Definition-only. |
configSchema | ZodObject | — | What a copy of this flow may be created with. The bag passed as config is parsed against it and frozen; blocks read it as ctx.flow.config. Must be a plain z.object({ ... }). Definition-only. |
actions | Record<string, ActionConfig> | required | Caller-addressed entry points (HTTP and, when enabled, MCP). |
requireUser | boolean | true | Shorthand for authentication.requireUser. If both are set, authentication.requireUser wins. |
authentication | AuthenticationConfig | — | Per-flow principal resolution. See Authentication. |
session | SessionConfig | — | Session state, client projection, retention, history window. |
request | RequestConfig | — | Request-scoped state, lifecycle hooks, heartbeats, concurrency default. |
user | UserConfig | — | User-scoped state and client projection. |
org | OrgConfig | — | Org-scoped state and client projection. |
resources | resource map | — | Flat map of defineResource / collection declarations. Each resource's own scope decides where it persists. |
tools | ToolsConfig | — | Default timeout, retry, and lifecycle hooks for tools. |
voice | VoiceConfig | — | Flow-level TTS provider and speak defaults. |
mcp | McpConfig | off | Opt-in MCP exposure for this flow. Definition-only: you cannot override it on the instance. |
webhooks | WebhookConfig | — | Webhook event bindings. Definition-only. |
schedules | SchedulesConfig | — | Static and dynamic scheduled actions. Definition-only. |
tokenCounter | TokenCounter | — | Custom token accounting. |
costEstimator | CostEstimator | — | Custom USD cost estimate from model usage. |
isolateUserState | boolean | false | Key user state (and the default for user resources) per flow instance — each named copy of a definition gets its own. A resource's own flowIsolation always wins. |
isolateOrgState | boolean | false | Org-scope equivalent of isolateUserState. |
mcp, webhooks, schedules, and configSchema belong on the definition. Passing them to the factory call (defineFlow({ ... })({ mcp: ... })) is rejected.
Instance settings
The factory call takes config, alongside id and the per-instance overrides:
const engineer = defineFlow({
kind: "engineer",
cardinality: "collection",
configSchema: z.object({ harness: z.string(), model: z.string(), retries: z.number().default(1) }),
actions: { /* ... */ },
});
export const alice = engineer({ id: "eng-alice", config: { harness: "codex", model: "gpt-5.4" } });
What refuses, and when:
| What | When |
|---|---|
| A key the schema doesn't declare, at the bag's top level | At the factory call, naming the flow, the instance id and the key |
| A key the schema doesn't declare, inside a nested object | Refused only if that nested shape is written .strict(). Otherwise dropped, unless one of the nested object's own keys is required — then the parse fails on the missing key |
| A value the schema rejects | At the factory call, with the schema's own message |
config on a flow with no configSchema | At the factory call |
A configSchema that isn't a plain z.object({ ... }) — a union, an intersection, or an object wrapped in .refine() | Where the flow is defined. A rule spanning two settings belongs in the block that reads them |
A configSchema carrying a .catchall(...) | Where the flow is defined. A catchall accepts and keeps undeclared keys, which is the opposite of what a bag is. Put open-ended data in one declared key whose own schema is a record |
A block's flowConfigSchema that would change the bag — a .default(), a .transform(), a coercion | At the factory call, naming the block and the keys. A block declares what it needs of the flow, not what it contributes; put the default on the flow's configSchema |
A required setting, when the call omits config | At the factory call. The empty bag is parsed, so defaults apply and required settings do not |
A block declaring flowConfigSchema on a flow with no configSchema at all | Where the flow is defined, naming the flow and the block |
A bag that doesn't satisfy a block's flowConfigSchema | At the factory call, naming the flow, the instance id and the block |
The two schemas
configSchema on the flow defines the bag: it says what a copy may carry, and it is what parses and
freezes the values. flowConfigSchema on a block says what that block needs of any flow it is
installed on — it types the block's read and makes the flow refuse when it cannot supply it. They
coexist; neither replaces the other. See Blocks.
The given-versus-learned line, and when to reach for a copy rather than a second flow, are in Flows.
Actions
Each key in actions is a public name. Clients call it with sendAction("chat", { message }) or POST /api/flows/:kind/actions/chat.
| Field | Type | Default | What it does |
|---|---|---|---|
block | BlockDefinition | required | The block that runs. |
inputSchema | Zod schema | the block's schema | Public input surface. Set this when the HTTP/MCP contract should differ from the block (richer .describe(), a narrower public shape). |
description | string | — | Required when the action is MCP-exposed. DevTool uses it for tooltips either way. |
userMessage | (input) => string | — | User-visible message recorded for the turn. |
concurrency | ConcurrencyConfig | flow request.concurrency, else "allow" | What happens if another request on the same key is in flight. See Concurrency policies. |
durable | boolean | false | Checkpoint at step boundaries and allow ctx.suspend(). Needs durable: true on createFlowState. |
tokenBudget | { maxTotalTokens, warnAt?, onExceeded? } | — | Cap tokens for the action. onExceeded is "error", "stop", or "warn". |
onCompleted / onErrored | BlockDefinition | — | Action-level hooks after the root block finishes or throws. |
mcp | ActionMcpConfig | exposed when the flow enables MCP | Per-action MCP overrides. |
action.mcp
| Field | Type | Default | What it does |
|---|---|---|---|
enabled | boolean | true | Set false to keep the action off the MCP surface. |
name | string | derived from the action key (recordPayment → record_payment) | MCP tool name. Must match [A-Za-z0-9_.-]{1,128}. |
session | string or { fromInput: string } | fresh ephemeral session per tools/call | How the adapter picks a flow sessionId. A string is a mint template (* → random token). { fromInput } reads a field from the tool input. The principal still comes from resolvePrincipal. |
Authentication
| Field | Type | Default | What it does |
|---|---|---|---|
resolvePrincipal | (ctx) => principal | null | — | Map the inbound request to { userId?, orgId }. The orgId is required — returning none, a blank one, one that is not well-formed Unicode (a lone UTF-16 surrogate), or DEFAULT_ORG_ID is refused with 401. Throw a PrincipalResolutionError to pick the HTTP status (401/403). |
defaultUserId | string | — | Used when the resolver returns no userId. Typical for schedules and webhooks. |
requireUser | boolean | true | Reject requests that still have no userId after the fallback. false forbids user-scoped state, client projections, and resources at registration. |
The host verifies credentials. The framework applies defaultUserId and requireUser after your resolver returns. See Authentication.
Session, user, and org
user and org accept stateSchema, cas, and client (same shape as session, minus retention and history).
session
| Field | Type | Default | What it does |
|---|---|---|---|
stateSchema | Zod object | — | Session state shape. Use .nullable().default(null) for fields that start empty. |
client | { expose?, derived? } | private | What crosses to the browser under clientData.session. expose copies named fields verbatim. derived computes named projections from { state, resources }. Names must not collide. |
metadata | Zod schema | — | Declares the session metadata shape (title, tags, and so on) for typing. Not enforced at runtime today: neither the session-metadata route nor ctx.session.setMetadata() parses against it, so a value outside the schema is persisted unchanged. |
retention | { maxItems?, maxAge? } | unbounded | Bounds the persisted item log. maxAge is milliseconds or a duration string ("7d"). Oldest completed requests evict first. A request stays until its run has finished, onFinished and any background work it started included, and for twice the configured LIVE_TAIL_LIVENESS_MS after (one minute by default), so a busy session can briefly exceed its limits; a request kept this way still counts toward maxItems, so older history is evicted in its place. Deleting a request also removes its stream events and cached step results. See Deleting requests for the edge cases and what a custom store must support. |
historyWindow | { turns: number } | 50 | Caps cross-turn history loaded per request. 0 or a negative number disables it. Per-call history({ limit }) can only shrink this window. |
cas | CASOptions | — | Optimistic-concurrency options for this scope. |
clientData is not a scope config key. defineFlow throws if a scope sets it: compute functions go under client.derived, verbatim passthrough under client.expose.
request
| Field | Type | Default | What it does |
|---|---|---|---|
stateSchema | Zod object | — | Request-scoped state. |
onStarted / onCompleted / onErrored / onFinished / onStepErrored | BlockDefinition | — | Request lifecycle hooks. |
heartbeatIntervalMs | number | 10000 | Active-request heartbeat. 0 disables the heartbeat and cross-process abort delivery to a running request. Each run still checks once for a cancellation as it starts. |
sseHeartbeatMs | number | 15000 | SSE : ping cadence. 0 disables. |
concurrency | ConcurrencyConfig | "allow" | Default for actions that omit concurrency. |
mutationTimeoutMs | number | 30000 | Budget for in-memory state writes. Infinity disables. Scopes that persist — request, session, user, org — are not covered by it. |
cleanupCheckpointsOnTerminal | boolean | false | Delete durable sequencer checkpoints when the request finishes. |
Tools defaults
tools.defaults applies to tools on generators in this flow.
| Field | Type | Default | What it does |
|---|---|---|---|
defaults.timeoutMs | number | — | Tool timeout. |
defaults.retry | RetryPolicy | — | { maxAttempts?, baseDelayMs?, maxDelayMs?, retryableErrors? }. |
onToolStarted / onToolCompleted / onToolErrored | hook or block | — | Observe tool lifecycle. Cache hits still fire started/completed; errors are never cached. |
A generator can override these with flowTools.
Inbound transports
These maps live on the flow definition. The matching adapter has to be mounted on the runtime for anything to listen. See Engine setup and the transport pages linked below.
mcp
| Field | Type | Default | What it does |
|---|---|---|---|
enabled | boolean | false | Mount MCP for this flow. |
exposeResources | boolean | true | Include flow resources in resources/list and resources/read, honoring client.content.read. |
See MCP.
schedules
| Field | Type | Default | What it does |
|---|---|---|---|
static | Record<id, ScheduleConfig> | — | Cron entries looked up by id first. |
resolve | (id, ctx) => ScheduleConfig | null | — | Dynamic lookup when static[id] is missing. Return null to 404. |
Each ScheduleConfig extends the action core (block, inputSchema, hooks, durable, …) plus:
| Field | Type | Default | What it does |
|---|---|---|---|
cron | POSIX 5-field string | required | Display-only. The host scheduler fires; the framework does not. |
input | value or () => input | — | Passed to the handler. |
principal | { userId, orgId? } | gateway principal | Who the action runs as. |
timezone | IANA string | "UTC" | Metadata for the host scheduler. |
onOverlap | "skip" | "allow" | "skip" | What to do if the same schedule id is already running. |
description | string | — | Listing and DevTool. |
enabled | boolean | true | Disabled static schedules list but 404 on dispatch. |
See Scheduled actions.
webhooks
Each provider's webhooks.<provider>.on maps event keys to a binding that extends the action core (block plus execution policy). They are not entries in actions. See Webhooks.
voice
voice.provider sets the TTS/STT implementation for this flow, and voice.tts carries the speak defaults (model, voice, speed). Those three are catalogued with the rest of the voice surface on Voice.
Resources
Declare resources on the flow (or on a block / capability). scope on defineResource decides the storage layer.
| Field | Type | Default | What it does |
|---|---|---|---|
scope | "session" | "user" | "org" | required | Where state and content persist. |
stateSchema | Zod object | required | Resource state shape. |
ref | string | accessor key | Storage namespace id. |
default | JSON value | — | Initial state. |
flowIsolation | boolean | false | Per-flow keying for user/org resources. Rejected on session scope. |
sharedToLineage | boolean | false | Session resources resolve against the lineage root, so a session and every session dispatched under it read and write one copy. Session-scope only. |
prefetchMode | "eager" | "lazy" | "eager" | When the runtime loads the resource. |
llmReadable / llmWritable | boolean | — | Whether generators may read or write the resource. |
client | ResourceClientConfig | omitted — state stays private | Opens the resource to clients. For a single resource, declaring expose, exclude, or data (mutually exclusive) is the opt-in; a client carrying only content keeps state private. Collections gate state on state.read and ship the full item state when no projection is set. See Client access. |
content / contentFile / contentTemplate | content source | — | Mutually exclusive ways to supply a body. |
Collections add pattern, maxInstances, eviction ("none" | "lru" | "oldest"), and create/delete hooks. See Resources and Collections.
See also
- Block options — generator, handler, sequencer, router fields
- Runtime options —
createFlowState - Concurrency policies
- Flow isolation — including the two-copy example. There is no separate flag for sharing between copies:
flowIsolationis the one control, and it is per resource. - Durable execution