Skip to content

Building flows

Draw a flow in the editor, wire branches, joins and loops, validate it and publish a version orders can run on.

A flow is a JSON graph of catalog nodes and the edges between them. The editor at /flows/:flowId saves it to D1 as a draft. Publishing snapshots the draft as an immutable, numbered version, and every order pins the version it started on.

New flow on /flows takes a name, an order type (purchase_request, b2b_order, reorder, subscription, storefront_order) and a starting point:

Start What you get
Template The built-in flow for that order type
Blank One trigger.manual node named start
Describe it A graph drafted by the copilot from your description, laid out automatically

The flow id is a slug of the name plus a short suffix. A new flow is a draft until you publish it.

  • Palette. Every node type in the catalog, grouped as Triggers, Commerce, Control and AI. Drag one onto the canvas.
  • Canvas. React Flow. Each output branch of a node is its own handle; an edge leaves from the branch it belongs to. A control.loop node has a second input, next, for its back-edge.
  • Inspector. The selected node’s label and config fields, generated from the node’s catalog entry, plus its per-node retries and timeout. Validation messages appear beside the field.
  • Copilot. A prompt bar that proposes a change to the whole graph (“add a fraud check after credit”). The proposal shows what it adds and removes; nothing changes until you accept it. See AI nodes and the copilot.

Save stores the draft and returns the current validation errors and warnings. Publish asks for confirmation, then publishes the next version. Test run publishes too when the graph differs from the published version, then starts a sample order on it: the input of the first dashboard scenario for the flow’s order type, or an empty order when there is none.

A flow created from a built-in template offers the template’s current graph when the template has changed since (layout differences don’t count). It is a proposal you review; nothing is published automatically.

validateGraph (packages/core/src/graph.ts) runs in the editor as you edit, on save, and before publishing. Publishing is refused while there are errors.

Errors:

  • A node id appears twice, or a node’s type isn’t in the catalog.
  • A config field is missing when required, or fails its kind’s check (see the node catalog for each field’s kind).
  • The flow doesn’t have exactly one trigger, or an edge points into a trigger.
  • An edge points at a missing node, or leaves from an output the source node doesn’t have.
  • Forward edges form a cycle. Repeating steps needs a Loop node with a next back-edge.
  • A next edge targets anything but a Loop node, or comes from outside that loop’s body.
  • A non-trigger node has no incoming forward edge.

Warnings, which don’t block publishing: a duplicate edge, and a node with outputs but no outgoing edge (“the run ends there”).

The rules below are the planner’s; the Workflow only makes them durable.

  • Branches. A node finishes by choosing its output branches: true/false for a Condition, approved/rejected/escalated for an Approval, one route for the Agent router, out for single-output nodes. Edges on the chosen branches become active; the rest become inactive.
  • Joins. A node with several inputs runs once every input is resolved and at least one is active. So a node after an if/else runs once, whichever side was taken.
  • Skips. A node whose inputs are all inactive is skipped (status skipped), and so is everything downstream that depends only on it.
  • Parallel work. Every node that is ready at the same time starts in the same batch.
  • Loops. A Loop node evaluates while each time it is entered, up to maxIterations. On body it runs the nodes reachable from its body output; the last of them connects back to the loop’s next input. When the condition is false (or the limit is reached) it takes done. Each iteration gets new step names (${nodeId}#${run}), so iterations never replay one another.
  • Continue as new. control.continue_as_new ends the current Workflow instance and starts a new one on the same flow version, with the order and variables carried over. The order keeps its id; its generation goes up by one. Use it for long-lived loops such as subscriptions, so the step history stays bounded.

A control.end node finishes the order with an outcome:

Outcome Order status Notes
completed completed The normal ending
declined declined A policy or approval “no”: not a person cancelling
cancelled cancelled Ends the run as cancelled
failed failed, then rolled_back Throws NonRetryableError; Workflows runs every registered compensation in reverse order

reason is a template and becomes the order’s reason. Every End node also sends the matching order.<outcome> outbound webhook. A run whose remaining nodes are all skipped, with no End node reached, finishes as completed.

A commerce node that throws after its retries fails the order the same way, without reaching an End node. Only reserve inventory, authorize payment and create shipment register compensations; they release the reservation, void the hold and cancel the shipment.

Condition, Wait (until), Set variables, Loop (while) and Refund (amount) take JSON Logic expressions. Text fields interpolate {{path}} templates, for example Approve order {{order.id}}. Both read the run context:

Path Holds
order The order: lines, totals, customerId, currency, meta, … as nodes update it
nodes.<id> The output of each node that has run (latest iteration)
vars Run variables: set by Set variables, AI nodes (into), event payloads, and the vars passed when the order starts
input The order’s meta
now When the instance started, epoch ms

The expressions reference has the operators and the policy rules.

Each commerce, AI, Condition and Set node runs as one step.do. Its retry limit is the node’s retries, else the node type’s default (commerce.charge has 0), else 3, with a 5-second exponential backoff. Its timeout is the node’s timeout, else "5 minutes". Approval, Wait-for-event and Wait nodes don’t use these; their waits have their own timeout field.

Waits and timeouts are divided by TIME_SCALE (default 1440, so a day takes a minute); see Configuration.

Publishing saves the draft, inserts the next row in flow_versions and points the flow at it. Versions are immutable. An order started through the API runs the latest published version unless the request names one (version); a running order keeps its version even after you publish again. Restarting an order from its page starts a new order on the flow’s current version, with the same customer and lines and without meta.simulate.