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.
The seam
Section titled “The seam”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=oktype: 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.
Supported child types
Section titled “Supported child types”cryosleep/script/v1 - polyglot script
Section titled “cryosleep/script/v1 - polyglot script”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.
Saying what input a graph takes
Section titled “Saying what input a graph takes”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_idTypes 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
requiredwould 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.
A child computed at run time
Section titled “A child computed at run time”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
workflownode whosedefinition: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.
A graph that names itself
Section titled “A graph that names itself”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.
One child per item
Section titled “One child per item”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.
What a child returns
Section titled “What a child returns”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.
Returning whichever branch ran
Section titled “Returning whichever branch ran”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.
Reading them from the parent
Section titled “Reading them from the parent”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: shaDownstream 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:
nodesis 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 checkcatches 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”# 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 runcryo 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.
Replay semantics
Section titled “Replay semantics”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.
When to compose vs when not to
Section titled “When to compose vs when not to”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.