The one principle
Put durable work in steps, and orchestrate those steps directly in the workflow’srun body (or a same-file helper). Everything else on this page follows from it.
A “durable step” is any action.run(...), agent.prompt(...), promptLlm(...), ctx.sleep(...), or ctx.hook(...). Each one is checkpointed: on a retry or resume, completed steps replay from their recorded result instead of re-running. Plain code between steps is not checkpointed — it re-executes on every replay. See Durability and retries for the runtime side of this.
Hard rules (the build blocks these)
These produce a canvas that is silently wrong (a step vanishes entirely) and a runtime step that can’t be attributed to a canvas node, sokeystroke build / keystroke deploy fails with a pointer to the exact line.
Never call a step outside a workflow file
A durable step only runs durably and only renders on the canvas when it lives in adefineWorkflow file — its run body or a same-file helper. A step called from a helper in src/lib/** (or any imported module) is invisible to the canvas and gets no runtime correlation id.
Never nest a step as an argument to another call
A step buried inside another call’s arguments is dropped from the canvas and can’t be attributed in runs. Assign it to a variable first.Soft rules (the build warns; canvas degrades to an opaque block)
These still work and still run correctly — they just render as a single opaque “code block” instead of detailed nodes. Each emits a build warning explaining why.Prefer calling steps directly in run() over helper functions
Inlining steps is the clearest render and the safest for run-lighting. A same-file helper called once is inlined into real nodes, so single-use helpers are fine. But a helper called from more than one site can’t be shown as distinct nodes (the build injects one id per physical call site, so every invocation shares it and folds like a loop) — each call collapses to an opaque block.
Prefer for-of loops over array-method iteration
for-of renders as an explicit loop group with the step inside. .map / .filter / .forEach / .reduce containing a step collapse to one opaque block.
Use Promise.all([ ... ]) with a literal array for parallelism
An inline array literal of step calls renders as parallel branches. Promise.all over a computed array (.map(...)), or Promise.race / allSettled / any, collapse to an opaque block.
List step input fields explicitly
Spread properties and computed ([key]:) keys in a step’s input object aren’t shown in the step inspector. Write the fields out so each renders as a value chip.
Prefer if / else (and switch) over ternaries for branching
A step-bearing ternary does render as a decision, but if / else / switch statements produce clearer condition labels and read better on the canvas. Reserve ternaries for plain value selection, not for dispatching step-bearing branches.
Why “logic in steps” matters
The workflow engine checkpoints steps, not the code between them. On a retry, crash-resume, or hook wait, the engine replays therun body from the top and short-circuits each already-completed step to its recorded output. That means:
- Put anything with a side effect or non-determinism inside a step. Network calls, DB writes, reading the clock, random ids — if it’s not in a step, it re-runs on every replay and can double-fire or drift.
- Prefer prebuilt integration apps (
@keystrokehq/gmail,@keystrokehq/github,@keystrokehq/googlecalendar,@keystrokehq/slackbot, …) over hand-rolledfetchcalls. Their actions are durable steps with typed I/O, credential resolution, and a canvas identity for free — a rawfetchin the workflow body is neither durable nor visible. - Keep the code between steps pure and deterministic (shaping inputs, picking branches). It’s fine to re-run, and it renders as edges and decisions, not blocks.
- Don’t reach for
Date.now(),Math.random(), or direct I/O inrun. Wrap them in an action so their result is recorded once.
In-grammar reference
Everything in this table renders as real nodes on the canvas.| You write | Canvas shows |
|---|---|
x.run(...), x.scope(s).run(...), x.prompt(...), promptLlm(...) | a step |
ctx.sleep(...), ctx.hook(...) | a wait / signal step |
if / else if / else, switch | a decision with one handle per branch |
| step-bearing ternary | a decision (prefer if) |
for / for-of / while / do with a step body | a loop group |
Promise.all([ a.run(), b.run() ]) (literal array) | parallel branches |
throw / early return in a branch | an error / output terminal |
try / catch (protected step) | an “on error” lane |
| same-file helper called once | inlined into its real nodes |
Quick checklist
- Every
.run()/.prompt()/promptLlm()/ctx.*is in a workflow file. - No step is nested inside another call’s arguments (hoist to a variable).
- Steps are called directly in
run(); helpers are single-use or pure. - Iterate step work with
for-of; parallelize with a literalPromise.all([...]). - Step inputs list fields explicitly (no spread / computed keys).
- All side effects and non-determinism live inside steps (prefer integration apps).
Next steps
Build workflows
Define workflows and compose actions, agents, and durable steps.
Test workflows
Run workflows in tests and assert their output.
Run workflows
Start runs from the CLI, triggers, the API, and agent tools.
Workflow runs
Inspect input, output, steps, errors, and traces.