Skip to content

Authoring on the canvas

The canvas is where you build a workflow as a graph: add steps, wire them edge to edge, and publish. A graph gets the same checkpoints and replay as any other workflow.

In the console, open Workflows > All graphs on the canvas to list the project’s graphs or create one with new graph. A graph opens on its workflow page, whose Canvas view is this editor. Beside it, Words reads the same graph as sentences, and its highlighted values edit the draft the canvas shows; adding and wiring steps stays on the canvas. The page also has Runs, Graph YAML and Settings.

An empty canvas offers a Start from a template menu. Each is a small working graph for a shape that’s awkward to get right from scratch:

  • Approve before deploy - a build, then an approval that waits until someone answers. The approval 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 the same shell step once per item, with a cap on how many go at once. See Repeating work.
  • Start a job elsewhere, wait for it to call back - wait for 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.

There is no template for building a repo: spool repo connect <url> does that with no graph (see CI from a repo). A graph is the right tool once one repo needs several pipelines, or a step that decides which of them runs.

A workflow page on its Canvas view: a four-step graph, the palette on the left, and the inspector for the selected fetch step on the right, showing its note and its config.

The panel on the left lists what you can add, with a Find a step… search at the top:

  • Sources - a trigger, the graph’s inbound side. It starts the run from a bus event or a webhook the graph owns. See Triggers.
  • Activities - the steps that do work: http, shell, code, and workflow (run another graph, a YAML pipeline, or a repo file). Plus transform, which the engine evaluates to reshape the value passing between two of them. See Reshaping data.
  • Flow - control and durable primitives: switch, fan_out, wait, approval, event, emit, state. A state step 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-calling workflow step are the two ways a graph repeats work; see Repeating work.
  • Integrations - connector actions (matrix.send_message, slack.post_message and the rest). See Connectors.
  • Presets - http steps already set up for common APIs (OpenAI, Anthropic, Gemini, OpenRouter, Telegram, Slack, Matrix, Discord, GitHub, Resend and more). Each names a credential after the service; create it under Settings > Connections > Credentials.

Every kind has a page in the node reference: its fields, what it returns, and a graph that uses it. The ? beside a step’s kind in its inspector opens that page.

Click an entry to add it; drag between the handles on two steps to connect them. Layout is computed, so positions aren’t saved; the document is.

Some step kinds run on an agent and some are evaluated by the engine itself:

Step Runs on Capability it needs
shell an agent shell
code an agent polyglot
http, and every connector action an agent connector
trigger, transform, switch, wait, approval, event, emit, state the engine none
fan_out an agent, once per item whatever its body needs
workflow the engine starts the child; the child’s own steps decide depends on the child

On Hyperspool, shell, polyglot, connector and build work runs on managed environments that start on demand, so a graph of those needs nothing set up. A step’s requires: adds to the capability its kind needs: a shell step with requires: [gpu] needs an agent that advertises both shell and gpu, and that means one you run (see Deploying agents). The same goes for arm64 or macos. Until such an agent exists, the run shows “no agent can run this” and names the missing capability.

A YAML matrix: job compiles to one fan_out, so a matrix over three targets needs an agent for each target’s requires:.

Locally, spool dev and spool run <file> embed an agent that advertises every capability, so every kind works with nothing to set up.

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 also name the branch it belongs to, as { "from": "route", "to": "on_big", "when": "big" }. The switch picks a case, only the edges naming that case stay active, and the steps on the other branches are skipped. The rules and a full example are in Pick one path with a switch.

A routed edge carries its branch name on the canvas. Click the edge and the inspector offers the branches its switch declares, plus “Always (no branch)” to clear it. A branch name the switch doesn’t declare, from an imported file or a hand edit, shows in the validation banner above the canvas.

Select a step to open its inspector. Every kind shows:

  • id - the step’s name, how expressions reference it (nodes.<id>.output). Rename in place; references update.
  • if - a CEL condition. Empty, the step runs whenever it is reached; otherwise only when the condition is true. The field autocompletes what’s in scope.
  • Fields - labeled inputs for the kind’s config, each with a help line from the kind’s schema.
  • config - the raw JSON, for anything the fields don’t cover. The fields and the JSON stay in sync.

In the document a node also takes retry:, timeout:, requires:, agent:, continue_on_error: and concurrency: beside its config. Edit those in the Graph YAML view, or in the Document drawer under the canvas, which holds the whole graph as JSON: open it, edit, and press Apply. retry:, timeout:, requires: and agent: apply to a step that runs on an agent (http, shell, code, a connector action); on any other kind publishing refuses them. The node fields are listed in the graph documents reference.

concurrency: is a durable lease, held from the moment the step acquires it until the run ends. It takes {{ … }} holes, so "record-{{ input.record_id }}" gives one lease per record instead of one lock for everything, and a run waiting for a busy lease holds no agent. A group that renders empty is refused.

Every field carries an fx button. A fixed value is what you type; a computed one is worked out when the step runs, from the run’s input or from what an earlier step produced. Click fx and the field becomes an expression, keeping what you had typed.

The expression field completes as you type, down into a step’s output:

nodes. → the steps upstream of this one
nodes.fetch. → output, status
nodes.fetch.output. → status, headers, body
nodes.fetch.output.body. → whatever that body contained

Once the graph has run, the keys are the ones those steps produced last time, so a body shows the real fields of the real response. Before the first run, they are what the kind declares it always produces. An output that is open (a shell step’s spool output set values, a child run’s return) offers nothing below output.

The prompt field of an approval step in expression mode, with nodes. typed and a suggestion below offering the step upstream of it, nodes.summarise.

Some kinds add their own controls: an http or connector step has a credential picker, fed by Settings > Connections > Credentials and Connected accounts (console), and a trigger has a transport selector.

Any config value can be a literal or an expression. The fx button writes these for you:

  • { "$expr": "nodes.fetch.output.status == 200" } - a CEL expression, resolved to a value.
  • { "$template": "build {{ input.sha }} failed" } - a string with {{ ... }} holes.

These 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 step, and nodes.<id>.status, one of success, skipped, failed. The step has to be upstream, reachable by following edges back. nodes holds those steps and no others, so size(nodes) counts only them. A draft saves with a read of any other step, marked on the canvas; publishing and running refuse it and name the edge to add.
  • vars.<name> - the graph’s own constants, fixed for the graph rather than carried in by a run.
  • run - the run’s metadata. run.id is 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, such as a fork pull request. Such a run gets no project credentials, so gate the privileged half of a CI graph with if: "!run.untrusted". A step that works without its credential can set optional_when_untrusted: true and run without it.
  • node - the step being evaluated. node.id is its name, and node.idempotency_key stays the same across every retry and replay of this call site, for an API that dedupes on one (see the authoring model).
  • item and index - inside a fan_out body, the current item and its position.
  • event - inside an event step’s when, the event being considered. An untrusted event doesn’t end the step unless it opts in; see Untrusted events.

There is no environment root. Environment variables exist only in the step’s process on the agent, and an expression is evaluated before the work reaches an agent.

A failure branch is an if: of nodes.<id>.status == 'failed' on the step that handles it. A failure holds back only the steps below it; the rest of the graph runs on, approvals included. How a run with a failed step ends is in Dependencies, which applies to graph steps as it does to pipeline jobs.

Settings on the canvas toolbar opens the graph’s own panel: a note on what the graph is for, then Vars, Input, Outputs and its triggers, each starting closed.

The graph’s constants, as name/value pairs. 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. 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. Vars are the same on every run; a value that changes per run is the run’s input.

List the fields a caller can 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 at submit, with the field named. It also turns the input box beside Run into a form, one control per field; the JSON box stays underneath for anything the schema doesn’t declare. Fields without required only check the type of whatever arrives, so documenting an existing graph never breaks a caller that was sending nothing.

What this graph hands back to whoever ran it, which is how a parent reads a value out of a child. Each is a name and an expression; the expression completes against every step, since outputs resolve after all of them have finished. nodes is refused as a name. An output written by hand as a template or nested structure is listed but edited in the document. Composing workflows covers what a caller sees.

As you edit, the canvas validates the graph and marks what’s wrong:

  • A step with a problem gets a red border and a dot; its inspector lists the 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 preview what a save would reject. Two checks run only while you edit: a retry.exit_codes: no failure could match, and a malformed input_schema:.

The inspector of a step with a half-written if: expression: above the field, the reason it will not compile and the column where it stops.

Undo and redo (⌘Z, ⇧⌘Z) walk the draft through your edits. They change only what’s on screen and cannot take back a save.

Duplicate copies the selected step (its config, note, if: and settings) under a new id, unwired.

Notes are for whoever opens this next. A step’s note sits in its inspector and shows on its card; the graph’s own note is in Settings. They travel with the document. There are no free-floating notes, because layout is computed every time and a note pinned to a coordinate would drift from its step.

  • Run this node runs the selected step on its own, for real (an http step calls the service). Its output is what the steps below read as nodes.<id>.output while you edit them.
  • Pin keeps that output in your browser, so a reload doesn’t mean calling the service again. It never reaches a run.
  • The result panel says where its data came from, so a kept sample is never mistaken for something that just ran.

A transform uses the sample as you type; see Reshaping data. To see a whole run’s shapes without calling anything, use Try it.

Save draft and Publish are in the workflow page’s header.

  • Save draft stores the next draft version.
  • Run saves first if there are unsaved edits, and runs that exact stored version. Each step shows its live status as the run goes.
  • Publish points the published version at the current draft. It also activates triggers, registering their subscriptions and owned webhooks. See Triggers.

You edit the draft. What runs from outside the editor (a trigger, a schedule, spool submit -w <name>, another graph’s workflow step) is the published version.

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 stored. Publish is ringed while either is true. Run is the exception: it saves and runs the version on your screen, so you can test an edit without publishing it.

Try it, beside Run, runs the draft on screen as a dry run: every step is placed, and nothing leaves the run.

  • transform, switch, trigger and a state get run for real.
  • fan_out expands its list for real; each item’s body is stubbed like the step it is.
  • http, shell, code, connector actions, workflow, emit and state writes are stubbed: nothing is sent, started or written.
  • wait and event steps finish at once.
  • An approval answers itself, with the answer from the seed run or the form’s defaults. A required field that has neither stops the dry run at that approval.

The seed is the graph’s newest normal run that has ended. A dry run takes that run’s input, unless you typed one into input, and each stubbed step returns what it returned in the seed run. With no seed, stubs return nothing. So once a graph has run for real, Try it replays its data through your edits: a changed switch or transform is checked against real values without calling any service.

On the canvas each step gets a chip saying what it would have done: ◇ would POST api.example.com, » skipped a 5m wait, ✓ answered with its defaults.

A graph after Try it: each step carries a chip saying what it would have done - would GET api.example.com, would run a script, answered with its defaults, would run a command - and the bar above says dry run, nothing sent.

Dry runs skip concurrency and throttle, publish no lifecycle events (a run.* handler never hears of them), and get no forecast. They are listed apart, under dry runs on the Runs page, and deleted a day after they end. A rerun of a dry run is dry too. From a terminal, spool workflow try dry-runs a stored graph or a graph file; see the CLI reference.

The workflow page’s Graph YAML view shows the stored draft or the published version as YAML. Edit opens the draft as text, validated as you type with the same checks a save runs. Save stores a new draft on top of the one you opened; if someone saved since, it asks whether to drop your edit or save over theirs. Comments don’t survive a save, because the stored form is JSON; a step’s note: is where the why belongs. The canvas and the YAML are the same graph, so an edit in one shows in the other.

The same document from a terminal:

Terminal window
spool workflow export order-report@draft > order-report.yaml # the draft, as YAML
spool workflow set order-report order-report.yaml # store it as the next draft
spool workflow set order-report order-report.yaml --publish # ...and publish it
spool workflow list --stored # what's stored, and what's published

workflow export prints YAML, or JSON with --format json; workflow set takes either. The format itself is in the graph documents reference.

A graph can limit its own runs with the same four top-level keys a pipeline takes:

version: hyperspool-graph/v1
name: deploy
tags: [deploy]
concurrency:
group: deploy-${{ input.env }} # one run per environment at a time
policy: cancel-running # a new run replaces the one running
throttle:
limit: 10
period: 1h # one start every six minutes
cancel_on:
events: [deploy.rollback] # this event cancels a run
nodes:
- id: ship
type: shell
config:
run: ./deploy.sh

They’re checked when a run is submitted, before any step runs, and a group or throttle key can read the run’s input and vars. A throttle’s default burst of 1 spaces starts evenly; burst: lets several start together. A throttle with no key counts under the graph’s name. Values passed with the run request (spool submit --concurrency-group, say) win over the graph’s. Edit them on the workflow’s Settings view, which saves a new draft, or in the YAML. Like everything else they apply once published. See run admission.

A graph file can live in a repo and run from there, the way a pipeline file does: as a repo’s .spool/ci.yaml, named by a workflow step’s from_repo:, or started with spool spawn by a script that cloned the repo. A file whose version: is hyperspool-graph/v1 runs as a graph, in JSON or YAML; any other file runs as a pipeline. The run records the repo, path and commit it came from. The workflow page shows it read only, and Fork to canvas copies it into the project as a graph you can edit, which from then on is separate from the file.

  • Export downloads what’s on the canvas as <name>.json, including unsaved edits.
  • Import loads a graph document from a file (JSON or YAML) onto the canvas. It replaces what’s there, asks first when you have unsaved edits, and stores nothing until you save. A file that doesn’t parse loads into the Document drawer with its error.

An imported file keeps the name of the graph you’re editing, whatever name: it carries, so importing into another graph copies rather than renames. 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. A YAML pipeline (jobs:) compiles to a graph when it runs, and spool workflow render prints that graph; there is no conversion back. To see a pipeline drawn, paste it into the pipeline preview at https://hyperspool.dev/pipelines.

Run leaves the run loaded on the canvas. Each step wears its live status (running, then success, skipped or failed) and the run stays put after it finishes.

Select a step on a loaded run and its inspector shows three faces:

  • In - the input the step received once every expression resolved.
  • Out - the structured output it produced, what nodes.<id>.output resolves to downstream.
  • Logs - that step’s captured output.
A finished run on the canvas: every step marked done, and the inspector for the summarise step showing the input it received, with In, Out and Logs tabs and Run from here.

A failed run raises a banner naming the step that broke and showing its output. A step names the credential it uses, never its value, so In shows credential: "matrix" and reading a run never exposes a secret, unless a step prints one itself.

On a loaded run, a selected step offers Run from here: a new run of that step and everything downstream, reusing what the upstream steps produced last time. Fix a step, run from it, and only the part you changed runs again.

For how long each step usually takes, see the workflow’s Runs view and the run page’s forecast: step stats and forecast. spool run follow <run> shows the same step-by-step view in a terminal; see the CLI.