Composition
Workflows can include other workflows. The includes registry maps names to file paths; a step references a name (or a path directly) via include. The included sub-workflow's steps execute as if they were part of the parent, with namespaced step IDs to avoid collision.
This is how you reuse a deploy flow across staging and production, share an audit checklist across multiple parent flows, or split a big workflow into reviewable pieces.
Two-level design
Top-level includes maps friendly names to file paths:
includes:
deploy: "workflows/deploy.yaml"
auth-flow: "workflows/auth-flow.yaml"Step-level include references a name from the registry, or a file path directly:
steps:
- id: deploy-staging
include: deploy # named reference
dependsOn: [build]
- id: setup-auth
include: "workflows/auth-flow.yaml" # direct path (no registry entry needed)
dependsOn: [design]You do not need an includes registry to use direct-path references. The registry is sugar for repeated includes - if the same sub-workflow is referenced in many places, naming it once keeps things tidy.
Worked example: include-reuse
name: include-reuse
description: Build, then deploy to staging and production using a reusable deploy workflow.
includes:
deploy: "workflows/deploy.yaml"
agents:
architect:
description: "System architect who designs the deployment strategy."
model: "claude-opus-4-6"
builder:
description: "Build engineer who compiles and packages the application."
model: "claude-sonnet-4-6"
tools: [bash]
steps:
- id: design
agent: architect
instructions: "Design the deployment strategy for the new release."
- id: build
agent: builder
instructions: "Build and package the application. Run all tests."
dependsOn: [design]
timeout: "20m"
- id: deploy-staging
include: deploy
dependsOn: [build]
timeout: "30m"
retries: 1
- id: deploy-production
include: deploy
dependsOn: [deploy-staging]
timeout: "45m"
retries: 2
options:
onStepFailure: abortWhat happens:
designruns.buildruns.deploy-stagingis an include step. The executor parsesworkflows/deploy.yaml, runs every step inside it under the namespacedeploy-staging.<inner-step-id>. The whole sub-workflow is bounded by the outer step'stimeout: "30m"andretries: 1.deploy-productiondoes the same with the same sub-workflow file but a different namespace prefix and different bounds. Samedeploy.yaml, two independent runs.
The sub-workflow author writes one deploy flow and parent workflows reuse it. The parent author wires it into different positions in the outer DAG.
Step-level include rules
A step with include is a delegation step. It cannot do anything else. The validator rejects an include step that also has:
agentinstructionsloopconditioncontextFilesmodel
These would be ambiguous - should condition apply before or after the sub-workflow loads? The clean answer is "use a wrapper step". To gate an included flow on a condition, put a separate guard step with a condition before it:
steps:
- id: precheck
agent: validator
instructions: "Decide whether to deploy."
# ... emits result.should_deploy
- id: deploy
include: deploy
dependsOn: [precheck]
# condition not allowed hereMove the gate up:
- id: deploy-gate
agent: gatekeeper
dependsOn: [precheck]
condition: "steps.precheck.result.should_deploy == true"
instructions: "Approve or block."
- id: deploy
include: deploy
dependsOn: [deploy-gate]A step with include may have these fields:
dependsOn- applies to the sub-workflow as a whole.timeout- applies to the entire sub-workflow execution.retries- retries the sub-workflow from the beginning.id- the parent step ID (used as namespace prefix).
Sub-workflow loading
The sub-workflow file is parsed as a full zenflow document. It can have its own agents, includes, options. Specific behaviours:
- Agent merge. Sub-workflow agents merge into the parent scope. Name collisions are an error at load time.
- Step ID namespacing. Sub-workflow step IDs are prefixed:
{parent-step-id}.{inner-step-id}. The deploy sub-workflow's steprun-testsbecomesdeploy-staging.run-testsin the staging include anddeploy-production.run-testsin the production include. - Recursive includes. A sub-workflow can itself contain includes. Includes are hard-capped at depth 5 (
MaxIncludeDepth). Going deeper returns aValidationError. Sub-workflow expansion (which counts nested loops + includes after expansion) has a separate cap of 20 (MaxNestingDepth). - Path resolution. File paths in
includesare relative to the including workflow file's directory, not the working directory.
Reference resolution
When a step has include: foo:
- If
foomatches a key in the parent's top-levelincludesmap, the value (a file path) is the target. - Otherwise,
foois treated as a file path directly.
Mix named and direct references in the same workflow.
Cross-namespace dependsOn
Inner step IDs are scoped to the sub-workflow. An outer step cannot reference inner step IDs, and inner steps cannot reference outer step IDs. Cross-scope qualified references are rejected by the step-ID validator, which only accepts [a-zA-Z][a-zA-Z0-9_-]* (dots are not allowed in step IDs), so any qualified form like deploy.verify fails to parse:
# Top-level workflow
steps:
- id: build
# ...
- id: deploy
include: deploy
- id: smoke-test
dependsOn: [deploy.verify] # ERROR: "deploy.verify" is not a valid step IDTo pass data from a sub-workflow to a downstream outer step, depend on the include step itself (dependsOn: [deploy]). The include step's content / result aggregates the sub-workflow's terminal state. Use sub-workflow result aggregation instead of trying to address inner steps directly.
When to compose
Compose when:
- The same flow runs multiple times. Deploy to staging then production. Audit code, then audit configs, with the same audit logic.
- The workflow is too big to read on one screen. A 30-step workflow split into 5 sub-workflows of 6 steps each is easier to reason about and review.
- Different teams own different pieces. The platform team owns
deploy.yaml, the application team owns the parent flow that calls it.
Do not compose when:
- The "sub-workflow" is one step. Just write the step inline.
- The composition crosses concerns: an include is a black box from the parent's point of view. If the parent needs to inspect / branch on intermediate sub-workflow state, inline the steps instead.
Composition vs forEach
Both produce many invocations of similar work, but they are different shapes:
- Composition (include): one definition reused at distinct, named positions in a DAG. Each include has its own
dependsOn,timeout,retries. The parent author chooses where each invocation goes. - forEach: one definition iterated over a runtime array. Each iteration is anonymous (indexed), runs in parallel, sees one element of the input.
If you have N "deploy" calls with N different positions, names, timeouts, and dependencies, use composition. If you have N parallel deploy calls all behaving identically except for the input data, use forEach.
Cross-links
- DAG scheduling - the parent DAG and its include nodes
- Loops - the forEach alternative for parallel-uniform work
- Conditions - how to gate an include with a wrapper step
- YAML: Workflow - the
includesfield in the schema