Durable state
A state cell is a named value a run reads and writes directly:
(scope, key) -> value, a small JSON document. It survives replay, it
survives the run, and it’s the same cell whichever surface you reach it
from - a shell step, a polyglot script, an SDK, or a canvas state
node.
Concepts covers what a cell is and when to reach for one. This page is how to use them, including the operations that stay correct when two runs touch the same key at once.
Scopes
Section titled “Scopes”Every cell lives in a scope, and there are two:
| Scope | How you address it | Lifetime |
|---|---|---|
| Run | the default - no flag | Dropped when the run is collected |
| Entity | --entity <name> (entity: on a node) |
Outlives every run |
Run scope is a durable scratchpad for one run. Entity scope is where
cross-run memory lives: two runs in the same project that pass the same
entity name reach the same cell. The name is yours to choose - crawler,
ledger, counters - and it’s unique within the project.
Keys starting _cryo/ are the engine’s own. You can read them; writing
one is refused, on the canvas as you type it and again when the node
runs. The engine reads those cells back and acts on them - a
rerun --from <node> seeds every node it skips from what a run
recorded - so they can’t be someone else’s scratchpad.
cryo state set cursor 1200 --entity crawler # writecryo state get cursor --entity crawler # 1200cryo state list --entity crawler # key<TAB>value per linecryo state delete cursor --entity crawlerThe same four verbs are state_get / state_set / state_delete /
state_list in every SDK, and op: get|set|delete on a canvas state
node.
The poll cursor
Section titled “The poll cursor”The shape entity scope exists for. Run this on a schedule and each tick picks up where the last one left off:
since="$(cryo state get last-seen --entity digest)"latest="$(cryo step "poll" -- ./fetch-since.sh "${since:-0}")"cryo step "notify" -- ./post-digest.sh "$latest"cryo state set last-seen "$latest" --entity digestThree consecutive runs print 0, 10, 20. The ${since:-0} covers
the first tick: cryo state get exits 0 and prints nothing for a cell
nobody has written, so $since is an empty string rather than an error.
A cryo step memo can’t do this job - memos are keyed inside one run,
and this value has to cross from one run to the next.
When two runs want the same key
Section titled “When two runs want the same key”get then set has a gap in the middle. Two runs read the same absent
cell, both see nothing, both decide they’re first, and the record gets
processed twice. Three operations close that gap.
All three want entity scope, and two of them require it: a state node
with op: add or op: cas and no entity: is refused when you save
the graph. A run-scoped cell is reachable by one run, so the claim can
never lose and the swap has nobody to race - the node reads as a dedup
while deduping nothing, and that only surfaces the day two runs arrive
together. get, set, delete and incr still take run scope, which
is the right place for scratch state. In a shell the same reasoning
applies to --entity: cryo state add without it claims a cell only
this run can see, and reports that it won every time.
add - claim a key
Section titled “add - claim a key”Writes the cell only if nothing holds it yet, and tells you whether you won. In a shell that’s the exit code: 0 you won, 3 someone else holds it, 1 something broke.
rc=0cryo state add "seen-$order_id" --entity ledger || rc=$?case $rc in 0) cryo step "process" -- ./process-order.sh "$order_id" ;; 3) echo "order $order_id already handled" ;; *) exit 1 ;; # unreachable agent, refused write - not an answeresacThe three-way branch is the point. A plain if cryo state add … sends
every failure down the else branch, so an agent that couldn’t reach the
server reads as “someone else has it” and the record is silently
skipped. Reserve the skip for exit 3.
The || rc=$? is not decoration. Scripts run under bash -eu, so a
bare cryo state add followed by case $? never reaches the case:
losing the claim exits 3, errexit ends the run there, and the second
run of a dedup ledger fails with no output at all. Capture the code,
then branch on it.
That’s a dedup ledger, and it’s what makes a workflow safe to trigger
twice with the same payload. The value is optional (it defaults to
true) because usually the answer is the point and the contents don’t
matter - though storing something useful is free:
cryo state add "seen-$order_id" "$(date -Iseconds)" --entity ledgerA ledger is for duplicates the run discovers about its own work. If what
repeats is the submission - a webhook that delivers twice - the caller
already knows, and cryo submit --idempotency-key <k> is cheaper:
the second delivery is answered with the first run, and no second run is
created, admitted, or counted.
On a canvas, the same thing is a state node with op: add, and its
output is {"claimed": true|false}. Gate the work on it:
{ "version": "cryosleep-graph/v1", "nodes": [ { "id": "claim", "type": "state", "config": { "op": "add", "key": { "$template": "seen-{{ input.order_id }}" }, "value": true, "entity": "ledger" } }, { "id": "process", "type": "shell", "if": "nodes.claim.output.claimed", "config": { "run": { "$template": "./process-order.sh {{ input.order_id }}" } } } ], "edges": [{ "from": "claim", "to": "process" }]}A duplicate trigger still starts a run; the run just does nothing, which is the honest outcome and leaves a record that it happened.
incr - count without losing updates
Section titled “incr - count without losing updates”{ "id": "bump", "type": "state", "config": { "op": "incr", "key": "processed", "by": 1, "entity": "counters" } }From a script, same op:
n=$(cryo state incr processed --entity counters --as tick)The output is {"value": <new total>, "version": <n>}, and the CLI
prints the new value. by: defaults to 1 and may be negative, so a
quota that hands out credits and returns them is two nodes with
by: -1 and by: 1.
A read-modify-write is what this is underneath - read the cell, add, write it back only if nobody else moved it in between, retry if they did. Concurrent runs each land their own increment instead of overwriting one another. A cell that holds something other than a number fails the node loudly rather than resetting your count.
cas - a read-modify-write of your own shape
Section titled “cas - a read-modify-write of your own shape”When the update isn’t addition. get returns the cell’s version
alongside its value; cas writes only if the version hasn’t moved:
{ "id": "read", "type": "state", "config": { "op": "get", "key": "config", "entity": "settings" } },{ "id": "merge", "type": "code", "config": { "script": "…produce the new document…" } },{ "id": "write", "type": "state", "config": { "op": "cas", "key": "config", "expected": { "$expr": "nodes.read.output.version" }, "value": { "$expr": "nodes.merge.output.document" }, "entity": "settings" } }The get carries the same entity: as the cas, otherwise the version
you read belongs to a different cell than the one you write.
From a script the same three steps are three lines, and the exit code carries the answer:
v=$(cryo state get config --version --entity settings)doc=$(./merge.sh "$(cryo state get config --entity settings)")if cryo state cas config "$doc" --expect "$v" --entity settings --as merge; then echo "landed"else echo "someone else wrote first" # exit 3, never 1fiThe output is {"swapped": true|false}. A false means someone wrote
between your read and your write, and what to do about it is yours to
decide - branch to a retry, or fail the run.
--as names the attempt. A retry loop cases the same cell several
times and each attempt is its own recorded answer, so they need distinct
names; the default is the key, which is right when there is only one.
Read the version before the value. They are two calls, and a write
landing between them lands differently depending on the order: version
first leaves you with an old version and a new value, so the swap fails
and you retry; value first leaves you with a stale value and a version
that already moved, so the swap succeeds and overwrites what the other
run wrote. The second one is silent and only shows up under contention,
which is the only time cas is doing anything.
That loop - read, modify, swap, retry - is how a list grows under
contention when several runs append to it at once.
Batch is the worked version, including
what it takes to keep such a loop replay-safe across a cryo sleep.
Omitting expected: means “only if the cell is absent”, which is what
add does. The CLI has no spelling for that - use cryo state add.
The precondition is on the version rather than on the value because cells are encrypted at rest (the same key that protects secrets and step memos). The database holds ciphertext and can’t compare it. The version is the store’s own counter, in the clear, so a condition on it works.
Replay and repetition
Section titled “Replay and repetition”A state read or write is a plain operation, not a suspend-and-replay
boundary. In a polyglot script - which re-runs from the top on every
wake - that means get, set and delete run again on each pass.
Prefer last-write-wins or idempotent values.
You cannot wrap one in cryo step to fix that. A step is a checkpoint
and a checkpoint is a leaf: its command runs without the agent socket,
deliberately, so a cryo call inside one has nothing to talk to. Reach
for the memoized ops below instead.
add, cas and incr are the exceptions, in both surfaces. Their
answers belong to the run that asked - which claim won, whether the swap
landed, what the count reached - and re-asking would have the run lose
to its own earlier attempt, or swap twice, or count twice. They’re
memoized, so a replay pass reads back the first answer rather than
re-running the race. A lost swap is journaled too, because losing is a
result the run acted on and replay has to reproduce it.
What each is keyed by follows from that. add is keyed by the cell:
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 repeatedly and each attempt is a separate
question. Reusing a name is refused, the same way a repeated
cryo step name is.

One gap, so it isn’t a surprise: the memo is written after the store
is, so an agent that dies in the window between them leaves the count
raised and no memo to read - the next pass counts again. Creating a
cell is safe either way, because the cell itself records who created
it. Raising an existing one has that window. It is small, and if the
count has to be exactly right across a crash, add a per-run marker
first and increment only when the claim is yours.
A retry: is different, and the difference matters here. A retry
deliberately re-runs the unit, memos and all, so a retried job’s incr
counts again and its cas re-attempts with a version its own first
attempt already moved - which comes back as “someone else got there
first” on a swap that had in fact won. add is the one that survives a
retry, because the cell itself records who claimed it. If a job carries
retry: and counts, count somewhere the retry can see: add a marker
first, or make the whole job idempotent.
The pattern this rules out, and what to write instead
Section titled “The pattern this rules out, and what to write instead”“Stamp a marker, sleep, then check whether I’m still the newest” does
not work in a script. The stamp is a set, so the replay pass after the
sleep re-stamps it and every arrival concludes it was last.
Draw a ticket instead. incr is memoized, so the number a run drew
before the sleep is the number it still holds after:
mine=$(cryo state incr "ticket:$key" --entity debounce --as ticket)cryo state set "latest:$key" "$value" --entity debounce
cryo sleep 3s
# A live read - nothing of mine shadows it.if [ "$mine" = "$(cryo state get "ticket:$key" --entity debounce)" ]; then ./handle.sh "$(cryo state get "latest:$key" --entity debounce)"fiFive arrivals half a second apart run handle.sh once, on the last
one’s value. The same shape on a canvas uses cas on the version the
stamp returned, since a graph node runs once per run and needs no
ticket. Debounce is this runnable,
both ways round.
The latest: cell in that recipe is a plain set, so a waking loser
re-stamps it with its own older value on the way past. That is harmless
when the winner wakes last, which is the normal case, and it is the
reason the ticket - not the value - decides who acts. If the value has
to be exactly the last arrival’s, carry it in the ticket cell itself
with a cas rather than in a second cell.
On a canvas, a get reads back your own write
Section titled “On a canvas, a get reads back your own write”The graph tier journals a cell write, so a get of a cell this run
has already written answers from the journal without going to the
store. That keeps a read deterministic across replay passes, and it
means a graph cannot write a cell, wait, and then read it to see what
another run did in the meantime - it sees its own value. Settle that
with cas on the version the write returned, which does go to the
store.
A polyglot script is the other way round: cryo state get is a plain
store read every time it runs, so it does see other runs’ writes - and
that is exactly why anything derived from it has to be pinned by a
memoized op (add, cas, incr) before the script branches on it.
A claim also survives the run dying mid-claim. Writing the cell and recording that you wrote it are two steps, and a run that dies between them holds the key with nothing in its log to say so. The cell remembers who claimed it, so the run asks again on restart and is told it still holds the key rather than losing to its own earlier attempt.
Reading a cell from outside the run
Section titled “Reading a cell from outside the run”A cell is held by the server, not by the agent, so reading one needs neither the run to be alive nor an agent to be free. From a terminal, name the scope you want - out here there is no run you are already in:
cryo state get progress --run dev:default:run-jD3sGl # that run's own cellscryo state list --run dev:default:run-jD3sGl # all of themcryo state get last-seen --entity digest # the project's storeA bare run-jD3sGl works too; it is qualified with your active project.
The output is what the same command prints inside a job, so a value reads
the same either way, and an unset cell prints nothing and exits 0.
Over HTTP the same two scopes are
GET …/runs/{id}/state[/{key}] and GET …/entities/{entity}/state[/{key}].
Fetching one key also returns its version, which is what a poller
compares to notice a change without re-reading the value.
This is how a run publishes something for a reader outside it: write the cell, and the reader fetches it. The run has to write it - there is no asking a workflow a question it didn’t prepare an answer for.
Three things to know before you rely on it:
- Reading a run’s own cells needs
Viewer; entity scope needsMember. A run’s cells are its working data, like its logs. The entity store spans the project and outlives every run, so it sits at the level that could already read it by running a workflow that does. - Cells are not masked. Secret masking applies to logs and deliberately not here, because a workflow reads a cell back authoritatively and a masked value would be wrong. A cell holding a credential hands back that credential, so write accordingly.
- Nothing outside a run can write. A write from outside would race the workflow that owns the cell. To tell a running workflow something, send it a signal.
Run-scoped cells go when the run is collected, so reading a swept run gives back nothing - an empty listing, or a 404 on a named key. That is the same lifetime its logs have.
Cleaning up
Section titled “Cleaning up”Cells in run scope go when the run is collected. Entity cells stay until you remove them:
cryo state delete last-seen --entity digestDeleting a project removes every cell under it, both scopes.
Where cells fit
Section titled “Where cells fit”| You want | Reach for |
|---|---|
| A value between steps of one run | Run-scoped cell, or cryo output set |
| Not re-running an expensive command on replay | cryo step (a memo) |
| A value from this run’s last execution | Entity-scoped cell |
| “Has anyone handled this id?” | add on an entity cell |
| A shared counter or quota | incr on an entity cell |
| Only one run at a time in a region of the pipeline | concurrency:, not a cell |
The last row is worth its own note, because the two overlap more than the table suggests. Both serialize; they answer different questions.
add answers “not more than once, ever”. The claim outlives the run, so a
second run doesn’t wait - it finds the key taken and skips.
A lease answers “not at the same time”. It is held until the run ends and
released whatever the outcome, so the next run in line proceeds. The group
is a value like any other, so it can be per-key as easily as global:
concurrency: "record-{{ input.record_id }}" on a node gives one lease per
record, and runs on different records never see each other. A parked run
holds no agent, so a queue on one hot record costs rows rather than
machines. See examples/per-record-lease for a run you can
watch, and the YAML reference for the
top-level
and job-level
forms.
So: “no two deploys at once” is a lease. “Process this record once, whoever
gets here first” is add. “One writer per customer, others wait their
turn” is a lease with the customer in the group.