Skip to content

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.

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 canvas: a four-node graph, the node palette on the left, and the inspector for the selected node on the right.

The toolbar groups the built-in node kinds so you can find them by what they do:

  • Sources - a trigger node, 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, and workflow (run another graph, a YAML pipeline, or a repo file). Plus transform, 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. A state node reads and writes a durable cell (get, set, delete) and settles races between runs (add, incr, cas) - see Durable state. fan_out and a self-naming workflow node 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.

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" }
  • when is a case name from the switch the edge leaves. Save checks it against that switch’s cases[].name and its default, so a name it doesn’t declare is an error listing the ones it does have.
  • Only an edge whose from is a switch may 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 when is 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.

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 shell node has a run field, a wait node has duration and signal, 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.

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 one
nodes.fetch. → output, status
nodes.fetch.output. → status, headers, body
nodes.fetch.output.body. → whatever that body contained

Where 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.

A field switched to expression mode with fx, holding nodes.summarise.output, and a suggestion below offering the next segment of the path.

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.

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, and nodes.<id>.status, one of success, 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.id is the run id, stable across replay, so it can seed an idempotency key. run.untrusted is 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 with if: "!run.untrusted" and it is skipped rather than attempted. A node that genuinely works without its credential can instead set optional_when_untrusted: true and run without it; the credential is still withheld, so that is not a way to obtain one.
  • node - the node being evaluated. node.id is its name, and node.idempotency_key is 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.

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>.

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.

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.

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.

A node the canvas has marked invalid: a red dot on the node itself, and the reason spelled out in the inspector - a half-written if: expression that will not compile.

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.

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 http node 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.

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 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.

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.

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:

Terminal window
cryo graph get order-report > order-report.json # export
cryo graph put order-report ./order-report.json # import (as a draft)
cryo graph ls # what's stored, and what's published

See the CLI reference for the full set.

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.

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>.output resolves to downstream.
  • Logs - that node’s captured text, just this node’s, not the whole run’s.
A completed run loaded on the canvas: a node is selected and its inspector shows the In face - the resolved input that node's activity received - alongside In / Out / Logs tabs and a Run from here button.

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.

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.

The same node's inspector switched to the Logs face, showing only that one node's captured output.

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.

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 shell

Move 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.