Node reference

All ten node types (request, grpc, websocket, assertion, condition, loop, parallel, script, ai-action, subflow) with fields and working examples.

16 min read

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:

  • body is not only a string. It also takes a multipart block for form-data uploads or a binary block for raw bytes, from a file or inline base64. See File uploads & binary bodies.
  • tls and proxy configure 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. A log() 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:

NameWhat it holds
variablesFlow variables, captures and overrides. Writable from a script node or a hook
env.env and manifest-environment values. Read-only
responseThe last completed response: { status, headers, body, durationMs }
dataThe 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:

FieldExtra bindings
sse.waitForevent (the frame just received), events (every frame so far)
websocket.waitFormessage (the frame just received), messages (every frame so far)
loop bodythe 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

BindingbeforeafteronErrorWhat it is
requestyesyesyesThe resolved outgoing call, by reference, so before can edit it
nodeyesyesyes{ id, type } of the node being wrapped
responsestaleyesstaleSee the note below before reading it in before or onError
durationMsnoyesyesHow long the call took, in ms
errornonoyesThe 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:

PhaseTimes it runsWhy
before3Once per attempt, so an expiring signature is recomputed, never replayed
onError2Once per failed attempt
after1Once 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: prerequestbefore, testafter. Scripts whose pm.* calls all have an equivalent run as written above a generated compatibility shim; anything relying on pm.test, CryptoJS, or require is preserved commented out with a note on what to replace, so nothing is lost silently.

Code in a separate file

Needs @bandura/cli 1.0.0-rc.4 or the desktop app 1.0.0-rc.5 or newer; every current build qualifies. Earlier builds reject file: 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, export or require either. Values travel the way they always do, through variables.
  • 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.

Last updated

Looking for something else? All 37 articles are on one page in the Help Center.