Skip to main content

Overview

This page collects guidelines for structuring workflows. They are not requirements — a workflow that ignores all of them will still run — but following them makes workflows easier to operate, debug, and change.

Keep Steps Small and Focused

Steps are the unit of retries, error handling, and observability. When a step fails, the whole step re-runs — so a step that performs one external action (one API call, one SQL write, one email) is much easier to retry safely than a step that performs five. Prefer:
workflow.then(
    Step("fetch-order", fetch_order)
).then(
    Step("generate-letter", generate_letter)
).then(
    Step("send-letter", send_letter, options={"num_retries": 3})
)
over a single step that fetches, generates, and sends. With separate steps, a failure in send-letter retries only the send — it does not regenerate the letter or refetch the order.

Make Steps Safe to Re-run

Steps re-run in two situations: when they are retried after a failure, and when they resume after a suspension. Design step bodies so running them twice does not cause duplicate side effects:
  • Start steps that suspend with the resume guard: if ctx.resume_data: return ctx.resume_data
  • Do reads and computation first; save external side effects (emails, faxes, database writes) for the end of the step — or better, their own step.
  • For database writes, prefer upserts (INSERT ... ON CONFLICT ... DO UPDATE) over plain inserts so a retried step does not create duplicate rows.

Pass Small Values Between Steps, Store Large State Elsewhere

Step outputs are stored and passed between steps as JSON (see Step Outputs). Keep them small and meaningful: IDs, statuses, and the few fields later steps actually need.
  • For documents, pass the document id and fileName rather than file contents.
  • For state that outlives the run (processing history, cross-run deduplication, reporting), use the database and pass row identifiers between steps.

Choose the Right Boundary Between Automation and Review

Screen steps are for judgment calls; normal steps are for everything else. A useful pattern is to compute a recommendation in an automated step, then let the screen present it for confirmation — rather than asking the user to do the analysis themselves. If a screen is approving the same thing every time, consider replacing it with a condition and only routing exceptional cases to a screen using conditionals.

Split Large Automations into Multiple Workflows

A single workflow run works best when it represents a single unit of work — one order, one fax, one claim. When a process spans many records or stages, split it up:
  • Use an emitter workflow on a cron schedule to fan records out to a processor workflow — see Emitter Workflows.
  • Use events to chain stages together, so each stage shows up as its own run with its own status and task list.
This keeps each run’s task list short, makes failures visible at the right granularity, and lets stages be retried independently.

Organize Workflow Code as It Grows

The workflow template starts with everything in src/workflow.py. As a workflow grows, split it into modules and keep workflow.py focused on the step graph itself:
src/
  workflow.py          # Workflow definition (steps, control flow)
  client_manager.py    # SampleHC client setup (from the template)
  config.py            # Constants, schemas, environment-specific values
  lib/
    orders.py          # Helper functions grouped by domain
  screens/
    review-screen.tsx
Step body functions can live in the helper modules and be imported into workflow.py, which makes the workflow definition read like an outline of the process.

Make Runs Identifiable

A workflow list full of runs that can only be told apart by start time is hard to operate. Use column values to surface the identifiers an operator would search for — order numbers, patient identifiers, statuses — and keep them populated from the first step, not just at the end.