Skip to main content

Anatomy of a Flow

This guide gives you a mental map of how a flow-state-dev application fits together. No code to build. Just concepts, connections, and orientation.

If you're new to the framework, read this first. It explains what the pieces are and why they exist.

1. Blocks are the building units​

Everything executable in flow-state-dev is a block. There are exactly five kinds:

  • Handler — Pure logic. Validates input, transforms data, mutates state. No LLM. Think of it as a function that can read and write scope state. Handlers are silent by default: they don't emit messages or components unless you call ctx.emit.message() or similar.
  • Generator — LLM calls. The framework handles prompt assembly, tool loops, streaming, and output parsing. Set itemVisibility when those streamed messages should reach the client or the next-turn history.
  • Evaluator — Asks a model questions whose possible answers you already know: which option, what score, yes or no. It returns typed answers your code can branch on.
  • Sequencer — Composes blocks into pipelines. Chains steps, runs them in parallel, adds error recovery. The composition primitive. A sequencer is itself a block, so you can nest sequencers inside other sequencers or pass them to routers.
  • Router — Selects one block at runtime based on input or state. Mode switching, intent routing, conditional flows. The router's execute function returns the block to run. The framework then executes that block with the router's input.

All blocks share the same contract: input in, output out. Any block composes with any other. You do not need different composition rules for model calls versus ordinary functions.

2. Sequencers compose blocks​

A sequencer chains blocks. Each step's output becomes the next step's input.

const pipeline = sequencer({ name: "chat-pipeline", inputSchema })
.step(chatGen) // output: assistant message
.tap(counter); // counter only mutates state, so the chain attaches it as a tap

The order matters. In this example, the generator produces the response. The counter runs after, incrementing session state. Because it has no transformation, we attach it with .tap() so the assistant message flows through as the pipeline's result. Data flows in one direction.

Sequencers also support conditional steps (stepIf), parallelism (parallel), loops (doUntil, doWhile), and error recovery (rescue). You can branch to different blocks based on conditions.

3. Flows tie it together​

A flow is the deployable unit. It bundles blocks, state, and client visibility into one registerable object.

  • kind — The identifier. Becomes the URL path (/api/flows/hello-chat/...). Clients use this to target the right flow.
  • actions — Entry points. Each action maps to a root block. When a client calls sendAction("chat", { message: "Hi" }), the framework looks up the "chat" action, validates the input, and runs its block.
  • State schemas — For request, session, user, and org scopes. Blocks declare partial schemas; the flow merges them into full scope contracts.
  • Resources — Named, typed data stores attached to scopes. Blocks can declare resource dependencies; the flow wires them up.
  • client block — The privacy gateway. Each scope's client block declares what state crosses to the browser via expose (verbatim field names) and derived (computed projections). State without a client entry stays on the server.

A minimal flow:

defineFlow({
kind: "my-app",
requireUser: true,
actions: {
chat: { inputSchema, block: chatPipeline, userMessage: (i) => i.message },
},
session: { stateSchema, client: { expose: ["count"] } },
});
Definition, instance, session: where each lives
Definition, instance and session, and where each lives. defineFlow returns a definition in your code. Calling it makes an instance with its own id, which you register with the server. Sessions live in the store, and each records the instance that owns it. A request that names a session through a different instance is refused 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

defineFlow returns a flow type. Call it, with no arguments, to produce the instance you register with the server. With no arguments, the instance's id is its kind, and requests address it by that id. Instances take merge-based overrides if you need to swap actions or config at creation time, and a definition that needs several configured copies declares that on itself — see Flows.

4. Actions are the public API​

Clients invoke actions by name. The framework:

  1. Validates input against the action's inputSchema — Zod validates before any block runs. Invalid input returns a validation error without touching your blocks.
  2. Resolves or creates a session — If the client sends a sessionId, the framework loads that session. Otherwise it creates an ephemeral one (or a persisted one, depending on flow config). Sessions carry state and items across requests.
  3. Executes the root block — The block runs in an execution context with access to scopes, emission, and model resolution. Items stream out as blocks emit them.
  4. Streams results via SSE — The client opens a stream for the requestId returned in the POST response. Events arrive in order with sequence numbers.
  5. Persists state when the run completes — When the block finishes, the framework commits scope mutations, fires lifecycle hooks, and marks the request complete.

The HTTP flow: POST /api/flows/:flowId/actions/:action (or with :sessionId for an existing session). The server returns 202 Accepted immediately with a requestId. Execution happens asynchronously. The client connects to the SSE stream for that requestId to receive items and deltas in real time.

5. State lives in scopes​

Four scopes, each its own record with typed atomic operations:

The four state scopes
Request, session, user and org state are four separate records, none inside another. Request state belongs to one action run and the next request cannot see it. Session state belongs to one conversation, keyed by the session id and by the tenant when one is sent. User state follows one user across conversations and flows inside one organization, keyed by the user and the organization, or one record per flow copy with isolateUserState. Org state is shared by everyone in the organization, keyed by the org id, or one record per flow copy with isolateOrgState. A session names its user and org but holds neither one's state. Blocks read and write all four; the browser sees only the fields a scope's client config exposes4 SEPARATE RECORDS · NONE SITS INSIDE ANOTHERSCOPE 01Requestone action runctx.requestScratch space betweenthe blocks of one runKEYthe requestThe next requestcannot see it.SCOPE 02Sessionone conversationctx.sessionIts state, items,metadata and journal,and the resources anaction declaresKEYthe session id, andthe tenant when sentSCOPE 03Userone personctx.userFollows the personacross conversationsand flows in oneorganizationKEYthe userId and orgIdisolateUserStateone record perflow copy insteadSCOPE 04Orgeveryone in the organizationctx.org?Settings and knowledgethe whole team sharesKEYthe orgIdisolateOrgStateone record perflow copy insteadNAMES · HOLDS NO COPY01A session is bound to one organization when it is created. It points at its user and org; it does not copy theirstate.02A tenant id splits sessions only. User and org records are the same across tenants.03Blocks read and write all four. The browser sees only the fields a scope's client config exposes.LASTS ONE ACTION RUNOUTLIVES THE REQUESTNAMES (A REFERENCE)

Request scope lasts for one action run. Session scope is where most state lives for chat-style apps: conversation mode, message counts, in-progress drafts. User scope spans sessions: preferences, feature flags, usage quotas. Org scope is shared across users: team config, shared resources. State and scopes compares the four lifetimes in one table.

Blocks declare partial schemas: they only specify the fields they read or write. A counter block doesn't need to know about preferences. The framework merges these declarations at the flow level. This keeps blocks portable and self-documenting.

An incState on a single field, and any pushState, do not lose updates on the built-in stores. Each sends the store the operation itself rather than a computed result, and the store applies it to the value it currently holds. The guarantee comes from the store, so a custom adapter without the matching operation doesn't provide it: those calls write the whole record at the version they read, in one attempt, and a concurrent write makes one of them return false instead of landing. atomicState, the updater form of patchState, and an incState across two or more fields carry the version they read instead: on a lost race they re-run your function against the value that won, so both updates end up in the record. setState carries a version too, but it replaces the record with the object you passed, so it overwrites a concurrent writer rather than merging with one.

6. Items are the data model​

Every output in the framework is a typed item: messages, reasoning, tool calls, state changes, custom components. Items persist in sessions, stream to clients, and feed back into LLM context (when their type permits).

Items are the durable record of what happened. They have a lifecycle: in_progress → completed (or failed, incomplete). Content streams within items via delta events. A message item might receive many content.delta events before it's finalized.

Item types determine audience routing. Some types go to the client UI (messages, components, status). Some go only to the LLM (context, tool results). Some are internal (block_output for DevTool). A generator's itemVisibility then decides whether its auto-emitted messages reach the client, the next-turn history, both, or neither.

7. Streaming happens automatically​

SSE with sequence numbers. Clients can disconnect and resume from where they left off. No manual reconnection logic. The client SDK and React hooks handle it.

The stream carries item.added, content.delta, item.done, and terminal events like request.completed. Each event has a sequence number for ordering and replay. Clients send Last-Event-ID or starting_after on reconnect; the server replays missed events then continues live. No data loss, no duplicates.

8. The server is generated​

List your flows in one config object. Get a full REST API. No route wiring.

You describe the runtime with createFlowState, passing your flows and where state lives, then mount the returned handle with a platform handler. It exposes POST for actions, GET for streams and state snapshots, DELETE for session cleanup. One catch-all route in Next.js is enough.

9. React hooks make it reactive​

  • FlowProvider — Sets flowKind and userId context. Wraps your app or a section of it. Registers custom renderers for item types (messages, reasoning, components). Nested providers merge renderers; child keys override parent.
  • useFlow — Session lifecycle. Create sessions, switch between them, track the active one. With autoCreateSession: true, creates a session on mount if none exists. Returns sessions, activeSessionId, createSession(), selectSession().
  • useSession — Connects to the SSE stream for a session. Delivers items in real time. Provides sendAction and isStreaming. Configure items.visibility to filter which items appear (e.g. "ui" for client-visible only). Re-renders when items change, streaming status changes, or the session detail updates.
  • useClientData — Reads the latest state snapshot. Only sees what each scope's client.expose / client.derived entries expose. Specify which keys to subscribe to: { session: ["messageCount", "mode"] }. Refetches after request.completed and when state invalidation events arrive.

The hooks subscribe to the right streams and re-render when data changes. You don't manage connections manually. The client package handles HTTP, SSE, reconnection, and cursor-based resume.

10. Testing is deterministic​

The testing harness uses mocked generators. No real LLM calls. No network. Same contracts as production: validation, session resolution, block execution, state persistence, lifecycle hooks.

You run flows and blocks in an isolated runtime with in-memory stores. Seed state with seed.session, seed.user, or seed.org to simulate specific scenarios. Tests stay fast and reproducible.

testFlow returns the full result: items, session state, request metadata. Assert on what matters: item types and content, final state values, error messages. For blocks in isolation, use testBlock from the same package. It runs a single block with optional scope seeding and mocked dependencies. Both use the same execution engine as production; only the stores and model resolution differ.


Go deeper​