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();
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:
| Surface | What a mismatched address gets you |
|---|---|
| HTTP action route | 409 wrong-instance-session (or wrong-instance-request when the call names a request id), before the action runs |
| Resume | 404, the same answer as a request that does not exist |
| Retry and continue | 400, naming the instance that does own the request |
| CLI | a non-zero exit, with the session untouched |
| Queue worker | an unrecoverable job failure rather than a retry |
runAction | FlowInstanceBindingMismatchError, 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:
- Input is validated against
inputSchema - Session is resolved or created
userMessage(input)emits a user message item (if defined)- The root block executes asynchronously
- 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 adispatcher({ 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 adispatcher({ 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.