Ten node types cover everything a flow does. Each example below is valid YAML you can
paste into the Code tab. Routing fields (next, then, else, body, branches) take a
node id (or a list of them, for branches), or null to end the branch.
request — call an API
- id: create_user
type: request
config:
method: POST # GET | POST | PUT | PATCH | DELETE | HEAD | OPTIONS
url: ${{ variables.baseUrl }}/users/{id}
pathParams: # fills {id} or :id placeholders in url
id: ${{ variables.userId }}
queryParams: # appended as ?include=profile
include: profile
headers:
Content-Type: application/json
body: |
{ "name": "John Doe" }
timeout: 30000 # optional, ms
capture: # save response values as variables
userId: response.body.id
next: verify_status
The response becomes response ({ status, headers, body }) for the nodes after it. Each
capture entry is a JS expression evaluated against the response; the result lands in
variables under that name, where every later node can read it. pathParams and
queryParams also have a form editor — select the node and open the inspector’s Params
tab (which also carries the timeout field); the body has its own JSON-aware editor in
the Body tab.
timeout bounds a single HTTP attempt in milliseconds (default 30 000 — each
retry attempt gets its own). For a whole-run deadline in CI, use the
CLI’s --timeout flag instead.
GraphQL
Add a graphql block to a request node to call a GraphQL API — same node type, same
headers/params/capture machinery:
- id: get_user
type: request
config:
method: POST # must be POST for GraphQL
url: ${{ variables.baseUrl }}/graphql
graphql:
query: |
query GetUser($id: ID!) {
user(id: $id) { name email }
}
variables: # optional — GraphQL variables
id: ${{ variables.userId }}
operationName: GetUser # optional
capture:
userName: response.body.data.user.name
next: verify_user
Bandura builds the JSON body ({ query, variables, operationName }) for you — so graphql
and body can’t be combined — and defaults Content-Type: application/json unless you set
one. If the response carries a non-empty errors array the node fails with the first
error’s message (GraphQL servers return HTTP 200 for execution errors — that should never
pass silently); set allowErrors: true in the block to inspect response.body.errors with
an assertion instead. Captures read the result under response.body.data.….
grpc — call a gRPC service
Call a unary or server-streaming gRPC method. Describe the service with a local .proto
file or let Bandura fetch it over server reflection. Like a request node, it stores a
{ status, headers, body } response you can assert on and capture from.
- id: get_user
type: grpc
config:
target: ${{ variables.grpcHost }} # host:port, no scheme
service: user.v1.UserService # fully-qualified service name
method: GetUser
proto: ./protos/user.proto # …or `reflection: true` (exactly one)
message: # the request message — supports ${{ }}
id: ${{ variables.userId }}
metadata: # optional — gRPC metadata (like headers)
authorization: Bearer ${{ variables.token }}
capture:
userName: response.body.name
next: verify_user
On success response.status is 0 (the gRPC OK code) and response.body is the decoded
message — an array of messages for a server-streaming method. A non-OK status (e.g.
NOT_FOUND = 5) is not a failure on its own — assert on it with response.status === 0,
exactly like a non-2xx HTTP status. Client- and bidirectional-streaming methods aren’t
supported yet.
websocket — open a socket, send, collect the reply
Open a WebSocket, optionally send one message, collect the reply frame(s), and close — a single request/reply exchange as one node.
- id: subscribe_prices
type: websocket
config:
url: ${{ variables.wsUrl }}/stream # ws:// or wss://
send: '{ "type": "subscribe", "channel": "prices" }' # optional
expectMessages: 1 # how many frames to collect (default: 1 if send/waitFor set, else 0)
waitFor: "message.type === 'data'" # optional — stop when this predicate is truthy
capture:
price: response.body[0].price
next: verify
response.body is the array of received frames (each parsed as JSON when possible),
response.status is the close code (1000 = clean). If the expected messages don’t arrive
before timeout (default 30s) the node fails loudly.
assertion — make the run fail loudly
- id: verify_status
type: assertion
config:
check: "response.status === 201 && response.body.id" # JS, must be truthy
next: null
If check is falsy, the node fails, the node rings red, and the run is marked failed.
condition — branch
- id: is_created
type: condition
config:
expression: "response.status === 201" # JS boolean
then: extract_id # runs when true
else: report_error # runs when false
On the graph the two edges are labeled true and false; after a run, the node’s Result tab shows which branch was taken and why.
loop — repeat over an array
- id: for_each_user
type: loop
config:
over: "response.body.users" # JS expression resolving to an array
itemVar: user # the current item, inside the body
indexVar: i # optional — the current index
body: fetch_user_detail # first node of the per-item subgraph
next: summarize # runs once, after the loop finishes
itemVar and indexVar exist only inside the loop body, fresh each iteration.
parallel — fan out, then join
- id: fan_out_checks
type: parallel
# no config — a parallel node is pure routing
branches: [check_billing, check_profile, check_settings] # each starts a branch chain
next: summarize # the join — runs once, after every branch completes
Each id in branches starts a branch chain that is walked to completion — serially, in
listed order — before next (the join) runs once. Branches share the flow’s variables, so
capture what each branch learns: response is overwritten by every request, captures
survive into the join. Each branch chain must end (next: null); a branch that throws
stops later branches and fails the flow, like any other node error.
script — arbitrary JavaScript
- id: build_signature
type: script
config:
code: |
// `variables`, `env`, and `response` are in scope.
const now = Date.now();
variables.timestamp = now;
variables.signature = `${env.API_KEY}:${now}`;
next: signed_request
The escape hatch for computation that doesn’t fit capture or check — derive values,
transform a response before the next request, compute signatures. Hand data forward by
assigning to variables.<name>. Note code is plain JS statements — no ${{ }} here. A
script that throws fails the node.
ai-action — ask a model mid-flow
- id: summarize_failures
type: ai-action
config:
prompt: "Summarize which assertions failed and suggest a fix."
output: aiSummary # optional — variable to store the answer
next: null
Sends the prompt (with your flow’s context) to Claude and stores the reply in the output
variable. Requires an API key.
subflow — run another flow inline
- id: run_auth
type: subflow
config:
path: ./auth-login.aether # relative to this file
inputs: # optional — variables passed in
username: ${{ variables.user }}
next: create_user
Reuse a flow (a login sequence, a setup routine) from other flows. Variables the child
captures are available after it returns. On the graph, adding a Subflow… node opens a
picker listing every .aether file in the workspace. The Flow Map view (activity bar)
draws these parent→child connections across your whole workspace.
retry & poll-until — re-run a step until it works
Any leaf node — request, assertion, ai-action, script — takes an optional retry
block that re-runs it. It sits beside next, not inside config.
# Retry a flaky request up to 3 times with exponential backoff.
- id: create_user
type: request
config: { method: POST, url: ${{ variables.baseUrl }}/users }
retry:
maxAttempts: 3 # total tries, including the first
delayMs: 500 # wait before the next try (optional, default 0)
backoff: 2 # multiply the delay each try (optional, default 1)
next: verify
# Poll a job until it reports ready, then continue.
- id: wait_ready
type: request
config: { method: GET, url: ${{ variables.baseUrl }}/jobs/${{ variables.jobId }} }
retry:
maxAttempts: 20
delayMs: 2000
until: response.status === 200 && response.body.state === "ready"
next: fetch_result
Without until, the node retries only when it throws — a network error, a failed
assertion, a throwing script. With until, it becomes a poller: after each try the
expression is checked against variables / env / response, and the node repeats while
it’s falsy. That’s the pattern for waiting on a resource to become ready — a request doesn’t
fail on a non-2xx status, so until (not plain retry) is what waits for a specific one.
Between tries Bandura waits delayMs × backoff^(try−1); a Stop cancels the wait
immediately. When the attempts run out the node fails with its last error. Retries are
internal to the node — the graph shows one node with a ⟳ poll ×N / ↻ retry ×N badge, and
it runs identically in the app, bandura run, and CI.