Skip to content

cryo CLI reference

cryo is the user-facing CLI and the in-script polyglot SDK rolled into one binary. The two contexts share global flags but expose different subcommands.

Terminal window
--server <url> cryosleep server URL (default $CRYOSLEEP_SERVER)
--local require a running local `cryo dev` (never the configured server)
--hosted target the configured (logged-in) server even if a `cryo dev` is up
--org <slug> active org (default $CRYOSLEEP_ORG)
--project <slug> active project (default $CRYOSLEEP_PROJECT)

$CRYOSLEEP_TOKEN provides the bearer token for authenticated calls. cryo login writes org/project/server into ~/.config/cryo/config.toml so subsequent calls don’t need any flags or env vars.

Server-facing commands resolve their target in this order: --server/$CRYOSLEEP_SERVER first, then --local/--hosted, then a running cryo dev (found automatically), then your logged-in config.toml, then the built-in default. So while a local cryo dev is up, commands hit it - not the hosted control plane - unless you pass --hosted or --server. Each command prints its resolved target (→ local dev … / logged in / configured / default); run cryo where to see it without running anything.

Inside a job, an unnamed target is refused

Section titled “Inside a job, an unnamed target is refused”

A job’s environment carries the agent socket, the run id and the activity id. It carries no server URL and no token, deliberately: the in-job subcommands go over the socket and need neither.

So a server-facing verb called from inside a job - cryo logs, cryo cancel, cryo submit - has nothing to resolve, and the last two steps of the order above would hand it whatever this machine holds: your logged-in control plane on a laptop, the built-in default in a container. That is a different deployment from the run the job belongs to, reached with credentials the job was never given, and it answers like a success - an empty list, a 404.

Those calls are refused instead, with an error saying so. To let a job talk to a control plane on purpose, name it in the job’s env::

jobs:
report:
env:
CRYOSLEEP_SERVER: https://api.example.com
CRYOSLEEP_TOKEN: ${CRYO_REPORT_TOKEN} # a project secret
script: |
cryo runs ls --tag release --limit 5

cryo where still answers inside a job, and says that the target it printed came from the machine rather than from the run.

Four verbs need no server at all inside a job, because they read the run’s own project over the socket:

Verb Answers
cryo status <run> state, name, tags, timing, what it’s waiting on
cryo events <run> the run’s history - where an approval’s answer and answerer are
cryo runs ls [--tag T] [--limit N] the project’s runs
cryo runs annotations <run> what a run has written on itself
jobs:
report:
depends_on: [build, test]
steps:
- name: summarise
run: |
cryo status "$CRYO_WORKFLOW_ID"
cryo runs ls --tag release --limit 5

The scope is the project, and the server decides it from the activity the job is claiming - not from anything the job sends. A run id from another project is refused rather than answered empty, so “no such run” never means “you asked the wrong place”. CRYO_WORKFLOW_ID is this run’s id, available in shell steps and scripts alike.

These reads are not durable. Two calls can give two answers, and a replay a third; nothing about them is recorded. Anything the run decides on has to go through cryo step, which records what it saw, or a state cell.

Two limits worth knowing:

  • A job that was given a server in its env: keeps reaching that server. These verbs only take the socket path when there is no named target - a server you named on purpose is not silently redirected.
  • A run marked untrusted-event - one started by a fork PR or an outside contributor - reads nothing. It is already denied every secret and credential, and reading the project’s other runs is denied on the same grounds.

Run from any shell; talk to a running cryosleep server.

Answering an approval gate, which asks for each field the gate declares, with its type and its options:

cryo approve walking through a gate’s declared fields: a required ticket, a staging/production choice, a bounded number, a yes/no, and a free-text note.

The same gate from cryo follow, without leaving the run you’re watching - select the parked wait, press a:

The follow TUI with an approval form open over the run: the gate’s prompt, one field per line with the active one marked, and the valid options listed under it.

Subcommand Purpose
cryo submit <file> [--as=yaml|script|graph] [--input <json>] [--follow|-f] [--tag <t>] Submit a pipeline (.yaml), a graph (JSON or YAML, recognised by its version: cryosleep-graph/… marker), or a polyglot script; --input supplies the run input; --follow opens the TUI on the new run
cryo submit … [--concurrency-group <g> [--concurrency-policy queue|cancel-running|cancel-queued|skip] [--concurrency-queue-max <n>]] [--cancel-on <event>]… Request-level admission: one running run per group (queue behind it, cancel the running/queued members, or skip and create nothing) and/or cancel this run when a bus event arrives. Works for scripts and pipelines alike; wins over the YAML concurrency:/cancel_on: blocks
cryo submit … --idempotency-key <k> Dedupe the submit: the key derives the run id, so a redelivery carrying the same one answers 200 with the original run instead of starting a second. Prints that run’s id, so --follow follows the run that happened
cryo submit … --throttle <limit>/<period> [--throttle-key <k>] [--throttle-burst <n>] [--throttle-policy queue|drop] Rate admission for this run - the request-level twin of the pipeline throttle:, and the only way a submitted script, graph or node run gets one. Outside a pipeline there is no name: to count under, so --throttle-key is required. cryo call node / cryo call graph have no throttle flag; reach the raw API for those
cryo submit --repo-path <p> --repo <owner/repo> --repo-credential <name> [--ref <r>] Fetch a pipeline out of a repo at a ref and run it server-side, so the run builds the definition that commit shipped with
cryo call node <kind> [--config <json>] [--input <json>] [--follow|-f] [--tag <t>]… Run one graph node-kind on its own and return its output - a builtin (http, code, shell, …) or a connector action (matrix.send_message). --config is the node config (may carry $expr/$template resolved against --input); both default to {}
cryo call graph <name> [--version <n>] [--input <json>] [--follow|-f] [--tag <t>]… Run a stored graph by name - the published version unless --version pins one; --input is the run input (default {})
cryo call graph --definition <file|-> [--input <json>] [--follow|-f] [--tag <t>]… Run an inline cryosleep-graph/v1 definition as an awaited durable child, with no publish step - how a job runs work it computed at runtime. - reads the definition from stdin
cryo graph ls The project’s stored graphs, each with its draft and published version - so a graph with edits that were never published says so
cryo graph put <name> <file|-> [--publish] Store a graph definition (JSON or YAML, normalised to the IR) as a new draft version under <name> - the same graph the canvas edits. --publish also points the published version at it so cryo call graph <name> runs it. The name comes from this command, not from any name: in the document; - reads the definition from stdin
cryo graph get <name> [--version <n>] [--published] Print a stored graph’s definition as JSON. Defaults to the current draft (what the canvas shows); --published prints what’s live, --version a specific one. The output round-trips straight back into cryo graph put
cryo graph show <name> The graph’s versions (draft, published) without printing the document
cryo graph delete <name> Delete a stored graph and all its versions (needs Admin on the project, where put and publish need Member). Runs it already produced are untouched
cryo status <run-id> Coarse run status (json)
cryo runs ls [--tag <t>]... [--limit <n>] List recent runs in this project (alias: cryo ls / cryo ps)
cryo where Show which server the CLI targets right now, and why
cryo runs annotations <run-id> List a run’s annotations as JSON
cryo events show <event-id> One bus event with its full payload
cryo follow <run-id> Live TUI for a run (same view submit --follow opens). On a parked wait, s sends a signal and a opens the approval gate’s own form - its prompt and its declared fields, filled in one at a time
cryo logs <run-id> [--follow|-f] Captured logs; --follow tails live, and on a finished run serves the raw byte stream - which is where a retried step’s earlier runs are. The itemised form prints the run that finished each step, and says under it how many there were
cryo events <run-id> [--follow|-f] Durable event log (json or NDJSON)
cryo steps <run-id> The run’s durable step and stepFn checkpoints, as JSON - how you see which checkpoints a replay reused and which re-executed
cryo activities <run-id> [--failed] The run’s polyglot fan-out children, one row per cryo activity-submit, with its exit code. Failures sort first, and --failed drops the rest - a thousand-child run is how you find the three that broke. A whole script is one activity to the event log, so cryo events and cryo steps can’t answer this. Same-named children of two different script activities are suffixed with their parent’s seq
cryo events ls Recent project bus events (cryo emit), newest first
cryo cancel <run-id> [--reason=<s>] Cancel an in-flight run
cryo rerun <run-id> [--from <node>] [--no-tags] Re-submit a terminal run as a fresh run (keeps its canonical input). A rerun re-enters the same admission gates: a pipeline from its document, and a script, graph or node from the concurrency-group: its original carries. cancel-running comes with it, so pressing rerun against a stuck holder still displaces it; skip and cancel-queued fall back to queue, since a rerun is you asking for this run now and those two would either produce nothing or drop runs somebody else is waiting on. queue_max is not reproduced, and neither is a --throttle given at submit, so a rerun of a rate-limited script is not itself rate-limited. --from <node> re-runs that node and everything downstream of it, seeding the untouched upstream nodes with their recorded outputs, so a long run that failed near the end doesn’t repeat completed work or its side effects. For a pipeline the node is a job name: the document is lowered to its graph to resolve it, so that rerun is dispatched as a graph run and carries kind:graph. A run that recorded no node outputs has nothing to seed from and rejects --from, as does a single node call. A SCRIPT accepts it and means something weaker: the point is a cryo step site, the steps before it return the results the original recorded, but the script around them runs again from line one - so anything it does outside a durable call happens a second time. A loop reaches one site many times, so a bare name means the first (dropping every iteration) and site#2 picks a later one - unless a site is literally named that, which wins. --from-family settles a name used by two kinds of call in one script (a cryo wait-signal deploy and a cryo step deploy), which is otherwise refused rather than guessed. --no-tags drops the original’s labels (so a commit-status closer doesn’t fire against an old commit) and keeps its lineage tag and its group - membership is admission, not a label. A rerun of an untrusted run stays untrusted: it is still the same unreviewed content, so it gets no credentials, and --no-tags does not shed the mark either
cryo signal <run-id> <name> [--payload <json>] Deliver a signal to a workflow. A run still queued behind a concurrency: group has nothing to receive one, so the signal is held and handed over when the run is promoted - you can answer a queued deploy without waiting for it to start
cryo approve <run-id> [<job>] [--field <name>=<value>]... [--payload <json>] [--no-input] Answer an approval gate - an approval node in a graph or an approval: job in a pipeline, same verb for both. The job name defaults to whatever the run is parked on. On a terminal, fields the gate declares are asked for one at a time; --field supplies them without prompting and values are converted to the declared type, so --field amount=5 reaches a number field as 5 and --field urgent=yes as true. --payload <json> sets the whole submission object at once and any --field overrides a key in it. --no-input never prompts. A rejected submission comes back with the whole schema
cryo secret list / get / set / rm Manage per-project secrets
cryo credential list / set / rm Typed auth (secrets): an auth header on an http or connector node, environment variables on a code/shell node or a pipeline job. Values are write-only
cryo account list / rm Connected accounts: an identity a project borrows, referenced by name wherever a credential is. Connecting one happens in the console, because the provider asks the granter to approve
cryo schedule create / list / show / edit / run / pause / resume / rm Recurring runs: a script/yaml file, --graph <name>[@<ver>] [--input <json>] for a stored graph resolved by reference each tick, or --node <kind> [--config <json>] [--input <json>] for one node kind. --overlap skip|allow|buffer_one|cancel_other says what a tick does when the last one is still going; --jitter <duration> spreads deadlines so schedules on one cadence don’t stampede. edit <id> takes the same flags and changes only what you pass, keeping the id its run history hangs off; show <id> prints the body, --json the stored row; run <id> fires it once now and prints the run id, leaving the cadence and --overlap out of it
cryo handler set <name> <file> [--as] [--tag <t>]... [--requires <c>]... --on <event-types> Register a named handler workflow; --on is a comma list of event-type patterns (push, run.failed, run.*, *) (events)
cryo handler set <name> --graph <graphname> --on <event-types> Register a stored canvas graph as a handler
cryo handler set <name> --node <kind> [--config <json>] --on <event-types> Register one node kind as a handler - a builtin (http, shell, code) or a <connector>.<action>. The smallest handler there is: a reaction that is a single call needs no graph around it
cryo handler init [<template> [file]] Print or write a starter router script (ci-router, deploy-on-tag, notify, monorepo-paths)
cryo handler ls List the project’s handlers
cryo handler show <name> Print one handler’s config
cryo handler rm <name> Remove a handler by name
cryo event-hook set <name> --event-type <t> Give the project an inbound HTTP endpoint whose deliveries become bus events; prints the ingest URL and a shared secret, shown once (events)
cryo event-hook set <name> --manifest <m> --key <signing-key> Same, verified by a built-in manifest (e.g. github): it checks the signature, shapes the payload, and derives the emitted type per delivery
cryo event-hook set <name> --event-type <t> --hmac-header <h> --hmac-key <k> [--hmac-prefix <p>] Same, verified by an HMAC signature you describe yourself
cryo event-hook ls / show <name> / rm <name> List, inspect, or remove the project’s event hooks
cryo token create <name> [--expires-in <dur>] Mint a personal access token; plaintext shown once
cryo token ls / rm <id> List or revoke your tokens
cryo org cap <n|unlimited> Set org concurrency cap (admin)
cryo project cap <n|unlimited> Set project concurrency cap (admin)
cryo agents ls Agent registry: id, capabilities, status, last seen (deployment-global)
cryo agents issue <agent-id> [--name <label>] --cap <cap>... [--project <org/project> | --org <slug>] [--admin] Mint an agent token; plaintext shown once. A scope needs owner in that org; an unscoped (deployment-wide) token needs an instance admin; --admin mints a scoped one as the operator (details)
cryo agents shutdown <agent-id> Ask an agent to drain and exit
cryo agents revoke <token-id> Invalidate an agent token immediately (instance admin). This is the leak response: shutdown only asks the process to drain and leaves the credential usable
cryo login [server-url] [--token <pat> | --token-stdin] Sign in with a PAT from the web UI; resolves org/project from your memberships, writes config.toml
cryo project ls / create / rm List, create (create <slug> [--name <n>], admin), or delete projects in the org
cryo org members ls / add / rm Org-wide members; add <email> --role <r> invites by email
cryo project members ls / add / rm Per-project grants, same shape (roles)
cryo config show / path Inspect resolved config

Inside a job, cryo call node / cryo call graph are durable: they submit the child, park until it finishes (suspend-and-replay), and print its output - capture it with output=$(cryo call node http --config '{"url":"…"}'). Every SDK exposes call_node and call_graph too; the SDKs’ call_graph takes a stored graph by name, so the inline --definition form is a CLI verb. Outside a job they’re the one-shot submits above (print a run id).

Operator commands for fresh or self-hosted deployments live under cryo admin --help:

  • cryo admin bootstrap mints the first user, org, and project on an empty deployment.
  • cryo admin org create <slug> --owner <email> creates an org (with an owner and a starter project) after that. Bootstrap only makes the first one; this is how you onboard every org after it.
  • cryo admin user token <email> mints a PAT for a user. On a deployment without an identity provider, this is how you hand a teammate a token, since they can’t sign in to mint their own.
  • cryo admin user invite <email> pre-authorises an email for OIDC sign-in (prefer cryo org members add).
  • The CRYOSLEEP_ADMIN_* server-side env-bootstrap (below) pins an admin user/org/project/token at startup.

Helpful patterns:

Terminal window
# Submit and tail
cryo submit ./pipeline.yaml | xargs -I{} cryo logs {} --follow
# Watch durable events
cryo events <run-id> --follow | jq -c
# Mint dev tokens (only valid against an empty DB or with a key).
# The key is compared to the server's CRYOSLEEP_BOOTSTRAP_KEY env.
cryo admin bootstrap --email me@x --org me --key secret

Pinning an admin user from the server side

Section titled “Pinning an admin user from the server side”

Setting these on the server process (not the CLI) makes the server idempotently upsert an admin user/org/project and import a caller-supplied PAT on every startup. Useful for dev (stable token across restarts) and for prod first-boot (operator pins the PAT from a secret manager instead of running cryo admin bootstrap then distributing the response).

Variable Purpose
CRYOSLEEP_ADMIN_EMAIL Admin email (required to enable env-bootstrap)
CRYOSLEEP_ADMIN_TOKEN PAT plaintext, must start with cspat_
CRYOSLEEP_ADMIN_TOKEN_FILE Path to a file containing the PAT (use instead of _TOKEN, not both)
CRYOSLEEP_ADMIN_DISPLAY_NAME Optional display name
CRYOSLEEP_ADMIN_ORG Org slug (default default)
CRYOSLEEP_ADMIN_ORG_NAME Org display name
CRYOSLEEP_ADMIN_PROJECT Project slug (default default)
CRYOSLEEP_ADMIN_PROJECT_NAME Project display name

Idempotent: re-running with the same token is a no-op; running with a different token leaves the old row in place (revoke manually if needed). A token already bound to a different user is rejected - env-bootstrap won’t silently transfer ownership.

Anyone with read access to the env owns the org. For prod, source the token from a secret manager and put it in _TOKEN_FILE rather than _TOKEN, and prefer per-user OAuth/SSO once the substrate supports it.

Registering the webhook that turns pushes into runs is done from the web UI; cryo submit --repo-path then runs a repo pipeline on demand from the CLI, naming the repo and the credential that reads it. Nothing binds a project to a repo, so both are given per request. The full walkthrough (pipeline source options, trigger env, push gating, and where the repo comes from) is in CI from a repo.

cryo check <file>... validates local files with no server and no network. A pipeline goes through the same lowering that starting a run performs, so the checks are the run’s own: the schema, the dependency graph, and every ${{ }} expression in a step body, an if:, an env: value or a script:, down to the references it makes - a job name nothing declares, an output key a job doesn’t list, a scope root that doesn’t exist. (Holes inside a workflow: job’s graph: or node: input belong to the child and are passed through unread.)

Terminal window
cryo check ci/*.yaml # ok / FAIL per file, exit 1 if any failed
cryo check --json graphs/*.json # machine-readable, for a PR check

It decides each file’s kind exactly as cryo submit does. A document whose version starts cryosleep-graph/ is a graph whatever it is called; .yaml and .yml are pipelines; anything else is a polyglot script, whose body belongs to its interpreter and is not checked here beyond reporting which interpreter will run it. A graph or a pipeline can be written in JSON or YAML, since YAML is a JSON superset. cryo submit and cryo run use the same rule, so the canvas’s .json graph submits as a graph rather than being handed to bash. Graph definitions report every problem in one pass, each named with its node:

Terminal window
FAIL graphs/deploy.json (graph)
node notify: unknown node type "slack"
node wait_ok: wait needs exactly one of `duration` or `signal`

Because it is the same code path, a definition that passes here is not rejected at submit - the point is to see a typo before the push rather than in a failed run, and a reference typo is worth catching early: it sits in a job that only runs after its upstream has already built, deployed, or waited.

A pipeline that will not parse is shown against the line it failed on, with the key you probably meant:

cryo render on a pipeline with a misspelled key: the error names the field, points at line 4 column 5 with the offending line quoted and a caret under it, and suggests steps.

An indentation mistake also names the block it began in, since the line the parser gives up on is rarely the line to fix.

cryo check says a document is valid. cryo render says what it will do: the graph the engine walks, plus the settings the control plane reads before that graph starts. No server, nothing run.

cryo render –summary on a release pipeline: three nodes and two edges, the matrix job showing as a single fan_out and the wait job as a wait node, then the tags, concurrency group and cancel_on it will be submitted with; then the same settings as JSON under submit.

A pipeline is not what runs. Jobs lower to nodes, depends_on becomes edges, a wait: job becomes a wait node, and a matrix: becomes one fan_out node rather than one node per combination - which is the sort of thing you would otherwise learn from the canvas after a run.

Without --summary it prints JSON with two keys. graph is the cryosleep-graph/v1 definition the interpreter walks. submit is everything the control plane reads before it starts - tags, the top-level if:, concurrency, cancel_on, and the input: the document pins. Both halves matter, because two pipelines whose graphs are byte-identical still behave differently if one queues and the other cancels the running member:

Terminal window
cryo render ci/release.yaml > .cryo/release.json # review it in the PR

That is what makes it worth having for generated CI. If a script writes your pipeline at commit time, the generated YAML is only half the story; this is the artifact, so a change in what your generator emits shows up as a diff rather than as a surprise in production.

A document that renders is one that runs. render applies the same validation a submit does, so a depends_on cycle, an edge to a job that doesn’t exist, or an unknown node type is an error here rather than a definition printed back and refused later. That is also why rendering a graph document is worth doing even though nothing is lowered: it is the answer to “did my generator emit something that runs”.

The graph does not depend on the run input a caller supplies. ${{ input.x }} survives compilation as a marker the engine resolves when the node runs, and a matrix: takes literal values - so one render answers for every run, and there is no flag to make it pretend otherwise. The input: the document pins is a different thing, and it is in submit where a diff will catch it.

Run a cryosleep instance on your machine - server + agent + storage in one process - without deploying anything.

Subcommand Purpose
cryo run <file> [--as=yaml|script|graph] [--input <json>] [--tag <t>]... [--tui] [--db <path>] Run a workflow once in an ephemeral instance, stream its output, exit with the run’s status. --input is readable as input.<field> from a pipeline or graph
cryo dev [--port N] [--bind ADDR] [--db <path>] [--no-discovery] Start a persistent local instance (web UI + discovery file) and leave it running. --bind 0.0.0.0 reaches it from the rest of your network
cryo init [dir] Write starter pipeline.yaml, durable.sh and signals.sh into a directory; refuses to overwrite files that are already there
cryo render [--summary] <file> Print what a document would run as, without running it: under graph, the cryosleep-graph/v1 the interpreter walks; under submit, the settings the control plane reads first (tags, if:, concurrency, cancel_on, a pinned input:). Validated as a submit validates, so a cycle or a dangling edge is an error here rather than at run time. --summary lists it instead of the JSON

cryo run is the zero-setup path - it spins up an in-memory instance, submits the file, streams the combined log to stdout, and tears everything down once the run is terminal. The process exit code is 0 if the run completed, non-zero otherwise. Plain output by default (scriptable); --tui opens the interactive follow surface. --db <path> persists to sqlite instead of in-memory.

Terminal window
cryo run ./pipeline.yaml # run a pipeline, stream, exit 0/1
cryo run ./deploy.sh --tui # a polyglot script, interactive follow

cryo dev is for when you want the instance to stick around - it serves the web UI and writes a discovery file, so other cryo commands in the same account auto-target it (no --server/--token needed):

Terminal window
cryo dev & # persistent; prints url + token
cryo submit ./pipeline.yaml # auto-finds the running cryo dev

Both embed an agent advertising shell, polyglot and connector, so YAML pipelines, durable scripts and connector/http nodes all run locally. A job that requires: a capability your local agent doesn’t advertise (e.g. build) won’t be claimed - that’s expected; those pin to a real runner.

cryo agent run <server-url> <agent-id> runs a production agent that claims work from a deployed server. It reads the bearer from $CRYOSLEEP_AGENT_TOKEN and advertises capabilities from repeated --cap flags (or $CRYOSLEEP_AGENT_CAPABILITIES). See Deploying agents for tokens, systemd, and self-update.

Run only from inside a script_run activity (the agent sets $CRYO_AGENT_SOCKET; outside that context these calls fail with a clear error). See Polyglot scripts for detailed semantics.

Subcommand Purpose
cryo step <name> [--label <text>] -- <cmd...> Durable step boundary; cached on replay. The name is the program point, so calling it in a loop is normal and each pass through gets its own checkpoint. --label says what THIS iteration was working on (web-2) and is display only - it never affects the memo, and defaults to the command. Names may not begin with _, which is reserved for the engine’s own durable primitives
cryo sleep <duration> Suspend wall-clock time; agent slot freed; script re-runs from top on wake
cryo wait-signal <name> [--timeout <duration>] Suspend until signal arrives; same suspend-and-replay primitive as sleep. With --timeout, gives up after that long and exits 124 instead (the timeout(1) convention), so if ! cryo wait-signal ship --timeout 7d; then … branches on it
cryo wait-event <type> [--timeout <duration>] Suspend until a matching project bus event arrives; the event envelope becomes stdout. Sugar over wait-signal with a server-side event-to-signal bridge, on a signal of its own so several jobs can wait for one type. --timeout behaves as above (exit 124)
cryo lock <group> [-- <cmd...>] Take a project-scoped concurrency lease, serializing a critical section across runs. The bracketed form scopes the lease to <cmd>: it is handed back as soon as the command finishes, so the next waiter starts then rather than when your run ends. A bare cryo lock <group> has no closure to scope to and holds until the run is terminal. Same lease a job’s concurrency: takes (yaml)
cryo activity <name> [flags] -- <cmd...> Submit + collect a cryosleep-registered activity
cryo activity-submit <name> [flags] -- <cmd...> Fire-and-forget submit
cryo activity-collect <name> Suspend until a previously-submitted activity completes; agent slot freed during the wait
cryo activity-wait-all Suspend until every activity this script submitted has a result, then print how many failed. Exits 0 whatever that count is - a failed child is yours to judge. Without it a script can exit while children are still running, and their results, including failures, are never read
cryo spawn <file> [--as pipeline|script] [--tag <t>]... Start a child workflow; memoized on replay (- reads stdin)
cryo emit <type> [--key <subject>] [--payload <json>] Emit a project bus event. --key sets its subject (omit for a broadcast). In-script: memoized on replay; from a terminal: posts to the server. Reserved types are rejected: push, manual, schedule.tick, spawn, and the run.* / cryo.* namespaces (events)
cryo annotate [--style <s>] [--title <t>] [--context <name>] [--append] [<body>|-] Attach a markdown note to the run (annotations)
cryo state get <key> [--version] [--entity <name>] [--run <id>] Read a durable state cell; prints the value, empty if unset. --version prints the cell’s version instead - the precondition a cas swaps on. Outside a job it reads over the API and needs a scope: --run <id> or --entity <name>
cryo state set <key> <value>|- [--entity <name>] Write a cell (JSON if the value parses, else a string; - reads stdin)
cryo state add <key> [value] [--entity <name>] Claim a cell if nothing holds it. Exit 0 you won, 3 someone else holds it, 1 the call failed
cryo state cas <key> <value>|- --expect <version> --entity <name> [--as <name>] Write only if the cell is still at <version>. Same exit codes as add. --as names the attempt (default: the key). --entity is required: settling a race inside one run settles nothing
cryo state incr <key> [by] [--as <name>] [--entity <name>] Add to an integer cell, creating it at by when absent. Prints the new value. by defaults to 1 and may be negative
cryo state delete <key> [--entity <name>] Remove a cell
cryo state list [--entity <name>] [--run <id>] List cells in the scope (key<TAB>value per line). Outside a job, same rule as get: name --run or --entity
cryo output set <k=v>... | --json <o>|- Stage this job/node’s structured output; committed on success (JSON or string)
cryo artifact put <name> [<path>] [--key <k>] Store a file or directory; prints its digest. No --key = this run, --key = the project cache
cryo artifact get <name> [<path>] [--key <k>] [--restore-prefix <p>] Materialise it. Exits 1 with no output on a miss, so a cache lookup is plain shell. --restore-prefix falls back to the newest cached key starting with <p>
cryo artifact ls [--key <k>] List the scope (name<TAB>size<TAB>digest per line)

sleep, wait-signal, activity-collect, activity-wait-all and lock all suspend the script the same way: the call parks and the agent’s claim slot is freed during the wait, so a single-agent deployment can still drain the children the parent is waiting on. When the wait is satisfied the script re-runs from the top, with prior cryo step/cryo sleep/etc returning their cached output until the call that suspended now returns its result. Anything between cryo primitives (plain shell, env reads, file writes outside cryo step) re-executes on every replay pass - keep it idempotent.

cryo annotate and cryo state are the exceptions: they’re plain side-channels, not suspend-and-replay primitives, so they run on every replay pass and are allowed in a shell step as well as in a polyglot script. A state write is applied each time its step runs, so prefer last-write-wins or idempotent values.

add, cas and incr are the exceptions inside that exception - the three that settle a race. Their answers are consumed by the run that asked, so they are memoized like cryo step: a replay pass reads back the first answer instead of losing the key to itself, swapping twice, or counting twice. A retry: is different - it re-runs the unit, memos and all - and only add survives one, because the cell records who claimed it. See durable state.

add is keyed by the cell, because two claims of one cell in a run should share an answer. cas and incr are keyed by --as <name>, because a read-modify-write loop attempts the same cell several times and each attempt is its own question. The name defaults to the key, which is right for the common once-per-run case; a second unnamed call on the same cell is refused with a “used more than once” error, and the fix is to name them. (A repeated cryo step name is fine and needs no such fix — a step names a program point, so reaching it again is an ordinary loop.)

This is what makes a counter safe:

Terminal window
# The body of a script runs again after every suspend. Without the memo
# this would count once per pass; with it, once per run.
n=$(cryo state incr requests --entity quota --as tick)
cryo sleep 1h

And it is why set is not in this list. A set is a plain write that lands again on each pass, so “stamp a marker, sleep, then check whether I’m still the newest” does not work - the marker is re-stamped on the way back. Draw a ticket with incr instead, or settle it with cas. See durable state.

cryo artifact sits between the two. It never suspends, so it works in a shell step as well, but in a script it is memoized: a replay pass returns the digest the first pass resolved without re-reading the path. That matters because the step that produced the files is itself a memo hit on that pass, so the files are not there any more - and the pass may be running on a different machine. Without the memo a put after any suspend would fail.

Storing the same content twice is cheap, since a digest already held skips the transfer. Storing changed content is not: the tree is re-packed and re-hashed to find that out, and re-uploaded if it differs. A put of a large tree after every build pays that each time.

Two scopes, and --key is what picks between them. Without it an artifact belongs to this run - how one job hands a build to the next, and swept when the run is. With it the artifact goes in the project-wide cache under that key, which outlives every run:

#!/usr/bin/env bash
key="cargo-$(sha256sum Cargo.lock | cut -c1-16)"
# Warm start if we've built these exact dependencies before; failing
# that, the newest earlier cargo- tree. Neither is an error.
cryo artifact get target --key "$key" --restore-prefix cargo- || true
cryo step build -- cargo build --release
cryo artifact put target --key "$key"
# Hand the binary to the next job in this run.
cryo artifact put app target/release/app

cryo artifact get exits 1 on a miss and prints nothing, which is what lets || and if read normally. Bytes move between the job and object storage directly, so a large artifact doesn’t go through the control plane. What you store is opaque and never scanned - see Artifacts are not scanned.

Flags for cryo activity[-submit]:

  • --requires <cap> - required agent capability (repeatable)
  • --target-agent <id> - pin to a specific agent
  • --retries <n> - total attempts (including the first)
  • --retry-backoff <dur> - delay before attempt 2; doubles each retry after (requires --retries)
  • --retry-on-exit <code> - only retry these exits (repeatable, requires --retries). Without it every non-zero exit is retried, so a command that already decided your input is wrong spends the whole ladder, and the backoff between attempts, proving it again:
Terminal window
# 75 is "the API rate-limited me"; 42 is "this record's schema is wrong".
# Only the first is worth another attempt.
cryo activity-submit "rec-$id" --requires shell \
--retries 3 --retry-backoff 1s --retry-on-exit 75 \
-- process-record "$id"

A child runs on whichever agent claims it, in that agent’s own working directory - not the one the parent script is running in. So name the command by something the agent can resolve: a program on its PATH, or an absolute path. A relative ./process.sh is the common way to get an exit 127 here, and 127 is a verdict like any other, so a condition that doesn’t name it stops the child on its first attempt.

A list names which of the command’s verdicts are transient, so it covers exit codes and nothing else. A failure that reached no verdict at all carries no exit code, and once you name codes it is not retried: a condition over exit codes can’t speak about a failure that produced none. In practice that means the agent dying under the child - a command that merely fails to start still exits (127), and a child killed by a signal still has a code (-1), so a condition decides both like any other. Without --retry-on-exit, every failure is retried, including the agent-died one.

This is the same rule as a YAML job’s retry: { exit_codes: [...] } - one condition, one answer, wherever you write it.

After the last attempt the child stops being retried and its result is recorded as it stands, so cryo activity-collect returns that attempt’s output and exits with its code - the same shape as a child that was never retried. Whether the run then fails is the script’s decision: collect under set -e fails it, and catching the non-zero exit is how you quarantine one bad record without losing the batch.

cryo submit prints the bare run id on stdout, so capture it directly:

Terminal window
run_id="$(cryo submit ./pipeline.yaml)"
cryo logs "$run_id" --follow

Recurring: schedule a health check every minute

Section titled “Recurring: schedule a health check every minute”
cat > probe.sh <<'EOF'
#!/usr/bin/env bash
curl -fsS https://my-service/health
cryo output set verdict=healthy
EOF
cryo schedule create "1m" probe.sh --name "myservice-health"

Long-running: send welcome, wait 7 days, send follow-up

Section titled “Long-running: send welcome, wait 7 days, send follow-up”
cat > nudge.sh <<'EOF'
#!/usr/bin/env bash
set -e
cryo step "welcome" -- send-email "$1" welcome.txt
cryo sleep "7d"
cryo step "followup" -- send-email "$1" followup.txt
EOF
cryo submit --as=script nudge.sh

See Composition for embedding a polyglot canary or a nested pipeline as a workflow: job.

Store a graph from a file, then run it by name

Section titled “Store a graph from a file, then run it by name”

Author a graph as YAML (or JSON) and store it under a name - the same graph you’d otherwise build on the canvas. The name is the one you pass, so this is also how you copy a graph: export one, store it under a new name.

Terminal window
# author ai-brain2.yaml, store + publish it, run it by name
cryo graph put ai-brain2 ./ai-brain2.yaml --publish
cryo call graph ai-brain2 --input '{}'
# what's in this project, and which graphs have unpublished edits
cryo graph ls
# ai-brain draft v7 · published v6 (unpublished edits)
# ai-brain2 draft v1 · published v1
# export the current draft (round-trips straight back into `graph put`)
cryo graph get ai-brain2 > ai-brain2.json
# copy an existing graph to a new name
cryo graph get ai-brain | cryo graph put ai-brain2 - --publish

graph put stores a draft (what the canvas Save draft does); --publish also points the live version at it (what Publish does). Without --publish a graph is editable on the canvas but cryo call graph <name> still runs the last published version, so publish once the draft is ready.

These are the same graphs and the same versions the canvas edits, so the two surfaces interchange: export from the canvas and cryo graph put the file, or cryo graph get a graph and import the file on the canvas. See the canvas.