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.
Defining a flow
import { defineFlow } 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,
},
},
session: {
stateSchema: z.object({
messageCount: z.number().default(0),
}),
resources: {
artifacts: { stateSchema: artifactSchema, writable: true },
},
clientData: {
messageCount: (ctx) => ctx.state.messageCount ?? 0,
},
},
user: {
stateSchema: z.object({
preferences: z.object({ theme: z.string().default("light") }).default({}),
}),
},
});
export default chatFlow({ id: "default" });
FlowType vs FlowInstance
defineFlow() returns a FlowType — a factory. Calling it with { id } 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({ id: "default" });
This separation lets you create multiple instances of the same flow type with different configurations if needed.
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
See Actions for the full picture.
Session configuration
Sessions carry state, resources, and clientData that persist across requests in a conversation:
session: {
stateSchema: z.object({
mode: z.enum(["chat", "agent"]).default("chat"),
messageCount: z.number().default(0),
}),
resources: {
plan: {
stateSchema: z.object({
steps: z.array(z.string()).default([]),
status: z.enum(["draft", "active", "complete"]).default("draft"),
}),
writable: true,
},
},
clientData: {
activePlan: (ctx) => ctx.resources.plan?.state ?? null,
},
},
Automatic resource collection
Blocks can declare resource dependencies directly (via sessionResources, userResources, projectResources using defineResource() values). When defineFlow is called, it collects declared resources from all action blocks and merges them into the session/user/project scope configs automatically:
const planManager = handler({
name: "plan-manager",
sessionResources: { plan: planResource },
execute: async (input, ctx) => { /* uses ctx.session.resources.plan */ },
});
const myFlow = defineFlow({
kind: "my-app",
actions: { manage: { block: planManager } },
// session.resources automatically includes { plan: planResource }
// from the block — no need to declare it again here
});
Flow-level resource declarations take priority. If both a block and the flow declare a resource with the same name, the flow's version wins.
See State for details on scopes, resources, and clientData.
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.
Registration
Flows are registered with a server registry to be served via HTTP:
import { createFlowRegistry, createFlowApiRouter } from "@flow-state-dev/server";
const registry = createFlowRegistry();
registry.register(chatFlow);
registry.register(agentFlow);
const router = createFlowApiRouter({ registry });
The registry discovers flows by kind and routes requests automatically. Multiple flows can coexist in the same server.