Workflows

Subworkflows

How a large process composes out of smaller ones: an action spawns a child workflow per row of a query, and a trigger resolves the parent’s activity once they all settle.

Early access

A subworkflow is a workflow instance created by another workflow. Reach for one when a parent process has to run the same smaller process over several items, then wait for those children to settle.

For example, a localization workflow can start one translation workflow per locale. Each translation runs independently, while the parent tracks the group and continues when no child remains active.

The children are ordinary instances. They have their own stages, activities, and fields, and they settle the same way any other run does. What makes them children is that a parent spawned them and waits on them.

Use spawn to coordinate workflow instances. A workflow instance, definition, or engine-owned guard cannot be the subject document of another workflow.

Spawn a child for each row

A spawn declaration belongs to an action. Its forEach expression returns one identifiable row per child, and with maps values from that row into the child’s input fields. A definition used only this way declares lifecycle: 'child', so it cannot be started on its own.

defineActivity({
  name: 'translate',
  actions: [
    {
      name: 'fan-out',
      when: 'true', // spawn on stage entry
      spawn: {
        forEach: '*[_id == $fields.subject._id][0].locales', // one child per locale on the article
        definition: {name: 'translate-article'}, // which workflow to spawn
        with: {
          locale: '$row',
          // a doc.ref read resolves to the referenced document, so rebuild the {id, type} pair
          source: '{"id": $fields.subject._id, "type": $fields.subject._type}',
        },
      },
    },
    {
      name: 'all-done', // no implicit gate: resolve once no child in the cohort is still active
      when: "count($subworkflows[activity == 'translate' && current && status == 'active']) == 0",
      status: 'done',
    },
  ],
})

Wait for the cohort

Spawning and waiting are separate actions. The children created by one spawn commit are a cohort, and the engine settles every one of them before the parent reacts. The all-done trigger then resolves the parent activity only when no child is still active.

Each child produces one cohort row. current selects children from the open stage visit. status distinguishes active, done, and aborted children. Both done and aborted are settled, so require status == 'done' when the parent must continue only after every child succeeds.

After a child action or effect completion, the engine reevaluates its parent in the same call and continues up the ancestor chain. The generated heartbeat also periodically reevaluates function-hosted parents that spawn children.

The full sequence follows from the definition above. The engine creates the child cohort in one commit, child actions update their own instances, and each child commit carries back to the parent until the completion trigger resolves the activity.

Loading...

Spawn on demand

An action without when spawns on demand instead. Nothing happens at stage entry, and the cohort is created in the commit that fires the action. That shape needs one more clause in the gate. Before the caller fires, the current cohort is empty, so a bare settled count passes for the wrong reason: there is nothing left to count, and the activity would resolve before anything spawns. Require a non-empty cohort first.

{
  name: 'kick-off', // no when: spawns when a caller fires it
  spawn: {/* same declaration as above */},
},
{
  name: 'all-done',
  // an empty cohort counts as settled, so require it exists first
  when:
    "count($subworkflows[activity == 'translate' && current]) > 0 && " +
    "count($subworkflows[activity == 'translate' && current && status == 'active']) == 0",
  status: 'done',
},

Choose what happens on exit

If the stage exits while children are still running, onExit on the spawn declaration decides their fate. 'detach', the default, lets them finish on their own, outside the gate. 'abort' cancels them, and cancels their own children too. Aborting the parent instance always cascades to its children.

Spawn inputs are checked at deploy

Every key in spawn.with must name a child workflow field that can take an input value. Deploy rejects a missing field, and a field whose initial value does not come from input. It reports every invalid parent-to-child contract together as SpawnContractsInvalidError. That stops a deployed parent from quietly handing the child data it would ignore.

Reference

Spawn declaration

Set this object as an action’s spawn property. When that action fires, the engine creates the declared child workflow instances in the same commit.

  • forEach

    Condition

    Required GROQ expression producing one identifiable row per child.

  • definition

    {name, version?}

    Required deployed child definition; version defaults to latest.

  • with

    Record<string, Condition>

    Optional initial child fields evaluated per $row.

  • context

    Record<string, Condition>

    Optional parent values delivered to each child as $context.

  • onExit

    detach | abort

    What happens to live children when their cohort leaves scope; defaults to detach.

Expression scope

  • Always available

    $self, $fields, $parent, $ancestors, $stage, $now, $context, $effects, $effectStatus, $activities, $allActivitiesDone, $anyActivityFailed, $subworkflows

    All spawn expressions; forEach sees workflow-scope fields only.

  • Caller-fired action

    $actor, $assigned

    Available when the spawning action is caller-fired.

  • Unavailable to forEach and context.

  • Unavailable

    $can, $params

    Never bound at spawn sites. See Conditions.

Cohort row

interface SubworkflowVarRow {
  rowKey: string
  activity: string
  action: string
  definition: string
  current: boolean
  status: 'active' | 'done' | 'aborted'
  // …
}

Visiting agent?

Was this page helpful?