Skip to content

Flows ​

A flow is a schema that defines which steps run, in what order, and under what conditions. Flows are data — plain arrays you can build, store, and compose.

Defining a Flow ​

ts
app.flow('flow-id', [ ...steps ])

The simplest flow is a sequence of step ids:

ts
app.step('validate', { handler: (ctx) => { /* ... */ } })
app.step('process', { handler: (ctx) => { /* ... */ } })
app.step('complete', { handler: (ctx) => { /* ... */ } })

app.flow('pipeline', ['validate', 'process', 'complete'])

Steps execute top to bottom. Each step receives the same shared context.

Providing Input to Steps ​

You can hardcode input for a step directly in the flow schema:

ts
app.flow('calculate', [
  { id: 'add', input: 5 },
  { id: 'add', input: 10 },
  { id: 'multiply', input: 2 },
])

This is useful when the same step is reused with different values across a flow.

Schema-hardcoded input is delivered to the handler's second argument (and to string handlers) — it does not reach useWfState().input(). See Hardcoding Input in Flows.

Conditional Steps ​

Attach a condition to skip a step when the condition is false. A condition is either a string expression evaluated against the workflow context or a function (ctx) => boolean | Promise<boolean>:

ts
app.flow('process-order', [
  'calculate-total',
  { id: 'apply-discount', condition: 'total > 100' },
  'charge-payment',
])

// The same condition as a function:
app.flow('process-order-fn', [
  'calculate-total',
  { id: 'apply-discount', condition: (ctx) => ctx.total > 100 },
  'charge-payment',
])

apply-discount only runs if context.total > 100. Use string conditions when the flow must be serializable; use functions for type-safe logic. while, break, and continue (below) accept the same two forms.

Subflows ​

A subflow is an anonymous group of steps nested inside a flow. Use subflows to apply a shared condition or loop to multiple steps at once.

ts
app.flow('onboarding', [
  'create-account',
  {
    steps: ['send-welcome-email', 'schedule-intro-call'],
  },
  'activate',
])

Without a condition, a subflow is just a grouping mechanism. It becomes powerful when combined with conditions or loops.

Conditional Subflows ​

ts
app.flow('onboarding', [
  'create-account',
  {
    condition: 'plan === "premium"',
    steps: ['assign-account-manager', 'send-premium-welcome'],
  },
  {
    condition: 'plan !== "premium"',
    steps: ['send-standard-welcome'],
  },
  'activate',
])

The entire subflow is skipped if its condition is false.

Loops ​

Use while instead of condition to repeat a subflow as long as the expression is true:

ts
app.flow('retry-until-success', [
  {
    while: 'attempts < 3 && !success',
    steps: ['attempt-operation', 'check-result'],
  },
  'finalize',
])

The subflow repeats until attempts >= 3 or success becomes truthy.

break — Exit a Loop Early ​

ts
app.flow('search', [
  {
    while: 'page < maxPages',
    steps: [
      'fetch-page',
      { break: 'found' },          // exit if context.found is truthy
      'increment-page',
    ],
  },
  'return-results',
])

When the break condition is met, execution jumps past the loop to the next step in the parent flow.

continue — Skip to Next Iteration ​

ts
app.flow('process-batch', [
  {
    while: 'index < items.length',
    steps: [
      'load-item',
      { continue: 'item.skip' },    // skip this item, go to next iteration
      'process-item',
      'save-result',
    ],
  },
])

When the continue condition is met, the remaining steps in the current iteration are skipped and the loop restarts from the top.

Flow Prefix ​

If all steps in a flow share a common prefix, you can set it once:

ts
app.step('order/validate', { handler: (ctx) => { /* ... */ } })
app.step('order/charge', { handler: (ctx) => { /* ... */ } })
app.step('order/fulfill', { handler: (ctx) => { /* ... */ } })

// Instead of:
app.flow('process-order', ['order/validate', 'order/charge', 'order/fulfill'])

// Use a prefix:
app.flow('process-order', ['validate', 'charge', 'fulfill'], 'order')

The third argument to flow() is prepended to every step id in the schema.

Flow Initialization ​

The fourth argument is an init callback that runs inside the workflow context before every execution of the flow — on each start() and each resume(). Make it idempotent, or it will overwrite state every time a paused run resumes:

ts
app.flow('report', ['gather-data', 'format', 'send'], '', async () => {
  const { ctx } = useWfState()
  const context = ctx<ReportContext>()
  if (!context.reportId) {
    context.startedAt = Date.now()
    context.reportId = await generateId()
  }
})

Use init to set up derived context values or run async setup. Composables like useWfState() are available inside init.

Parametric Flows ​

Flow ids support the same routing syntax as steps:

ts
app.flow('process/:type', ['validate', 'transform', 'save'])

await app.start('process/json', { data: '...' })
await app.start('process/csv', { data: '...' })

Flow Output ​

Both app.start() and app.resume() return a TFlowOutput object:

ts
const output = await app.start('my-flow', initialContext)

output.finished          // true if the flow completed, false if it paused
output.state.context     // the final (or current) context
output.state.schemaId    // the flow id
output.state.indexes     // position in the schema (for resuming)
output.inputRequired     // set if the flow paused for input
output.error             // set if a StepRetriableError was thrown
output.stepId            // id of the last step the engine touched
output.resume?.(input)   // shortcut to resume the flow

When finished is false, the flow paused because a step needs input or threw a retriable error. See Input & Resume for how to continue execution.

Gotchas ​

  • Register steps before flows. app.flow() validates every step id in the schema at registration time and throws Step "/<id>" not found. if a referenced step isn't registered yet.
  • Flow ids are unique per app. Registering the same flow id twice throws Workflow schema with id "<id>" already registered.
  • Parametric references are validated against parametric routes. A schema entry like 'add/5' is valid as long as a step 'add/:n' is registered.

Released under the MIT License.