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.
A request node also takes an auth block instead of a hand-written Authorization header,
covering basic, bearer, API key, OAuth2 and AWS SigV4, and a cookies: false flag to opt
out of the automatic cookie jar. Both are covered in
Authentication & cookies.
Three more optional blocks are documented on their own pages:
bodyis not only a string. It also takes amultipartblock for form-data uploads or abinaryblock for raw bytes, from a file or inline base64. See File uploads & binary bodies.tlsandproxyconfigure the connection: client certificates, a private CA, and an HTTP or HTTPS proxy. See Client certificates & proxies.
And anywhere an expression or a ${{ }} template appears, the
dynamic value helpers are in scope: ${{ $uuid() }},
${{ $randomEmail() }}, ${{ $hmac(body, env.SECRET) }} and the rest.
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 and defaults
Content-Type: application/json unless you set one; because it owns the body, graphql and
body can’t be combined. 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, and 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.…. The wider case for testing GraphQL this way, including the honest
limits, is on GraphQL testing.
In the app, a node with a graphql block gets the
GraphQL editor on its Body tab: the query, its
variables, the operation name, and the errors toggle.
Server-Sent Events
Add an sse block to read a text/event-stream response, the protocol behind every streaming
LLM API and behind most “live updates” endpoints. Same node type again: only the reading of the
response changes.
- id: stream_completion
type: request
config:
method: POST
url: ${{ variables.baseUrl }}/v1/chat
headers:
Content-Type: application/json
body: '{ "model": "m", "stream": true }'
sse:
waitFor: 'event.data === "[DONE]"' # stop when this is true…
# expectEvents: 20 # …or after N events
timeout: 120000 # ms, covers getting the response and reading it (default 30000)
capture:
chunks: response.body.length
next: verify
response.body becomes the array of events, each { event, data, id?, retry? }. event
defaults to "message", and data is parsed as JSON when it is JSON and left as text otherwise,
so a [DONE] sentinel stays a string while streamed deltas arrive as objects. Collection stops at
whichever comes first: expectEvents, a truthy waitFor, the stream ending, or timeout. With
neither expectEvents nor waitFor it reads to the end of the stream.
If you asked for something specific (a count, or a waitFor) and the stream ends without it, the
node fails and tells you what it did receive. A short read that quietly looks like a pass is
exactly the failure this is here to catch. And a response that isn’t an event stream at all (a
429 with a JSON body, say) is read as a normal body, so your assertion sees the real status
instead of the node waiting for events that will never come.
Use sse.timeout, not the request’s own timeout, for streams: the latter bounds the whole
response and would cut a healthy long-lived stream short. The inspector keeps the two apart for
the same reason: the Streaming (SSE) section of the
Params tab has its own timeout field, separate
from the request timeout above it.
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 }}
tls: true # optional, use TLS to the target (default: insecure)
deadline: 10000 # optional per-call deadline in ms (default 30000)
capture:
userName: response.body.name
next: verify_user
Point proto at extra lookup directories with importPaths: [ ... ] when your .proto
imports others. Note the per-call timeout knob here is deadline (not timeout). On
success response.status is 0 (the gRPC OK code) and response.body is the decoded
message, or 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. How this fits a whole suite, with WebSocket and SSE beside it, is on
gRPC testing.
websocket: open a socket, send, collect the reply
One node covers the whole exchange: open a WebSocket, optionally send one message, collect the reply frame(s), and close.
- id: subscribe_prices
type: websocket
config:
url: ${{ variables.wsUrl }}/stream # ws:// or wss://
subprotocols: ["graphql-ws"] # optional subprotocols to negotiate
headers: # optional, sent on the opening handshake
Authorization: Bearer ${{ variables.token }}
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.
Asserting on response time
response carries a durationMs alongside status, headers and body for every node that
actually made a call (request, grpc, websocket), so a latency assertion needs no script
node and no Date.now() bookkeeping:
- id: check_fast
type: assertion
config:
check: response.status === 200 && response.durationMs < 500
It measures wall clock from issuing the call to having the full response in hand. For an
SSE request, collecting the event stream counts as still receiving the
response. For gRPC it excludes schema resolution, the .proto load or reflection call that
precedes it. For a WebSocket it spans the whole open, send, wait, close exchange.
Under retry each attempt overwrites response,
so durationMs describes the most recent attempt rather than a total across attempts.
This is a narrower number than the one the Result panel shows beside the status. That one times the
whole node, hooks and auth and retries included, which is what a “why is this step slow”
question wants. response.durationMs is the request itself, and it is the one available inside the
flow’s own JavaScript.
A condition can read it too, which is how a flow takes a slow path deliberately rather than
failing.
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 and 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, response, data, log() and the $ helpers 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, with no ${{ }} here. A
script that throws fails the node.
config.file takes the same body from a file beside the flow instead of writing it inline, which
is how several flows share one step. See code in a separate file.
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 your configured model and stores the reply in the
output variable. Requires an AI provider: Anthropic, Gemini, or
any OpenAI-compatible endpoint including a local one.
output names a variable, so it follows the same naming rule as a capture key: a JavaScript
identifier, and never env or data, both of which are reserved scope names.
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.
log(): print from any expression
Every JavaScript scope in a flow gets a log() binding: script code, hooks phases,
check/expression/over expressions, capture expressions, and ${{ }} interpolation.
- id: build_signature
type: script
config:
code: |
const now = Date.now();
log("signing at", now, { key: env.API_KEY.slice(0, 4) });
variables.signature = `${env.API_KEY}:${now}`;
log() takes any number of arguments and formats them like console.log would, printing
strings verbatim, everything else as JSON, joined by spaces. It’s the flow’s stdout. Before it
existed, the only way for a script or a hook to say anything was to assign to variables, which
mixed debug output into the flow’s data.
Two behaviours are deliberate rather than incidental:
- A failing node still reports what it logged. Lines are flushed after the node settles, including when it threw. A log line you added to debug a failure would be worthless if the failure ate it.
- Output is capped at the source, not at each consumer: 2 000 characters per line and 200
lines per node, after which the node reports
… further log() output from this node suppressed. Alog()inside a loop body is the obvious way to accidentally emit tens of thousands of lines, and those lines reach a UI console, a CI log, and an AI agent’s context window.
Every host renders the same lines: the desktop Console prefixes each one │ <nodeId>:,
bandura run prints them dimmed under the node (and into <system-out> in JUnit
XML), and the MCP server’s run_flow result carries a logs array on the
node.
There is no log.warn/log.error. One primitive, one shape. Prefix your own string if you need
levels.
Expression scope: what a JavaScript field can read
Every JavaScript field in a flow (check, expression, over, until, each capture entry,
script code, hook bodies, and the inside of a ${{ }} template) is evaluated against the same
scope:
| Name | What it holds |
|---|---|
variables | Flow variables, captures and overrides. Writable from a script node or a hook |
env | .env and manifest-environment values. Read-only |
response | The last completed response: { status, headers, body, durationMs } |
data | The current row of a data-driven run. Undefined outside one |
log() | Prints to the console, the CLI report and the MCP result |
$uuid(), … | All 24 dynamic value helpers, spread in flat, not namespaced |
Four fields add a binding of their own on top:
| Field | Extra bindings |
|---|---|
sse.waitFor | event (the frame just received), events (every frame so far) |
websocket.waitFor | message (the frame just received), messages (every frame so far) |
loop body | the itemVar and indexVar names you chose |
hooks.* | request, node, and on two phases durationMs and error (above) |
What is deliberately not in scope: require, import, process, console and the file
system. A script node computes, it does not reach outside the run. Use log() rather than
console.log, which is not defined.
Two naming rules the parser enforces, because both fail late and confusingly otherwise. Any name
you introduce (a capture key, loop.itemVar, loop.indexVar, ai-action.output) must be a
plain JavaScript identifier, and env and data are reserved: they are the scope names above,
so a capture called env would shadow your whole environment. Both are parse errors, caught before
the run starts.
retry & poll-until: re-run a step until it works
Six node types take an optional retry block that re-runs them: request, grpc,
websocket, assertion, ai-action and script. It sits beside next, not inside config.
The four routing node types (condition, loop, parallel, subflow) have no retry key at
all, and writing one is a parse error rather than a setting that does nothing. Retry the node
inside the loop body, not the loop.
# 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, meaning a network error, a failed
assertion, or 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. 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.
What each phase can read
| Binding | before | after | onError | What it is |
|---|---|---|---|---|
request | yes | yes | yes | The resolved outgoing call, by reference, so before can edit it |
node | yes | yes | yes | { id, type } of the node being wrapped |
response | stale | yes | stale | See the note below before reading it in before or onError |
durationMs | no | yes | yes | How long the call took, in ms |
error | no | no | yes | The error that failed the node |
response is always the last one stored, which is not always this node’s. In before nothing
has been sent yet, so it is the previous node’s. In onError it usually still is, because a call
that threw (a network fault, a timeout) stored no response at all. Read error in onError and
response in after, and neither is a trap.
variables, env, log() and the dynamic value helpers are in scope in
every phase, as they are everywhere else.
How often each phase runs under retry
The retried unit is the call, not the node, and the three phases deliberately do not agree on
how often they fire. Over a node with maxAttempts: 3 whose first two attempts fail:
| Phase | Times it runs | Why |
|---|---|---|
before | 3 | Once per attempt, so an expiring signature is recomputed, never replayed |
onError | 2 | Once per failed attempt |
after | 1 | Once per completed call, however many attempts that took |
after describes a call that finished. A twenty-attempt poller is one call however many times it
asked, so a hook that writes an audit row or fires a webhook does that once rather than twenty
times. In a poll-until loop, before still runs
per poll and onError does not run at all, because a non-2xx status is a response rather than an
error.
A node inside a loop body is wrapped once per iteration, which is the one case where the counts
above multiply.
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.
A phase can read its body from a file too, which is the point at which one signing hook stops being copied into every flow that needs it. See code in a separate file.
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.
Code in a separate file
Needs
@bandura/cli1.0.0-rc.4 or the desktop app 1.0.0-rc.5 or newer; every current build qualifies. Earlier builds rejectfile:at the parser, so a flow using it fails to load rather than ignoring the key.
A script node’s body and a hook phase’s body can each live in their own file. Write file:
where code: or script: would go:
- id: check_envelope
type: script
config:
file: ./scripts/assert-envelope.js
next: report
hooks:
before:
file: ./scripts/sign-request.js
after:
script: log(response.status) # inline and file-backed phases mix freely
Exactly one of the two. Both on the same block, or neither, is a parse error, because one of them would otherwise be silently ignored and a body that never loaded is a step that does nothing and passes.
The reason to reach for it is reuse. A signing hook written once is the same hook in every flow
that points at it, and fixing it is one edit rather than one per flow. Your editor also treats a
.js as JavaScript, so you get highlighting, your project’s own linter and formatter, and a
readable diff when it changes. A body used once is better off inline, where the flow reads without
opening a second file.
What to know before you use it:
- Extensions:
.js,.mjs,.cjs,.ts,.mts,.cts. Anything else is a parse error, so a dataset or a shell script can’t be pointed at by accident. - Nothing is transpiled, and there are no modules. The file runs as JavaScript statements,
in exactly the scope an inline block gets. The TypeScript extensions are accepted because that
is what editors and repositories name these files, not because a compiler runs: a type
annotation is a syntax error at run time. There is no
import,exportorrequireeither. Values travel the way they always do, throughvariables. - Paths are relative to the flow that names them, and a subflow’s paths to the subflow. Hooks never merge across levels, and neither do their paths.
- Loaded once per run. Each distinct path is read a single time for the whole run, so a data-driven run over a hundred rows reads each file once, not a hundred times. A file that is missing, empty or unreadable fails before any node of the flow that declares it executes, naming the node or phase and the path.
- No sandbox is gained. A file-backed body runs under exactly the same rules as an inline one.
In the app, the inspector’s flow panel (no node selected) edits the three hook phases directly and can point any of them at a file.
Next: Run & debug flows to watch these node types execute, or The .aether file for how the top-level blocks fit together.