Sub-workflows
const enrich = defineSubWorkflowStep({ type: 'enrich', workflowInputSchema, childWorkflow: enrichmentWorkflow, // a built workflow input: (ctx) => ({ listingId: ctx.workflowInput.id }), outputSchema: enrichmentOutputSchema, // see below — it's the child's step map dependencies: { fetchListing },});Starts the child workflow (same partition), suspends the parent step as waiting, and resumes
it with the child’s output when the child terminates. A failed or cancelled child fails the
parent step, which cascades normally.
→ examples/09-sub-workflows.ts
The output is the child’s whole step map
Section titled “The output is the child’s whole step map”This is the part that surprises people. A workflow has no “return value” — its output is an object keyed by step key, holding each completed step’s output. That object, verbatim, is what the parent step completes with:
const enrichmentWorkflow = buildWorkflow({ type: 'enrichment', inputSchema, steps: { geocode, classify }, // ← these keys});
// so the parent step's output is:// { geocode: { lat, lng }, classify: { category } }Which makes the parent’s outputSchema a schema over that map:
const enrichmentOutputSchema = z.object({ geocode: z.object({ lat: z.number(), lng: z.number() }), classify: z.object({ category: z.string() }),});
// downstream:ctx.deps.enrich.geocode.latOmit outputSchema and the step’s output is typed as an opaque Record<string, unknown> —
fine when nothing downstream reads it, awkward when something does.
Two details that follow from “it’s the completed-step map”: skipped steps are absent from it (so a child with a conditional branch produces a partial object — make those keys optional), and adding a step to the child workflow changes the parent’s output shape.
Registration is automatic
Section titled “Registration is automatic”Pass the built child workflow and its step handlers are registered alongside the parent’s when
you call wf.register(registry) — you do not register the child separately. That is why
childWorkflow is typed as { definition, register } rather than just a definition, and why
the child must share the parent’s TContext.
Lifecycle
Section titled “Lifecycle”- The parent step’s
input(ctx)runs like an ordinary handler — its deps are validated and typed. Its “output” is the child’s input. - The engine marks the parent step
waitingfirst, then starts the child. (In that order, so a child that finishes immediately always finds its parent suspended.) - The child runs as a full workflow of its own: its own row, its own steps, its own retries,
its own event stream. It carries
parentWorkflowId/parentStepIdlinkage — which the public view strips. - When the child reaches a terminal state, the engine bridges it:
completedcompletes the parent step with the child’s output;failedorcancelledfails it with aSub-workflow failed: …message.
The bridge is idempotent — it does nothing unless the parent step is still waiting.
What it does not give you
Section titled “What it does not give you”- No recursion guard. A workflow that starts itself will keep starting itself. Bound the depth yourself (pass a counter through the child’s input and stop at a limit).
- Cancelling the parent does not cancel the child.
cancelWorkflowskips the parent’swaitingstep and finishes the parent; the child keeps running and its eventual bridge finds a non-waitingparent step and does nothing. Cancel children explicitly if you need that. - No shared transaction. Parent and child are separate workflows; a child’s completed side effects survive a parent failure unless you compensate them.
- Sub-workflow starts are not yet transactional. Unlike
startWorkflowand step completion, this path still writes then enqueues — see transactional dispatch.