Skip to main content

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​

FieldTypeDefaultWhat it does
kindstringrequiredFlow 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.
configSchemaZodObject—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.
actionsRecord<string, ActionConfig>requiredCaller-addressed entry points (HTTP and, when enabled, MCP).
requireUserbooleantrueShorthand for authentication.requireUser. If both are set, authentication.requireUser wins.
authenticationAuthenticationConfig—Per-flow principal resolution. See Authentication.
sessionSessionConfig—Session state, client projection, retention, history window.
requestRequestConfig—Request-scoped state, lifecycle hooks, heartbeats, concurrency default.
userUserConfig—User-scoped state and client projection.
orgOrgConfig—Org-scoped state and client projection.
resourcesresource map—Flat map of defineResource / collection declarations. Each resource's own scope decides where it persists.
toolsToolsConfig—Default timeout, retry, and lifecycle hooks for tools.
voiceVoiceConfig—Flow-level TTS provider and speak defaults.
mcpMcpConfigoffOpt-in MCP exposure for this flow. Definition-only: you cannot override it on the instance.
webhooksWebhookConfig—Webhook event bindings. Definition-only.
schedulesSchedulesConfig—Static and dynamic scheduled actions. Definition-only.
tokenCounterTokenCounter—Custom token accounting.
costEstimatorCostEstimator—Custom USD cost estimate from model usage.
isolateUserStatebooleanfalseKey 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.
isolateOrgStatebooleanfalseOrg-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:

WhatWhen
A key the schema doesn't declare, at the bag's top levelAt the factory call, naming the flow, the instance id and the key
A key the schema doesn't declare, inside a nested objectRefused 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 rejectsAt the factory call, with the schema's own message
config on a flow with no configSchemaAt 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 coercionAt 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 configAt 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 allWhere the flow is defined, naming the flow and the block
A bag that doesn't satisfy a block's flowConfigSchemaAt 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.

FieldTypeDefaultWhat it does
blockBlockDefinitionrequiredThe block that runs.
inputSchemaZod schemathe block's schemaPublic input surface. Set this when the HTTP/MCP contract should differ from the block (richer .describe(), a narrower public shape).
descriptionstring—Required when the action is MCP-exposed. DevTool uses it for tooltips either way.
userMessage(input) => string—User-visible message recorded for the turn.
concurrencyConcurrencyConfigflow request.concurrency, else "allow"What happens if another request on the same key is in flight. See Concurrency policies.
durablebooleanfalseCheckpoint 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 / onErroredBlockDefinition—Action-level hooks after the root block finishes or throws.
mcpActionMcpConfigexposed when the flow enables MCPPer-action MCP overrides.

action.mcp​

FieldTypeDefaultWhat it does
enabledbooleantrueSet false to keep the action off the MCP surface.
namestringderived from the action key (recordPayment → record_payment)MCP tool name. Must match [A-Za-z0-9_.-]{1,128}.
sessionstring or { fromInput: string }fresh ephemeral session per tools/callHow 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​

FieldTypeDefaultWhat 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).
defaultUserIdstring—Used when the resolver returns no userId. Typical for schedules and webhooks.
requireUserbooleantrueReject 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​

FieldTypeDefaultWhat it does
stateSchemaZod object—Session state shape. Use .nullable().default(null) for fields that start empty.
client{ expose?, derived? }privateWhat crosses to the browser under clientData.session. expose copies named fields verbatim. derived computes named projections from { state, resources }. Names must not collide.
metadataZod 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? }unboundedBounds 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 }50Caps cross-turn history loaded per request. 0 or a negative number disables it. Per-call history({ limit }) can only shrink this window.
casCASOptions—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​

FieldTypeDefaultWhat it does
stateSchemaZod object—Request-scoped state.
onStarted / onCompleted / onErrored / onFinished / onStepErroredBlockDefinition—Request lifecycle hooks.
heartbeatIntervalMsnumber10000Active-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.
sseHeartbeatMsnumber15000SSE : ping cadence. 0 disables.
concurrencyConcurrencyConfig"allow"Default for actions that omit concurrency.
mutationTimeoutMsnumber30000Budget for in-memory state writes. Infinity disables. Scopes that persist — request, session, user, org — are not covered by it.
cleanupCheckpointsOnTerminalbooleanfalseDelete durable sequencer checkpoints when the request finishes.

Tools defaults​

tools.defaults applies to tools on generators in this flow.

FieldTypeDefaultWhat it does
defaults.timeoutMsnumber—Tool timeout.
defaults.retryRetryPolicy—{ maxAttempts?, baseDelayMs?, maxDelayMs?, retryableErrors? }.
onToolStarted / onToolCompleted / onToolErroredhook 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​

FieldTypeDefaultWhat it does
enabledbooleanfalseMount MCP for this flow.
exposeResourcesbooleantrueInclude flow resources in resources/list and resources/read, honoring client.content.read.

See MCP.

schedules​

FieldTypeDefaultWhat it does
staticRecord<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:

FieldTypeDefaultWhat it does
cronPOSIX 5-field stringrequiredDisplay-only. The host scheduler fires; the framework does not.
inputvalue or () => input—Passed to the handler.
principal{ userId, orgId? }gateway principalWho the action runs as.
timezoneIANA string"UTC"Metadata for the host scheduler.
onOverlap"skip" | "allow""skip"What to do if the same schedule id is already running.
descriptionstring—Listing and DevTool.
enabledbooleantrueDisabled 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.

FieldTypeDefaultWhat it does
scope"session" | "user" | "org"requiredWhere state and content persist.
stateSchemaZod objectrequiredResource state shape.
refstringaccessor keyStorage namespace id.
defaultJSON value—Initial state.
flowIsolationbooleanfalsePer-flow keying for user/org resources. Rejected on session scope.
sharedToLineagebooleanfalseSession 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 / llmWritableboolean—Whether generators may read or write the resource.
clientResourceClientConfigomitted — state stays privateOpens 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 / contentTemplatecontent 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​