Aviary
Authoring

Child workflows

Compose workflows by calling other workflows — await a child's result with ctx.child, or kick one off fire-and-forget with ctx.startChild. Pass the workflow class for a typed input and result, or a name string for a cross-runtime child.

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 in another service or language. 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.

ctx.child — run a child and await its result

ctx.child(workflow, input) 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).

@Workflow({ name: 'checkout', version: '1' })
export class CheckoutWorkflow {
  async run(ctx: WorkflowCtx, order: Order) {
    const payment = await ctx.step('charge', () => this.payments.charge(order));
    // Hand shipping to its own workflow and wait for the tracking number.
    const shipment = await ctx.child(ShippingWorkflow, { 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.

Class ref or string

Pass the child's class (ctx.child(ShippingWorkflow, input)) and the call is fully typed: the input is checked against the child's run, and the result is the child's return type — no manual type parameter, and renaming the workflow can't silently break the caller.

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

Pass a string name when there's no class to import — most importantly a cross-runtime child, e.g. a workflow implemented in Python. There you annotate the result yourself:

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

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.

ctx.startChild — fire-and-forget

ctx.startChild(workflow, input) 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:

@Workflow({ name: 'publish-post', version: '1' })
export class PublishPostWorkflow {
  async run(ctx: WorkflowCtx, post: Post) {
    await ctx.step('publish', () => this.posts.publish(post));
    // Kick off indexing + notifications; don't make publishing wait on them.
    await ctx.startChild(ReindexSearchWorkflow, { postId: post.id });
    await ctx.startChild(NotifyFollowersWorkflow, { postId: post.id });
    return { published: true };
  }
}

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

Scatter-gather: start many, then join

Because startChild returns the child id and the start is idempotent by that id, you can fan out with startChild and later join with ctx.child using the same id — the child runs exactly once, and the second call just attaches to it:

async run(ctx: WorkflowCtx, batch: Batch) {
  // 1. Start every item's workflow without waiting — they run concurrently.
  const ids = await Promise.all(
    batch.items.map((item) => ctx.startChild(ProcessItemWorkflow, 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(ProcessItemWorkflow, item, ids[i])),
  );
  return results;
}

When to use which

ctx.childctx.startChild
Parent waits for the result✅ suspends until the child finishes❌ returns the id immediately
Child failure affects the parent✅ throws in the parent❌ independent (inspect/retry the child run)
Typical usea sub-task whose output you needside work; scatter-gather (then join with child)

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.

On this page