The flow file format (YAML / JSON)
Everything on a flow’s canvas is also a plain text file. View file shows the flow as YAML or JSON, you can edit it there and watch the canvas follow, and the same shape is what a connected Git repository stores as .flow.yaml. The file holds only meaning — steps, links and settings — never positions: the canvas lays itself out from the structure.
- Open, read and edit a flow as YAML or JSON
- Know the keys: trigger, a, b, c… and onError
- Write each binding form and each structural node
- Copy a flow between flows, and keep it in Git
A flow is a file
Open the flow’s ⋯ menu and pick View file (“Read this flow as YAML”). A drawer opens with the whole definition and a YAML / JSON switch. Changes on the canvas show up in the file at once; edits in the file reach the canvas about half a second after you stop typing, as long as the text parses — otherwise the drawer says YAML parse error and the canvas keeps the last good version. Edits are unsaved until you click Save, exactly like edits on the canvas.
Branch pills, end caps, the join under parallel branches and the shaded loop body are not in the file either. They are derived from the structure each time the canvas is drawn, which is why a file written by hand — or by an AI assistant, or pulled from Git — renders as neatly as one built by dragging.
A complete example
A flow that loads orders over a minimum total, scores each one in a parallel loop, reports when there were any, and has an On error step — exactly as View file writes it:
trigger:
parameters:
- name: minTotal
type: number
default: 100
next: a
a:
kind: script
label: Load orders
alias: loadOrders
lang: postgresql
code: |
-- @param min_total number = 100
SELECT id, email, total FROM orders WHERE total >= :min_total
integration: 9b1c6e2a-5d1f-4c1e-9a7e-2f0c3b8d4e61
inputs:
min_total:
from: trigger
path: minTotal
next: b
b:
kind: loop
label: Each order
alias: eachOrder
iterate:
from: a
path: rows
mode: parallel
concurrency: 5
body:
- c
next: d
c:
kind: script
label: Score
alias: score
lang: python
code: |
def main(total: float):
return {"vip": total > 500}
inputs:
total:
from: b
path: item.total
retry:
attempts: 2
delay: exponential
delaySec: 2
d:
kind: condition
label: Any orders?
alias: anyOrders
if: nodes.loadOrders.output.rowCount > 0
then:
label: "Yes"
nodes:
- e
else:
label: "No"
nodes: []
e:
kind: script
label: Report
alias: report
lang: typescript
code: |
export async function main() {
return { ok: true }
}
onError:
kind: script
label: Notify
alias: notify
lang: python
code: |
def main(message: str = "", step: str = ""):
print(f"{step} failed: {message}")triggeris always the Start node; itsparametersare the flow’s inputs andnextnames the first step.- Every other step gets a short key —
a,b, …z,aa,ab— in breadth-first order from Start. Keys are local to the file and reassigned each time it is written; the step keeps its real id behind the scenes, so runs and bindings stay attached to it. aliasis the name expressions use (nodes.loadOrders.output); it is derived from the label and shown for reference.onError(andpreprocessor) are top-level entries with nonext— they are not on the chain.
Node kinds and their fields
| kind | Fields |
|---|---|
script | lang (python, typescript, javascript, c, cpp, postgresql, mysql), code for an inline step or script (its path) for a linked one, integration and rowLimit for SQL, dependencies for inline Python. |
condition | if, then: {label, nodes}, optional elseIfs: [{id, label, if, nodes}], else: {label, nodes}. nodes lists every step inside that branch. |
loop | iterate (a binding, a list, or an expression), mode: parallel + concurrency or mode: while + maxIterations, breakIf, body (every step inside). |
parallel | branches: [{id, name, mayFail?, nodes}], optional sequential: true. |
flow | flowRef (the child’s id — the real reference) and flowName (a readable hint, never read back). |
aiScraper | scraper (id), scraperName (hint), optional timeoutMs. |
email | provider (absent = the project’s default), template; recipients and values are inputs (to + one per template value). |
approval | fields, approvers, timeoutSec, onTimeout, notify, allowLinks; the message is inputs.message. Defaults are omitted. |
c:
kind: parallel
label: Fan out
branches:
- id: b1x9k2
name: Send e-mail
nodes: [d]
- id: b7qq01
name: Notify team
mayFail: true
nodes: [e]
next: f
m:
kind: flow
label: Enrich customer
flowRef: 5f0e8a3c-2b7d-4e19-8c55-1a2b3c4d5e6f
flowName: Enrich customer
inputs:
customerId: { from: a, path: id }
retries: { manual: "3" }
apiKey: { var: CRM_API_KEY }
next: nAny executable step can also carry its run settings under the same names as on the step — retry, continueOnError, skipIf, stopIf, sleepAfterSec, cacheTtlSec, timeoutMs — and a short note. Anything off is simply absent, so a flow without settings has none of these keys.
Binding forms
| Form | Means |
|---|---|
{ from: a, path: rows.0.total } | A value from step a’s output, at that path (no path = the whole output). Inside a loop, from the loop with path: item.total reads the current item. |
{ manual: "42" } | A constant, converted to the parameter’s declared type. |
{ var: NAME } | A project or global variable, read when the step runs. |
{ expr: "vars.RETRIES * 2" } | An expression. View file currently shows it as { manual: "__expr__:vars.RETRIES * 2" }; Git files and the MCP server write expr. |
Copy, export and keep in Git
- Export
Open View file, choose YAML or JSON and copy the text. That text is the whole flow; keep it, share it, review it in a pull request.
- Import into another flow
Create an empty flow, open its View file, replace the text with the copy and click Save. Steps get fresh ids; linked scripts, connections and child flows are referenced by id, so they must exist in the target project.
- Keep it in Git
With the project connected to a repository (Git), every flow is a
<folder>/<name>.flow.yamlfile in the same shape. A commit writes the flow; a pull lands the file as the flow’s draft — never published — so you review and publish it yourself.
Git files differ from View file in small ways: expressions are written { expr: … }, constants keep their JSON type, default branch labels and alias are left out, and the file is validated when it is read — an unknown key or kind, a missing if or iterate, or a step no path reaches is reported instead of guessed.
What the file does not hold
- No positions, no colours, no zoom: layout is always derived.
- No version pin on a
floworaiScraperstep: callers always run the callee’s latest published version. - Not the flow’s own settings — its time limit, concurrency limit, triggers and state live outside the definition.