Spawn Workflow Steps
Spawn Workflow Steps
Section titled “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.
Basic Example
Section titled “Basic Example”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: trueConfiguration Properties
Section titled “Configuration Properties”| 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. |
Fan-in Strategies (wait_for)
Section titled “Fan-in Strategies (wait_for)”| 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. |
How Child Data Is Built
Section titled “How Child Data Is Built”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:. |
Example: Bulk Reimbursement
Section titled “Example: Bulk Reimbursement”Parent form fields:
department(text) — global contextreimbursement_date(date) — global contextreimbursement_items(line_items) — rows withemployee_name,description,amount,category
Spawn step:
map: employee_name: employee_name # explicit (identical source/dest — optional) description: description amount: amountpass: - department - reimbursement_dateChild 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:.
Parent Form: line_items Field
Section titled “Parent Form: line_items Field”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: MealsSee Field Types Reference for the full line_items schema.
Dynamic Child Workflow Names
Section titled “Dynamic Child Workflow Names”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_failureThis 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.
Child Workflow Auto-Scaffolding
Section titled “Child Workflow Auto-Scaffolding”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 correcttype,required,currency, andvalidationcarried over from the parent’sitem_fields - All fields from
pass:— with their full definitions copied from the parent form - Any remaining
item_fieldsnot covered bymap:orpass:, 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.
Coordinator Step
Section titled “Coordinator Step”While children are running, the parent instance holds a system:spawn_coordinator step in PENDING state. This step stores:
items_field— theline_itemsfield nameworkflow_name— the child workflow (the literalworkflow: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 allwait_for— the configured fan-in strategyreturn— optional child-to-parent field mappingon_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.
Returning Data from Children
Section titled “Returning Data from Children”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_byRules:
returnis 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
nullchild 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.
Instance Visibility
Section titled “Instance Visibility”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_rolescompany 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.
Test Mode Behaviour
Section titled “Test Mode Behaviour”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.
Edge Cases
Section titled “Edge Cases”| 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. |
