# subworkflow node

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

```yaml
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](/docs/configuration/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](/docs/recipes/closed-loop-feedback).                        |
| `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:

```yaml
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:

```yaml
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:

```yaml
outputMapping:
  summary: summary # alias matches child output name
  briefSummary: tldr # rename on the way out
```

Downstream nodes reference them as `{{ <subNodeId>.<alias> }}`:

```yaml
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

```yaml
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.

## Related

- [Authoring / YAML structure](/docs/authoring/yaml-structure) — workflow
  top-level shape, including `inputs:` / `outputs:` declaration.
- [Output chaining](/docs/authoring/output-chaining) — how `{{ ... }}`
  template references resolve.
- [Recipes / closed-loop feedback](/docs/recipes/closed-loop-feedback) —
  the `maxIterations` pattern.
