Skip to content

Composition

A run can start another run and wait for it. In a pipeline a workflow: job starts a child run, and in a graph a workflow node does. The child has its own run page, log and status; the parent sees one step that succeeds or fails with it.

A script’s spool call starts a run and waits for it. That run is listed as the caller’s child and ends with it, but goes through its own admission, concurrency and throttle. See From a script. Connecting pipelines, graphs and scripts shows each combination with a runnable example.

Compose when the child should be inspectable and rerunnable on its own, or when it is owned and versioned separately. Two shapes where it pays off:

  • A pipeline that ends in a long canary. build → test → deploy is YAML; “watch the metrics for 30 days” is a durable script with spool sleep in it, and becomes one job of the pipeline.
  • A pipeline of pipelines: a release that runs build, deploy-staging, soak and deploy-prod, each a pipeline or graph someone else owns and versions.

Don’t compose for:

  • A sequence of shell commands. Use steps: in one job.
  • Parallel work. Jobs with nothing ordering them already run together.
  • A durable script as one part of a pipeline. A script: job runs it inline in the same run, with spool step, spool sleep and spool wait signal available. Use a workflow: child only when the script should be its own run.
canary:
depends_on: [build]
outputs:
verdict: verdict
workflow:
type: hyperspool/script/v1
script: |
#!/usr/bin/env bash
set -e
spool step "smoke" -- bash -c 'echo "smoke OK"'
spool step "metrics" -- bash -c 'echo "metrics within band"'
spool output set verdict=ok

type: selects the child’s workflow type. script: is rendered against the parent’s ${{ steps.X.output.Y }} / ${{ input.Y }} scope, then handed to the child. The child’s output is the job’s raw output, and outputs: picks keys out of it for downstream ${{ steps.canary.output.verdict }}.

A job names its child in one of three ways, and a graph workflow step has a fourth, from_repo:.

type: hyperspool/script/v1 - a polyglot script

Section titled “type: hyperspool/script/v1 - a polyglot script”

script: is a script body, bash by default, run on an agent with the polyglot capability. See Polyglot scripts. Its output is one flat JSON object: exit_code, duration_ms, and every key the script staged with spool output set.

type: hyperspool/pipeline/v1 - a nested pipeline

Section titled “type: hyperspool/pipeline/v1 - a nested pipeline”

script: is a pipeline, the document spool submit accepts. The parent’s scope is rendered into it first, so ${{ input.region }} below is the parent’s input:

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

It returns what its top-level outputs: declares (below). One that declares none hands back only its per-job statuses, so gate on the job’s success. A child document that doesn’t compile fails the job with the reason before any child run exists.

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

Section titled “graph: and node: - a stored graph or one node kind”
deploy:
workflow:
graph: { name: build-image } # the published version; `version: 3` pins one
input: { ref: main }
outputs:
sha: sha

node: runs a single node kind, a builtin or <connector>.<action>:

ping:
workflow:
node:
kind: http
config: { url: "https://example.com" }

input: is passed as structured data, and ${{ }} holes in it are filled from the parent’s run before the child starts (${{ steps.pick.output.service }}, say). A node: child returns that node’s output; a graph: child returns what the graph declares. See the YAML reference for every field.

The kinds mix. A stored graph, an inline script and a single node can be three parallel jobs of one run, joined by a fourth that reads all three:

decide:
depends_on: [deploy, canary, ping]
steps:
- name: decide
run: echo "${{ steps.deploy.output.sha }} ${{ steps.canary.output.verdict }}"

A graph workflow node can run a pipeline or graph file straight from a repository, read when the node starts:

- id: deploy
type: workflow
config:
from_repo:
repo: acme/infra # owner/name or a clone URL
path: .spool/deploy.yaml
ref: main # branch, tag or commit; default HEAD
credential: infra-read # a bearer or url-token credential that can read the repo
input: { env: production }

repo and credential are both required: nothing else says which repo to read or with what token. The file’s version: line decides whether it is a graph or a pipeline. A graph runs as a child with input as its run input; a pipeline gets input as its document-level input:. Either way the step’s output is what the file’s top-level outputs: declares.

On Gitea or Forgejo, add forge: gitea (or forgejo) and give repo as a URL, https://git.example.com/acme/infra, since that is where the file is read from. The token only goes to a host the project already trusts, one it connected a repository on with spool repo connect. Any other host is refused before the credential is sent.

The fetch spends a project credential, so on a run started by an untrusted event (a fork pull request, an outside contributor) the step fails before reading anything, saying the credential is withheld. Add if: "!run.untrusted" to skip it on those runs instead.

What a child inherits, and what it doesn’t

Section titled “What a child inherits, and what it doesn’t”

A child’s event waits are live. A pipeline child’s wait_for_event: and cancel_on:, and a graph child’s event nodes and top-level cancel_on:, are subscribed when the child starts, correlated to the same subject as the run it belongs to. A cancel_on: event cancels the child, which fails the parent’s step. A matrix: over a workflow job subscribes every expanded child separately, and one matching event releases all of them.

A child skips run admission. A top-level concurrency: or throttle: in a child pipeline or graph has no effect; declare it on the parent. Naming a group the parent already holds deadlocks, and that includes a job-level concurrency: in the child: the parent’s workflow: step holds its lease while the child runs, so the child job waits for a release only its own completion can produce.

A script’s spool spawn and spool call are the exceptions. The run either one starts is a new top-level run and goes through admission like a submit; see below.

A child keeps its parent’s trust. Under a run started by an untrusted event, run.untrusted is true in every child too, and project credentials are withheld there as well.

A stored graph can declare the input it expects, so a misspelled key is caught at submit instead of several steps in:

version: hyperspool-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:
- id: send
type: shell
config:
run: { $template: "echo notify {{ input.customer_id }} about {{ input.order_id }}" }
$ spool call -w notify --input '{"custmer_id": "c-1", "order_id": "o-9"}'
error: call returned status 400 Bad Request: `input` is missing required field: customer_id

Types are string, number, integer, boolean, object, array and null; nothing is coerced ("3" is not an integer). Only required makes a field mandatory, and extra keys are fine. Other keywords, including enum and pattern, are ignored without a warning.

The check runs where someone chose the input: spool call -w, a script’s call_graph, a parent’s workflow: job, and a schedule when you create it. It skips a schedule’s ticks (republishing with a new required would otherwise kill every live schedule), event handlers (the input is the event envelope), and reruns (they replay the recorded input).

When the work is only known once something has run, a graph workflow node can take its definition: from an upstream output and start a child run of whatever that output describes.

In a pipeline, a script: job writes the document and runs spool call <file|-> (dynamic pipelines). That starts a separate run, admitted on its own like any submit, which the job waits for and which ends when the calling run ends; from a script covers it.

A definition is a graph document in JSON or YAML: a version, a list of nodes, and optional edges (every field is in graph documents). spool workflow check <file> validates one before you wire it into a run.

Here a step produces the document: generate decides what to build and build_all runs it:

version: hyperspool-graph/v1
nodes:
- id: generate
type: code
config:
interpreter: [node]
script: |
// Stand-in for the real decision: a diff, a manifest, a service.
const targets = ["linux", "docs", "site"];
const graph = {
version: "hyperspool-graph/v1",
nodes: targets.map((t) => ({
id: `build_${t}`,
type: "shell",
config: { run: `echo building ${t}` },
})),
};
require("child_process").execFileSync(
"spool", ["output", "set", "--values", JSON.stringify({ graph })]);
- id: build_all
type: workflow
config:
definition: { $expr: "nodes.generate.output.graph" }
edges:
- { from: generate, to: build_all }

This is also the way around matrix: refusing an expression: a run’s node set is fixed when it starts, and the child run is a new one. The sequencing is coarse. No edge reaches from the parent into one generated step, so a parent step 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 is how a graph loops: do one pass, and let a switch decide whether to start the next. Each pass is a child run, and the parent waits for it holding no agent. Nesting stops at 20 levels, so a loop that needs more passes belongs in a script or a schedule. See Repeating work.

A fan_out with a workflow body 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 can be inspected and rerun on its own, so rerunning the one service that failed leaves the nine that worked alone. The step’s output is the list of what each child returned, in item order. Repeating work covers when to choose this over an activity body.

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

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

Values take the same markers as a node config and resolve once, when the run finishes, so an output can read any step’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, 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: 'spool 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 block reads only what a job exposes, so naming a key the job’s outputs: doesn’t list is rejected at submit.

A child that routes has a branch that didn’t run, and a skipped step’s output is null. Reading straight through it fails the run at the end, after 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"

Declared outputs land beside the per-node status map. The child’s output is {"nodes": {…}, "sha": "abc123"}, so a graph workflow node reads nodes.<child>.output.sha, and a workflow: job’s outputs: picks sha: sha as in the deploy job above. Downstream jobs then read ${{ steps.deploy.output.sha }}.

  • nodes is reserved for the status map; 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 before reading them.
  • An output whose expression can’t be computed fails the run, naming the output. A caller gets every declared output or a failure.
  • Output expressions compile when the definition is saved, so spool workflow check catches a typo before a run spends an hour getting to the end.

A hyperspool/script/v1 child has no outputs: block; its output is the flat object described above.

A script has two ways to start another run. Both are recorded in its history, so a replay doesn’t start a second one:

  • spool call -w <name> (or spool call <file|-> for a graph, a pipeline or a script) waits for the run it starts and prints its output. The called run is listed with the caller as its parent, and ends when the caller ends.
  • spool spawn <file> starts a run and prints its id without waiting. A file whose version: is hyperspool-graph/v1 runs as a graph, a .yaml or .yml file as a pipeline, and anything else as a script; --as pipeline|graph|script overrides the guess. The spawned run carries on by itself after the caller ends.

Both kinds of run go through admission as a submit would, so the document’s own concurrency: and throttle: apply. A busy skip group, a full queue or a drop throttle fails the spawn:

error: spawn returned status 422 Unprocessable Entity: spawn refused: the spawned pipeline was refused: concurrency group "deploy" is busy and its policy is skip

Don’t use spool submit inside a job. A job has no server address or token of its own, so the command refuses, and a run started that way would have no link to the one that started it.

A child is started once and its outcome recorded in the parent’s log. On the parent’s replay the start is matched to the existing child and the recorded outcome resolves the wait, so the parent survives a restart without starting the child again. The child’s own log replays on its own terms.

Script fan-out is a separate mechanism: spool activity submit enqueues activities and spool activity wait gathers them, and the run page lists them under Activities. Use that for hundreds of units of work from one script, and a child run when each unit should be its own run. See script commands.