Polyglot scripts
A polyglot script is a normal program (bash, python, node, a
binary) that runs on an agent and uses the cryo CLI to cross
durable boundaries. The “SDK” is a single CLI binary plus shell;
no per-language library to install. Python and TypeScript scripts
can alternatively use the SDK packages’ in-script primitives
(cryosleep.script / @cryosleep/sdk’s step, sleep, …),
which speak the same agent protocol over the socket directly -
identical semantics, no process spawn per call.
The workflow type is cryosleep/script/v1. Two ways to invoke:
- Inside a YAML pipeline as a
workflow:job (see Composition). - Top-level:
cryo submit --as=script ./my-script.shsubmits the script as a freshcryosleep/script/v1workflow.
Bash scripts run under bash -eu (choosing the
interpreter), so errexit and nounset are
already on before your first line. Two consequences worth knowing up
front: any command that fails ends the run unless you handle it (|| true,
an if, a case), and reading an unset variable is a hard error, so
optional env goes through ${VAR:-default}. The set -e at the top of
the examples below is habit, not a requirement.
Determinism contract
Section titled “Determinism contract”This is the rule you need to keep in your head:
Your script must call
cryo step/cryo activity/cryo activity-submit/cryo wait-signal/cryo sleep/cryo activity-collectwith the same names in the same order across replays of the same input. State you want preserved across calls must come back through their stdout. Anything between those calls (env reads, clocks, intermediate variables, files written outsidecryo step) is non-durable, will re-execute on every replay pass, and may differ between passes - that’s fine as long as it doesn’t change which cryo-call names are emitted next, and as long as you can tolerate it running multiple times.
sleep, wait-signal, activity-collect and activity-wait-all all
suspend the script and the server re-runs it from the top on resume - so
“every replay pass” includes one pass per wait you go through.
Wrap real side-effecting work in cryo step so it’s cached after
the first pass. The server enforces the structural half: a replay
that reaches a different durable call than the one recorded at that
position is rejected with a clear error.
examples/replay-proof is this paragraph as a run you can
count: a body that executes twice, and two steps
that each execute once. Worth doing before writing a script whose
steps cost money, because the run’s own log will not show you the
difference - replayed output is recorded once rather than
re-emitted, so a run that parked twice reads like a run that
parked once.
Doing a thing exactly once
Section titled “Doing a thing exactly once”The rule above in one concrete mistake. This script posts to an API on every replay pass:
#!/usr/bin/env bashcurl -fsS -X POST https://api.example.com/deploy # ← runs every passcryo wait-signal approvedcryo step notify -- ./notify.shYour script restarts from the top on every wait, so that curl fires
once when the script first reaches the wait, and again on the pass that
resumes. Two deploys, one run. Nothing warns you: the live tail shows
each pass’s output, so it looks like it ran once per attempt because it
did.
Put it in a step and it happens once:
cryo step deploy -- curl -fsS -X POST https://api.example.com/deploycryo wait-signal approvedcryo step notify -- ./notify.shThe second pass replays deploy from its recorded stdout without
running curl at all. Same in the SDKs, where the durable unit can be
your own function rather than a subprocess:
import { stepFn, waitSignal } from '@cryosleep/sdk';
await stepFn('deploy', () => deploy()); // once, across every passawait waitSignal('approved');script.step("deploy", ["curl", "-fsS", "-X", "POST", DEPLOY_URL])script.wait_signal("approved")When the request fails inside the step
Section titled “When the request fails inside the step”A step records its result whether it succeeded or not, so a failed
request is remembered as a failure. Under bash -eu the non-zero exit
ends the run, and replaying that run - a resume, a lease redelivery -
returns the recorded failure rather than trying again. That is the point
of a memo, and it means “just run it again” needs saying more precisely.
Four ways, cheapest first.
Retry inside the step. The step only records once the command settles, so the retry never becomes durable state:
cryo step deploy -- curl -fsS --retry 5 --retry-connrefused \ -X POST https://api.example.com/deployDon’t fail - capture the outcome and decide. The step records a success whose stdout says what happened, and the script branches on it. Deterministic, because the recorded value is stable across passes:
result="$(cryo step deploy -- sh -c 'curl -fsS -X POST "$0" || echo UNREACHABLE' \ https://api.example.com/deploy)"if [ "$result" = UNREACHABLE ]; then cryo step page-oncall -- ./page.shfiA policy retry (retry: on the job, --retry at submit) re-runs the
whole script for real: between attempts the server purges that
activity’s recorded steps and releases its signal claims, so nothing
replays. That is what you want when the failure was environmental. It
redoes everything, not just the step that failed - earlier steps run
again too.
cryo rerun --from <step> after the fact, when a person has looked
at it. A fresh run keeps the steps before that point and re-runs from
there. See rerunning from a point for what
that does and does not promise.
The choice is mostly about who notices. Transient network failure: retry inside the step. A dependency that might legitimately be down: capture and branch. A bad deploy that needs a human: rerun from the step once they have fixed it.
In-script CLI primitives
Section titled “In-script CLI primitives”cryo step <name> -- <cmd...>
Section titled “cryo step <name> -- <cmd...>”Durable boundary. On first run it executes <cmd>, records the stdout
and exit code under the step name, echoes that stdout verbatim, and exits
with that code. A replay returns the cached output and exit code without
re-running the command.
result="$(cryo step "fetch-prices" -- curl -fsS https://api/prices)"cryo step "publish" -- post-prices "$result"A step name is a program point, so reaching it again is an ordinary loop:
the name is paired with an ordinal counting how many times this run has
reached it, and each iteration records its own result. Three passes
through cryo step process are three steps, and all three run.
for host in web-1 web-2 web-3; do cryo step process --label "$host" -- ./drain.sh "$host"done--label is free text for a reader - it shows up in cryo steps and the
run view beside the ordinal, and it never affects which memo a call
resolves to. Put the loop variable there rather than in the name.
Mutating step names between replays is a determinism violation.
A step’s output is capped at 2 MiB, and a step that goes over is
refused with a message saying how far over it went. The output is
memoized, so it is stored in the run and handed back on every replay
pass rather than transferred once - a step that returns a build
artifact or a full dataset makes every later pass carry it. Write
large output to a file, or to an artifact with cryo artifact put,
and record the path:
cryo step "render" -- bash -c 'render > /tmp/out.bin && echo /tmp/out.bin'A step body runs without $CRYO_AGENT_SOCKET, so <cmd> can’t call
cryo itself - a nested cryo step or cryo sleep exits 1 with
CRYO_AGENT_SOCKET is not set. Durable calls belong in the script,
between steps, where the log can sequence them; a step is the leaf.
The one CRYO_* a step body does get is $CRYO_IDEMPOTENCY_KEY: a
token for this step of this job of this run, to hand to a remote API
that dedupes on one.
cryo step charge -- sh -c ' curl -fsS https://api.example.com/charges \ -H "Idempotency-Key: $CRYO_IDEMPOTENCY_KEY" \ -d "account=$1"' _ "$account"Note the single quotes: the variable only exists inside the body, so
writing it unquoted on the cryo step line expands it in the script’s
own shell, where it is not set - under set -u the script aborts with
CRYO_IDEMPOTENCY_KEY: unbound variable, and without it the call goes
out with no key at all. The script’s own values, like $account here,
come in as arguments; the body sees a sealed env.
A step that dies after acting but before its result is recorded runs again, and so does one whose agent was lost. Both send the same token, so the remote system recognises the second call as the first. Two steps get two tokens, and a rerun of the run gets new ones. See the rules for the graph-side equivalent.
In the Go, Python, TypeScript, and Rust SDKs, stepFn is the same durable
boundary around a native function rather than a subprocess - it
records the function’s return value, so you call your own code instead
of shelling out and parsing stdout. See
workflows in your language.
cryo sleep <duration>
Section titled “cryo sleep <duration>”Wait for a wall-clock duration; the agent slot is freed during
the sleep (the activity is parked server-side and re-dispatched
on wake). Humantime: 5s, 1m, 2h, 30d.
user="$(cryo step "lookup" -- fetch-user "$EMAIL")"cryo step "send-welcome" -- send-email "$EMAIL" welcome "$user"cryo sleep "7d"cryo step "send-followup" -- send-email "$EMAIL" followup "$user"On wake the script restarts from the top - cryo step calls
above the sleep return their cached outputs immediately, so the
script “resumes” at the next uncached call. Plain shell commands
between cryo step calls re-execute on every replay; wrap
side-effecting work in cryo step if that matters.
That restart is a fresh process, and the wake-up is dispatched to
whichever agent is free, so it may well be a different machine a week
later. $user is still there on the far side because lookup replays
what it recorded - a value is durable exactly when it came out of a
step’s stdout. send-welcome doesn’t send a second email either, for
the same reason.
cryo wait-signal <name>
Section titled “cryo wait-signal <name>”Suspend until a signal of name is delivered. Returns the
signal’s payload as JSON on stdout.
payload="$(cryo wait-signal "approved")"echo "got approval: $payload"Mechanism (same suspend-and-replay primitive as cryo sleep):
on the first call the signal isn’t there yet, so the script
suspends and parks server-side until it arrives - cryo signal <run-id> <name> delivers it and wakes the run. A future claim
re-runs the script from the top; this same call now finds the
delivered signal and returns it. The parent’s claim slot is
freed during the wait.
Bounded waits
Section titled “Bounded waits”Without --timeout the wait has no upper bound. With one it
gives up after that long and exits 124, the timeout(1)
convention, so bash branches on it without parsing anything:
if payload="$(cryo wait-signal "approved" --timeout 7d)"; then cryo step "ship" -- ./deploy.shelse cryo step "expire" -- ./close-out.shficryo wait-event takes the same flag.
Two things to know. Errexit is on by default, so a bare bounded wait
ends the run on timeout, which is rarely what you want - put it in an
if, or append || true. And the outcome is pinned the first time it
resolves: a wait that took an arrival returns that same one on every
later pass, and a wait with no arrival and a recorded timeout keeps the
timeout - so a signal landing a moment after the deadline does not
change a branch the script already took.
Waiting on one name more than once
Section titled “Waiting on one name more than once”A name is a queue, so each wait on it takes the next signal that arrived. A loop can wait on one name every time round:
while :; do verdict="$(cryo wait-signal "approve")" [ "$verdict" = '"stop"' ] && break cryo step apply -- ./apply.sh "$verdict"doneThree cryo signal <run-id> approve calls feed three turns of that
loop, in the order they arrived. Signals sent while nothing is waiting
queue up rather than being dropped, so a sender that runs ahead of the
script does not lose them.
That also means the sender never has to know where the script is. It addresses the run and the name, and the script’s own position decides which arrival it gets. If a particular turn needs a particular answer, put the discriminator in the payload - the sender can see that, and cannot see a loop counter.
Two jobs in one run waiting on the same name draw from the same queue,
one signal each. cryo wait-event works the same way: waiting on one
event type repeatedly hands you successive events of that type.
From an SDK the same wait returns your language’s “nothing arrived” value instead of an exit code:
if script.wait_signal("approved", timeout=timedelta(days=7)) is None: script.step("expire", ["./close-out.sh"])waitSignal(name, { timeoutMs }) resolves null in TypeScript,
WaitSignalTimeout(name, d) returns ok=false in Go, and
wait_signal_timeout(name, d) returns None in Rust. All four
need an agent speaking socket protocol v2; an older one is
refused at the call site rather than quietly dropping the
deadline and waiting forever.
cryo wait-event <type>
Section titled “cryo wait-event <type>”Suspend until an event of type lands on the project bus
(emitted by another run via cryo emit, or forwarded from a
forge webhook). Returns the event envelope as JSON on stdout.
event="$(cryo wait-event "deploy.approved")"echo "unblocked by: $event"Sugar over wait-signal: the call registers a one-shot
event-to-signal bridge server-side, then waits on a signal of its
own, named for the event type and the job doing the waiting.
Everything about the signal wait applies - suspend-and-replay and
the freed claim slot.
Every waiting job gets its own wait, so any number of jobs, runs and graph nodes can wait for one event type and all of them are handed the event when it arrives. A job that starts waiting after an earlier event of that type already fired waits for the next one; it never inherits an event that arrived before it.
Waiting twice on the same type in one script hands you two events - the type’s signal is a queue like any other, so the second wait takes the next one. The bridge is registered again on the pass that reaches that second wait.
event: is the engine’s namespace, shared with a pipeline’s
wait_for_event: jobs, which park on event:<node> - the node
the job compiles to, so a ship-it: job parks on event:ship_it
(see the YAML reference).
To unblock one of those by hand, cryo signal <run-id> event:<node>. A script’s wait has a longer name of its own - read
it off cryo status <run-id>, which reports the name under
substate.name for the run’s most recent parked activity.
For the same reason cryo wait-signal will not take a name
starting with event:. Pick a name of your own, or use
cryo wait-event for the event you meant.
cryo lock <group> [-- <cmd...>]
Section titled “cryo lock <group> [-- <cmd...>]”Serialize a critical section across runs. Acquire a durable,
project-scoped lease on group; while another run holds it, this
one waits.
What a lock protects is the read-modify-write, where two runs interleaving lose an update:
cryo lock tallyn="$(cryo state get total --entity tally)"cryo state set total "$(( ${n:-0} + 1 ))" --entity tallyecho "wrote $(( ${n:-0} + 1 ))"Submit three of those at once against two agents and they print 1,
2, 3. Delete the cryo lock line and they print 1, 1, 2: two
runs read 0, both write 1, and one increment is gone. Nothing errors
- the tally is just wrong afterwards, which is why the lock goes in before you can measure the problem.
Keep the read and the write in one pass. State operations are not
checkpoints, so they re-execute on every replay; the shape above is safe
because the only suspension is the lock itself, which happens before the
read. Put a cryo sleep between the get and the set and the resumed
pass increments a second time, lock or no lock.
A bracketed command cannot call cryo. The bracketed form records
<cmd> as a step, which is what makes it run once however many times the
script replays. Being a step, it gets a step’s rule: it runs without
$CRYO_AGENT_SOCKET, so a nested cryo state exits 1 with
CRYO_AGENT_SOCKET is not set. And since the inner shell usually isn’t
under errexit, the section carries on and writes nothing.
Bracket external commands; take the bare lease when the critical section is itself cryo work, as above.
./build.sh # runs concurrently across runscryo lock deploy -- ./deploy.sh # waits for the lease, then deploysA bare cryo lock <group> (no -- <cmd>) acquires and returns,
holding the lease for the rest of the run.
Mechanism: the same suspend-and-replay primitive as wait-signal and
sleep. On a held group the script suspends and its agent slot is freed;
the control plane grants the lease FIFO when the current holder finishes
and wakes the run, which replays and proceeds into <cmd>. While
parked the run reports the awaiting_lease
substate.
It’s the same lease a YAML job’s
concurrency:
takes: a script’s cryo lock deploy and a job’s concurrency: { group: deploy } in the same project contend on one lock, so whichever gets
there first runs and the other waits.
The lease is scoped to the bracketed command: it is handed back as
soon as <cmd> finishes, so the next waiter starts then rather than
when your run ends. A bare cryo lock GROUP (no command) has no
closure to scope to and holds until the run is terminal (released on run terminal,
then handed to the next waiter). That fits a deploy at the tail of a
run; a mid-run release verb is future work.
cryo activity <name> [flags] -- <cmd...>
Section titled “cryo activity <name> [flags] -- <cmd...>”Dispatch a cryosleep-registered activity to a different agent matching its requirements. Synchronous (blocks until complete), and the child’s stdout comes back on yours:
# runs on a machine tagged arm64-mac; this script may be on Linuxsha="$(cryo activity "build-mac" --requires arm64-mac \ -- bash -c 'cd /srv/repo && ./build.sh --print-sha')"cryo step "record" -- ./publish-manifest.sh "$sha"--requires is how the work gets to hardware this script isn’t running
on. That machine is a different filesystem: the command is evaluated
there, so a relative path resolves against its working directory,
not this script’s. Send absolute paths, or cd first as above.
cryo activity-submit / cryo activity-collect
Section titled “cryo activity-submit / cryo activity-collect”Fan-out primitive: submit many activities up front, collect
results later. activity-submit returns immediately once the
child is enqueued; activity-collect suspends the parent until
the child terminates.
# every submit goes out before the first collect - that's what makes# the shards overlap instead of running one at a timefor shard in 1 2 3; do cryo activity-submit "test-$shard" --requires shell \ -- bash -c "cd /srv/repo && ./run-tests.sh --shard $shard --count"done
passed=0for shard in 1 2 3; do passed=$(( passed + $(cryo activity-collect "test-$shard") ))doneecho "$passed tests passed across 3 shards"Each child returns its own count and the parent adds them up, so the
total is only right if all three ran and each was collected separately.
Submitting inside the first loop and collecting in the second is the
part that matters: interleaving them (submit; collect; submit; collect)
serializes the shards and throws the parallelism away.
A failed child fails the parent. activity-collect exits with the
child’s exit code, and with errexit on by default that ends the run at
the collect - which is usually what you want for a test shard. To decide
for yourself, catch it: out="$(cryo activity-collect "test-1" || echo skipped)".
A child you never collect is a different matter: nothing reads its result, so a failure among those children leaves the run green and says nothing. A script can also exit while they are still running, and then the run reaches a terminal state before they report at all. When you don’t need each child’s output, wait for them as a group:
for shard in $(seq 8); do cryo activity-submit "test-$shard" --requires shell -- run-shard "$shard"donefailed="$(cryo activity-wait-all)"[ "$failed" = 0 ] || { echo "$failed shard(s) failed"; exit 1; }Note run-shard, not ./run-shard.sh: a child runs on whichever agent
claims it, in that agent’s own working directory rather than the
script’s, so a relative path resolves against somewhere you didn’t
choose. Name a program on the agent’s PATH, or an absolute path.
activity-wait-all suspends the same way collect does - once per
child it actually has to wait for, which is usually far fewer than one
per child - and prints how many of them failed. It exits 0 whatever that
number is, so the decision above stays yours.
It covers the children this script submitted and has not already
collected. A child you collected and handled yourself doesn’t get
counted a second time, and a script in a different script: job can’t
wait on this one’s children - each job’s children are its own. A run
that succeeds with children still unreported gets an annotation on
its page saying so.
activity-collect uses the same suspend-and-replay primitive as
wait-signal and sleep: if the child hasn’t finished yet the
parent suspends. Crucially, the parent’s agent slot is freed
during the wait - a single-agent deployment can drain the children
the parent is waiting on without head-of-line blocking. (No
local bash wait / & backgrounding needed; the suspend-and-
replay model handles that for you.)
Polyglot fan-out children are tracked separately from workflow:
child runs - they don’t nest in the parent’s event log; instead the
web UI surfaces them in a dedicated “Activities (fan-out)” panel with
status, exit code, and captured stdout per child.
The loop above scales as written: swap 1 2 3 for $(seq 100) and one
script drives a hundred children across every agent that will claim
them, on one suspend-and-replay collect each. Names have to stay
distinct, which is what test-$shard is doing.
cryo spawn <file> [--as pipeline|script] [--tag <t>]...
Section titled “cryo spawn <file> [--as pipeline|script] [--tag <t>]...”Submit a child run (a YAML pipeline or another polyglot script)
from inside a script, and print its run id. - reads the source from
stdin. The child runs independently (fire-and-forget); it’s a separate
run, not a fan-out activity.
# a router script that decides what to run, then submits itchild="$(cryo spawn .cryo/deploy.yaml --tag from-dispatch)"echo "spawned $child"
cryo sleep 30secho "still watching $child"This is the primitive that makes a script an orchestrator: inspect inputs, then spawn the workflows you choose.
Each spawn is checkpointed by its position in the script, so the sleep above is safe: the replay pass prints the same id it printed the first time and no second child is submitted.
spawned dev:default:run-14f9593fa81a407dcryo: script suspended for 30000ms; agent re-dispatch on wakeup
- replaying from checkpoint (completed steps are cached, not re-run)spawned dev:default:run-14f9593fa81a407dstill watching dev:default:run-14f9593fa81a407dThat is worth checking rather than assuming, because a spawn that
re-ran would deploy twice and the second run id would be the only
evidence. Position is the key here, not a name - spawn takes none, so
moving a spawn across a suspend point changes which memo it lands on.
Like the other durable primitives it only works inside a polyglot script
run, not a YAML shell step.
cryo call node <kind> / cryo call graph <name> / --definition <file|->
Section titled “cryo call node <kind> / cryo call graph <name> / --definition <file|->”Submit a child run and wait for it. The script parks
(suspend-and-replay, so the agent slot is freed), and when the child
terminates its output is printed on stdout - capture it with $(...):
status="$(cryo call node http --config '{"url":"https://api.example/ping"}')"report="$(cryo call graph nightly-report --input '{"day":"mon"}')"
# a graph the script just computed./plan.py > plan.jsoncryo call graph --definition plan.json--definition takes an inline cryosleep-graph/v1 document (- reads it
from stdin), so a script can run work it decided at run time without
publishing a graph first. cryo spawn, by contrast, returns a run id
straight away and never yields the child’s result.
cryo emit <type> [--payload <json>]
Section titled “cryo emit <type> [--payload <json>]”Publish a custom event to the project’s event bus and print its event id. If a project handler matches the type, the emit triggers a handler run - and a handler’s own custom emits chain on to the next handler, so scripts can hand work down a routing chain. Durable and replay-safe - each emit is checkpointed by its position, so a replay returns the same event id instead of emitting a duplicate.
Some types are reserved and rejected: push, manual, schedule.tick,
spawn, and the platform-emitted run.* / cryo.* namespaces.
cryo emit deploy.finished --payload '{"env":"prod","sha":"'"$SHA"'"}'cryo state get|set|delete|list [--entity <name>]
Section titled “cryo state get|set|delete|list [--entity <name>]”Read and write durable state cells -
named, mutable state that survives replay. No --entity = run scope
(private to this run); --entity <name> = the project’s cross-run entity
scope. get prints the value (empty if unset); set takes JSON (or a
bare string) and reads stdin on -; list prints key<TAB>value lines.
Unlike the durable primitives above, state is a plain read/write, not a suspend-and-replay boundary - it runs on every replay pass, so prefer last-write-wins or idempotent writes.
cursor="$(cryo state get cursor --entity crawler)"process-from "${cursor:-0}"cryo state set cursor "$new_offset" --entity crawlerSide channels
Section titled “Side channels”cryo annotate works from a script too - it attaches a markdown
note to the run without creating a durable boundary. See the
annotations guide.
The event, and routing
Section titled “The event, and routing”Every run carries an event describing what started it, written to a
file named by $CRYO_INPUT_FILE - a JSON envelope with a type and
type-specific fields, complete whatever its size.
A push is {"type":"push","forge":…,"ref":…,"sha":…,"repo":…,"sender":…};
scheduler, manual, and custom triggers add their own type on the same
shape. The same facts are also present as CI_TRIGGER_* vars (see
env below).
Because any workflow can read the event and start more workflows with
cryo spawn, routing is just a workflow that spawns. A push runs one
entrypoint pipeline; a job in it reads the event and picks what to run.
No separate “dispatcher” concept - it’s the same cryo spawn primitive.
This router job reads that file with jq, picks workflows by branch,
and spawns them:
#!/usr/bin/env bashset -euo pipefail
branch="$(jq -r '.ref | sub("refs/heads/"; "")' "$CRYO_INPUT_FILE")"echo "routing branch=$branch"
case "$branch" in main) cryo spawn .cryo/ci.yaml --tag routed cryo spawn .cryo/deploy.yaml --tag routed ;; release/*) cryo spawn .cryo/ci.yaml --tag routed cryo spawn .cryo/release.yaml --tag routed ;; *) cryo spawn .cryo/ci.yaml --tag routed ;;esaccryo spawn is memoized (see above), so if the router suspends and
replays it won’t submit duplicate children. The spawned runs are
independent - the router doesn’t wait on them. To collect results
instead, use cryo activity-submit / cryo activity-collect.
cryo spawn <file> reads a local file, so a router that spawns
repo-tracked pipelines needs those files on the agent: check out the
repo first (the job has $CI_TRIGGER_SHA), the same clone a normal CI
job does. Every push is durable regardless - the entrypoint run is
created when the push arrives and waits for an agent if none is free, so
nothing is dropped between delivery and execution.
Structured output (cryo output set)
Section titled “Structured output (cryo output set)”The script stages structured output with cryo output set. The keys
are merged onto the child workflow’s final output alongside exit_code
and duration_ms. They’re committed only if the script exits
successfully.
cryo step "metrics" -- bash -c 'echo "checking metrics"'cryo output set verdict=ok score=0.97A YAML pipeline that spawns this as a workflow: job can pick
those keys via its outputs: mapping:
canary: outputs: verdict: verdict score: score workflow: type: cryosleep/script/v1 script: { ... }Env exposed to scripts
Section titled “Env exposed to scripts”Each script body sees:
$CRYO_AGENT_SOCKET- path to the IPC socket thecryoCLI uses to talk to the server through this agent.$CRYO_AGENT_PID- agent process pid.$CRYO_AGENT_ID- the id of the agent running this body, the same onecryo agents lslists. Scoped to this execution on purpose: a run that suspends is re-dispatched to whichever agent is free, so a script that slept twice may have run on three agents and no single value describes the run. Read it where you need it and record it in a step if it has to survive. Shell jobs in a YAML pipeline get it too.$CRYO_WORKFLOW_ID- the parent workflow’s id.$CRYO_ACTIVITY_ID- this script_run activity’s id.$PATHfrom the agent process (so thecryoCLI is reachable).
On top of those, a script run gets whatever env its workflow supplies,
plus the context of whatever started it - a webhook-dispatched script
(above) gets the push, a manual one gets CI_TRIGGER_EVENT=manual:
CI_TRIGGER_*- one per scalar field on the run’s event:EVENT(the type),EVENT_ID(the bus id, on a delivery),SHA,SHORT_SHA,REF,REPO,SENDER,FORGE,SCHEDULE,PARENT. Which ones appear depends on the event; a field that isn’t a scalar (paths,payload) has no flat form and stays in the file.$CRYO_INPUT_FILE- a file holding what started the run, as one JSON object with atype:push(webhook, plusforge/ref/sha/repo/sender/paths),manual(acryo submit),schedule.tick(plus thescheduleid), orspawn(plus theparentrun id). Every run with an input carries one, and it is never truncated - a forwarded forge body arrives whole.
The cryo-internal vars (CRYO_AGENT_SOCKET and the others above) are
applied last and always win, so a workflow’s env can’t shadow them.
The ambient agent environment does not pass through. Scripts run
with a sealed env: besides the vars above, only a fixed allowlist of
standard vars is forwarded from the agent process (HOME, USER,
LOGNAME, SHELL, TERM, LANG/LANGUAGE/LC_*, TZ, TMPDIR,
and the SSL_CERT_FILE/SSL_CERT_DIR/NIX_SSL_CERT_FILE cert
paths). Any other env you need, set it inside the script or supply it
via the workflow’s env.
Choosing the interpreter
Section titled “Choosing the interpreter”The interpreter defaults to bash -eu. cryo submit picks it up
from the file extension - .py → python3, .ts → bun run,
.js/.mjs → node, .go → go run - and --interpreter
overrides (space-split, so --interpreter "bun run" works):
cryo submit deploy.py --follow # python3, auto-detectedcryo submit deploy.ts --interpreter "deno run" # explicit overrideThe runtime has to be installed where the script runs. The
interpreter is invoked on the machine running the agent, so a .py
workflow needs python3 on that machine’s PATH, .ts needs bun,
.go needs the Go toolchain, and so on. Running locally with
cryo run / cryo dev, that machine is yours, so cryo run probe.py fails if you don’t have python3. On a deployed agent the
requirement is the agent host’s PATH (see
deploying agents). Only bash and raw
cryo-verb scripts have no extra runtime beyond the cryo binary
itself.
A runnable local example - install the CLI plus python3, then:
from cryosleep import script
# `step` runs a command as a durable checkpoint; on replay it# returns the recorded stdout instead of re-running.reading = script.step( "probe", ["python3", "-c", "import random; print(random.randint(0, 99))"])print(f"probe read {reading.stdout.strip()}")
# Seconds or a timedelta, not the CLI's "5s" humantime string.script.sleep(5)
# Same reading: the second pass replayed the memo rather than# sampling again.print(f"still {reading.stdout.strip()} after the sleep")cryo run ./probe.pyfrom cryosleep import script is the Python SDK - a script that
imports it needs the SDK importable by the python3 that runs it
(see workflows in your language).
A script that only shells out through the cryo CLI (cryo step …,
cryo sleep …) has no import at all and runs anywhere the cryo
binary and the interpreter both exist.
Local cryo dev / cryo run execute the file in place from the
directory you submit it in, so bun, go run, and python3 resolve
the SDK from your node_modules / go.mod / virtualenv with no extra
setup - cryo submit release.go just works from your module. Editing
that file while a run is parked won’t change what the parked run does:
it recorded its own copy at submit time and replays that, so the edit
lands on your next cryo submit. A deployed agent instead runs the
script from a scrubbed scratch dir, so there a Go or TypeScript
workflow that imports the SDK needs its module context: run it from a
checked-out repo (a steps: job doing go run . / bun run .). See
getting the SDK onto the agent.
What survives kill-9
Section titled “What survives kill-9”- The agent dying mid-script: the activity is re-dispatched; some
other agent claims it; the script restarts from the top, with
every
cryo step/cryo sleep/cryo wait-signal/cryo activity-collectreturning its cached result until reaching the point of the original death. - The server dying mid-script: the script keeps running on the
agent. When the server restarts, the agent’s pending
cryo step/ completion calls land normally. - A
cryo sleep/wait-signal/activity-collectstraddling either restart: the suspended activity stays parked; on server start the eligible agent resumes it at wake / signal / completion.
Rerunning from a point
Section titled “Rerunning from a point”cryo rerun <run-id> --from <site> starts a fresh run at one of your
cryo step sites, reusing what the original recorded before it. The
build was fine, the deploy flaked, don’t rebuild:
cryo rerun dev:default:run-3EMw --from deployThe new run’s build returns the bytes the original captured. deploy
and everything after it actually run.
It promises less than the same flag on a pipeline or a graph. There,
the upstream nodes do not execute. Here, the calls before your point
don’t re-perform, but the script around them runs again from line one,
the same way it does after a cryo wait-signal. So:
curl -X POST https://example.com/announce # fires againcryo step deploy -- ./deploy.sh # returns its recorded resultA script that already survives a wait is safe here by construction, for
the same reason it survives the wait. If yours has never suspended, this
is the first time that discipline is tested: wrap side effects in
cryo step and they happen once.
A loop reaches one site many times, so a bare name means the first
time - which drops every iteration of it - and --from check#2 starts
at the third, keeping the two before it.
If the prefix no longer reproduces (the world changed and your script takes a different branch), the run fails loudly on the determinism guard rather than doing something subtly wrong.
Logs and replay
Section titled “Logs and replay”Replay paths return cached cryo step / cryo sleep /
cryo activity-collect output to your script’s $(...)
capture, but don’t re-emit those bytes to the live log
sink: logs record each execution attempt once, which keeps
the live tail readable on long-running workflows. The web UI
shows only the latest pass’s tail, trimming at the start of each
script re-run. Per-step terminal panels in the event stream and
the “Activities (fan-out)” panel keep prior-pass output durably
available.
Plain shell between cryo calls (e.g. a bare printf) re-runs
on every pass and will appear once per pass in the live tail.
Wrap it in cryo step if you only want to see it once.
To see kill-9 durability for yourself, run cryo init and follow
the crash test in getting started.