> ## Documentation Index
> Fetch the complete documentation index at: https://app.keystroke.ai/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Best practices

> Write workflows that stay replayable at runtime and legible on the canvas.

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](/learn/workflows/build-workflows#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.

```ts theme={null}
// 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.

```ts theme={null}
// 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.

```ts theme={null}
// 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.

```ts theme={null}
// 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.

```ts theme={null}
// 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.

```ts theme={null}
// 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 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           |

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

<CardGroup cols={2}>
  <Card title="Build workflows" href="/learn/workflows/build-workflows">
    Define workflows and compose actions, agents, and durable steps.
  </Card>

  <Card title="Test workflows" href="/learn/workflows/test-workflows">
    Run workflows in tests and assert their output.
  </Card>

  <Card title="Run workflows" href="/learn/workflows/run-workflows">
    Start runs from the CLI, triggers, the API, and agent tools.
  </Card>

  <Card title="Workflow runs" href="/learn/logs/workflow-runs">
    Inspect input, output, steps, errors, and traces.
  </Card>
</CardGroup>
