Skip to content

Nested Workflows

duckflux workflows can call other workflow files as steps, enabling composition and reuse. A sub-workflow is just another participant — it receives inputs, executes its own flow, and returns output — the same interface as any other step.

This is the primary mechanism for breaking large workflows into manageable, testable, and reusable pieces.


A participant of type workflow delegates execution to another .flow.yaml file:

participants:
reviewCycle:
type: workflow
path: ./review-loop.flow.yaml
input:
repo: workflow.inputs.repoUrl
branch: workflow.inputs.branch
FieldTypeRequiredDescription
typestringYesMust be workflow.
pathstringYesPath to the sub-workflow file. Resolved relative to the parent workflow’s directory.
inputmapNoInput values passed to the sub-workflow. Values are CEL expressions.
timeoutdurationNoMaximum total time for the sub-workflow to complete.
onErrorstringNoError strategy applied to the sub-workflow as a whole.

Define the sub-workflow in the participants block and reference it by name in the flow. Use this when the same sub-workflow is called more than once, or when you want to keep the flow clean and readable:

participants:
reviewCycle:
type: workflow
path: ./review-loop.flow.yaml
input:
repo: workflow.inputs.repoUrl
branch: workflow.inputs.branch
flow:
- coder
- reviewCycle
- deploy

Define the sub-workflow directly inside the flow using as for its name. Use this for one-off sub-workflow calls that do not need to be reused:

flow:
- coder
- as: reviewCycle
type: workflow
path: ./review-loop.flow.yaml
input:
repo: workflow.inputs.repoUrl
- deploy

The as field is optional. An inline participant without as is anonymous — its output is only accessible via the implicit I/O chain and cannot be referenced by name in CEL expressions.


The input field maps data from the parent workflow to the sub-workflow’s declared inputs. Values are CEL expressions and can reference any variable available at that point in the flow:

participants:
analyze:
type: workflow
path: ./analyze.flow.yaml
input:
repo: workflow.inputs.repoUrl
commit: coder.output.commitSha
threshold: "7"
  • workflow.inputs.repoUrl — from the parent workflow’s inputs.
  • coder.output.commitSha — from a previous step’s output.
  • "7" — a literal string value.

The sub-workflow receives these as its own workflow.inputs.* variables. Any inputs not provided default to the sub-workflow’s declared default values, if set.


After a sub-workflow step completes, its output is accessible in the parent workflow as <step>.output.*, exactly like any other participant:

flow:
- reviewCycle
- deploy:
when: reviewCycle.output.approved == true

The output exposed to the parent is whatever the sub-workflow defines in its own output block. If the sub-workflow has no output block, the output of its last executed step is used.

review-loop.flow.yaml
flow:
- loop:
until: reviewer.output.approved == true
max: 5
steps:
- reviewer
- coder:
when: reviewer.output.approved == false
output:
approved: reviewer.output.approved
score: reviewer.output.score
rounds: loop.iteration

In the parent workflow, reviewCycle.output.approved, reviewCycle.output.score, and reviewCycle.output.rounds are all accessible.


Sub-workflows behave like any other step from the parent’s perspective, but run with their own isolated context:

  • Isolated execution.context — the sub-workflow gets its own scratchpad, separate from the parent. Data does not leak between them.
  • onError and timeout apply to the whole sub-workflow — if the sub-workflow exceeds its timeout or fails, the parent’s onError strategy for that step applies.
  • Nesting is supported — a sub-workflow can itself call other sub-workflows. There is no hard depth limit, but deep nesting adds complexity.
  • Path resolution — paths in path are resolved relative to the parent workflow file’s directory, not the working directory at runtime.

Since sub-workflow steps follow the same interface as all other participants, they work inside any flow construct.

flow:
- loop:
as: attempt
until: reviewCycle.output.approved == true
max: 3
steps:
- as: reviewCycle
type: workflow
path: ./review-cycle.flow.yaml
input:
round: attempt.iteration
flow:
- runTests
- if:
condition: runTests.output.passed == true
then:
- as: deploy
type: workflow
path: ./deploy.flow.yaml
input:
env: workflow.inputs.targetEnv
else:
- as: notify
type: workflow
path: ./notify-failure.flow.yaml
flow:
- parallel:
- as: lintCheck
type: workflow
path: ./lint.flow.yaml
- as: securityScan
type: workflow
path: ./security.flow.yaml
- as: buildArtifact
type: workflow
path: ./build.flow.yaml

All three sub-workflows run concurrently. Their outputs are available after the parallel block completes.


A parent pipeline that orchestrates three sub-workflows — a review cycle, a build, and a deployment — using outputs from each step to drive the next:

pipeline.flow.yaml
id: release-pipeline
name: Release Pipeline
version: "1"
defaults:
timeout: 30m
inputs:
branch:
type: string
default: "main"
environment:
type: string
default: "staging"
max_review_rounds:
type: integer
default: 5
participants:
reviewCycle:
type: workflow
path: ./review-cycle.flow.yaml
input:
branch: workflow.inputs.branch
maxRounds: workflow.inputs.max_review_rounds
timeout: 20m
onError: fail
buildArtifact:
type: workflow
path: ./build.flow.yaml
input:
branch: workflow.inputs.branch
commitSha: reviewCycle.output.commitSha
timeout: 10m
deployRelease:
type: workflow
path: ./deploy.flow.yaml
input:
artifact: buildArtifact.output.artifactUrl
environment: workflow.inputs.environment
timeout: 15m
notifySuccess:
type: http
url: https://hooks.example.com/success
method: POST
onError: skip
notifyFailure:
type: http
url: https://hooks.example.com/failure
method: POST
onError: skip
flow:
- reviewCycle
- buildArtifact:
when: reviewCycle.output.approved == true
- deployRelease:
when: buildArtifact.status == "success"
- if:
condition: deployRelease.status == "success"
then:
- notifySuccess
else:
- notifyFailure
output:
approved: reviewCycle.output.approved
artifact: buildArtifact.output.artifactUrl
deployed: deployRelease.status

Each sub-workflow encapsulates its own logic, inputs, and outputs. The parent pipeline remains concise and focused on orchestration — routing data between steps and making high-level decisions.