Authoring on the canvas
The canvas is where you build a workflow as a graph: drop nodes, wire them edge to edge, and publish. Every graph lowers to the same durable core as a YAML pipeline or a polyglot script, so what you draw is a real workflow with checkpoints and replay, not a diagram.
Open Canvas from the nav, then create a graph or open an existing one.
Start from a template
Section titled “Start from a template”An empty canvas offers a Start from a template menu. Each one is a small working graph built around a shape that’s awkward to get right from a blank canvas:
- CI from a repo - a push webhook that routes by branch to a pipeline fetched from the repo. See CI from a repo.
- Approve before deploy - a build, then a gate that parks until someone answers. The gate has a timeout, and the deploy is guarded on it, because an expired wait succeeds. See approvals.
- Poll something, remember where you got to - a cursor read from durable state, a fetch of what’s newer, the cursor written back. The cell is entity-scoped, so it outlives the run. See the poll cursor.
- Fan out over a list - a computed list, then one child run per item with a cap on how many go at once. See Repeating work.
- Start a job elsewhere, wait for it to call back - park on a bus event until another system reports back. See Events and handlers.
- Notify on failure - post to a room whenever a run in this project fails.
Pick one to drop in a working graph you can edit, or ignore the menu and build from the palette.
The palette
Section titled “The palette”The toolbar groups the built-in node kinds so you can find them by what they do:
- Sources - a
triggernode, the graph’s inbound side. It starts the run, from a bus event or a webhook the graph owns. See Triggers. - Activities - the nodes that do work:
http,shell,code, andworkflow(run another graph, a YAML pipeline, or a repo file). Plustransform, which reshapes the value passing between two of them - a field table or a line of JavaScript, evaluated in the engine rather than on an agent. See Reshaping data. - Flow - control and durable primitives:
switch,fan_out,wait,approval,event,emit,state. Astatenode reads and writes a durable cell (get,set,delete) and settles races between runs (add,incr,cas) - see Durable state.fan_outand a self-namingworkflownode are the two ways a graph repeats work; see Repeating work. - Integrations - connector actions (
matrix.send_message,slack.post_message, and the rest), picked from the integration dropdown. See Connectors.
Click a node to add it; drag between the handles on two nodes to connect them. Layout is computed for you - position isn’t saved, the document is.
Edges and branches
Section titled “Edges and branches”An edge means “after”: the target waits for everything wired into it, and runs once those have finished.
An edge out of a switch can go further and name the branch it belongs
to. The switch picks a case, and only the edges naming that case stay
active:
{ "from": "route", "to": "on_big", "when": "big" }whenis a case name from the switch the edge leaves. Save checks it against that switch’scases[].nameand itsdefault, so a name it doesn’t declare is an error listing the ones it does have.- Only an edge whose
fromis aswitchmay carry one. - A target the switch didn’t route to is skipped, the same skip a false
if:gives it, so a join node below several branches still runs. - Several branch edges into one node all have to match for it to run.
- An edge with no
whenis always active.
A routed edge carries its branch name on the canvas, so which way a graph goes is readable without opening anything. Click the edge to select it and the inspector offers the branches its switch declares, plus “Always (no branch)” to clear it. An edge out of anything else says so rather than offering a control that save would refuse.
A branch name the switch doesn’t declare - from an imported file or a hand-edit in the Document drawer - shows up in the validation banner above the canvas, since it’s a problem with the graph rather than with one node.
A node’s if: is still how you gate on anything that isn’t a branch
name: a status, a count, a value an upstream node produced. Edge and
condition compose, so the edge decides whether a node is reachable and
its if: then decides whether it runs. Different work for different
branches has the full
example.
Editing a node
Section titled “Editing a node”Select a node to open its inspector. Every kind shows:
- id - the node’s name, how expressions reference it
(
nodes.<id>.output). Rename in place; references update. - if - a CEL condition. Empty means the node runs whenever it is reached (see Edges and branches); otherwise it runs only when the condition is true. This field autocompletes what’s in scope (see Expressions).
- Fields - labeled inputs for the kind’s config, each with a short
help line drawn from the node’s schema. A
shellnode has arunfield, awaitnode hasdurationandsignal, and so on. - config - the raw JSON, as an escape hatch. Anything the labeled fields don’t cover - a nested object, an array - you edit here. The fields and the JSON stay in sync.
A node also takes retry:, timeout:, requires:, continue_on_error:
and concurrency: beside its config. Those are the node’s own settings
rather than its kind’s, so they live in the document; the inspector shows
if: and leaves the rest to the drawer.
concurrency: is a durable lease, held from the moment the node acquires
it until the run ends. It takes {{ … }} holes like any other value, so
"record-{{ input.record_id }}" gives one lease per record rather than one
lock for everything, and a run waiting on a busy lease holds no agent. A
group that renders empty is refused rather than quietly serializing every
run together. examples/per-record-lease is a worked example.
Fixed values and computed ones
Section titled “Fixed values and computed ones”Every field carries an fx button. A fixed value is what you type; a computed one is worked out when the node runs, from the run’s input or from what an earlier node produced. Click fx and the field becomes an expression, keeping what you had already typed.
The expression field completes as you type, and it continues into a node’s output:
nodes. → the nodes upstream of this onenodes.fetch. → output, statusnodes.fetch.output. → status, headers, bodynodes.fetch.output.body. → whatever that body containedWhere the keys come from depends on what’s known. Once the graph has
run, they are the keys those nodes actually produced last time - so a
body shows the real fields of the real response. Before the first run,
they are what the node’s kind declares it always produces. A node whose
output is genuinely open - a shell node’s cryo output set values, a
sub-workflow’s return - offers nothing below output, because guessing
there would send you after a key that never arrives.
Pick a value with the arrow keys and Enter, or click it. A suggestion
ending in . descends a level.
Some kinds add their own controls: an http or connector node has a
credential dropdown fed by your connections, and a trigger
node has a transport selector.
Expressions
Section titled “Expressions”Any config value can be a literal or an expression. The fx button writes these for you; this is what it writes:
{ "$expr": "nodes.fetch.output.status == 200" }- a CEL expression, resolved to a value.{ "$template": "build {{ input.sha }} failed" }- a string with{{ ... }}interpolation.
A single value gets $expr (the expression is the value); a multi-line
body gets $template (text with holes in it).
Five roots are in scope:
input- the run’s input (the triggering event for a graph that starts on one).nodes.<id>.output- the output of an upstream node, andnodes.<id>.status, one ofsuccess,skipped,failed.vars.<name>- the graph’s own constants, declared once on the document and readable from any expression. Use them for values that are fixed for the graph rather than carried in by a run.run- the run’s own metadata.run.idis the run id, stable across replay, so it can seed an idempotency key.run.untrustedis true when the run was started by a delivery the project doesn’t vouch for - a fork pull request, an outside contributor. Such a run is refused project credentials, so gate the privileged half of a CI graph withif: "!run.untrusted"and it is skipped rather than attempted. A node that genuinely works without its credential can instead setoptional_when_untrusted: trueand run without it; the credential is still withheld, so that is not a way to obtain one.node- the node being evaluated.node.idis its name, andnode.idempotency_keyis a token that stays the same across every retry and replay of this call site - what you hand an API that dedupes on one (see the authoring model).
There is no environment root. Environment reaches a job’s process, can be
set per agent, and is read there as $NAME; an expression is evaluated
before the work reaches an agent, so it cannot see one.
A failure branch is an if: of nodes.<id>.status == 'failed' on the
node that handles it.
The if: field offers these as you type: enter nodes. for a menu of
upstream ids, then .output or .status, or vars. for the graph’s own
constants. The inspector also lists a selected node’s upstream outputs as
one-click references.
Declaring vars
Section titled “Declaring vars”With no node selected, the inspector shows a Vars panel: the graph’s
constants, and a name/value pair to add one. A value is JSON, so 0.9 is a
number and ["a","b"] a list; text that isn’t valid JSON is kept as a
string, which is what typing us-east means. The panel shows the resolved
type next to each name.
A name has to be readable as vars.<name> - letters, digits and _, not
starting with a digit - and can’t be true, false, null or in, which
are literals in the expression language. The panel says so before you save
rather than letting publish reject it.
Vars belong to the document, so they are the same on every run of it. A
value that changes per run is the run’s input, read as input.<field>.
Declaring what the run returns
Section titled “Declaring what the run returns”The same panel has an Outputs section: what this graph hands back to whoever ran it, which is how a parent graph reads a value out of a child. Each one is a name and an expression, and the expression field completes against every node in the graph - outputs resolve after all of them have finished, so there is no upstream to be restricted to.
nodes is refused as a name: the run output already carries every node’s
status under it. A name that isn’t a plain identifier still works, but a
caller has to reach it as nodes.child.output["my name"] rather than with
a dot, so the panel says so.
An output written by hand as a template or a nested structure is listed but edited in the Document drawer - collapsing it into one expression field would rewrite what you wrote. Composing workflows covers what a caller sees.
Live validation
Section titled “Live validation”As you edit, the canvas validates the graph and marks what’s wrong:
- A node with a problem gets a red border and a dot; its inspector lists the specific errors (a config that doesn’t fit, an expression that won’t compile).
- Whole-graph problems (a cycle, a dangling edge) show in a banner above the canvas.
A graph has to validate before it saves, so the markers are a live preview of what a save would reject.
Saying what input the graph takes
Section titled “Saying what input the graph takes”Graph settings has an Input section: name the fields a caller has to
send, give each a type, and tick the ones that are required. It writes
the graph’s
input_schema:, so a
caller who misspells a key is refused when they submit, with the field
named - rather than the run failing several nodes in at an expression
that resolved to nothing.
Declaring one also turns the Run button’s input box into a form, one control per field, with the required ones marked before you press Run. The JSON box stays underneath for a nested value or a key the schema doesn’t declare; both edit the same object.
Ticking required is what makes an input mandatory. Fields on their own
describe what the graph reads and check the type of whatever arrives, so
documenting an existing graph never breaks a caller that was sending
nothing.
Two kinds of problem show up here that a submit deliberately doesn’t
re-check: a retry.exit_codes: no failure could ever match, and a
malformed
input_schema:. Both
are about what you meant rather than what will execute, so they are
raised while you’re editing and never used to refuse a graph that was
saved before the check existed - a rule that turned old graphs away at
submit would take running systems down with it.
Editing
Section titled “Editing”Undo and redo (⌘Z, ⇧⌘Z) walk the draft backwards and forwards through your edits. They move what’s on screen, not what’s stored: saving is still a separate act, and undo cannot take back a save. Opening a graph is not an edit, so there is nothing to undo when you arrive.
Duplicate copies the selected step - its config, its note, its if:
and its settings - under a new id, and selects the copy. It isn’t wired
to anything: an edge means “after”, and where a copy belongs in the
order is yours to say.
Notes are for whoever opens this next, including you in six months. A step’s note sits in its inspector and shows on its card; the graph’s own note lives in Settings. They travel with the document, so an export or a hand edit keeps them.
There are no free-floating notes on the canvas, because there are no saved positions: layout is computed from the document every time, and a note pinned to a coordinate would be moved out from under the step it was about. A note that belongs to a step goes where the step goes.
Working against real data
Section titled “Working against real data”Editing a step that reshapes what another step produced is guesswork until you can see the data. Three things, in the order you reach for them:
- Run this node executes the selected step on its own. It really
runs - an
httpnode calls the service - and its output becomes the sample every step below it is edited against. - Pin keeps that output. Without a pin, reloading the page means running the step again to get your sample back, which is slow and, for somebody else’s API, rude. A pinned sample stays in your browser: it is real response data, and a graph gets published, exported and pasted into chat. It never reaches a run - what a run sees is what its own steps produce.
- The result panel says where its data came from, so a kept sample is never mistaken for something that just ran.
A transform uses that sample immediately: it shows what each field
produces as you type. See Reshaping data.
Letting a child answer an expression
Section titled “Letting a child answer an expression”A workflow node targeting a single node kind is written in this
document but runs against the input: you hand it, so there are two
scopes and only you know which one you meant. By default every marker
resolves here:
{ "id": "call_api", "type": "workflow", "config": { "type": "cryosleep-node/v1", "input": { "kind": "http", "config": { "url": { "$defer": { "$expr": "input.endpoint" } }, "method": { "$expr": "vars.http_method" } }, "input": { "endpoint": "https://api.example.com/v1/items" } } }}method reads vars from this graph. url is wrapped in $defer,
which hands the expression down one scope instead of answering it here -
so input.endpoint means the input: on the line below rather than this
graph’s own input.
The child’s scope holds only input and run. A deferred expression
reaching for nodes, vars or steps is refused at save, naming the
root and the wrapper to drop: those belong to the document you wrote them
in. If you need one of them below, put the value in the child’s input:
and read it from there.
$defer is refused anywhere else, because nothing below a plain node’s
config would ever answer it. The same shape and the same rules apply to a
YAML node: job - see letting the child answer a
marker.
Save, publish, run
Section titled “Save, publish, run”- Save writes the next draft version to the graph store.
- Run saves first (if there are unsaved edits) and submits that exact stored version, so what runs is what you see. A run overlay paints each node with its live status.
- Publish points the graph’s published version at the current draft. Publishing is also what activates triggers: a graph with trigger nodes registers its subscriptions and provisions any owned webhooks on publish. See Triggers.
Draft or published
Section titled “Draft or published”You edit the draft. What runs from outside the editor - a trigger, a
schedule, cryo call graph <name>, another graph’s workflow node - is the
published version. Saving does not change that; publishing does.
The header says which state you’re in: draft v7 · not published while the
stored draft is ahead (or was never published), and an unsaved chip while
there are edits not yet written to it. Publish is ringed whenever either is
true, so it’s the obvious next action until what you see is what runs. Once
the header reads draft v7 and published v7 with nothing else, they
match.
The Run button is the exception, and deliberately so: it saves and runs the version on your screen, so you can test an edit without publishing it to everything else.
Import and export
Section titled “Import and export”A graph is a document, so it can leave the canvas and come back.
- Export downloads what’s on the canvas as
<name>.json, including edits you haven’t saved. Use it to check a graph into a repo, hand it to someone, or copy it into another project. - Import loads a definition from a file (JSON or YAML) onto the canvas. It replaces what’s there and stages the result, so nothing is stored until you Save or Publish. With unsaved edits on screen it asks first, because an import replaces the whole document rather than adding to it. A file that doesn’t parse loads into the document drawer with its error, so you can fix it there.
An imported file keeps the name of the graph you’re editing, not any name:
inside the file - a graph is addressed by the name you opened, so “export
one graph, import it into another” copies rather than renames.
A definition carries node config, not the things it points at: credentials and secrets are referenced by name, so a graph imported into another project needs those names to exist there too.
Import takes graph documents, the ones starting version: cryosleep-graph/v1. A YAML pipeline (jobs:) is a
different document type and won’t load here, even though it compiles to the
same node graph when it runs. To see one drawn as a graph, paste it into the
read-only preview at /pipelines. Pipelines and
graphs covers how the two formats relate
and why there’s no conversion between them.
The same documents work from the terminal. cryo graph get <name> prints a
graph’s stored definition and cryo graph put <name> <file> stores one, so
a file from either side loads on the other:
cryo graph get order-report > order-report.json # exportcryo graph put order-report ./order-report.json # import (as a draft)cryo graph ls # what's stored, and what's publishedSee the CLI reference for the full set.
Debugging a run
Section titled “Debugging a run”Run leaves the last run loaded on the canvas. Each node wears its live status - running, then success, skipped, or failed - and the run stays put so you can pick through it after it finishes. This is where you debug a graph without leaving the editor.
What went in and what came out
Section titled “What went in and what came out”Logs tell you what a step printed. When you’re debugging a graph, what you usually want is the structured data instead: the value that went into a node and the value it handed to the next one. Select a node on a loaded run and its inspector shows three faces of that node’s run:
- In - the input the node received once every expression resolved: the value it actually ran on, not the template you wrote.
- Out - the structured output the node produced, the same value
nodes.<id>.outputresolves to downstream. - Logs - that node’s captured text, just this node’s, not the whole run’s.
A node that hasn’t run yet shows its template input and an empty Out. A failed node shows what it received, whatever it managed to return, and its logs - and the failure reads on the canvas too: a failed run raises a banner naming the node that broke and showing its output, so you don’t have to open the run page to see the cause.
Run from here
Section titled “Run from here”Once a run is loaded, a selected node offers Run from here. It starts a fresh run of that node and everything downstream of it, feeding the nodes upstream from what they produced last time rather than re-running them. Fix a node, run from it, and you re-execute the part you changed against the inputs the rest of the graph already produced - the tight loop for iterating on one node deep in a graph.
Credentials in the inspector
Section titled “Credentials in the inspector”A node names the credential it uses; it never carries the secret value. So
the In face shows credential: "matrix", the name, and the token stays out
of the run’s record entirely - the agent resolves it only when the work
runs. Reading a run’s inputs and outputs never exposes a secret, so there’s
nothing to lock down and no reason to gate who can debug.
The exception is a value a job itself prints or returns - a step that fetches a token and echoes it lands in that step’s output and logs like any other value. That’s true of the run page today, independent of the inspector; treat a job that emits a secret as the thing to fix.
In the terminal
Section titled “In the terminal”cryo follow <run-id> gives the same node-by-node view in the terminal.
A graph run draws as a vertical DAG - one row per node, lanes connecting
them, a status glyph on each - and selecting a row shows that node’s In /
Out / Logs, the same three faces the canvas does:
✓ fetch http├─╮✗ │ parse code│ ││ ○ notify shell├─╯○ archive shellMove between nodes with ↑↓, switch In / Out / Logs with ←→ (or Tab),
and press r to run from the selected node. g toggles between the graph
and the flat span list. It reads the same durable log the console does, so
it works against a local cryo dev run exactly as it does against a hosted
one - a whole debugging loop without a browser.
See also
Section titled “See also”- Triggers - starting a graph from an event or a webhook.
- Connectors - the integration nodes and their credentials.
- Composition - the
workflownode and running graphs from graphs. - Different work for different branches - switches, routed edges, and the three places a run decides what to do.
- CI from a repo - the push-to-build walkthrough.
- The CLI -
cryo followand the rest of the terminal tools.