Agora
Authoring

Child workflows

Compose workflows by calling other workflows — await a child's result with Inner.start (or ctx.child), kick one off fire-and-forget with Inner.dispatch (or ctx.startChild), or fan out over a list with ctx.all. The same statics work at the top level, context-aware.

A workflow can run another workflow as a child — to break a big process into reusable pieces, to fan out work, or to hand part of the job to a workflow that runs elsewhere. Children are durable runs in their own right: their own history, retries, and dashboard entry.

There are two ways to call one, depending on whether the parent needs the result.

Inner.start / Inner.dispatch — the ergonomic form

When the child is a BaseWorkflow class you can import, its statics are the shortest way to compose — and they are context-aware, so the same call does the right thing inside a workflow versus outside one:

export default class CheckoutWorkflow extends BaseWorkflow {
  static workflow = { name: 'checkout', version: '1' }

  async run(ctx: WorkflowCtx, order: Order) {
    const kyc = await KycWorkflow.start({ userId: order.userId }) // linked child — awaits the result
    await AuditWorkflow.dispatch({ orderId: order.id })           // fire-and-forget child
    return { verified: kyc.passed }
  }
}
  • Inner.start(input, opts?) = "I want the result." Inside a running workflow it creates a linked child (the parent suspends until the child settles, then resumes with its result) — exactly ctx.child(Inner, input).
  • Inner.dispatch(input, opts?) = fire-and-forget. Inside a running workflow it kicks off a child and returns { runId } while the parent keeps going — exactly ctx.startChild(Inner, input).

The same two statics also run a top-level run from a controller/service (Wf.dispatch returns { runId }; Wf.start blocks until the run settles and returns its result). One consistent rule — .start = "give me the result", .dispatch = "fire and forget" — in both contexts:

Static callOUTSIDE a workflow (controller / service / script)INSIDE a running workflow body
Wf.start(input, opts?)engine.start + waits for the run to settle → returns the resultlinked child (ctx.child) — parent suspends until it settles, returns the child's result
Wf.dispatch(input, opts?)enqueues a top-level run → fire-and-forget, returns { runId }fire-and-forget child (ctx.startChild) → returns { runId }, parent keeps going

Why the same call is context-aware

Inside a running body it is required to route through the child path — calling the engine directly from a workflow would break determinism. The static detects the ambient run and does this for you, so Inner.start / Inner.dispatch are always safe to call from either place. ctx.child / ctx.startChild are the underlying primitives and stay available.

Which to reach for. Outside a workflow, prefer the statics: a controller that only kicks off a run doesn't need the engine for it. Inside a body, prefer ctx.child / ctx.startChild. They do the same thing, but ctx is the visible determinism boundary: a reviewer scanning for ctx. sees every history operation at a glance, and Inner.start() no longer looks durable. The statics earn their place inside a body in deep helpers, a domain service a few calls below run that would otherwise thread ctx through several signatures just to spawn a child.

Where the statics see no workflow

"Inside" means the body's own async path, read from an AsyncLocalStorage. A dispatched step handler is not the body: the engine clears the ambient run around it, even on an in-process transport, so a static called from a @Step method starts a top-level run, not a child. Inside a ctx.localStep body the static resolves the ambient run and throws a NestedWorkflowCallError instead, since a step that replays from its checkpoint would never claim that child's position again.

opts — outside vs inside

opts is { runId? } plus the engine's start options (version, priority, namespace, startAt, …). Outside a workflow, all of them apply; runId is a random UUID when omitted and acts as an idempotency key when you provide one. Inside a workflow only runId is used, as the child id; the other start options are ignored. When omitted, the child id is derived deterministically from the call position (replay-stable). Do not pass a random one, as that would break replay. Pass an explicit stable id only when you deliberately want to join a specific child (see Fanning out by hand). For a child's dispatch priority, call ctx.child / ctx.startChild with { priority } (see Child call options).

The rest of this page documents ctx.child / ctx.startChild — the primitives the statics delegate to. Use them directly when you only have the child's name string (no class to import), when the calling code is already an engine.register(...) body, or when you need a child call option the statics don't forward. Fanning out over a list (ctx.all / ctx.allSettled) has no static form either.

ctx.child — run a child and await its result

ctx.child(workflow, input, childId?) starts the child once and suspends the parent — zero compute — until the child reaches a terminal state, then resumes the parent with the child's output. If the child fails, the call throws in the parent (so a child failure fails the parent unless you catch it).

engine.register('checkout', '1', async (ctx, order: Order) => {
  const payment = await ctx.localStep('charge', () => payments.charge(order))
  // Hand shipping to its own workflow and wait for the tracking number.
  const shipment = await ctx.child<{ tracking: string }>('shipping', { orderId: order.id })
  return { paymentId: payment.id, tracking: shipment.tracking }
})

Because the parent suspends rather than blocking, a child that takes hours (or itself sleeps, waits on a signal, or spawns its own children) costs the parent nothing while it runs.

Name or class ref

Pass the child's registered name string (the portable form). When you have a typed workflow class to import, pass the class and the call is fully typed — the input is checked against the child's run and the result is its return type:

const shipment = await ctx.child(ShippingWorkflow, { orderId: order.id })
//    ^? the return type of ShippingWorkflow.run, inferred — and { orderId } is type-checked

The name form is the only one for a cross-runtime child, such as a workflow implemented in Python. There you annotate the result yourself:

const result = await ctx.child<EnrichResult>('python-enrich', { recordId })

Child call options

Both forms take an optional third argument: a childId string, or a ChildCallOptions object.

await ctx.child('python-enrich', { recordId }, { childId: `enrich:${recordId}`, priority: 10 })
OptionWhat it does
childIdThe child's run id. Defaults to <parentRunId>.child.<seq>, derived from the parent run and the call position, so it's stable across replay.
priorityDispatch priority for a remote child workflow (another runtime), stamped on the child run and carried onto every task that advances it. Higher wins. Ignored for an in-process (TypeScript class) child.

parentRunIdOf(runId) (exported from @adonis-agora/durable) reads the parent back out of an engine-minted child id (.child.<seq>, .all.<seq>.<i>, .onError.<n>). It returns undefined for a child started with an id you chose.

1@Workflow({ name: 'onboard', version: '1' })2export default class OnboardWorkflow {3  constructor(4    private accounts: AccountSteps,5    private email: EmailSteps,6  ) {}7 8  async run(ctx: WorkflowCtx, user: User) {9    const account = await ctx.step(this.accounts.create, user)10 11    // run a child workflow and await its result:12    const kyc = await ctx.child(KycWorkflow, { userId: account.id })13 14    await ctx.step(this.email.welcome, { user, kyc })15    return { verified: kyc.passed }16  }17}
parent · onboardchild · KycWorkflowcreatectx.childwelcomedoneverifyscoredone
completesThe parent returns the child's verified flag; the run completes.
6 / 6

ctx.startChild — fire-and-forget

ctx.startChild(workflow, input, childId?) dispatches a child and returns its run id immediately — the parent keeps running instead of suspending. Use it for side work the parent doesn't need to wait on:

engine.register('publish-post', '1', async (ctx, post: Post) => {
  await ctx.localStep('publish', () => posts.publish(post))
  // Kick off indexing + notifications; don't make publishing wait on them.
  await ctx.startChild('reindex-search', { postId: post.id })
  await ctx.startChild('notify-followers', { postId: post.id })
  return { published: true }
})

The dispatch is checkpointed (replay-safe — it won't re-fire on resume) and idempotent by child id.

1@Workflow({ name: 'publish-post', version: '1' })2export default class PublishPostWorkflow {3  constructor(private posts: PostSteps) {}4 5  async run(ctx: WorkflowCtx, post: Post) {6    await ctx.step(this.posts.publish, post)7 8    // fire-and-forget — don't make publishing wait on indexing:9    await ctx.startChild(ReindexSearchWorkflow, { postId: post.id })10 11    return { published: true }12  }13}
reindex-search.workflow.ts
⋯6  async run(ctx: WorkflowCtx, input: { postId: string }) {7    const doc = await ctx.step(this.search.reindex, input)8    await ctx.step(this.search.warmCache, doc)9  }⋯
parent · publish-postchild · ReindexSearchWorkflowpublishstartChilddonereindexwarmdone
child lives onThe child finishes its own steps later; a failure there never touches the already-settled parent — inspect or retry it from the dashboard.
4 / 4

ctx.all — fan out over one workflow

Running the same child workflow across a list of inputs is common enough to have its own primitive. ctx.all(workflow, inputs, opts?) starts one child per input, suspends the parent while they run concurrently, and resumes with their outputs in input order:

engine.register('batch', '1', async (ctx, batch: Batch) => {
  const results = await ctx.all(ProcessItemWorkflow, batch.items)
  //    ^? the return type of ProcessItemWorkflow.run, as an array

  return { processed: results.length }
})

Pass a registered name string instead of the class when you have no class to import, with the output type as a generic: ctx.all<ItemResult>('process-item', batch.items).

The child ids are derived from the parent and the position the fan-out occupies — <parentRunId>.all.<seq>.<i> — so they are stable across replay without you inventing them. Every sibling is also tagged with the same parallel group, which is what lets the dashboard render one fan-out as a single unit rather than N unrelated children. An empty inputs array returns [] immediately and starts nothing.

A fan costs O(N), however wide it is. Settling children do not wake the parent one by one: a join barrier holds the wake until the last child settles, so the parent replays the fan about once, not once per child. A failFast fan is the exception, and wakes on its first failure. The barrier needs the store's hasSignalWaiter, which the bundled stores implement. A custom store without it still runs ctx.all correctly, but the parent then replays once per settled child, which is O(N²) across the fan. Sending the fan is batched too: children are registered 500 at a time through the store's registerChildWaits, one transaction per batch.

Once a fan of 100 or more children settles with every child successful, its per-child checkpoints are folded into a single fan:<parentRunId>.all.<seq>. checkpoint holding the outputs in order. Every later turn of the parent then loads one row for the fan instead of one per child. A fan with concurrency folds each wave as it settles. Folding keeps the outputs, so it saves the per-row overhead and the row count, not the bytes of the outputs themselves: have children return small results (ids, counts) and write anything large to your own tables. A group with a failed child is not folded, so a retry can still re-run that child. A fan under 100 children keeps its per-child checkpoints, so the dashboard can show each child. Folding needs the store's compactCheckpoints, which the bundled stores implement. Cancel cascades, deleteRun and purgeRuns still reach every child of a folded fan.

waitAll vs failFast

opts.mode decides what a failing sibling does to the rest:

const results = await ctx.all(ProcessItemWorkflow, batch.items, { mode: 'failFast' })
ModeBehaviour
waitAll (default)Every child runs to a terminal state. If any failed, the call then throws — nothing is cancelled, so a partial batch is fully recorded and inspectable.
failFastThe first observed failure cancels every sibling that has not completed, then throws.

Either way the error is a GatherError carrying which items failed:

import { GatherError } from '@adonis-agora/durable'

try {
  await ctx.all(ProcessItemWorkflow, batch.items)
} catch (error) {
  if (error instanceof GatherError) {
    // error.failures: [{ index, id, error }, ...]
    const failed = error.failures.map((f) => batch.items[f.index].id)
    await ctx.step(reportPartialBatch, { batchId: batch.id, failed })
  }
  throw error
}

Choose waitAll when each item is independent and you want the whole picture — a nightly import where three bad rows should not hide the other nine hundred. Choose failFast when the items are facets of one operation and finishing the rest is wasted work.

failFast cancellation is best-effort and carries no saga compensation: it is a plain cancel, and a sibling in the middle of a step only observes it at its next checkpoint, so a fast sibling can still slip through and complete. If a partially-run child needs to be undone, give the child its own compensations and cancel it with { compensate: true } yourself instead.

When a failed item is an expected outcome — ctx.allSettled

When some items failing is normal (a line that fails validation, a record a downstream system rejects), throwing for the whole batch is the wrong shape. ctx.allSettled is the durable Promise.allSettled: the same fan as ctx.all in waitAll mode, but it resolves with one result per input, in input order, and leaves the parent to decide what a failure means:

const results = await ctx.allSettled(ProcessItemWorkflow, batch.items, { concurrency: 1000 })
// results[i]: { status: 'fulfilled', value } | { status: 'rejected', id, error, code?, details? }

const rejected = results.flatMap((r, i) =>
  r.status === 'rejected' ? [{ item: batch.items[i], error: r.error }] : []
)
if (rejected.length > 0) await ctx.step(recordRejectedLines, rejected)

It takes the same concurrency and lays the fan out exactly as ctx.all does (same child ids and parallelGroup). There is no mode: it always waits for every child. Cancelling the parent still cancels the run; only the children's failures are collected. For a failure that retrying can't fix, throw FatalError in the child's step: it skips the retries and the child settles as rejected straight away, with the error's code and details on the item. A failed ctx.child throws a FatalError carrying them too.

Capping how many children run at once — concurrency

By default the whole fan is sent at once. For a wide fan that floods the queue and keeps every child run and waiter alive together, so pass concurrency to cap it:

const results = await ctx.all(ProcessItemWorkflow, batch.items, { concurrency: 1000 })

Children then go out in waves of floor(concurrency / 2), two waves at a time (so an odd limit runs one child under it), and the parent sends the next wave as soon as one settles. A slow child holds back only its own wave, not the whole window. Each settled wave costs the parent one turn, and since settled waves are folded, that turn reads one row per earlier wave rather than one per earlier child. concurrency must be a positive integer; a limit at or above the fan size changes nothing.

concurrency is part of the fan's identity: the wave is in each child's id (<parentRunId>.all.<seq>.w<k>.<i>). Changing it while a run is in flight is a non-deterministic change, the same as changing inputs.

Fanning out over a step

When each item is just one step rather than a workflow of its own, pass the step's reference (a defineStep ref or a @Step method) instead of a workflow. Each input becomes one durable step instead of a child run, which is far cheaper for a large batch: a child costs a whole run (its row, a turn, a lease, a completion signal), a step costs a checkpoint and a dispatch.

// One processRecord step per record, at most 500 in flight. The workflow wakes once per settled
// wave, not once per record, and the dashboard shows the steps as one fan.
const results = await ctx.all(processRecord, batch.records, { concurrency: 500 })

Outputs come back in input order. The same mode and GatherError apply: waitAll throws once every step has settled, with the failed indexes in .failures; failFast throws on the first failed step. concurrency sends the steps in waves of that size, the next once the previous has settled (unlike a child fan, which keeps two half-size waves in flight). It must be a positive integer, and unset sends every step at once. Every step of the fan uses the @Step's retry policy, or the per-call retries / backoff you pass, and a failed step with attempts left is retried inside the fan rather than failing it. transport, priority and fairnessKey apply to every step too. A flow-control queue and a liveness timeoutMs (on the call or on the step) are rejected: a step fan needs durable steps.

failFast cancels nothing. A step already dispatched still runs to the end on its worker; the steps of waves not yet sent are never sent.

ctx.allSettled takes a step reference too, with the same options minus mode. It waits for every step and resolves with one { status: 'fulfilled', value } | { status: 'rejected', id, error, code?, details? } per input, where id is the step id. A step that throws FatalError settles as rejected without spending its retries.

const results = await ctx.allSettled(validateLine, lines, { concurrency: 500 })

Prefer it to Promise.all over ctx.step past a handful of steps: every result of a Promise.all replays the workflow, which makes a fan of N steps cost O(N²) checkpoint reads. A step fan replays once per settled wave, because a result resumes the workflow only when no step of the fan is still pending (or when one fails). The steps share a parallelGroup of steps:<firstSeq>:<size>, with a :failFast suffix in that mode (an allSettled fan is a waitAll one, so it never has the suffix), so any one step's checkpoint names the whole block the barrier waits on. The barrier asks whether any step of the fan is still pending through the optional StateStore.hasCheckpoints, which the Lucid and in-memory stores implement; a store without it is asked countCheckpoints, and one with neither falls back to loading the run's checkpoints on every result.

A child fan's parallelGroup is all:<firstSeq>:n<size>, with the same :failFast suffix. The size is there so the dashboard can show how many children are still to be started. A run that began before the size was part of the group still has all:<firstSeq>, and the barrier reads both forms.

Fanning out by hand

ctx.all covers one workflow over N inputs, dispatched and joined in one call. When you need something it doesn't give you — a different workflow per item, child ids you choose, or work interleaved between starting the children and joining them — build it from startChild + child. Because startChild returns the child id and the start is idempotent by that id, you can fan out first and join later with the same id: the child runs exactly once and the second call just attaches to it.

engine.register('batch', '1', async (ctx, batch: Batch) => {
  // 1. Start every item's workflow without waiting — they run concurrently.
  const ids = await Promise.all(
    batch.items.map((item) => ctx.startChild('process-item', item, `item:${item.id}`)),
  )

  // 2. ...do other work while they run...

  // 3. Join: await each by the same id (no re-dispatch — they're already running).
  const results = await Promise.all(
    batch.items.map((item, i) => ctx.child('process-item', item, ids[i])),
  )
  return results
})
children
Live model of ctx.all: the parent suspends (amber, zero compute) while N children — each a full durable run, labelled #i — execute concurrently and join as they settle, stamping a per-child ✓/✗ under the join. Toggle a failing child: the others still finish green, and the join resolves as a GatherError naming exactly which index failed.

When to use which

ctx.childctx.startChildctx.allctx.allSettled
Parent waits for the result✅ suspends until the child finishes❌ returns the id immediately✅ suspends until every child finishes✅ suspends until every child finishes
Child failure affects the parent✅ throws in the parent❌ independent (inspect/retry the child run)✅ throws a GatherError❌ returned as a rejected item
Typical useone sub-task whose output you needside work; heterogeneous fan-outthe same workflow over a list of inputsa list where some items failing is expected

Inner.start(input) and Inner.dispatch(input) are the ergonomic equivalents of ctx.child(Inner, input) and ctx.startChild(Inner, input) — same behavior, they just resolve the ambient ctx for you.

Both establish the same durable parent→child relationship, so a child started either way is a normal run you can inspect, retry, or cancel from the dashboard. Read the children of a run programmatically with engine.getRunChildren(parentRunId).

Continue-as-new

A workflow that would otherwise loop forever (a long-lived entity, a poll loop) should not accumulate unbounded history. ctx.continueAsNew(input?) completes the current run and starts a fresh one — same workflow, new input, a clean history — keyed runId~N:

engine.register('counter', '1', async (ctx, input: { n: number }) => {
  await ctx.localStep(`work-${input.n}`, async () => input.n)
  if (input.n < 3) await ctx.continueAsNew({ n: input.n + 1 }) // → runId~1, runId~2, …
  return `done at ${input.n}`
})

Each generation completes cleanly, so the run never grows without bound — the durable equivalent of tail recursion. continueAsNew always throws, so code after it never runs. Carry everything the next generation needs in its input.

A generation that continues completes with no output, so a parent awaiting it with ctx.child (or Wf.start) resolves with undefined as soon as the first generation hands off, not when the chain ends. Start a looping workflow with startChild / dispatch, or have the caller wait on something the last generation publishes (an event or a signal). The next generation starts on the newest registered version of the workflow, so a continue-as-new is also how a long-lived loop picks up a new version.

The handoff itself is crash-safe: the continuation run is persisted before the parent's terminal write, so a crash in the gap between "generation N completed" and "generation N+1 started" cannot lose the chain — on recovery the already-persisted continuation is simply driven forward. The continuation inherits the parent run's namespace, so a chain pinned to a worker pool stays in it across generations. Nothing else carries over: run-scoped tags, search attributes and priority from the original start are not copied to the next generation. A cancel requested while the generation runs wins over the continue-as-new: the run is cancelled and no next generation is started.

On this page

Partner

Your product as the official integration for Durable, right here.

Become a partner →