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

# Best practices

> Write workflows whose steps are small, typed, safe to retry, and legible on the canvas.

These practices keep your workflows **reliable at runtime** and **legible on the canvas**. A workflow is a committed graph of steps (`.then()`, `.parallel()`, `.branch()`, `.foreach()`, …), and the canvas is drawn from that graph: every step becomes a node (its kind — action, agent, LLM, hook, wait, sub-workflow — comes from the step itself), and run history lights up each node by its step id. Nothing is parsed from your source, so any valid TypeScript renders correctly; the habits below are about making the graph clear and the steps safe to retry.

## The one principle

**Put durable work in steps, and compose those steps with the builder.** Everything else on this page follows from it.

A step is an action, a `createStep()`, or a helper step (`agentStep`, `llmStep`, `hookStep`, `workflowStep`, `.sleep()`). Each step's result is recorded: when a run is retried or resumed, completed steps keep their results and execution continues from the step that failed or was in flight. A step that fails is re-run from its start, so the work inside it should be safe to repeat. See [Durability and retries](https://docs.os.meetluno.ai/learn/workflows/build-workflows.md#durability-and-retries) for the runtime side of this.

## Prefer actions as steps

Chain actions directly with `.then(action)` instead of calling them from inside a custom step. Each chained action is its own recorded step, its own retry boundary, its own credential binding, and its own node on the canvas.

```ts theme={null}
// Avoid — two actions hidden in one opaque step
const syncLead = createStep({
  id: "sync-lead",
  inputSchema: Lead,
  outputSchema: z.object({ ok: z.boolean() }),
  execute: async ({ inputData }) => {
    const { brief } = await researchLead.run(inputData);
    return postBrief.run({ text: brief });
  },
});

// Prefer — two steps, two nodes, retried independently
defineWorkflow({ ... })
  .then(researchLead)
  .map(({ inputData }) => ({ text: inputData.brief }))
  .then(postBrief)
  .commit();
```

Reach for `createStep()` when a step needs logic of its own (formatting, a decision, a call that isn't worth an action), or when you need `try`/`catch` around an action to handle its failure yourself.

## Keep step ids stable and unique

A step's id is its identity: run events, retries, credential assignments (`step:<id>`), and canvas nodes are all keyed by it. Action steps use the action slug; helper steps use the id you pass (`agentStep` and `workflowStep` default to the agent or child slug).

* **Reuse an action under a new id with `.as("id")`.** The same action used twice in one workflow needs distinct ids.
* **Set `id` on `agentStep` / `workflowStep`** when the same agent or child appears more than once, e.g. in `.parallel()`. The canvas then names the node by that id (a step keyed by its slug shows the agent, action or workflow name).
* **Don't rename ids casually.** Renaming a step detaches the credential assignments bound to it and changes the keys of `.parallel()` / `.branch()` outputs.

```ts theme={null}
  .parallel([
    workflowStep(enrichLead, { id: "enrich-primary" }),
    workflowStep(enrichLead, { id: "enrich-billing" }),
  ])
```

## Reshape with `.map()`, keep it pure

Use `.map()` to adapt one step's output to the next step's input, and to build the final `output`. Its result is recorded (`map-<n>`, or the `{ id }` you pass so the canvas shows a readable name), but it has no retries and no credentials. Keep it pure — no network calls, writes, clock reads, or random ids. Anything with a side effect belongs in a step.

```ts theme={null}
// Avoid — an untracked side effect in .map()
  .map(async ({ inputData }) => {
    await fetch(webhookUrl, { method: "POST", body: JSON.stringify(inputData) });
    return inputData;
  })

// Prefer — the side effect is an action step, recorded once
  .then(notifyWebhook)
```

Branch conditions (`.branch()`, `.dowhile()`, `.dountil()`) follow the same rule: decide from `inputData`, `getInitData()`, and `getStepResult()`, not from outside state.

## Make side effects idempotent

A failed step is retried from its start (per `retries`, and again when the queue retries the run). Design actions so running them twice with the same input is harmless: upsert instead of insert, pass idempotency keys to APIs that support them, and check before creating.

**Wrap API calls in actions** (`defineAction`) instead of calling `fetch` in a custom step. Actions get typed I/O, credential resolution, a trace span, and a named node on the canvas.

## Use the builder for structure

Control flow written with builder methods shows up as real structure on the canvas and in run history; control flow hidden inside one `execute` function shows up as a single step.

| You want | Write |
| - | - |
| Steps in sequence | `.then(a).then(b)` |
| Independent steps at once | `.parallel([a, b])` (output keyed by step id) |
| Conditional steps | `.branch([[condition, a], [condition, b]])` |
| One step per array item | `.foreach(step, { concurrency })` |
| Repeat until done | `.dowhile(step, condition)` / `.dountil(step, condition)` |
| A delay | `.sleep("1h")` / `.sleepUntil(date)` |
| Wait for a human or external system | `hookStep(id, { schema })` |
| Reuse another workflow | `workflowStep(child)` |
| Judgment or tool use | `agentStep(agent, { prompt })` |
| One model call | `llmStep(id, { prompt, model })` |

## Reuse workflows with `workflowStep`

To share orchestration between workflows, import the child and run it with `workflowStep(child)`. It runs as its own tracked child run with its own history and durable waits, and the parent waits for it. Share **pure logic** (formatting, date math, prompt building) as ordinary functions in `src/lib/**`.

## Let `.commit()` check the output

`.commit()` is a type error when the last step's output does not match the declared `output`. Fix it with a final `.map()` that builds exactly the output shape, rather than loosening the `output` schema.

## Quick checklist

* [ ] Each action is its own step (`.then(action)`), not hidden inside a custom step.
* [ ] Step ids are unique (`.as("id")`, `id` on helper steps) and stable.
* [ ] `.map()` and conditions are pure; side effects live in steps.
* [ ] Side-effecting steps are idempotent and safe to retry.
* [ ] Branches, loops, parallelism, and waits use builder methods.
* [ ] Shared orchestration is a sub-workflow (`workflowStep`); shared logic is a pure function.

## Next steps

> **[Build workflows](https://docs.os.meetluno.ai/learn/workflows/build-workflows.md)**
>
> Define workflows and compose actions, agents, and durable steps.

> **[Test workflows](https://docs.os.meetluno.ai/learn/workflows/test-workflows.md)**
>
> Run workflows in tests and assert their output.

> **[Run workflows](https://docs.os.meetluno.ai/learn/workflows/run-workflows.md)**
>
> Start runs from the CLI, triggers, the API, and agent tools.

> **[Workflow runs](https://docs.os.meetluno.ai/learn/logs/workflow-runs.md)**
>
> Inspect input, output, steps, errors, and traces.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.