2.2 Namespaces, composition and inputs
Included from Basic tier
After this you can: organise actions in namespaces, compose one action from others with uses:, and pass inputs, outputs and expressions between steps.
Before you start — tick each one:
- Lesson 2.1 — Your first action (
opschain learn 2.1)
In the GUI: the Run form renders an action's declared typed inputs as form fields — an enum as a select over its values, a boolean as a toggle.
One action per file is where lesson 2.1 left you. Real catalogs are
dozens of actions that call each other and hand data along. This lesson
adds the three things that make that tidy: namespaces to organise,
uses: to compose, and inputs, outputs and expressions to pass data —
every one of them checked when the action compiles, before anything
runs.
1. Namespaces
Directories under .opschain/actions become namespaces. The file
.opschain/actions/db/start.yaml defines the action db:start:
action: start # just the leaf — the db namespace comes from the directory
steps:
- name: boot
run: echo booting the database
opschain runs create change db:start --env dev --local
[db:start/boot] booting the database
2026-11-07T00:25:48Z db:start/boot running -> success
2026-11-07T00:25:48Z db:start running -> success
run 01a1b365-b243-77fc-8ec3-48140b4be67f success (0.1s)
Namespaces keep a growing catalog tidy: db:start, db:stop,
web:deploy. Inside a namespace, a bare uses: reference resolves
locally first, then walks up to the root — so db:start can say
uses: stop and get db:stop.
2. Composition with uses:
A step whose uses: names another action inlines that action's steps as
its children — build small actions, compose big ones:
# .opschain/actions/release.yaml
action: release
steps:
- name: build
run: echo building
- name: publish everywhere
uses: publish_fanout
# .opschain/actions/publish_fanout.yaml
action: publish_fanout
steps:
- stage: fan
strategy: parallel
steps:
- name: site_a
run: echo published site a
- name: site_b
run: echo published site b
--plan shows the composed tree, the callee's steps nested under the
calling step:
opschain runs create change release --env dev --plan --local
· release
├─ · build
└─ · publish everywhere
└─ · fan
├─ · site_a
└─ · site_b
Cycles are rejected with the chain named.
Where uses: pulls work INTO this change (one approval envelope, one
tree), a change: step pushes work OUT to another node as its own
governed child change — the right shape when the foreign action must run
under its own node's properties, template pin, approvals and
entitlements:
- name: deploy_db
change:
target: ../assets/database # node path, absolute or project-relative
action: db:deploy # resolved against the TARGET's catalog
property_overrides: { opschain: { release: "${{ inputs.version }}" } }
The step is fulfilled server-side: when it is reached the server creates
the child change on the target (through the ordinary create funnel — the
target's own gates apply), waits for it, completes with the child's
outcome and republishes the child's outputs as
${{ steps.deploy_db.outputs.* }}. The child renders as a hop in the
change's correlation chain (lesson 5.1). One target per step, named
literally — fan-out across targets stays a workflow capability — and the
target and action are checked when the change is created (a dangling
reference is an immediate named refusal) and again when the step is
reached. when: and on_error: apply as on run: steps; retries:
does not (retry the child change instead). Child-of-child composition is
capped by the orchestration.change_depth_limit setting (default 5);
past it the step fails naming the chain. change: steps need the
server — local mode refuses them by name.
MintPress pack composition (uses: mintpress#<ref>, requires the
packs:mintpress entitlement): see opschain docs packs-mintpress.
3. Inputs
Inputs make a composed action parameterisable. The callee declares
them (typed: string, number, boolean, enum with values,
secret, list<string>), the call site binds them with with:, and
the binding validates when the action compiles — before anything runs:
# callee: declares its contract
action: greet
inputs:
who: { required: true }
greeting: { default: "hello" }
steps:
- name: say
run: echo "${{ inputs.greeting }}, ${{ inputs.who }}"
# call site
steps:
- name: greet the team
uses: greet
with: { who: "ops" }
[release/greet the team/say] hello, ops
Leave the required input unbound and the plan refuses, naming the step, the action and the input:
Error: action release: step greet the team calls action greet without its required input "who" — bind it with a with: entry at the call site
An unknown with: name, a missing required input, or a literal value
that violates the declared type (a non-number for a number, a value
outside an enum's values, a secret that is not a secret-vault://
reference) is a named error of the same kind. A with: value carrying
${{ … }} resolves at the consuming step's dispatch instead. A
top-level action's inputs are supplied at run time with -i key=value
(lesson 2.5 shows the refusal when one is missing).
${{ inputs.who }} splices the value into the command as shell text,
which is fine for a word like ops. An input that may carry quotes,
$, braces or newlines — a JSON body, a list of tags, a script — must
not be spliced, because the shell re-parses it before your tool sees it.
Every input also reaches the step as an environment variable,
OPSCHAIN_INPUT_<NAME> (the name upper-cased), holding the raw value;
read it quoted and nothing is re-parsed:
inputs:
body: { type: string, required: true }
steps:
- name: apply
run: deploy --body "$OPSCHAIN_INPUT_BODY"
OPSCHAIN_INPUT_FILE_<NAME> names a file with the same bytes, for tools
that take a path — and it is the only form for a value over 32 KiB.
4. Outputs
Outputs pass data forward. A step publishes with
opschain step set-output <key> <value> (add --sensitive for values
that must be masked in logs — a sensitive output is also stored encrypted
at rest, on a server's store with its keys and on your local store with
its own generated keyset), and any later step reads it:
steps:
- name: produce
run: opschain step set-output build_id "$(date +%s)"
- name: consume
run: echo "deploying build ${{ steps.produce.outputs.build_id }}"
2026-11-07T00:25:49Z release/produce running -> success
2026-11-07T00:25:49Z release/consume queued -> running
[release/consume] deploying build 1794011149
2026-11-07T00:25:49Z release/consume running -> success
For a value too large to pass as an argument, --value-file <path> reads
it from a file (- reads stdin); a step's outputs share a 64 KiB budget,
and an over-budget value is refused at the write with the budget named —
outputs are metadata, and anything larger travels as a declared step
artifact (artifacts: on the step — lesson 3.6). Files need no channel
at all: every step of a change run executes in the run's shared
workspace — locally a git worktree of your repository, so git describe
and friends work — and a file one step writes is on disk for the next.
The workspace is swept at run end, cancel included; --keep-workspace
on a local run retains it for debugging.
5. Expressions
Expressions resolve before a step's command runs:
${{ properties.<path> }}— the merged configuration view (lesson 1.3).${{ context.<path> }}— the run's own context, e.g.${{ context.change.action }}.${{ steps.<name>.outputs.<key> }}— an earlier step's output.
A reference to a path that does not exist fails the step with the
reference named — a typo never silently becomes an empty string. Ordinary
shell syntax ($VAR, ${VAR}) passes through untouched.
Spans are full CEL expressions, so logic goes beyond substitution:
comparisons, ternaries, has(), the filter/map/exists macros, and
chunk/uniq over lists. Two highlights:
-
when:on any step gates it at entry — false records the step skipped with the evaluated condition and its operand values; an evaluation failure fails the step loudly:- name: snapshotrun: dbctl snapshotwhen: steps.precheck.outputs.role == 'primary' -
estate('<node path>')reads another node's converged properties (read-authorised at entry, versions recorded, vault references never resolved):- name: configurerun: wire --db ${{ estate('/projects/core/environments/prod/assets/db-01').properties.listener.host }}
6. When a step fails
Two more primitives shape the aftermath. A step whose when: calls
error() (or always()) runs because the scope failed — the abort
cascade spares it for compensation/cleanup, and the run still finishes
error (with error_handled recorded, so nothing is masked). And
retries: {attempts, backoff} re-executes a failing step in place
before any of that:
- name: patch
run: dbctl patch
retries: { attempts: 3, backoff: 30s }
- name: rollback
run: dbctl rollback
when: error()
- name: report
run: notify ops
when: always()
The scope's state is readable too. success() (true while no sibling
has failed) is the explicit spelling of the condition every ordinary
step carries implicitly, and steps.<name>.status reads one sibling's
outcome ('success' | 'error' | 'skipped' | 'cancelled') for surgical
conditions — when: error() && steps.patch.status == 'error' runs a
handler only when it was patch that failed. The status functions are
legal in when: only.
A step that declares no retries: of its own inherits the
orchestration.retries settings ladder — global → project →
environment → asset, nearest wins, resolved once at run creation — and
each automatic retry records the level that drove it as its
policy_source, so the run's record says which policy re-ran the step.
A run step may also declare its error policy inline with on_error::
continue lets the scope proceed past the step's failure (the step
still records error honestly), and the object form names a
compensating action — sugar that desugars into exactly the hand-written
handler above, as a sibling step <step>.on_error gated by
when: error('<step>'):
- name: patch
run: dbctl patch
retries: { attempts: 3, backoff: 30s }
on_error:
uses: rollback
with:
reason: ${{ error.message }}
then: fail # fail (default) | continue
Inside the handler's scope — its with: values and everything the
invoked action runs — error.step, error.status and error.message
carry the failed step's recorded error, beside its published outputs.
Retries exhaust first; then: fail keeps the run finishing error,
then: continue behaves as ignore-after-handler-success; a handler that
itself fails is terminal, and an action invoked as a handler may not
carry an object-form on_error of its own.
7. Fan out over a list
Action-file steps fan out too: a run: or stage: step may declare
for_each: — a list expression evaluated at the step's entry; each
element becomes an ordinary child step, binding item/index (or an
as: alias and <as>_index) into everything the child evaluates:
- name: ping
run: ping -c1 ${{ host }}
for_each: ${{ properties.hosts }}
as: host
An empty list records the step skipped; an expansion past the
orchestration.fan_out_limit setting (default 50) fails loudly naming
the cap; nested fan-outs must bind distinct as: names. A fanned-out
stage instantiates its nested steps once per element.
Every expression is compile-checked when the action compiles
(error() and friends are legal only in when:), and
opschain docs expressions is the full reference. Cross-node property
layering itself is lesson 1.3's topic.
Check yourself
- How does a step read the output of the step before it, and what does a missing required input do to a plan?
What you learned
- A directory under
.opschain/actionsis a namespace, anddb:startis the action indb/start.yaml. uses:inlines another action's steps under the calling step;change:pushes work out to another node as a governed child change.- Inputs are declared by the callee, bound with
with:, and validated when the action compiles — a missing one refuses the plan by name. opschain step set-outputpublishes a value;${{ steps.<name>.outputs.<key> }},${{ properties.… }}andwhen:are expressions resolved before each step runs.
Next: lesson 2.3 — Waits and gates — the policy way (opschain learn 2.3), or back to your track (opschain learn).