Skip to main content
These practices keep your workflows replayable at runtime and legible on the canvas. The canvas is produced by a deploy-time AST parser that reads your workflow source directly. It is deliberately simple (single-file, syntactic), so a few habits keep every workflow rendering as real, inspectable nodes instead of opaque blocks — and keep run history lighting up the right steps. The build enforces the two rules that would otherwise make the canvas wrong (missing or invisible steps), and warns on the rest (which merely render coarser).

The one principle

Put durable work in steps, and orchestrate those steps directly in the workflow’s run 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, so keystroke 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 a defineWorkflow 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.
// BLOCKED — src/lib/agenda.ts
export async function loadInbox() {
  return await gmailFetchEmails.run({ query: "is:unread" }); // step in a non-workflow file
}

// OK — keep cross-file helpers PURE (return data; the workflow calls the step)
// src/lib/agenda.ts
export function inboxQuery(): string {
  return "is:unread newer_than:1d";
}
// src/workflows/agenda.ts
const inbox = await gmailFetchEmails.run({ query: inboxQuery() });
Cross-file helpers are encouraged for pure logic (formatting, date math, prompt building). Just don’t hide steps in them.

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.
// BLOCKED — the inner step disappears
await slackbotSendMessage.run({
  markdown_text: `Hi ${(await gmailGetProfile.run({})).emailAddress}`,
});

// OK — hoist the step, then use its value
const profile = await gmailGetProfile.run({});
await slackbotSendMessage.run({ markdown_text: `Hi ${profile.emailAddress}` });

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.
// WARN — sendToSlack is called from two branches → each call renders opaque
async function sendToSlack(text: string) { return slackbotSendMessage.run({ text }); }

// OK — call the step directly at each site
await slackbotSendMessage.run({ text: daily });
// …
await slackbotSendMessage.run({ text: weekly });

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.
// WARN — opaque block
await Promise.all(ids.map((id) => downloadLogs.run({ id })));

// OK — explicit loop
for (const id of ids) {
  await downloadLogs.run({ id });
}

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.
// OK — parallel branches
const [run, jobs] = await Promise.all([
  getRun.run({ id }),
  listJobs.run({ id }),
]);

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.
// WARN — user_id / subject won't show in the inspector
await gmailSendEmail.run({ ...base, body });

// OK — explicit
await gmailSendEmail.run({ user_id: "me", subject: base.subject, body });

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 the run 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-rolled fetch calls. Their actions are durable steps with typed I/O, credential resolution, and a canvas identity for free — a raw fetch in 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 in run. 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 writeCanvas shows
x.run(...), x.scope(s).run(...), x.prompt(...), promptLlm(...)a step
ctx.sleep(...), ctx.hook(...)a wait / signal step
if / else if / else, switcha decision with one handle per branch
step-bearing ternarya decision (prefer if)
for / for-of / while / do with a step bodya loop group
Promise.all([ a.run(), b.run() ]) (literal array)parallel branches
throw / early return in a branchan error / output terminal
try / catch (protected step)an “on error” lane
same-file helper called onceinlined into its real nodes
Anything outside this list is still valid TypeScript — it just renders as an opaque code block (with a build warning) on the canvas, and the two hard rules above are the only patterns that fail the build.

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