Skip to main content

Flows

A flow is the top-level unit — the thing you register with the server and clients connect to. It ties together your blocks, actions, state, resources, and client data into a single, deployable definition.

Think of a flow as the complete specification of an AI-powered feature: what actions users can trigger, what state is tracked, and what data is exposed to the frontend.

Every defineFlow field is tabulated in Flow options. This page teaches the shape; that page is the lookup.

Defining a flow​

import { defineFlow, defineResource } from "@flow-state-dev/core";
import { z } from "zod";

const chatFlow = defineFlow({
kind: "my-chat", // Unique identifier — becomes the URL path
requireUser: true, // Require userId on every request

actions: {
chat: {
inputSchema: z.object({ message: z.string() }),
block: chatPipeline,
userMessage: (input) => input.message,
},
reset: {
inputSchema: z.object({}),
block: resetHandler,
},
},

resources: {
artifacts: defineResource({
scope: "session",
stateSchema: artifactSchema,
writable: true,
}),
},

session: {
stateSchema: z.object({
messageCount: z.number().default(0),
}),
client: {
expose: ["messageCount"],
},
},

user: {
stateSchema: z.object({
preferences: z.object({ theme: z.string().default("light") }).default({}),
}),
},
});

export default chatFlow();

FlowType vs FlowInstance​

defineFlow() returns a FlowType, a factory. Calling it creates a FlowInstance, the thing you actually register with the server:

// FlowType — the blueprint
const chatFlow = defineFlow({ kind: "my-chat", ... });

// FlowInstance — what you register and deploy
export default chatFlow();
Definition, instance, session: where each lives
Definition, instance and session, and where each lives. defineFlow returns a definition in your code: its kind, its cardinality, the configSchema a copy's settings must match, the webhooks, schedules and MCP entry points every copy serves, and the entries only the flow can call; it answers no request itself. Calling it makes an instance with its own id, settings and resources, and its own isolated state when set, such as review-east and review-west of kind review, which you register; a singleton's id is its kind, and the settings stay in the process, never listed or stored. Requests name an instance id, and the kind is never a fallback. Sessions live in the store: session chat-1 records flowId review-east as its owner and flowKind review only as a label for listings. Named through review-west, it is refused with 409 wrong-instance-session before anything runs01 · DESCRIBESYour codethe definition02 · ANSWERSRegistered instancesin the process · requests name the id03 · REMEMBERSYour storekept across restarts whenpersistentdefineFlow()kind: "review"cardinality · configSchemawebhooks · schedules · mcpentries only the flowcan callShared by every instance.Answers no request itself.Requests name an instanceid; the kind is never afallback.review-eastconfig: { region: "east" }resources: { rubric: east }Its own isolated state,when set. Settings stay inthe process. Never listed,never stored.review-westSame kind; its own id,settings, resources andisolated stateA singleton's id is its kind.Session chat-1flowId: "review-east"its ownerflowKind: "review"a label for listings,not an addressIts state and its requestsNamed through review-west:409 wrong-instance-sessionrefused before anythingruns01020301Calling defineFlow makes an instance: the definition plus an id and settings. The definition is unchanged andshared.02A session belongs to one instance. Two instances of one kind never share a session.03Named through another instance, the session is refused with 409 wrong-instance-session before anything runs.04Settings are not stored. A request resumed after a deploy runs on the settings the new process started with.REALREFUSEDOUTLIVES THE REQUEST

A definition describes a flow. A registered instance names one configured copy of it. Most flows have exactly one copy, and that is the default: a flow is a singleton unless you say otherwise, and its instance id is its kind. Calling the factory with no argument gives you that instance; chatFlow({ id: "my-chat" }) is the same thing spelled out, and registering a singleton under any other id is refused.

To run several configured copies of one definition on one server, declare the definition a collection and give every instance its own id:

const reviewFlow = defineFlow({
kind: "review",
cardinality: "collection",
resources: { rubric: rubricResource },
actions: { run: { block: review } },
});

export const east = reviewFlow({ id: "review-east", resources: { rubric: eastRubric } });
export const west = reviewFlow({ id: "review-west", resources: { rubric: westRubric } });

A collection instance has to be given an id. Ids are unique across the whole registry, whatever their kind, so registering two instances with the same id throws at registration time.

Copies that differ by settings​

Two copies of one definition can be set up differently. The definition declares what a copy may be given with configSchema; each copy is created with a bag of values matching it, and every block inside that copy reads them from ctx.flow.config.

The line to hold: anything a copy is given goes in the bag; anything a copy learns goes in its own storage. A worker's harness and model are given. Its current task and its counters are learned — those belong in isolated state, because the bag is fixed when the copy is created and frozen for the copy's life.

import { z } from "zod";

const workerConfig = z.object({
harness: z.enum(["claude-code", "codex", "cursor"]),
model: z.string(),
personaPath: z.string().optional(),
});

const engineerWork = handler({
name: "engineer-work",
// What this block needs of whatever flow installs it.
flowConfigSchema: workerConfig,
execute: async (input, ctx) => {
const { harness, model } = ctx.flow.config;
// ...
},
});

const engineer = defineFlow({
kind: "engineer",
cardinality: "collection",
configSchema: workerConfig,
actions: { work: { block: engineerWork } },
});

export const alice = engineer({ id: "eng-alice", config: { harness: "claude-code", model: "opus" } });
export const bob = engineer({ id: "eng-bob", config: { harness: "codex", model: "gpt-6.1-sol" } });

One definition, one block graph, two registered copies that behave differently. A roster mints the rest in a loop — engineer({ id: worker.id, config: worker.config }) per row — with no graph rebuilt.

A copy carries only settings the definition declared. A key that isn't in the schema throws where the copy is created, naming the flow, the id and the key:

engineer({ id: "eng-carol", config: { harnes: "codex", model: "gpt-6.1-sol" } })
// Flow "engineer" instance "eng-carol" has an invalid config bag:
// "harnes" is not a declared setting.

Keys are closed at the top level of the bag. A nested object is closed only if you close it, so add .strict() to any nested shape you want typos refused in:

const workerConfig = z.object({
harness: z.string(),
limits: z.object({ retries: z.number().default(1) }).strict(),
});

engineer({ id: "eng-carol", config: { harness: "codex", limits: { retrys: 3 } } });
// Without .strict() on `limits`, `retrys` is dropped and `retries` quietly stays 1.

Without it, an undeclared key inside a nested object is dropped rather than refused whenever that object's own keys are all optional or defaulted — the setting goes missing rather than coming out wrong, but nothing tells you.

The schema must be a plain z.object({ ... }) — not a union, an intersection, or an object wrapped in .refine(). It also cannot carry a .catchall(...), which would accept and keep undeclared keys. A rule spanning two settings belongs in the block that reads them.

Omitting config doesn't skip the schema: the empty bag is parsed, so defaults apply and a required setting throws. A flow whose settings all have defaults can be registered bare, and reads those defaults. A flow with a required setting has to be minted with a bag before you register it.

The values stay in the process. They are not on the flow listing, not in DevTool, and not written to any store, so a request that resumes after a deploy runs on the settings the process was started with.

Blocks and their flowConfigSchema are covered in Blocks; the field-by-field reference is in Flow configuration.

How an instance is addressed​

Every entry point reaches an instance by its id. The HTTP action routes, the CLI, webhooks, schedules, MCP, and a queue worker all carry the id and nothing else.

For a singleton the id is the kind: POST /api/flows/my-chat/actions/send, fsdev run my-chat send. For a collection it is the id you registered: POST /api/flows/review-east/actions/run. The kind of a collection is not an address — POST /api/flows/review/... is a miss, not a fallback to whichever copy was registered first.

Route templates across these docs write the segment that carries the id as :flowId; some, following the router's own parameter name, write :flowKind. The value is the instance id either way.

The kind still says what an instance is. Sessions and requests record it alongside the id, listings group by it, and the definition-level transports below apply to every instance of the definition.

A session belongs to the instance that created it, as the figure above shows. One started through review-east is refused when a later call names it through review-west, and each entry point refuses it in its own way:

SurfaceWhat a mismatched address gets you
HTTP action route409 wrong-instance-session (or wrong-instance-request when the call names a request id), before the action runs
Resume404, the same answer as a request that does not exist
Retry and continue400, naming the instance that does own the request
CLIa non-zero exit, with the session untouched
Queue workeran unrecoverable job failure rather than a retry
runActionFlowInstanceBindingMismatchError, with nothing written

Persistence covers what is recorded, and how to attribute records that name no owner.

What the definition owns​

Inbound transports belong to the flow type. Declare webhooks, schedules, and mcp on defineFlow():

const supportFlow = defineFlow({
kind: "support",
webhooks: { /* ... */ },
schedules: { /* ... */ },
actions: { /* ... */ },
});

export default supportFlow();

Every instance of a type serves the same transports. Pass one of the three to the instance call and TypeScript rejects it; a plain-JavaScript caller gets a thrown error naming the option. The internal and task entry maps described below are definition-only in the same way, and so is configSchema — the definition says what a copy may carry, and each copy supplies the values.

Everything else is settable per instance: id, kind, config, actions, session, request, user, org, resources, tools, voice, authentication, requireUser, tokenCounter, costEstimator, isolateUserState, and isolateOrgState.

voice sits on both sides. Unlike the three transports above, you can set it on defineFlow() as the default for every instance of the type, then override it on any single instance.

Actions — the flow's public API​

Each action is an entry point that maps a name to an input schema and a root block. Clients call actions by name — that's the only way to trigger execution.

actions: {
chat: {
inputSchema: z.object({ message: z.string() }),
block: chatPipeline,
userMessage: (input) => input.message,

// Lifecycle hooks — observe, don't control
onCompleted: async (result, ctx) => { /* succeeded */ },
onErrored: async (error, ctx) => { /* failed */ },
},
reset: {
inputSchema: z.object({}),
block: resetHandler,
},
},

When an action executes:

  1. Input is validated against inputSchema
  2. Session is resolved or created
  3. userMessage(input) emits a user message item (if defined)
  4. The root block executes asynchronously
  5. Lifecycle hooks fire on completion or error

A block can also stop another request running in its own session, the way the abort route does from outside. It's how an action that takes a message can make room for it:

const outcome = await ctx.session.stopRequest(requestId);
// "stopped" | "already-finished" | "already-resolved" | "not-in-this-session"

The stop is recorded on the request, so it reaches a request running in another process on that process's next heartbeat, exactly like an abort. A request paused at ctx.suspend() can be stopped too, on a runtime with durable execution: it ends aborted. That holds even when someone already approved it, if the request hasn't run again yet (say its process restarted in between): the stop wins, and the approved step never runs. The approval itself isn't kept: the request records only that it was stopped. "already-resolved" means it was paused, but something resumed it before the stop arrived, so it is running again; stop it again to stop the running request. "not-in-this-session" is the answer for any id outside the session, including one that doesn't exist, so the call can't be used to learn about other sessions' requests.

See Actions for the full picture.

Entries only the flow can reach​

Beside actions, a flow can declare two more maps of entries. Each entry has the same shape as an action, { block, inputSchema?, concurrency?, onCompleted?, onErrored?, userMessage? }, minus the client-facing description and mcp, and no client, MCP server, or HTTP caller can invoke one.

const documents = defineFlow({
kind: "documents",
actions: {
upload: { block: summarizeInBackground }, // a dispatcher()
},
internal: {
actions: {
summarize: { block: summarizeDocument }, // reached by a dispatcher() in this flow
},
},
task: {
actions: {
implement: { block: implementBlock }, // reached by a task-board worker in this flow
},
},
});
  • internal.actions — reached by a dispatcher({ action }) block running in this flow, which sends the work to a session of its own (a dispatch run) or to one that already exists. See Starting a job from a flow.
  • task.actions — reached by a dispatcher({ action, session }) sitting as a worker on a task board, which hands each claimed task to a dispatch run. See Workers that dispatch.

Each map is looked up on its own. An action named summarize does not satisfy a dispatcher whose action is the internal entry summarize, and defineFlow() throws at definition time when a reachable dispatcher names an entry its map does not hold. Both maps nest under actions; the flat spelling internal: { summarize: { block } } is refused by name.

Reads: looking without storing​

An app that polls a session, say to keep a task list on screen, shouldn't leave a request behind every few seconds. A flow's reads are for that. Each one runs a block on the session and hands back its output, and nothing is stored: no request record, no item, no history entry.

const board = defineFlow({
kind: "board",
actions: { createTask: { block: createTask } },
reads: {
tasks: { block: listTasks },
},
});

// In the app
const { tasks } = await client.flow("board").read("tasks", {}, { sessionId });

A read sees the session, its resources, and its user the same way an action does, and it is fenced the same way: another user's session answers as one that doesn't exist. What it can't do is change anything. A resource write, a session state write, or a dispatch inside a read fails with read_only. If the thing you want to run should change state or show up in the session's history, make it an action.

Session configuration​

Sessions carry state and a client block that persist across requests in a conversation. Resources live on the flow as a flat resources map — each resource's own scope decides where it is stored:

import { defineFlow, defineResource } from "@flow-state-dev/core";
import { z } from "zod";

defineFlow({
kind: "my-app",
resources: {
plan: defineResource({
scope: "session",
stateSchema: z.object({
steps: z.array(z.string()).default([]),
status: z.enum(["draft", "active", "complete"]).default("draft"),
}),
writable: true,
}),
},
session: {
stateSchema: z.object({
mode: z.enum(["chat", "agent"]).default("chat"),
messageCount: z.number().default(0),
}),
client: {
derived: {
activePlan: (ctx) => ctx.resources.plan?.state ?? null,
},
},
},
actions: { /* ... */ },
});

History windowing​

session.historyWindow bounds how much cross-turn history each request loads:

session: {
historyWindow: { turns: 50 },
},

turns (default 50) caps the number of recent completed turns the execution context loads per request, so per-turn cost stays flat as a conversation grows. A generator's history slot and ctx.session.items.history() see at most this many turns; a per-call history({ limit }) narrows within the window but cannot widen it. The full session remains retrievable through the state endpoint. See Flow-level history bounds.

Automatic resource collection​

Blocks can declare resource dependencies directly with a flat resources map of defineResource() values. When defineFlow is called, it collects declared resources from all action blocks, including blocks a generator lists in its tools array, and merges them into the flow's resources map:

const planManager = handler({
name: "plan-manager",
resources: { plan: planResource },
execute: async (input, ctx) => { /* uses ctx.resources.plan */ },
});

const myFlow = defineFlow({
kind: "my-app",
actions: { manage: { block: planManager } },
// resources automatically includes { plan: planResource }
// from the block — no need to declare it again here
});

If both a block and the flow declare a resource with the same name, they must pass the same defineResource() object (the one the block imported), not a second definition with the same config. A different definition under that name throws when the flow is defined, so a block never reads from a resource it didn't ask for.

See State for details on scopes, resources, and the client block.

Lifecycle hooks​

Observe execution at both the action and request level:

// Action-level
actions: {
chat: {
onCompleted: async (result, ctx) => { /* action succeeded */ },
onErrored: async (error, ctx) => { /* action failed */ },
},
},

// Request-level
request: {
onStarted: async (ctx) => { /* request began */ },
onCompleted: async (ctx) => { /* request succeeded */ },
onErrored: async (error, ctx) => { /* request failed */ },
onFinished: async (ctx) => { /* always fires, success or failure */ },
onStepErrored: async (error, ctx) => { /* non-terminal step failure */ },
},

Hooks are observational — they run after the fact and can't modify the result. The past-tense naming (onCompleted, not onComplete) makes this intent clear.

request.concurrency sets a flow-wide default concurrency policy that every action inherits unless it declares its own. See Concurrency policies.

Block-level completion (onCompleted)​

Individual blocks can also declare an onCompleted callback on their config. This is distinct from the action-level and request-level hooks shown above, which are lifecycle observers run by the request executor. A block-level onCompleted fires immediately after the block's execute succeeds:

generator({
name: "my-gen",
model: "intent/chat",
prompt: "...",
onCompleted: async (output, ctx, meta) => {
// meta.model carries the resolved ModelIdentity (generators only)
},
});

For generators, meta is a GeneratorCompletedMeta carrying { model: ModelIdentity } — the concrete model that produced the output. Other block kinds receive only (output, ctx) with no meta argument. See models — reading the resolved model at completion time for a worked example projecting the model into session state.

Registration​

Flows are registered with a server registry to be served via HTTP:

import { createFlowRegistry, createFlowApiRouter } from "@flow-state-dev/engine";

const registry = createFlowRegistry();
registry.register(chatFlow);
registry.register(agentFlow);

const router = createFlowApiRouter({ registry });

The registry indexes instances by id and routes requests to the one an address names. Several flows, and several instances of a collection flow, can coexist in the same server. See createFlowRegistry for how lookup, misses, and duplicate ids behave.