Beyond HTTP
gRPC testing with a GUI, a file, and a CI runner.
Bandura is a gRPC testing tool with a desktop GUI and a headless runner, and it needs
no account. A grpc node calls a unary or server-streaming method, described by a
local .proto file or fetched over server reflection, and stores a
response you assert on and capture from like any HTTP call. The test is one
plain-YAML .aether file in your repo, edited on a graph canvas or as
code, and the same engine runs it in the desktop app, the CLI, and CI. WebSocket
exchanges and Server-Sent Event streams live in the same file, so one suite covers
all four protocols.
v1.0.0-rc.8 is out now on Windows and Linux. v1.0.0 final, and signed macOS builds, in August 2026.
The grpc node
Unary and server-streaming, from proto or reflection.
Describe the service one of two ways: point proto at a
.proto file checked into the repo (with importPaths for
its imports), or set reflection: true and let Bandura ask the server.
Exactly one of the two, so a flow never carries an ambiguous service description.
Every field the node accepts is listed in the
node reference.
- id: get_user
type: grpc
config:
target: ${{ variables.grpcHost }} # host:port, no scheme
service: user.v1.UserService
method: GetUser
proto: ./protos/user.proto # ...or `reflection: true`
message:
id: ${{ variables.userId }}
metadata:
authorization: Bearer ${{ variables.token }}
deadline: 10000
capture:
userName: response.body.name
next: verify_user
- id: verify_user
type: assertion
config:
check: 'response.status === 0 && variables.userName.length > 0'
next: null The response is a value, not a printout
response.status is the gRPC status code (0 is OK),
response.body is the decoded message, or an array of messages for
a server-streaming method. A non-OK status is not a failure by itself:
NOT_FOUND is a legitimate thing to test for, so you assert
response.status === 0 the way you assert a 2xx.
Metadata, TLS, deadline, retry
metadata carries auth the way headers do, tls: true
secures the channel, and the per-call knob is deadline. The same
retry block every request node takes works here too, so polling a
method until a job settles is configuration, not code. A flow-level
auth block covers the token the metadata
carries, and mTLS and proxies are declared
the same way.
The limit, stated plainly
Client-streaming and bidirectional-streaming methods are not supported yet. If your suite leans on either, Bandura today does not cover it, and this page would rather say so than sell you a checkmark.
One suite, four protocols
The gRPC call is a step, not a silo.
Every step stores { status, headers, body }, so values move
between protocols without glue code: a REST login's captured token lands in gRPC
metadata, a gRPC response feeds a WebSocket assertion, and flow-level hooks run
around every request-like node for signing and correlation ids.
WebSocket
Open, send, collect, close: one node.
The exchange most suites need is a handshake, one message out, and some frames
back. A websocket node does exactly that: headers and optional
subprotocols on the handshake, an optional send, then frames
collected until expectMessages is reached or a
waitFor predicate is truthy.
- id: subscribe_prices
type: websocket
config:
url: ${{ variables.wsUrl }}/stream
send: '{ "type": "subscribe", "channel": "prices" }'
waitFor: "message.type === 'data'"
capture:
price: response.body[0].price
next: verify Frames you can assert on
response.body is the array of received frames, parsed as JSON where
possible, and response.status is the close code
(1000 is clean). If the expected frames do not arrive before the
timeout, the node fails and says what it did receive, instead of a suite that
hangs in CI. Retry works here as well, and flow hooks wrap the node like any
other call.
Server-Sent Events
Assert on a stream the way you assert on a body.
SSE is the protocol behind most streaming LLM endpoints and live-update feeds. An
sse block on an ordinary request node collects the event stream into
response.body as an array of { event, data } objects,
data parsed as JSON where it is JSON, so a [DONE]
sentinel stays a string while streamed deltas arrive as objects.
- id: stream_completion
type: request
config:
method: POST
url: ${{ variables.baseUrl }}/v1/chat
body: '{ "model": "m", "stream": true }'
sse:
waitFor: 'event.data === "[DONE]"'
timeout: 120000
capture:
chunks: response.body.length
next: verify Short reads fail, on purpose
Collection stops at whichever comes first: expectEvents, a truthy
waitFor, the stream ending, or the timeout. If you asked for
something specific and the stream ends without it, the node fails and reports
what arrived. A short read that quietly looks like a pass is exactly the failure
this exists to catch. Use sse.timeout for streams rather than the
request's own timeout, which would cut a healthy long-lived stream short.
Automate in CI
The same four protocols, headless.
The CLI is a compiled binary with the engine inside:
npm install -g @bandura/cli, then bandura run flows/
executes gRPC, WebSocket and SSE steps exactly as the desktop app does, with
deterministic exit codes, JUnit XML, and GitHub annotations on the pull request.
macOS can run the CLI today, ahead of the signed desktop build.
API tests in CI →
Questions, answered straight
FAQ
Does Bandura support gRPC streaming?
Server-streaming, yes: response.body becomes an array of decoded messages you can assert on and capture from. Client-streaming and bidirectional-streaming methods are not supported yet, and this page says so rather than hiding it under a protocols checkmark. Unary calls are fully supported, with metadata, TLS to the target, a per-call deadline, and retry.
Do I need a .proto file to test a gRPC service?
No, if the server exposes reflection. A grpc node takes exactly one of proto (a local .proto file, with importPaths for its imports) or reflection: true, in which case Bandura fetches the service description from the server itself. Reflection is the faster start; a checked-in .proto keeps the test self-contained and reviewable, which is usually what a repo wants.
How do gRPC status codes work in assertions?
response.status is the gRPC status code, 0 meaning OK, and a non-OK status is deliberately not a failure on its own: NOT_FOUND is a legitimate thing to test for. Assert response.status === 0 when success is what you expect, exactly as you assert a 2xx on HTTP. The decoded response message is response.body, or an array of messages for a server-streaming method.
Can Bandura test WebSocket connections?
Yes, as one node covering the whole exchange: open the socket (ws:// or wss://, with handshake headers and optional subprotocols), optionally send one message, collect reply frames until a count or a waitFor predicate is met, and close. response.body is the array of frames, parsed as JSON where possible, and response.status is the close code. If the expected frames never arrive, the node fails loudly at the timeout instead of hanging.
Can Bandura test Server-Sent Events?
Yes, as an sse block on an ordinary request node, which is the protocol behind most streaming LLM APIs and live-update endpoints. The event stream is collected into response.body as an array of { event, data } objects, with data parsed as JSON where it is JSON. Collection stops at expectEvents, a truthy waitFor, the stream ending, or the timeout, and asking for events that never arrive is a failure, not a quiet short read.
Can gRPC, WebSocket, SSE and REST live in one flow?
Yes, and that is the argument for a flow format over a per-protocol client. Every step stores { status, headers, body }, so a flow can log in over REST, call a gRPC method with the captured token in its metadata, wait for the WebSocket event the call should trigger, and assert on all three, with conditions, loops and retry between the steps. Flow-level hooks run around every request-like node, gRPC and WebSocket included.
Does retry work on gRPC and WebSocket nodes?
Yes. The same retry block a request node takes (maxAttempts, delayMs, an optional until predicate) works on grpc and websocket nodes, so polling a gRPC method until a job settles is configuration rather than a loop you write. This is worth stating because it once was not true: a bug where a declared retry on those two node types silently did nothing was found and fixed; the fix is in the current build.
How do I authenticate a gRPC call?
Through metadata, which carries credentials the way HTTP headers do: an authorization entry with a Bearer token, an API key, whatever the service expects, each value interpolated with ${{ }} so it can come from env.* or from a capture two nodes earlier. A common shape is a REST login step whose captured token lands in the gRPC metadata. Set tls: true when the target speaks TLS, and flow-level hooks wrap grpc nodes like any other call, so a signing hook covers them too.
Keep reading
The rest of the case.
GraphQL testing
The fourth non-REST surface: queries and variables in a
graphql block, with execution errors that fail loudly.
Tests as files in your repo
The .proto and the flow that calls it live in the same repo and
review together in one pull request.
Run the suite in CI
The compiled CLI runs gRPC, WebSocket and SSE steps headless, so the four-protocol suite gates every push.
Everything that works offline
Reflection against a service on localhost needs no network at all; this page maps where everything sits on disk.
Let an agent run your tests
An agent can run the same gRPC flows you do, over a local stdio MCP server bundled in the CLI binary.
Comparing Insomnia?
Insomnia speaks the same five protocols; the difference is what happens around a request. The comparison concedes the rows it should.
v1.0.0-rc.8 is out now. v1.0.0 final in August 2026
Point it at a gRPC service with reflection on.
reflection: true, a service name and a method is a complete first
test: no proto wrangling before the first green run. The 30-day evaluation
disables nothing. Windows and Linux builds are up now; on a
Mac, leave an address and you get the signed build the day it is notarized.
The exact fields for all three node types are in the node reference, including the defaults this page rounded off.