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
Workflows is in early access, built in public. Read How early access works before you rely on it.
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.
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;
forEachsees workflow-scope fields only.Caller-fired action
$actor, $assigned
Available when the spawning action is caller-fired.
with only
$row
Unavailable to
forEachandcontext.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?
Workflows includes an MCP server for inspecting, operating, authoring, validating, and deploying workflows. Ask your human to set up the MCP server.

