Runs another workflow as a single node in the current one. The child gets
its own workflow_runs row linked to the parent through parentNodeRunId,
inherits the parent's mode, and reports back to the parent through three
locked transitions: success, failure, cancelled.
Purpose#
Use subworkflow when a sequence of steps is a meaningful unit that
multiple workflows want to share: a document summarization pipeline, a
review gate, a multi-step validator. Composing two workflows is cheaper
than maintaining two copies of the same five nodes; the child can be
edited and rerun in isolation while every caller picks up the change.
The child is not exploded onto the parent's canvas. It renders as one node; drill into it (web UI, CLI tree, or run page) to inspect the nested graph.
YAML schema#
states:
call-summarize:
type: subworkflow # discriminator
label: Summarize document # required
workflowId: summarize-doc # required — SAFE_ID of the child workflow
inputMapping: # map child inputs to parent context / upstream outputs
document: '{{ fetch.body }}'
maxWords: 100
outputMapping: # expose child outputs under aliases on this node
summary: summary
tldr: tldr
maxIterations: 1 # optional 1-50; re-runs the child up to N times
on:
success: review
failure: notify-failure
cancelled: cleanupConfiguration#
| Field | Type | Required | Default | Meaning |
|---|---|---|---|---|
type |
'subworkflow' |
yes | — | Discriminator. |
label |
string | yes | — | Human-readable name for the step. |
workflowId |
SAFE_ID | yes | — | The child workflow's name. Resolved at pre-flight against the project and global workflow stores (project shadows global); see workflow sources. |
inputMapping |
Record<string, string> |
no | {} |
Per-child-input mapping. Keys are declared input names on the child; values are templates ({{ ... }}) or literals. |
outputMapping |
Record<string, string> |
no | {} |
Local-alias → child-output-name. Each entry becomes a field on this node's output, addressable as {{ <nodeId>.<alias> }} downstream. |
maxIterations |
integer (1-50) | no | 1 |
If set above 1, the runner re-invokes the child up to N times unless the child returns failure. See Recipes / closed loops. |
on |
transition map | one of* | — | Locked keys: success, failure, cancelled (declare none or all three). *Required unless outputMapping declares at least one alias. |
workflowId is a SAFE_ID: [a-z0-9_-]+ matching the child workflow's
name. The child must exist on disk at pre-flight; if it doesn't, the run
refuses to start with a graph error naming the missing id.
Workflow inputs and outputs#
A workflow that wants to be called as a sub-workflow declares its contract at the top level:
name: summarize-doc
version: 1
inputs:
- name: document
type: string
description: Raw document text to summarize
- name: maxWords
type: number
optional: true
default: 200
outputs:
- name: summary
type: string
source: '{{ extract.body }}'
- name: tldr
type: string
source: '{{ tldr-node.text }}'
states:
# ...inputs[] and outputs[] use the same IoParam shape:
| Field | Type | Required | Notes |
|---|---|---|---|
name |
identifier | yes | JS-identifier-ish. __-prefixed names are reserved. |
type |
'string' | 'number' | 'boolean' | 'json' |
yes | The declared shape. Strict: string does NOT auto-coerce from number. |
description |
string | no | Surfaced in the editor's I/O panel and in get_workflow MCP responses. |
optional |
boolean | no | Required if absent. An optional input can also carry a default. |
default |
matches type |
no | Only legal when optional: true. Type-checked at schema parse. |
source |
template string (outputs only) | yes | Where the value comes from inside the workflow, typically {{ <nodeId>.<field> }}. |
Workflows without an inputs: / outputs: contract run fine standalone;
they can't be referenced as sub-workflows in a meaningful way (no inputs
to wire, no outputs to read).
Inputs and mapping#
inputMapping is per-required-input. Every required input on the child
must be supplied; optional inputs fall back to default (or undefined)
when the parent doesn't map them.
Mapping values are interpolated against the parent's variables map:
inputMapping:
document: '{{ fetch.body }}' # upstream node output
language: '{{ lang }}' # parent's own declared input
maxWords: 100 # literal (number)
prefix: 'TL;DR:' # literal (string)Coercion is strict. A child input declared type: number with a mapping
that resolves to the string "42" is rejected. Wrap explicitly with
a literal, or surface the value as a number upstream. This avoids the
stdout: "0" truthy trap that bites script-node callers.
Outputs and aliasing#
outputMapping exposes the child's declared outputs as fields on this
node. Keys are the alias other states reference; values are the
child's declared output name:
outputMapping:
summary: summary # alias matches child output name
briefSummary: tldr # rename on the way outDownstream nodes reference them as {{ <subNodeId>.<alias> }}:
review:
type: ai_judge
prompt: |
Is this a good summary?
{{ call-summarize.summary }}A synthetic __outcome field is always available: it's 'success',
'failure', or 'cancelled', matching the transition that fired.
Transitions#
| Event | When emitted |
|---|---|
success |
Child run completed with outcome: success. |
failure |
Child run completed with outcome: failure, OR the child failed mid-run with no terminal end node reached. |
cancelled |
Child run was cancelled, either explicitly via the API/CLI, or cascaded from the parent being cancelled. |
These keys are locked: if the on map declares any one of them, graph
validation requires all three. Declaring only some of the three fails
pre-flight validation, before the run ever starts, rather than failing
partway through a run.
Run-time semantics#
- Pause cascade. If the child pauses (for example on a
humannode), the parent pauses too: the parent'spausedNodeRunIdpoints at this sub-workflow node, and the output marker is{ awaitingChild: true, childRunId: '<id>' }. Resume the child directly; the parent auto-continues once the child terminates. The parent refuses an explicit resume in this state. - Cancel cascade. Cancelling the parent recursively cancels every
active child. Cancelling a child propagates upward through the
cancelledtransition. - Nesting depth. Capped at 8 levels by default
(
ROSCOE_MAX_SUBWORKFLOW_DEPTHoverrides). A workflow that closes a cycle (A → B → A) is rejected at pre-flight. - Iterations.
maxIterations > 1re-invokes the child for the next iteration unless the previous iteration returnedfailure. Iteration state lives on the parent's node_run; a pause mid-iteration resumes at the same iteration when the child completes.
Worked example#
name: doc-pipeline
version: 1
description: Fetch, summarize, review.
initial: fetch
inputs:
- name: docUrl
type: string
states:
fetch:
type: script
label: Fetch document
script: |
import { $ } from 'bun';
const url = `{{ docUrl }}`;
const res = await $`curl -s ${url}`.nothrow();
export default res.exitCode === 0;
validator:
kind: boolean
on:
'true': summarize
'false': fail
summarize:
type: subworkflow
label: Summarize document
workflowId: summarize-doc
inputMapping:
document: '{{ fetch.stdout }}'
maxWords: 150
outputMapping:
summary: summary
on:
success: review
failure: fail
cancelled: fail
review:
type: ai_judge
label: Is the summary acceptable?
model: claude-haiku-4-5
prompt: |
Rate the quality of this summary on a 0-1 scale:
{{ summarize.summary }}
validator:
kind: confidence
threshold: 0.7
on:
'true': done
'false': fail
done:
type: end
label: Done
outcome: success
fail:
type: end
label: Failed
outcome: failureCommon pitfalls#
- Forgetting the contract on the child. A workflow that doesn't
declare
inputs:has no inputs to wire. AnyinputMappingkey fails the cross-workflow graph check with[child: <id>] unknown input. - Typed-coercion surprises. Mapping
'{{ fetch.exitCode }}'(number) into a child input declaredtype: stringis rejected. Mapping a JSON blob intotype: jsonworks; intotype: stringdoes not. - Cycles. A → B → A is rejected at pre-flight. The picker in the editor disables cycle-closing options with a tooltip.
- Resuming the parent. Don't. Resume the child instead. The CLI tells you this; the API refuses the parent resume with a structured error carrying the child run id.
- Renaming the child. Use
roscoe rename(or the MCPrename_workflowtool); it cascades the new id into every referrer. Editing the YAML directly leaves callers pointing at the dead id. - Deleting a referenced child.
roscoe delete(and the MCP tool) refuse a referenced workflow unless--forceis passed. Runlist_workflow_referencesfirst to see who depends on it.
Related#
- Authoring / YAML structure — workflow
top-level shape, including
inputs:/outputs:declaration. - Output chaining — how
{{ ... }}template references resolve. - Recipes / closed-loop feedback —
the
maxIterationspattern.