subworkflow node

Run another workflow as a single node, for composition not duplication.

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: cleanup

Configuration#

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 out

Downstream 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 human node), the parent pauses too: the parent's pausedNodeRunId points 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 cancelled transition.
  • Nesting depth. Capped at 8 levels by default (ROSCOE_MAX_SUBWORKFLOW_DEPTH overrides). A workflow that closes a cycle (A → B → A) is rejected at pre-flight.
  • Iterations. maxIterations > 1 re-invokes the child for the next iteration unless the previous iteration returned failure. 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: failure

Common pitfalls#

  • Forgetting the contract on the child. A workflow that doesn't declare inputs: has no inputs to wire. Any inputMapping key fails the cross-workflow graph check with [child: <id>] unknown input.
  • Typed-coercion surprises. Mapping '{{ fetch.exitCode }}' (number) into a child input declared type: string is rejected. Mapping a JSON blob into type: json works; into type: string does 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 MCP rename_workflow tool); 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 --force is passed. Run list_workflow_references first to see who depends on it.

View this page as Markdown

Predictable workflows from unpredictable AI