Skip to main content

Overview

Most frameworks force a choice: Agent (the LLM decides what to do next) or Workflow (your code decides). flow-state.dev rejects that split. Sequencers are the composition model. You chain blocks with .then(), .thenIf(), .tap(), and other DSL methods. Each step's output feeds into the next step's input. Type inference flows through the whole chain.

You can interleave deterministic and non-deterministic steps. Validate input with a handler, generate a response with a generator, extract structured data with a handler, refine with another generator. All in one pipeline. No artificial boundary between "AI steps" and "logic steps."

Sequencers compose blocks

A block is the unit of work: a handler (deterministic logic), a generator (LLM call), a router (runtime dispatch), or another sequencer. See Blocks for the four block kinds. Sequencers compose them:

import { handler, generator, sequencer } from "@flow-state-dev/core";
import { z } from "zod";

const validate = handler({
name: "validate",
inputSchema: z.object({ message: z.string() }),
outputSchema: z.object({ message: z.string() }),
execute: async (input) => {
if (!input.message.trim()) throw new Error("Empty message");
return input;
},
});

const agent = generator({
name: "agent",
model: "preset/fast",
prompt: "You are a helpful assistant.",
inputSchema: z.object({ message: z.string() }),
user: (input) => input.message,
});

const extractJson = handler({
name: "extract-json",
inputSchema: z.string(),
outputSchema: z.record(z.unknown()),
execute: async (input) => {
const match = input.match(/\{[\s\S]*\}/);
return match ? JSON.parse(match[0]) : {};
},
});

const pipeline = sequencer({
name: "chat-pipeline",
inputSchema: z.object({ message: z.string() }),
})
.then(validate)
.then(agent)
.map((out) => out.text)
.then(extractJson);

Here, a handler validates, a generator produces text, a .map() extracts the text, and a handler parses JSON. The chain's output type is inferred from the last step.

DSL methods

MethodPurpose
thenRun a block, pass output to next step
thenIfRun a block only when a condition holds
mapInline transform (no block)
parallelRun multiple blocks concurrently, merge named outputs
thenAllRun blocks concurrently, collect results as ordered array
thenAnyTry blocks sequentially, return first success
raceRun blocks concurrently, return first success
exitIfExit the chain early when a condition is true
forEachProcess array items with a block (blocking)
forEachBackgroundFire-and-forget fan-out over array items (non-blocking)
doUntilLoop until a condition is true
doWhileLoop while a condition is true
loopBackJump back to a named step
work / backgroundFire-and-forget side work (doesn't block)
workIfConditional variant of work — dispatches only when condition is truthy
waitForWorkWait for .work() tasks, optional failOnError
tapRun a block or function without changing the payload
tapIfConditional tap
rescueCatch errors, route to recovery blocks
branchRoute to first matching branch
Inline block factories.then(handler, { outputSchema, execute }) etc.

Each method returns a sequencer. You chain them: .then(a).thenIf(cond, b).tap(c).then(d).

Output flows forward

The pipeline is linear: step 1 output → step 2 input → step 2 output → step 3 input. Connectors let you reshape data when types don't match. See Connectors for details. TypeScript infers the chain's output from the last step's schema.

Sequencers are blocks

A sequencer is a block. It composes with any other block. You can nest sequencers, use a sequencer as a generator tool, or register it as a flow action:

const inner = sequencer({ name: "inner" }).then(blockA).then(blockB);
const outer = sequencer({ name: "outer" })
.then(inner)
.then(blockC);

// As a tool
const agent = generator({
name: "agent",
tools: [inner],
// ...
});

Container wrapping

A sequencer can emit a container item that groups its child items for UI display. Register a component for the container on the client to control how it renders.

sequencer({
name: "chat-pipeline",
inputSchema: chatInputSchema,
container: {
component: "chat-container",
label: "Processing chat message",
},
});

This is a rendering hint — it has no effect on execution order or block behavior.

Where to go next

  • Control Flow — Common composition patterns with code examples
  • Side Chains — Fire-and-forget work with .work() and .waitForWork()
  • Connectors — Shaping data between steps, typed refs, and portability