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.
hooks — run logic around every call
A flow-level hooks block runs around every call the flow makes — request, grpc, and
websocket nodes alike — so shared concerns like signing, correlation ids, auth headers and audit
logging live in one place instead of being pasted into a script node ahead of each request. It
sits at the top level, beside nodes.
hooks:
before: # on the resolved request, before it is sent — may rewrite it
script: |
request.headers['X-Request-Id'] = 'req_' + Date.now()
request.headers['Authorization'] = 'Bearer ' + variables.token
after: # once the response is stored and captures have landed
script: |
variables.audit.push({ node: node.id, status: response.status, ms: durationMs })
onError: # when the node fails — observational only
script: |
variables.failures.push(node.id + ': ' + error.message)
Every phase is optional, but an empty block or a mistyped phase name (beforeEach:) is a
parse error, not a silent no-op — a hook that never fires is the failure this format works
hardest to avoid.
before sees the request with ${{ }} already resolved, and the call is built from the object
it mutates — so setting a header genuinely signs the request. It re-runs on each retry
attempt, so an expiring signature is recomputed rather than replayed. after runs once captures
have landed, so it can read a value the request just captured. onError can’t swallow a
failure: the original error is always rethrown.
after and onError also get durationMs — how long that attempt took — so a monitoring hook can
record timings without any extra plumbing.
Hooks are per-flow and never merged across levels — a subflow runs its own hooks, not its
caller’s, and each hook gets a fresh scope, so nothing leaks between phases or nodes.
variables and request are the only channels between them.
One block covers all three transports. request is normalized, so a hook doesn’t branch on which
kind of node it’s wrapping: for grpc, request.headers is the call’s metadata and request.body
the request message; for websocket, they’re the upgrade headers and the send payload.
Importing from Postman? Collection, folder, and request
event[]scripts come across into this block —prerequest→before,test→after. Scripts whosepm.*calls all have an equivalent run as written above a generated compatibility shim; anything relying onpm.test,CryptoJS, orrequireis preserved commented out with a note on what to replace, so nothing is lost silently.