One .aether file describes one flow. It’s plain YAML with embedded JavaScript expressions,
readable in any editor and diffable in any pull request. You never have to write it by hand
(the graph and inspector edit it for you), but knowing the shape pays off quickly.
The shape of a file
version: "1.0" # required, the format version
name: User CRUD Flow # required
description: Optional prose about what this flow covers.
variables: # optional flow-level defaults
baseUrl: https://api.example.com
start: login # optional entry node id; defaults to the first node
nodes: # required, the steps
- id: login
type: request
config:
method: POST
url: ${{ variables.baseUrl }}/auth/login
capture:
accessToken: response.body.token
next: get_profile
- id: get_profile
type: request
config:
method: GET
url: ${{ variables.baseUrl }}/me
headers:
Authorization: Bearer ${{ variables.accessToken }}
next: null
The rules worth knowing
- Every node has
id,type, andconfig. Theidmust be unique in the file; it’s what routing points at. An optionallabelgives the node a friendlier display name on the graph. - Routing lives on the node. There is no edges list. A node names its successor in
next(orthen/elsefor conditions,body+nextfor loops,branches+nextfor parallel fan-outs). Omit it or set it tonullto end that branch. The graph derives its edges from these fields, so the file can never disagree with the picture. - Two kinds of dynamic values, deliberately distinct:
${{ ... }}does string interpolation inside YAML string values likeurl,headers, andbody:url: ${{ variables.baseUrl }}/users.- With bare JavaScript, the entire value of
check,expression, andoveris a JS expression withresponse,variables, andenvin scope. Don’t wrap these in${{ }}.
- Every JavaScript scope can print.
log("…")is available inscriptcode, hooks,check/expression/over,captureexpressions, and${{ }}interpolation. It formats its arguments likeconsole.logand reaches the Console,bandura run, and the MCP server alike, even when the node it ran in failed. See the Node reference. - Layout is part of the file. Dragging a node on the graph writes
ui.positioninto its YAML, so the arrangement survives commits and travels to teammates. A node without a position is laid out automatically.
Optional top-level blocks
Five optional keys sit alongside nodes at the top level:
-
dataturns the flow into a data-driven run: it runs once per row, with that row in scope asdata(use${{ data.column }}in any URL, header, body, or assertion). Give a file or inline rows, exactly one of the two:data: file: users.csv # a .csv or .json file next to the flow # or, instead of a file: rows: - { email: [email protected], role: admin } - { email: [email protected], role: viewer } stopOnFailure: true # optional, stops at the first failing row (default: keep going)Each row is an independent test case (a
capturein one row never leaks into the next), and the run reports a per-row passed/failed tally.In the app this block has a Dataset editor on the inspector’s flow panel, the one you get with no node selected. It switches between no dataset, a file and inline rows, and it counts what you have as you type: “3 rows of 4 columns”, naming them. Rows are written as a JSON array, and Convert pasted CSV turns a block of CSV or TSV pasted straight from a spreadsheet into that array. It also warns when a column is missing from a row, which otherwise surfaces much later as one puzzling failure, because a missing value interpolates as the empty string with no error of its own.
-
hookswrap every request-like node (request/grpc/websocket) withbefore,after, and/oronErrorsnippets, for cross-cutting work like signing, correlation ids, or audit logging. Each phase is a{ script: "…" }block; hooks are per-flow and never merged across a subflow boundary. A phase can also read its body from a.jsfile beside the flow (file:in place ofscript:), which is how one signing hook is shared by several flows rather than pasted into each. See the Node reference for the full shape and for what a file-backed body can and cannot do. -
authdeclares how every request node authenticates, so you are not writing anAuthorizationheader into each one. Basic, bearer, API key, OAuth2 (with a token that is fetched once and refreshed for you) and AWS SigV4:auth: type: bearer token: ${{ env.API_TOKEN }}A node can carry its own
auth, which replaces the flow-level block rather than merging with it, so one request’s credential is always readable from one place. Cookies need no block at all: they are captured and replayed automatically. Both are covered in Authentication & cookies. -
tlsandproxyconfigure the connection rather than the credential: a client certificate for mutual TLS, a private CA to trust, and an HTTP or HTTPS proxy with its own login.tls: ca: ./certs/internal-ca.pem proxy: url: ${{ env.HTTP_PROXY }}Both follow the same replace-rather-than-merge rule
authdoes when a node sets its own. See Client certificates & proxies.
There is also a variables block for the flow’s own defaults. Note that its values are seeded
literally: ${{ }} is resolved where a value is used, not where it is declared, so a
dynamic value belongs in a script node rather than here.
Why it matters that this is just a file
Commit flows next to the code they test. Review a flow change as a normal diff; a changed URL or assertion reads exactly like a changed line of code. Hand the suite to CI with the CLI or to an AI agent with the MCP server. Both read the same files the editor does. If you’re weighing Bandura against Bruno, the other tool built on plain files in your repo, Bandura vs Bruno compares the two file formats and what each describes.
For every node type and its config fields, see the Node reference.