Skip to content

Composition

A YAML pipeline can spawn another workflow inline as one of its jobs - that’s the workflow: job kind. The composition seam.

Two real shapes you’ve probably hit:

CI pipeline that needs a 30-day canary. Your build → test → deploy is YAML; “wait 30 days while monitoring metrics” is a durable polyglot script with cryo step boundaries. Without composition, you’d run two separate tools and bolt their outputs together. With composition, the canary sits inline as one job.

Pipeline-of-pipelines. A “release” workflow that orchestrates “build”, “deploy-staging”, “soak”, “deploy-prod”, each itself a full YAML pipeline. Each child has its own DAG, its own log, its own UI, but the parent sees one composed run.

canary:
depends_on: [build]
outputs:
verdict: verdict
workflow:
type: cryosleep/script/v1
script: |
#!/usr/bin/env bash
set -e
cryo step "smoke" -- bash -c 'echo "smoke OK"'
cryo step "metrics" -- bash -c 'echo "metrics within band"'
cryo output set verdict=ok

type: selects the child workflow type. script: is rendered against the parent’s ${{ steps.X.output.Y }} / ${{ env.Y }} scope, then handed to the child as input.

The child’s final output JSON becomes the parent job’s “raw output” - the same role cryo output set values play for shell jobs. The parent’s outputs: mapping picks keys out of it for downstream ${{ steps.<this>.output.<key> }} references.

script: is a polyglot script body (bash by default). The child runs as one script_run activity on a polyglot-capable agent; durable boundaries inside via cryo step / cryo sleep / cryo wait-signal. See Polyglot scripts.

The child’s final output is a flat JSON object: exit_code, duration_ms, plus every key the script staged with cryo output set. All of them are selectable from the parent’s outputs: mapping.

cryosleep/pipeline/v1 - nested YAML pipeline

Section titled “cryosleep/pipeline/v1 - nested YAML pipeline”

script: is a YAML pipeline source - same shape cryo submit accepts at the top level. The child runs as a full pipeline with its own jobs, its own DAG, its own run id.

release:
workflow:
type: cryosleep/pipeline/v1
script: |
jobs:
build:
steps: [{ name: x, run: 'echo build ${{ env.REGION }}' }]
deploy:
depends_on: [build]
steps: [{ name: y, run: 'echo deploy' }]

The parent’s template scope is rendered into the child YAML before submission, so ${{ env.REGION }} from the parent is substituted in.

What comes back is whatever the child document declares in its own top-level outputs: block - see What a child returns. A nested pipeline that declares none hands back its per-job statuses and nothing more, so gate on the job’s success.

A child is checked before it becomes a run: a document that would not compile fails the workflow node with the reason, rather than starting a child that dies inside.

Its wait_for_event: and cancel_on: are subscribed too, correlated to the same subject as the run the child belongs to. Two things follow that a document written for standalone CI would not predict. A top-level cancel_on: in the child now cancels the child, which fails the parent’s workflow node - inert before, since nothing was subscribed. And a matrix: over a workflow node subscribes every expanded child separately, so one matching event releases all of them at once.

A child does not re-enter submit-time admission, so a top-level concurrency: or throttle: in a child document has no effect. Declare the group on the parent. Naming a group the parent already holds is a deadlock either way, and that applies to a job-level concurrency: in the child too: the parent’s workflow: job holds its lease for as long as the child runs, and lease keys are scoped per project, so a child job asking for the same name waits for a release that only its own completion can produce.

graph: / node: - a stored graph or one node-kind

Section titled “graph: / node: - a stored graph or one node-kind”

Instead of type: + script:, a workflow: job can name a stored graph or a single node-kind (see the YAML reference). The child’s output becomes the job’s output - a node’s output for node:, and for graph: the values that graph declares it returns (below). Structured input: is passed through and resolved by the child’s own $expr/$template markers. This is the same composition seam a script reaches with call_graph / call_node.

The three kinds mix. Jobs with nothing ordering them run together, so a stored graph, an inline script and a single node kind can be three parallel children of one run, joined by a fourth job that reads all three as values: Joined children.

A stored graph can declare the shape it expects, so a caller that misspells a key hears about it at submit rather than several nodes into a run that has already started:

{
"version": "cryosleep-graph/v1",
"input_schema": {
"description": "Notify one customer about one order",
"properties": {
"customer_id": { "type": "string" },
"order_id": { "type": "string" },
"attempts": { "type": "integer", "description": "retries before giving up" }
},
"required": ["customer_id", "order_id"]
},
"nodes": [ ... ]
}
$ cryo call graph notify --input '{"custmer_id": "c-1", "order_id": "o-9"}'
error: submit returned 400 Bad Request: `input` is missing required field: customer_id

Types are string, number, integer, boolean, object, array, null. Anything else in the schema is ignored, so a schema you already have still checks on the parts we read - but enum and pattern are among the ignored ones today, and nothing warns you about that. Extra keys in the input are fine; only what you declare is checked, and no value is ever coerced ("3" is not an integer).

required is what makes an input mandatory. A schema of properties alone documents what the graph reads, so a caller that sends nothing is still fine - otherwise describing a graph’s input would break every caller that had been sending none.

The check applies wherever somebody chose the input and is waiting on the answer: cryo call graph, a script’s call_graph, a parent’s workflow: job, and a schedule at the moment you create it. Three places it deliberately doesn’t reach:

  • A schedule’s ticks. They were vetted at create. Re-checking each tick would read whatever version is published then, so republishing with a new required would silently kill every live schedule of the graph.
  • An event handler, whose input is the triggering event’s envelope
    • you did not choose that shape and could not make it match. A handler graph that wants the guarantee checks the envelope in a node.
  • A rerun, which replays the input its original run recorded.

Declaring nothing means the graph takes anything, which is how every graph behaved before this existed.

When the work isn’t known until something has run, the child’s definition comes from the run rather than from the document. Two surfaces express that:

  • A graph workflow node whose definition: resolves from an upstream node’s output. The node runs whatever that output describes.
  • cryo call graph --definition <file|-> from a script step, which is the route a YAML pipeline takes. See Dynamic pipelines.

A definition is a cryosleep-graph/v1 JSON document: a version, a list of nodes (each { "id", "type", "config" }), and optional edges. Unknown fields are rejected, and cryo check <file> validates one before you wire it into a run.

{
"version": "cryosleep-graph/v1",
"nodes": [
{ "id": "build", "type": "shell",
"config": { "run": "cryo output set tag=v1.2.3" } },
{ "id": "ship", "type": "shell",
"config": { "run": { "$template": "echo shipping {{ nodes.build.output.tag }}" } } }
],
"edges": [ { "from": "build", "to": "ship" } ]
}

ship logs shipping v1.2.3. The edge sequences the two nodes and nodes.build.output.tag is how the value crosses - cryo output set in one node, a $template (or $expr) reading nodes.<id>.output in the next. Between nodes there’s no outputs: hop to declare: the staged keys are the node’s output directly, where a YAML job has to expose them. A graph’s own top-level outputs: is a separate thing - what the finished run hands its caller. Canvas lists the other expression roots.

The document above is written by hand to show the shape. The point of the feature is that something produces one: examples/computed-graph is a run whose first node decides what to build and hands back a definition its second node then runs. That is also the answer when matrix: refuses an expression - a run’s node set is fixed when it starts, so a fan-out cannot be built from an output that doesn’t exist yet, and the indirection is what gets you past it.

The sequencing this buys is coarse: the parent depends on the whole child. There is no edge from a parent node to one specific generated node, so a parent node that needs one generated result waits for all of them.

graph: accepts any stored graph in the project, including the one the node is in. That’s how a graph loops: do one pass, and let a switch decide whether to spawn the next. Each pass is a child run, and the parent parks (holding no agent) until it finishes. See Repeating work.

A fan_out whose body is a workflow node starts a child run per item, which is how you get N children from a list you only have at run time:

{ "id": "release", "type": "fan_out",
"config": {
"items": { "$expr": "nodes.plan.output.services" },
"body": { "type": "workflow",
"config": { "graph": { "name": "service-release" },
"input": { "service": { "$expr": "item" } } } }
} }

Each child is inspectable and rerunnable on its own, so re-running the one service that failed leaves the nine that worked alone. The node’s output is the list of what each child returned, in item order. Repeating work covers when to reach for this over an activity body.

A run declares what it hands back. In a graph definition that’s a top-level outputs: block, beside nodes and edges:

{
"outputs": {
"sha": { "$expr": "nodes.build.output.sha" },
"tag": { "$template": "v{{ nodes.build.output.sha }}" }
}
}

The values carry the same $expr / $template markers a node config does and resolve once, when the run finishes, so an output can read any node’s output, the run’s input, or vars. A value with no marker is a constant.

A pipeline declares the same block at the top of the document, written with ${{ … }} holes over the scope a job’s if: reads:

outputs:
sha: "${{ steps.build.output.sha }}"
image: "registry/app:${{ steps.build.output.sha }}"
jobs:
build:
outputs:
sha: sha
steps:
- name: build
run: 'cryo output set sha=$(git rev-parse HEAD)'

A value that is one whole hole keeps its JSON type; a hole with text around it produces a string. The producing job declares its own outputs: as usual - the document reads what a job exposes, so naming a key the job doesn’t list is rejected at submit, the way any cross-job reference is.

A child that routes has a branch that didn’t run on every given run, and a skipped node’s output is null. Reading straight through it fails the run at the last step, after all the work is paid for, so check the status first:

{
"outputs": {
"who": {
"$expr": "nodes.fast.status == 'success' ? nodes.fast.output.who : nodes.slow.output.who"
}
}
}

The failure names the skipped node and this guard, so you don’t have to recognise CEL’s “No such overload” to know what happened.

Declared outputs land beside the per-node status map rather than under it. The child’s output is {"nodes": {…}, "sha": "abc123"}, so a graph workflow node reads nodes.<child>.output.sha, and a YAML workflow: job’s outputs: mapping - a flat key pick over the child’s output - reaches the same keys:

deploy:
workflow:
graph: { name: build-image }
input: { ref: main }
outputs:
sha: sha

Downstream jobs then read ${{ steps.deploy.output.sha }}. A workflow node pointing at a git-stored pipeline (from_repo:) reads it the same way - the child document’s declared outputs are the node’s output.

Four things follow from that shape:

  • nodes is reserved. The status map already holds that key, so a definition declaring an output by that name is refused when you save it.
  • Only a successful run produces outputs. A failed run ends with no output at all, so gate on the child’s success and read its values on that path.
  • An output whose expression can’t be computed fails the run, and the error names the output. A caller gets every declared output or a failure, never a success with a hole in it.
  • Output expressions compile when the definition is saved, alongside the node configs, so cryo check catches a typo before a run spends an hour getting to the end.

A cryosleep/script/v1 child has no outputs: block of its own. Its output is one flat JSON object: exit_code, duration_ms, and every key the script staged with cryo output set. All of them are selectable from the parent’s outputs: mapping.

Anti-pattern: don’t recreate composition with cryo submit

Section titled “Anti-pattern: don’t recreate composition with cryo submit”
Terminal window
# don't do this from inside a script:
result_id="$(cryo submit other-pipeline.yaml | jq -r .id)"
# now the parent has no relationship to the spawned run

cryo submit from inside a script creates an independent run. The parent’s run history doesn’t know about it; failures don’t cascade; the UI doesn’t nest it. Use workflow: instead - it records the parent/child relationship in the parent’s log, so the run stays durable across restarts and the UI can render the nesting. From a script, cryo call graph is the awaited form that does record the relationship.

A workflow: job spawns its child once and records the child’s outcome in the parent’s log. On the parent’s replay the spawn is matched to the already-running child (no re-spawn) and the recorded outcome resolves the parent’s wait, so the parent survives a restart without re-spawning. The child’s own log is independent and replays on its own terms.

Polyglot fan-out is a peer mechanism. A polyglot script: job can call cryo activity-submit to enqueue many child activities and cryo activity-collect to gather their outputs. These children are tracked separately from workflow: children and surfaced in a dedicated “Activities (fan-out)” panel in the web UI. Use this when you need hundreds of parallel units of work from a single script; use a workflow: job when you need each child to be its own inspectable run with its own DAG. See Polyglot scripts.

Compose when:

  • The child has fundamentally different runtime semantics (e.g. a 30-day soak that needs cryo sleep-style eviction; a separate CI sub-pipeline with its own DAG).
  • You want failure isolation: child failure surfaces as the parent job’s failure, but other parent siblings can still observe and react.
  • The child run should be inspectable on its own (independent run id, own logs, own status).

Don’t compose when:

  • It’d just be a sequence of shell commands. Use steps: in the parent.
  • You only need to fan out work in parallel. Use multiple shell jobs at the same DAG layer.
  • The “child” is really a one-shot subprocess. Use a regular run: step.