Node reference

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

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.