Workflows and Stages
Workflows in workflow-engine are built as a sequence of execution groups. Each execution group contains one or more stages.
Defining a Stage
A stage is an atomic, retryable unit of execution, defined with defineStage. A stage that has to wait — for a provider batch, a polled job, a human — does so from inside its execute() with durable steps (ctx.step.waitFor, waitForSignal, sleep, ctx.step.ai.map); the separate defineAsyncBatchStage of 0.x is no longer exported. Every stage must define:
id: Unique string identifier.name: A human-friendly display name.schemas: Zod schemas defining its validatedinput,output, andconfig.execute: The core execution function.
Standard Sync Stage
Standard stages execute immediately and complete in a single invocation.
import { defineStage } from "@bratsos/workflow-engine";
import { z } from "zod";
export const extractTextStage = defineStage({
id: "extract-text",
name: "Extract Text",
schemas: {
input: z.object({ url: z.string().url() }),
output: z.object({ text: z.string(), wordCount: z.number() }),
config: z.object({ maxLength: z.number().default(50000) }),
},
async execute(ctx) {
const response = await fetch(ctx.input.url);
const text = (await response.text()).slice(0, ctx.config.maxLength);
return {
output: {
text,
wordCount: text.split(/\s+/).length
},
};
},
});
Typed Context with the Curried Form
The example above lets TypeScript infer everything from schemas, but ctx.require()/ctx.optional() stay untyped unless you also tell defineStage what the workflow context looks like. Call the curried form — defineStage<TContext>()({ ... }) — supplying only the context type and letting TypeScript infer the stage's id (as a string literal), input, output, and config from the definition object itself:
type MyContext = { "previous-stage": { value: string } };
export const myStage = defineStage<MyContext>()({
id: "my-stage", // TId inferred as the literal "my-stage"
name: "My Stage",
schemas: { input: InputSchema, output: OutputSchema, config: ConfigSchema },
async execute(ctx) {
const prev = ctx.require("previous-stage"); // typed via MyContext
return { output: { /* ... */ } };
},
});
The older form that spells out all five generics positionally (defineStage<TId, TInput, TOutput, TConfig, TContext>({...})) still works but is deprecated in favor of the curried form above — spelling out all five by hand is verbose, and silently loses TId string-literal inference if any of them are mistyped.
Defining a Workflow
Workflows chain stages together. The recommended way to build a workflow is defineWorkflow; the positional WorkflowBuilder constructor still works but is deprecated in its favor.
defineWorkflow API (recommended)
defineWorkflow returns a WorkflowBuilder. It takes either an options object or (id, { input?, name?, description? }). The workflow's output schema is always the last stage's output schema (or the merged object of the last parallel group); there is no output option.
import { defineWorkflow } from "@bratsos/workflow-engine";
import { z } from "zod";
import { extractTextStage } from "./stages";
export const myWorkflow = defineWorkflow({
id: "text-analyzer",
name: "Text Analyzer",
description: "Downloads and analyzes text",
input: z.object({ url: z.string().url() }),
})
.pipe(extractTextStage)
.build();
Inline stages with .stage()
.stage(id, definition) defines a stage and adds it in one call, and the builder infers the stage's context from every earlier stage — so ctx.require() is typed without a hand-written context type, and dependencies only accepts earlier ids:
const In = z.object({ repo: z.string() });
export const repository = defineWorkflow("repository", { input: In })
.stage("chapter-index", {
schemas: { input: In, output: z.object({ chapters: z.array(z.string()) }), config: z.object({}) },
async execute(ctx) {
const chapters = await ctx.step.run("list", () => listChapters(ctx.input.repo));
return { output: { chapters } };
},
})
.stage("extract", {
dependencies: ["chapter-index"], // "nope" is a type error
schemas: { input: "none", output: z.object({ count: z.number() }), config: z.object({}) },
async execute(ctx) {
const idx = ctx.require("chapter-index"); // typed: { chapters: string[] }
return { output: { count: idx.chapters.length } };
},
})
.build();
.stage(prebuilt) and .pipe(prebuilt) accept a defineStage() result and check its declared context against what earlier stages produce: a stage that requires a key no earlier stage produces does not compile (the parameter resolves to { __error: "stage requires context keys not produced by earlier stages: ..." }). A stage built without an explicit context is accepted anywhere. .parallel([...]) and .parallel((group) => group.stage("a", {...}).stage("b", {...})) carry the same inference, and InferWorkflowContext, InferWorkflowInput, InferWorkflowOutput, InferWorkflowStageIds and InferStageOutputById expose the inferred types to code outside the stages.
Declaring a version
defineWorkflow(...).version("2026-09-04.1") declares the definition version yourself; otherwise the engine derives one from the pipeline's structure. See Definition Versioning.
Positional WorkflowBuilder API (deprecated)
Deprecated: inputSchema and the output schema are both plain z.ZodTypeAny, so positional arguments of the same type are easy to transpose by accident. Prefer defineWorkflow above instead.
import { WorkflowBuilder } from "@bratsos/workflow-engine";
import { z } from "zod";
import { extractTextStage } from "./stages";
export const myWorkflow = new WorkflowBuilder(
"text-analyzer", // Workflow ID
"Text Analyzer", // Display Name
"Downloads and analyzes text", // Description
z.object({ url: z.string().url() }), // Input Schema
z.object({ wordCount: z.number() }) // Final Output Schema
)
.pipe(extractTextStage)
.build();
Pipeline Control: Sequential vs. Parallel
Workflows are built incrementally by chaining .pipe() and .parallel() onto the builder.
Sequential execution (.pipe)
.pipe() schedules a single stage to run after all previously added stages.
const workflow = defineWorkflow({ ... })
.pipe(extractStage) // Execution Group 1
.pipe(summarizeStage) // Execution Group 2 (runs after Group 1 completes)
.build();
Parallel execution (.parallel)
.parallel() runs multiple independent stages concurrently in the same execution group. Parallel stages receive the same input (the cumulative workflow context from the previous group).
const workflow = defineWorkflow({ ... })
.pipe(extractStage)
.parallel([
sentimentAnalysisStage, // stage id: "sentiment"
keywordExtractionStage, // stage id: "keywords"
])
.pipe(generateReportStage)
.build();
State & Workflow Context
When stages run, their outputs are saved to the Workflow Context database record. The context maps stage IDs to their outputs.
Accessing Context: ctx.require and ctx.optional
Within a stage's execute function, you can retrieve outputs of preceding stages from the context:
ctx.require(stageId): Gets the output of a preceding stage. Throws a compile-time and runtime error if the stage did not run or is not declared in dependencies.ctx.optional(stageId): Gets the output of a preceding stage, returningundefinedif it was not run.
For stages executing after a .parallel() block, the outputs are keyed by their respective stage IDs.
What else is on ctx
Beyond input, config, require/optional, log, annotate and storage, every stage context carries:
ctx.step— the durable step API (run,waitFor,waitForSignal,sleep,ai). See Durable Steps.ctx.aiandctx.aiLogger— anAIHelperscoped to this run and stage, built lazily from the kernel'sservices. See AI Overview.ctx.abortSignal— aborted with aStageAbortedErrorwhen the run is cancelled or this worker's job lease is lost while the stage executes. Pass it tofetchand toctx.ai.*.ctx.stageRecordId,ctx.workflowRunId,ctx.stageId— the identity of this execution; the stage row'sattempt(viastageRecordId) is the retry count.
ctx.log and ctx.onLog return void; awaiting them still compiles but is unnecessary.
// Inside generateReportStage's execute(ctx)
async execute(ctx) {
// Retrieve outputs from the concurrent group
const sentiment = ctx.require("sentiment"); // output of sentimentAnalysisStage
const keywords = ctx.require("keywords"); // output of keywordExtractionStage
// These are fully type-safe based on their respective Zod definitions!
ctx.log("INFO", `Sentiment was: ${sentiment.sentiment}`);
return {
output: {
report: `Sentiment: ${sentiment.sentiment}. Keywords: ${keywords.list.join(", ")}`
}
};
}
Zod Schema Design Best Practices
To maximize the benefits of type-safety and AI integrations, follow these design principles:
1. Default and Optional Fields
Utilize .default() and .optional() in config schemas. Hosts auto-generate default configurations from these schemas.
const ConfigSchema = z.object({
model: z.string().default("gemini-2.5-flash"),
temperature: z.number().min(0).max(2).default(0.7),
promptPrefix: z.string().optional(),
});
2. No-Input / Context-Only Stages
If a stage reads entirely from previous stages and doesn't require any immediate input when launched, set its input schema to "none" (or import NoInputSchema):
schemas: {
input: "none",
output: OutputSchema,
config: ConfigSchema,
}