Skip to content

Spawn Workflow Steps

The spawn step type is used for fan-out workflow execution. A parent workflow creates one child workflow instance per row in a line_items form field, optionally waits for their completion, then routes to the next step.

Typical uses: bulk reimbursement requests, multi-vendor invoice processing, onboarding task assignment, or any process where the number of sub-approvals is determined at submit time.

spawn_individual_approvals:
name: Process Individual Reimbursements
type: spawn
workflow: individual-reimbursement-approval # child workflow name
items: reimbursement_items # line_items field on the parent form
wait_for: all
pass:
- department
- reimbursement_date
map:
employee_name: employee_name
description: description
amount: amount
return:
approved_amount: approved_amount # child field → parent item row field
approver_name: approver_name
on_complete:
continue_to: completed
on_failure:
end_workflow: true
Property Type Required Description
workflow String Yes Name of the child workflow to spawn (looked up by name in the same company). Can also be a dynamic reference like "{{app_registry_field}}", resolved per item — see Dynamic Child Workflow Names below.
items String Yes Name of the line_items form field whose rows drive the fan-out. One child is created per row.
wait_for String No Fan-in strategy: all (default), any, or none.
pass List No Top-level parent form fields to copy verbatim into every child.
map Dict No Explicit field renames: child_field: source_field. Source is resolved from parent top-level first, then from the current item row.
return Dict No Values copied back from each completed child into the parent item row: child_field: row_field.
on_complete Dict No Routing when all (or any) children finish successfully.
on_failure Dict No Routing when a child is rejected.
Value Behaviour
all (default) Parent pauses until every child instance completes.
any Parent advances as soon as the first child completes.
none Fire-and-forget — parent advances immediately after spawning. Children continue independently.

Each child’s form data is assembled from three sources in priority order. A field set by an earlier source is never overwritten by a later one.

Priority Source Description
1 (highest) map: entries Explicit renames. Source is resolved from the parent’s top-level request_data first; if not found there, from the current item row.
2 pass: fields Copies parent top-level fields by the same name into the child. Skipped if the key was already set by map:.
3 (lowest) Item row keys Every key in the current line item row is merged in, filling any slots not already set by map: or pass:.

Parent form fields:

  • department (text) — global context
  • reimbursement_date (date) — global context
  • reimbursement_items (line_items) — rows with employee_name, description, amount, category

Spawn step:

map:
employee_name: employee_name # explicit (identical source/dest — optional)
description: description
amount: amount
pass:
- department
- reimbursement_date

Child instance for row {employee_name: "Alice", description: "Hotel", amount: 250, category: "accommodation"}:

{
"employee_name": "Alice",
"description": "Hotel",
"amount": 250,
"department": "Engineering",
"reimbursement_date": "2026-06-01",
"category": "accommodation"
}

category arrives via step-3 auto-merge even though it is not listed in map: or pass:.

The items: property must reference a line_items field on the parent form. Each row in that field becomes one child instance.

form:
fields:
- name: reimbursement_items
type: line_items
label: Reimbursement Line Items
required: true
min_items: 1
item_fields:
- name: employee_name
type: text
label: Employee Name
required: true
- name: amount
type: currency
label: Amount
required: true
currency: USD
validation:
min: 0.01
max: 100000
- name: category
type: dropdown
label: Category
required: true
options:
- value: travel
label: Travel
- value: meals
label: Meals

See Field Types Reference for the full line_items schema.

Instead of a fixed name, workflow: can reference a field on the current item row, resolved independently for each row in the fan-out:

provision_all_apps:
type: spawn
workflow: "{{register_workflow_id}}" # resolved per row, not looked up once for the whole step
items: apps_to_register
wait_for: all
on_complete:
continue_to: send_invite
on_failure:
continue_to: notify_it_partial_failure

This is what lets one spawn step onboard an employee to several different apps (Metabase, Slack, GitHub…) in a single pass, when each app needs its own child workflow with its own role/permission mapping — a fixed workflow: name can only ever point at one child, which can’t express that. A common pattern is to resolve the field from a registry lookup earlier in the same workflow — a type: asset step listing all registered apps (list_by_category: app_registry), then an automatic step that maps each row’s app name to its registered register_workflow_id via a field_mapping with a jsonata expression — and carry the resolved workflow name through as a row field, as shown above.

Per-row isolation. If one row’s resolved name doesn’t match any existing workflow, only that row is skipped — logged, but not an abort of the whole spawn. The other rows still spawn normally. Because wait_for: all can never be satisfied while one row has no child instance at all to report completion, an unresolved row is counted as a failure for fan-in purposes, so the coordinator still resolves (via on_failure) instead of waiting forever.

Test Mode prints what each of the first 3 rows actually resolves to, not just the literal "{{field}}" template string, so you can confirm the registry lookup is producing the workflow names you expect before running for real.

Validation: because different rows can resolve to entirely different child workflows with entirely different forms, a dynamic workflow: value can’t be checked against a single child’s fields at save time. The validator shows an informational note instead of an error, and does not attempt to verify pass:/map:/return: against any specific child form.

If the child workflow named in workflow: does not exist, the Validate button in the Workflow Designer shows an error with a Generate & Open Child Workflow button.

Clicking it creates a new child workflow pre-populated with:

  • All fields from map: targets — with correct type, required, currency, and validation carried over from the parent’s item_fields
  • All fields from pass: — with their full definitions copied from the parent form
  • Any remaining item_fields not covered by map: or pass:, also with full definitions

This means a currency field with validation: {min: 0.01, max: 100000} will appear as a currency field (not text) in the generated child workflow.

While children are running, the parent instance holds a system:spawn_coordinator step in PENDING state. This step stores:

  • items_field — the line_items field name
  • workflow_name — the child workflow (the literal workflow: value, or the dynamic "{{field}}" template — each spawned child’s own metadata separately records its own resolved name)
  • item_count — number of rows the spawn fanned out over, used to detect a dynamic row whose name never resolved to any child at all
  • wait_for — the configured fan-in strategy
  • return — optional child-to-parent field mapping
  • on_complete / on_failure — routing for when the children resolve

When the coordinator is resolved to APPROVED, any return mapping is applied first: child field values are written back into the corresponding rows of the parent’s items field. The parent workflow then advances.

Use the return block to copy values produced by each child workflow back into the parent row that spawned it.

return:
approved_total: total_approved # child.request_data.approved_total → row.total_approved
approver_name: approved_by # child.request_data.approver_name → row.approved_by

Rules:

  • return is applied only on a successful fan-in (on_complete). It is ignored when children fail.
  • The left-hand key is a field name in the child workflow’s request_data.
  • The right-hand value is a field name in the parent item row.
  • Only rows with a completed child are updated. With wait_for: any, rows whose children did not finish are left unchanged.
  • Missing or null child values do not overwrite existing row values.

This is useful for aggregating per-child outcomes (e.g. the approved amount, assigned approver, or final status) back into the parent’s line items for reporting or downstream steps.

Users who can view the parent workflow instance automatically have visibility into all its child instances. This applies to:

  • The employee who submitted the parent request
  • Managers (by org hierarchy) of the submitter
  • Approvers who acted on any parent step
  • Anyone with a view_all_roles company role on the parent workflow

This means a finance manager who approved the parent bulk-reimbursement request can open each individual child reimbursement approval without needing a separate permission grant.

In test mode (is_test_mode: true in instance metadata), the spawn step logs what it would do — item count, field mappings — without creating real child instances. The coordinator step is created as APPROVED immediately, and the parent advances via on_complete routing. This lets you test parent-level logic without needing the child workflow to exist.

Scenario Behaviour
items field is an empty list Coordinator resolves immediately (APPROVED); zero children created; parent follows on_complete.
wait_for: none All children are created, then the coordinator resolves immediately; parent does not wait.
Child workflow not found (literal workflow: name) Parent follows on_failure routing (or is rejected if on_failure is not set) — the whole spawn fails, since there’s only one possible target.
One row’s dynamic workflow: "{{field}}" doesn’t resolve Only that row is skipped; other rows still spawn. Counted as a failure for fan-in, so wait_for: all/any still resolves correctly instead of hanging.
Every row’s dynamic name fails to resolve No children created at all — the coordinator resolves immediately via on_failure, the same as a literal name not being found.
map: source key missing in both parent and item row That child field is left unset (not set to null), allowing item_fields auto-merge to fill it.