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.

opts.runId — outside vs inside

opts is { runId? } plus the usual start options (priority, namespace, …). Outside a workflow, runId is auto-generated when omitted, and acts as an idempotency key when you provide one. Inside a workflow the child id is derived deterministically from the call position (replay-stable) when omitted — 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 scatter-gather).

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), or when the calling code is already an engine.register(...) body.

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

Both forms take an optional third childId argument; it defaults to a deterministic id derived from the parent run and the call position, so it's stable across replay.

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.

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. If a partially-run child needs to be undone, give the child its own compensations and cancel it with { compensate: true } yourself instead.

Fanning out by hand

ctx.all covers one workflow over N inputs. When the fan-out is heterogeneous — different workflows, or work interleaved between starting and joining — 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.all
Parent waits for the result✅ suspends until the child finishes❌ returns the id immediately✅ suspends until every child finishes
Child failure affects the parent✅ throws in the parent❌ independent (inspect/retry the child run)✅ throws a GatherError
Typical useone sub-task whose output you needside work; heterogeneous fan-outthe same workflow over a list of inputs

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.

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 also inherits the parent run's namespace, so a chain pinned to a worker pool stays in it across generations.

On this page