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.
Start from a template
Section titled “Start from a template”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.
The palette
Section titled “The palette”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, andworkflow(run another graph, a YAML pipeline, or a repo file). Plustransform, 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. Astatestep reads and writes a durable cell (get,set,delete) and settles races between runs (add,incr,cas) - see Durable state.fan_outand a self-callingworkflowstep are the two ways a graph repeats work; see Repeating work. - Integrations - connector actions (
matrix.send_message,slack.post_messageand the rest). See Connectors. - Presets -
httpsteps 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.
Where steps run
Section titled “Where steps run”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.
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 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.
Editing a step
Section titled “Editing a step”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.
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 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 onenodes.fetch. → output, statusnodes.fetch.output. → status, headers, bodynodes.fetch.output.body. → whatever that body containedOnce 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.
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.
Expressions
Section titled “Expressions”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, andnodes.<id>.status, one ofsuccess,skipped,failed. The step has to be upstream, reachable by following edges back.nodesholds those steps and no others, sosize(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.idis 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, such as a fork pull request. Such a run gets no project credentials, so gate the privileged half of a CI graph withif: "!run.untrusted". A step that works without its credential can setoptional_when_untrusted: trueand run without it.node- the step being evaluated.node.idis its name, andnode.idempotency_keystays the same across every retry and replay of this call site, for an API that dedupes on one (see the authoring model).itemandindex- inside afan_outbody, the current item and its position.event- inside aneventstep’swhen, 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.
Graph settings
Section titled “Graph settings”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.
Outputs
Section titled “Outputs”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.
Live validation
Section titled “Live validation”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:.
Undo, duplicate and notes
Section titled “Undo, duplicate and notes”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.
Working against real data
Section titled “Working against real data”- Run this node runs the selected step on its own, for real (an
httpstep calls the service). Its output is what the steps below read asnodes.<id>.outputwhile 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, publish, run
Section titled “Save, publish, run”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.
Draft or published
Section titled “Draft or published”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 (dry runs)
Section titled “Try it (dry runs)”Try it, beside Run, runs the draft on screen as a dry run: every step is placed, and nothing leaves the run.
transform,switch,triggerand astategetrun for real.fan_outexpands its list for real; each item’s body is stubbed like the step it is.http,shell,code, connector actions,workflow,emitand state writes are stubbed: nothing is sent, started or written.waitandeventsteps finish at once.- An
approvalanswers 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.
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 graph as YAML
Section titled “The graph as YAML”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:
spool workflow export order-report@draft > order-report.yaml # the draft, as YAMLspool workflow set order-report order-report.yaml # store it as the next draftspool workflow set order-report order-report.yaml --publish # ...and publish itspool workflow list --stored # what's stored, and what's publishedworkflow export prints YAML, or JSON with --format json; workflow set takes either. The
format itself is in the graph documents reference.
When a run may start
Section titled “When a run may start”A graph can limit its own runs with the same four top-level keys a pipeline takes:
version: hyperspool-graph/v1name: deploytags: [deploy]concurrency: group: deploy-${{ input.env }} # one run per environment at a time policy: cancel-running # a new run replaces the one runningthrottle: limit: 10 period: 1h # one start every six minutescancel_on: events: [deploy.rollback] # this event cancels a runnodes: - id: ship type: shell config: run: ./deploy.shThey’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 in a repo
Section titled “A graph in a repo”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.
Import and export
Section titled “Import and export”- 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.
Debugging a run
Section titled “Debugging a run”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.
What went in and what came out
Section titled “What went in and what came out”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>.outputresolves to downstream. - Logs - that step’s captured output.
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.
Run from here
Section titled “Run from here”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.
See also
Section titled “See also”- Triggers - starting a graph from an event or a webhook.
- Connectors - the integration steps and their credentials.
- Composition - the
workflowstep and running graphs from graphs. Letting the child answer a marker covers$defer, which works the same in a graph’sworkflowstep. - Different work for different branches - switches and routed edges.
- CI from a repo - the push-to-build walkthrough.