Bruno is an open-source, offline-first API client for exploring, testing, and automating HTTP, GraphQL, gRPC, WebSocket, and Server-Sent Event APIs. It is a direct alternative to tools like Postman and Insomnia, with one defining difference: Bruno stores your API collections as plain-text files on your own filesystem, so you can version them with Git like the rest of your code.
This guide covers everything you need to use Bruno productively — installing the desktop app, building and sending requests, working with variables and environments, scripting and testing, importing collections from other tools, collaborating through Git, and running collections headlessly in CI/CD with the Bruno CLI.
Note
Bruno comes in two forms that share the same file format and engine: the Bruno desktop app (a graphical client for Mac, Windows, and Linux) and the Bruno CLI (
bru, a command-line runner for automation). This guide documents both. Where a feature is desktop-only or CLI-only, it is called out.
Table of Contents
- What Is Bruno
- Quick Start
- Installation
- Core Concepts
- Creating and Sending Requests
- Request Bodies
- Other Request Types: GraphQL, gRPC, WebSocket, SSE
- Authentication
- Variables and Environments
- Scripting
- Assertions and Tests
- Cookies
- Importing Collections
- Code Generation
- Collaborating with Git and Workspaces
- The Bruno CLI
- The Bru File Format
- Troubleshooting
- Frequently Asked Questions
1. What Is Bruno
Bruno is an API client — a tool that lets you compose HTTP (and other protocol) requests, send them, inspect the responses, and organize those requests into reusable collections. Where Bruno differs from the established players is in its philosophy:
- Offline-only. Bruno has no cloud account, no sync server, and no telemetry-gated features. Your collections never leave your machine unless you move them. The project has publicly committed to never adding cloud sync.
- Plain-text, on disk. Each request is saved as a human-readable
.brufile (or, optionally, a.ymlfile). A collection is just a folder of these files. There is no opaque binary workspace database. - Git-native collaboration. Because collections are folders of text files, you collaborate on them the same way you collaborate on source code: commit, branch, diff, review, merge. Bruno even ships a built-in visual diff viewer and Git integration.
- A markup language called Bru. The
.bruformat is a small, purpose-built markup language for describing API requests. It is designed to be readable and diff-friendly.
Tip
If you have ever lost work because a SaaS API client logged you out, or struggled to share a collection with a teammate without exporting and re-importing a giant JSON file, Bruno's file-on-disk model is the feature that will matter most to you. Put your collection folder in a Git repo and your whole team works from the same source of truth.
Who Bruno is for
| You are… | Bruno gives you… |
|---|---|
| A developer testing your own API | A fast client with scripting, environments, and tests, all version-controlled alongside your code |
| A QA engineer | Assertions, test scripts, and a CLI that runs the same collections in CI |
| A team lead | Git-based review of API collections — no shared cloud account required |
| A privacy-conscious user | A tool that keeps 100% of your data on your device |
Licensing
Bruno's desktop app and CLI are MIT-licensed and free. A subset of advanced features is offered through paid commercial editions (the "Golden Edition" and other paid tiers); the overwhelming majority of functionality is free and open source. See usebruno.com/pricing for the current commercial offering.
2. Quick Start
This section gets you from zero to your first successful request in a few minutes.
Step 1 — Install Bruno
Download the installer for your platform from usebruno.com/downloads, or use a package manager (see Installation). For example, on macOS:
brew install bruno
Step 2 — Create a collection
- Open Bruno.
- Click Create Collection.
- Give it a name (for example,
My First API) and choose a folder on disk where the collection will live.
Bruno creates a folder at that location containing a small configuration file. Everything you add to the collection becomes a file inside this folder.
Note
The folder you choose is your collection. You can open it in your editor, commit it to Git, or copy it to another machine — Bruno will read it back identically.
Step 3 — Create and send a request
-
Right-click the collection in the sidebar and choose New Request.
-
Name it
Get Posts, set the method to GET, and enter a URL:https://jsonplaceholder.typicode.com/posts/1 -
Press Ctrl/Cmd + Enter (or click Send).
You will see the response body, status code, headers, timing, and size in the response pane.
Step 4 — Save it
Press Ctrl/Cmd + S. Bruno writes a file named Get Posts.bru into your collection folder. Open it in any text editor and you will see the request expressed in the Bru language:
meta {
name: Get Posts
type: http
seq: 1
}
get {
url: https://jsonplaceholder.typicode.com/posts/1
}
That is the whole model: a request is a file, a collection is a folder.
3. Installation
Bruno ships as a desktop application for macOS, Windows, and Linux. You can install it from the website or from your platform's package manager.
Desktop app
Download (all platforms)
Download the latest installer from the official downloads page and run it:
https://www.usebruno.com/downloads
Builds are available for macOS (Apple Silicon and Intel), Windows, and Linux (AppImage, .deb, .rpm, and Snap).
macOS
# Homebrew
brew install bruno
Windows
# Chocolatey
choco install bruno
# Scoop
scoop bucket add extras
scoop install bruno
# winget
winget install Bruno.Bruno
Linux
# Snap
snap install bruno
# Flatpak
flatpak install com.usebruno.Bruno
# Arch Linux (AUR)
yay -S bruno
Linux (APT)
sudo mkdir -p /etc/apt/keyrings
sudo apt update && sudo apt install gpg curl
curl -fsSL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x9FA6017ECABE0266" \
| gpg --dearmor \
| sudo tee /etc/apt/keyrings/bruno.gpg > /dev/null
sudo chmod 644 /etc/apt/keyrings/bruno.gpg
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" \
| sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install bruno
Tip
On Linux, installing via APT (the steps above) means
apt upgradekeeps Bruno up to date along with the rest of your system packages.
Verifying the desktop install
Launch Bruno from your applications menu. The window title reads Bruno. Open Help → About (or the application menu) to confirm the installed version.
The Bruno CLI
The CLI is a separate, smaller package distributed through npm and Docker. See The Bruno CLI for installation and full usage. The short version:
npm install -g @usebruno/cli
bru --version
1.16.0
Important
The desktop app and the CLI are versioned independently. The desktop app and CLI both read and run the same
.brucollections, so a collection authored in the app runs unchanged under the CLI.
4. Core Concepts
A handful of concepts underpin everything in Bruno. Understanding them makes the rest of the tool obvious.
Collections are folders
A collection is a folder on your filesystem. At its root sits a configuration file — bruno.json (the classic format) or opencollection.yml (the OpenCollection format). Subfolders inside the collection become folders in Bruno's sidebar, letting you organize requests hierarchically.
My API/
├── bruno.json ← collection configuration
├── environments/
│ ├── Local.bru ← an environment
│ └── Production.bru
├── Auth/
│ └── Login.bru ← a request inside the "Auth" folder
└── Get Posts.bru ← a request at the collection root
Note
Because the collection is a normal folder, you can move it, copy it, zip it, or — most importantly — commit it to Git. There is no hidden database.
Requests are .bru files
Each request is a single .bru file. The file records the method, URL, query parameters, headers, body, authentication, variables, scripts, assertions, tests, and documentation for that one request. The format is described in full in The Bru File Format.
Environments hold variables
An environment is a named set of variables, stored in the collection's environments/ folder. Typical use: a Local environment pointing baseUrl at http://localhost:3000, and a Production environment pointing it at https://api.example.com. You switch the active environment from a dropdown; your requests reference {{baseUrl}} and resolve to whichever environment is selected.
Variables interpolate with {{ }}
Anywhere a value is expected — a URL, a header, a body, an auth field — you can interpolate a variable with double curly braces:
get {
url: {{baseUrl}}/users/{{userId}}
}
Bruno resolves {{baseUrl}} and {{userId}} from the active set of variables before sending. See Variables and Environments for the full list of variable scopes and how they layer.
Scripts run around requests
Each request can carry a pre-request script (runs before the request is sent) and a post-response script (runs after the response arrives). Scripts are JavaScript and have access to a bru object for reading and writing variables, a req object for inspecting and mutating the outgoing request, and a res object for the response. See Scripting.
Tests and assertions verify responses
Assertions are declarative checks (for example, res.status equals 200). Tests are JavaScript test(...) blocks using a Chai-style expect API. Both run after the response and report pass/fail. See Assertions and Tests.
5. Creating and Sending Requests
Anatomy of a request
A Bruno request consists of:
- A method —
GET,POST,PUT,DELETE,PATCH,OPTIONS,HEAD,CONNECT,TRACE, or a custom method. - A URL — including any path parameters (
/users/:id). - Query parameters — appended to the URL as
?key=value. - Headers — request headers.
- A body — for methods that send one.
- Authentication — an auth scheme applied to the request.
Creating a request
- Right-click a collection or folder in the sidebar → New Request.
- Enter a name, pick the method, and type the URL.
- Configure parameters, headers, body, and auth in the tabbed request pane.
- Send with Ctrl/Cmd + Enter, then save with Ctrl/Cmd + S.
Query parameters
Add query parameters in the Query tab, or type them directly into the URL — Bruno keeps the two in sync. In the .bru file they appear as a params:query block:
get {
url: {{baseUrl}}/search?q=bruno&limit=10
}
params:query {
q: bruno
limit: 10
}
Path parameters
Use a :name segment in the URL to declare a path parameter, then provide its value. In the file this is a params:path block:
get {
url: {{baseUrl}}/users/:id
}
params:path {
id: 42
}
Headers
Add headers in the Headers tab. They are stored in a headers block:
headers {
Content-Type: application/json
Accept: application/json
}
Tip
Any row in a parameters, headers, or body table can be disabled without deleting it. In the file, a disabled entry is prefixed with a tilde (
~), for example~X-Debug: true. This is handy for keeping optional headers around without sending them.
Sending and reading the response
After sending, the response pane shows:
- Status — code and text (for example
200 OK). - Time — how long the request took.
- Size — header and body byte sizes.
- Body — pretty-printed JSON/XML/HTML, or raw text, with a built-in viewer.
- Headers — the response headers.
- Timeline — a low-level view of the request/response exchange.
Note
Bruno automatically detects and pretty-prints common content types. If a response is misdetected, you can switch the response viewer's format manually.
6. Request Bodies
Bruno supports a full range of request body types. Select the body type in the Body tab of the request pane. Each maps to a specific block in the .bru file.
| Body type | Bru block | Use it for |
|---|---|---|
| JSON | body:json |
REST APIs that accept JSON |
| Text | body:text |
Plain text payloads |
| XML | body:xml |
SOAP / XML APIs |
| SPARQL | body:sparql |
SPARQL query endpoints |
| GraphQL | body:graphql |
GraphQL queries (with a companion body:graphql:vars for variables) |
| Form URL-encoded | body:form-urlencoded |
application/x-www-form-urlencoded forms |
| Multipart form | body:multipart-form |
File uploads and mixed form data |
| File / binary | body:file |
Sending a raw file as the body |
JSON body
post {
url: {{baseUrl}}/users
body: json
}
body:json {
{
"name": "Ada Lovelace",
"email": "[email removed]"
}
}
Form URL-encoded body
post {
url: {{baseUrl}}/login
body: form-urlencoded
}
body:form-urlencoded {
username: ada
password: {{secretPassword}}
}
Multipart form (file uploads)
In a multipart body, a field whose value uses the @file(...) reference uploads a file from disk. You can also pin a content type to a field with @contentType(...):
post {
url: {{baseUrl}}/upload
body: multipart-form
}
body:multipart-form {
caption: My holiday photo
avatar: @file(/Users/ada/pictures/me.png)
}
Tip
The
@file(...)reference stores only the path to the file in the.bru, not the file's contents — so your collection stays small and diff-friendly even when requests upload large files. The referenced file must exist on the machine running the request.
File / binary body
post {
url: {{baseUrl}}/raw-upload
body: file
}
body:file {
file: @file(/path/to/data.bin) @contentType(application/octet-stream)
}
7. Other Request Types: GraphQL, gRPC, WebSocket, SSE
Bruno is not limited to plain HTTP. It is a multi-protocol client.
GraphQL
Create a GraphQL request and write your query in the GraphQL body editor; provide variables in the variables panel. Bruno ships a GraphQL docs explorer so you can browse a schema's types and fields inside the app.
post {
url: {{baseUrl}}/graphql
body: graphql
}
body:graphql {
query GetUser($id: ID!) {
user(id: $id) { id name email }
}
}
body:graphql:vars {
{
"id": "42"
}
}
gRPC
Bruno can call gRPC services. Point it at a service, load the .proto definitions (or use server reflection), pick a method, and send a message. gRPC requests are stored with a grpc block and body:grpc message blocks.
Note
gRPC support includes unary and streaming method types and a method browser populated from your proto files or via reflection.
WebSocket
Open a WebSocket connection, send messages, and watch incoming frames in real time. WebSocket requests use a ws block with body:ws message blocks.
Server-Sent Events (SSE)
Bruno can subscribe to an SSE endpoint and display the streamed events as they arrive, which is useful for testing event streams and LLM token streams.
Tip
For long-lived protocols (WebSocket, SSE, gRPC streaming) the response pane updates continuously as data arrives, rather than showing a single static response.
8. Authentication
Bruno supports authentication at the request, folder, and collection levels. Lower levels can inherit auth from their parents, so you can configure auth once on a collection and have every request use it.
Supported auth schemes
| Scheme | Bru block | Notes |
|---|---|---|
| Basic | auth:basic |
Username + password |
| Bearer | auth:bearer |
A bearer token |
| API Key | auth:apikey |
Key, value, and placement (header or query) |
| OAuth 2.0 | auth:oauth2 |
Four grant types — see below |
| OAuth 1.0 | auth:oauth1 |
Full OAuth 1.0a signing |
| Digest | auth:digest |
HTTP Digest auth |
| AWS Signature v4 | auth:awsv4 |
Access key, secret, session token, service, region, profile |
| NTLM | auth:ntlm |
Username, password, domain |
| WSSE | auth:wsse |
WS-Security username token |
Basic, Bearer, and API Key
auth:basic {
username: ada
password: {{password}}
}
auth:bearer {
token: {{accessToken}}
}
auth:apikey {
key: X-API-Key
value: {{apiKey}}
placement: header
}
For API key auth, placement is either header (sent as a header) or queryparams (appended to the URL).
OAuth 2.0
Bruno implements four OAuth 2.0 grant types:
| Grant type | grant_type value |
Typical use |
|---|---|---|
| Authorization Code | authorization_code |
Web apps with user login (supports PKCE) |
| Client Credentials | client_credentials |
Machine-to-machine |
| Password | password |
Resource owner password (legacy) |
| Implicit | implicit |
Browser-based (legacy) |
Bruno can automatically fetch and automatically refresh tokens (auto_fetch_token, auto_refresh_token), supports PKCE for the authorization code flow, and lets you control where credentials are placed (request body vs. Basic header) and where the resulting token is placed (header with a configurable prefix, or query parameter). Fetched tokens are cached under a credential ID so multiple requests can share them.
auth:oauth2 {
grant_type: authorization_code
callback_url: {{callbackUrl}}
authorization_url: {{authUrl}}
access_token_url: {{tokenUrl}}
client_id: {{clientId}}
client_secret: {{clientSecret}}
scope: openid profile
pkce: true
token_placement: header
token_header_prefix: Bearer
auto_refresh_token: true
}
Tip
In scripts you can read OAuth 2.0 credential variables with
bru.getOauth2CredentialVar(name)and force a re-fetch by clearing a cached credential withbru.resetOauth2Credential(credentialId).
AWS Signature v4
auth:awsv4 {
accessKeyId: {{awsAccessKey}}
secretAccessKey: {{awsSecret}}
service: execute-api
region: us-east-1
}
Bruno can also resolve AWS credentials from a named profile (profileName) and supports session tokens.
Important
Never commit real secrets in plain text. Use environment variables (
{{apiKey}}) and keep secret values out of the.brufiles you check into Git — see Secrets and the.envfile.
9. Variables and Environments
Variables are how you keep requests reusable across different servers, users, and runs. Bruno has several variable scopes, and they layer in a defined order of precedence.
Variable scopes
| Scope | Set where | Lifetime | Settable from scripts |
|---|---|---|---|
| Global environment | Workspace-level global environments | Across collections | bru.setGlobalEnvVar() |
| Collection | Collection settings | The collection | Read-only from scripts |
| Environment | The active environment | Until you switch environments | bru.setEnvVar() (optionally persisted) |
| Folder | Folder settings | Requests in that folder | Read-only from scripts |
| Request | Request's Vars tab | One request | Read-only from scripts |
| Runtime | Scripts at run time | The current run | bru.setVar() |
| Process env | OS / .env file |
The process | Read-only (bru.getProcessEnv()) |
Note
When you reference
{{name}}, Bruno merges all scopes into one lookup. If the same name exists in several scopes, the higher-precedence scope wins. The effective precedence (lowest to highest) is: global environment → collection → environment → folder → request → OAuth2 credentials → runtime → prompt variables, withprocess.envavailable under{{process.env.NAME}}.
Environments
Create environments from the environment selector (top-right). Each environment is a .bru file under environments/. Switching the active environment changes which values {{...}} resolves to.
vars {
baseUrl: https://api.staging.example.com
apiKey: {{process.env.STAGING_API_KEY}}
}
Tip
Reference secrets indirectly. Instead of writing your real API key into an environment file, point the environment variable at a process environment variable (
{{process.env.STAGING_API_KEY}}) and supply the actual value through your shell or a.envfile that you do not commit.
Pre-request and post-response variables
A request can declare variables that are computed before it runs (vars:pre-request) or extracted from the response after it runs (vars:post-response). Post-response vars are the standard way to capture a token from a login response and reuse it in later requests.
vars:post-response {
accessToken: res.body.token
}
A variable name prefixed with @ is local — it is available within the run but not persisted to the environment.
Secrets and the .env file
If a .env file exists at the root of a collection, Bruno (and the CLI) loads it and exposes its keys under process.env. This is the recommended way to keep secrets out of version control:
- Add real secret values to
.envat the collection root. - Add
.envto your.gitignore. - Reference them as
{{process.env.MY_SECRET}}in requests and environments.
Caution
The
.envfile is read in plain text. Treat it like any other secret file: never commit it, and restrict its filesystem permissions.
Secret variables and encryption (desktop)
The desktop app can store certain sensitive values (such as secret environment variables) encrypted at rest using your operating system's secure storage. This keeps secrets out of the plain-text collection files while still letting you use them in the app.
10. Scripting
Scripts let you run JavaScript before a request is sent and after its response arrives. Use them to build dynamic payloads, set headers, capture tokens, chain requests, and more.
Pre-request and post-response scripts
script:pre-request {
// Runs before the request is sent
bru.setVar("requestId", crypto.randomUUID());
req.setHeader("X-Request-Id", bru.getVar("requestId"));
}
script:post-response {
// Runs after the response arrives
if (res.status === 200) {
bru.setEnvVar("accessToken", res.body.token);
}
}
The bru object
The bru object is your handle on the run. Key methods:
Variables
| Method | Purpose |
|---|---|
bru.getVar(key) / bru.setVar(key, value) |
Runtime variables |
bru.getEnvVar(key) / bru.setEnvVar(key, value, {persist}) |
Environment variables (set persist: true to write back to the environment file; persisted values must be strings) |
bru.getGlobalEnvVar(key) / bru.setGlobalEnvVar(key, value) |
Global environment variables |
bru.getCollectionVar(key) |
Read a collection variable |
bru.getFolderVar(key) / bru.getRequestVar(key) |
Read folder / request variables |
bru.getProcessEnv(key) |
Read a process.env value |
bru.hasVar(key) / bru.deleteVar(key) / bru.getAllVars() |
Inspect and manage runtime vars |
bru.getOauth2CredentialVar(key) / bru.resetOauth2Credential(id) |
OAuth 2.0 credentials |
Flow control and utilities
| Method | Purpose |
|---|---|
bru.setNextRequest(name) |
Jump to a named request next (collection runs) |
bru.runner.skipRequest() |
Skip the current request |
bru.runner.stopExecution() |
Stop the whole run |
bru.runRequest(path) |
Run another request from within a script |
bru.sendRequest(options) |
Send an ad-hoc HTTP request from a script |
bru.sleep(ms) |
Await a delay |
bru.getEnvName() |
The active environment's name |
bru.getCollectionName() |
The collection's name |
bru.cwd() |
The collection's path on disk |
bru.isSafeMode() |
true if running in the safe (sandboxed) runtime |
bru.interpolate(str) |
Interpolate {{...}} against the current variables |
bru.utils.minifyJson(x) / bru.utils.minifyXml(x) |
Minify helpers |
Note
Variable names must contain only alphanumerics plus
-,_, and.. Setting a variable with an empty name, or persisting a non-string environment variable, throws an error.
The req object (pre-request)
In a pre-request script, req represents the outgoing request and lets you read and mutate it before it is sent:
req.url,req.method,req.headers,req.body,req.timeout,req.name,req.tagsreq.getUrl()/req.setUrl(url),req.getMethod()/req.setMethod(m)req.getHeader(name)/req.setHeader(name, value)/req.deleteHeader(name)req.getBody({raw})/req.setBody(data, {raw})req.headerList— a rich, case-insensitive header collection API (get,add,upsert,remove,toObject, …)req.getAuthMode()— which auth scheme is activereq.setTimeout(ms),req.setMaxRedirects(n)req.onFail(callback)— register a handler that runs if the request failsreq.disableParsingResponseJson()— keep the response body as a raw string
The res object (post-response)
In a post-response script (and in tests), res represents the response:
res.status,res.statusText,res.headers,res.body,res.responseTimeres.getStatus(),res.getHeader(name),res.getBody(),res.getResponseTime(),res.getSize()res.setBody(data)— rewrite the body (useful for downstream tests)res.headerList— a read-only header collection APIresis callable:res('users[0].name')runs a query against the JSON body and returns the matched value, a concise alternative tores.body.users[0].name.
Inbuilt libraries
Scripts can require a curated set of bundled libraries without installing anything, including:
| Library | Use |
|---|---|
axios |
Make HTTP requests from scripts |
chai |
Assertions (expect, assert) |
crypto-js |
Hashing and crypto |
jsonwebtoken / jose |
Create and verify JWTs |
uuid, nanoid |
Generate IDs |
lodash |
Utilities |
moment |
Dates and times |
cheerio |
Parse and query HTML |
xml2js |
Parse XML |
ajv, tv4 |
JSON Schema validation |
@faker-js/faker |
Generate fake test data |
btoa / atob |
Base64 encode/decode |
Tip
A collection can also declare its own npm dependencies (in a
package.jsonat the collection root) for additional libraries you want available in scripts.
Sandbox modes: safe vs. developer
Because scripts are arbitrary JavaScript, Bruno runs them in one of two sandboxes:
| Mode | Runtime | What it allows |
|---|---|---|
| Safe (default) | QuickJS (a WebAssembly JS engine) | A restricted, isolated environment — no direct access to the host filesystem or Node internals |
| Developer | Node.js VM | Full Node.js capabilities for power users who trust their collection's scripts |
bru.isSafeMode() returns true in safe mode. In the CLI you choose the mode with --sandbox safe (default) or --sandbox developer.
Warning
Developer mode runs scripts with full Node.js access. Only enable it for collections whose scripts you trust. Running an untrusted collection in developer mode is equivalent to running an untrusted program. Safe mode is the default precisely to protect you from malicious or buggy scripts in shared collections.
11. Assertions and Tests
Bruno offers two complementary ways to verify a response.
Assertions (declarative)
Assertions live in the Assert tab and are simple expression → operator/value checks. They are stored in an assert block:
assert {
res.status: eq 200
res.body.id: isNumber
res.headers.content-type: contains application/json
}
Each assertion targets a value (like res.status or a JSON path into res.body) and checks it against an expected condition. Disabled assertions are prefixed with ~.
Tests (scripted)
Tests are JavaScript blocks in the Tests tab, using a Chai-style expect/assert API. They give you full control and can contain arbitrary logic:
tests {
test("status is 200", function () {
expect(res.status).to.equal(200);
});
test("response has an id", function () {
expect(res.body).to.have.property("id");
});
test("responds quickly", function () {
expect(res.responseTime).to.be.below(500);
});
}
Test results (passed / failed) appear in the response pane in the app and in the run summary in the CLI.
Tip
You can write tests that also run inside pre-request and post-response scripts. The CLI's
--tests-onlyflag runs only requests that actually contain a test or an active assertion, which is handy for fast verification runs.
12. Cookies
Bruno maintains a cookie jar. By default, cookies returned by responses are stored and sent automatically on subsequent requests to matching domains.
Managing cookies in the app
The desktop app has a cookie manager where you can view, add, edit, and delete cookies per domain.
Cookies in scripts
The bru.cookies API lets scripts read and write cookies. Scoped to the current request URL:
script:post-response {
// Read a cookie set by the response
const sid = bru.cookies.get("sid");
// Add or replace a cookie for the current URL
await bru.cookies.add({ key: "lang", value: "en" });
// Remove a cookie
await bru.cookies.remove("sid");
}
For cross-URL operations, use the jar handle, which interpolates {{...}} in URLs:
script:pre-request {
const jar = bru.cookies.jar();
const token = await jar.getCookie("{{authBaseUrl}}/login", "access_token");
await jar.setCookie("{{apiBaseUrl}}", { key: "sid", value: "abc123", secure: true });
}
The cookie list API also supports all(), count(), has(name), find(fn), filter(fn), toObject(), toString(), and clear().
Note
In the CLI, automatic cookie handling can be turned off for a run with
--disable-cookies.
13. Importing Collections
Migrating from another tool, or have an API spec? Bruno can import from several sources.
In the desktop app
Use Import Collection to bring in:
- Postman collections
- Insomnia exports
- OpenAPI / Swagger specifications
- WSDL (SOAP) definitions
- Other Bruno collections
The import converts the source into native Bruno requests and writes them as .bru files.
From the CLI
The CLI imports OpenAPI and WSDL sources directly:
# Import an OpenAPI spec into a new Bruno collection
bru import openapi --source api.yml --output ~/Desktop/my-collection --collection-name "My API"
# Import from a remote spec
bru import openapi --source https://example.com/api-spec.json --output ~/Desktop --collection-name "Remote API"
# Import a WSDL (SOAP) service
bru import wsdl --source service.wsdl --output ~/Desktop/soap-collection --collection-name "SOAP Service"
See bru import for all flags, including grouping (--group-by tags|path) and output format (--collection-format bru|opencollection).
Tip
When importing an OpenAPI spec,
--group-by tagsorganizes requests by the spec's tags, while--group-by pathmirrors the URL path hierarchy. Pick whichever matches how you think about the API.
14. Code Generation
Bruno can generate ready-to-use client code for any request — curl commands, language-specific HTTP snippets, and more — from the request's current configuration. Open a request's code generation panel, pick a target (language and library), and copy the generated snippet.
This is useful for:
- Sharing a reproducible
curlcommand in a bug report. - Bootstrapping client code in your application's language.
- Documenting exactly how to call an endpoint.
Note
Generated snippets reflect the request as configured, including headers, body, and (where applicable) auth. Variables may be emitted as literal placeholders, so review the snippet before sharing it if it contains secrets.
15. Collaborating with Git and Workspaces
Bruno's headline collaboration model is Git. Because a collection is a folder of text files, collaboration is just version control.
The Git workflow
- Put your collection folder inside a Git repository (or
git initit directly). - Commit the
.brufiles. - Push, branch, open pull requests, and review changes exactly as you would with code.
- Teammates clone the repo and open the folder in Bruno.
Because .bru is plain text, diffs are meaningful: a reviewer can see precisely which header changed or which assertion was added.
Tip
Add a
.gitignoreto your collection that excludes.env(secrets) and any local-only files. Commit the requests, environments (without secret values), and collection config.
Visual diff and built-in Git
The desktop app includes a visual diff viewer for comparing request changes and built-in Git integration for common operations, so you can review and manage changes without leaving Bruno.
Workspaces and global environments
A workspace groups multiple collections and can hold global environments that apply across all collections in the workspace. A workspace is defined by a workspace.yml file. Global environments live in the workspace's environments/ folder and are selected the same way as collection environments.
Note
Global environments require the collection to be part of a workspace. In the CLI, you select one with
--global-env <name>and, if needed, point at the workspace with--workspace-path.
16. The Bruno CLI
The Bruno CLI (bru) runs your collections from the command line — perfect for CI/CD pipelines, scheduled jobs, and scripted automation. It runs the exact same .bru collections as the desktop app.
Installing the CLI
npm
npm install -g @usebruno/cli
bru --version
Docker
docker pull usebruno/cli:latest
docker run --rm usebruno/cli --version
The CLI's two commands are run and import.
bru run
Run one or more requests or folders. Execute it from inside your collection directory (the directory containing bruno.json / opencollection.yml).
# Run a single request
bru run request.bru
# Run every request in a folder
bru run folder
# Run a folder recursively
bru run folder -r
# Run the entire collection (no path = current collection, recursive)
bru run
# Run with a named environment
bru run request.bru --env local
# Override a single variable (repeatable)
bru run request.bru --env local --env-var secret=xxx
bru run options
| Option | Description |
|---|---|
-r |
Run recursively through subfolders |
--env <name> |
Use a named environment from environments/ |
--env-file <path> |
Use an environment from a specific .bru or .json file |
--global-env <name> |
Use a workspace global environment |
--workspace-path <path> |
Path to the workspace (auto-detected if omitted) |
--env-var <name=value> |
Override one variable; may be passed multiple times |
--sandbox <safe|developer> |
JavaScript sandbox to use (default safe) |
-o, --output <path> |
Write results to a file |
-f, --format <json|junit|html> |
Format of the results file (default json) |
--reporter-json <path> |
Write a JSON report |
--reporter-junit <path> |
Write a JUnit XML report |
--reporter-html <path> |
Write an HTML report |
--reporter-skip-all-headers |
Omit all headers from the report |
--reporter-skip-headers <h…> |
Omit specific headers from the report |
--reporter-skip-request-body |
Omit request bodies from the report |
--reporter-skip-response-body |
Omit response bodies from the report |
--reporter-skip-body |
Omit both request and response bodies |
--tests-only |
Only run requests that have a test or active assertion |
--bail |
Stop the run after the first failure |
--delay <ms> |
Delay between requests (milliseconds) |
--tags <a,b> |
Only run requests with these tags |
--exclude-tags <a,b> |
Skip requests with these tags |
--insecure |
Allow insecure (unverified TLS) server connections |
--cacert <path> |
Custom CA certificate to verify the peer |
--ignore-truststore |
Use only the --cacert file, ignoring the default truststore |
--client-cert-config <path> |
Path to a client certificate config (JSON) |
--disable-cookies |
Do not store or send cookies automatically |
--noproxy |
Disable all proxy settings (collection and system) |
--cache-ssl-session |
Reuse TLS sessions across requests for faster handshakes |
--verbose |
Verbose output for debugging |
Reporters
Bruno can emit machine- and human-readable reports in three formats:
# JSON report
bru run --reporter-json results.json
# JUnit XML (for CI test reporters)
bru run --reporter-junit results.xml
# HTML report (open in a browser)
bru run --reporter-html results.html
# All three at once
bru run --reporter-json results.json --reporter-junit results.xml --reporter-html results.html
The legacy --output + --format pair still works (--output results.xml --format junit).
Tip
JUnit XML is the format most CI systems understand for test reporting. Pair it with your CI's test-report step (GitHub Actions, GitLab CI, Jenkins) to see Bruno results rendered as test cases.
Exit codes
bru run exits non-zero when something fails, which lets CI fail the build automatically:
| Code | Meaning |
|---|---|
0 |
Success |
1 |
One or more requests, tests, or assertions failed |
2 |
The specified output directory does not exist |
3 |
A request chain caused an infinite loop |
4 |
bru was run outside a collection root |
5 |
A specified file was not found |
6 |
The specified environment was not found |
7 |
Environment override not provided as a string/object |
8 |
Environment override format incorrect |
9 |
Invalid output format requested |
10 |
Invalid file format |
11 |
The specified workspace was not found |
12 |
A global environment was requested without a workspace |
13 |
The specified global environment was not found |
255 |
Generic / unexpected error |
bru import
Import an OpenAPI or WSDL source into a Bruno collection.
bru import openapi --source api.yml --output ~/Desktop/my-collection --collection-name "My API"
| Option | Alias | Description |
|---|---|---|
--source |
-s |
Path or URL to the source file (required) |
--output |
-o |
Output directory (conflicts with --output-file) |
--output-file |
-f |
Output JSON file (conflicts with --output) |
--collection-name |
-n |
Name for the imported collection |
--collection-format |
bru or opencollection (default opencollection) |
|
--group-by |
-g |
Group OpenAPI requests by tags or path (default tags) |
--insecure |
Skip TLS verification when fetching from URLs |
The positional <type> argument is openapi or wsdl.
Running the CLI in CI/CD
GitHub Actions
jobs:
api-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run Bruno collection
run: |
docker run --rm \
-v ${{ github.workspace }}:/bruno \
usebruno/cli:latest run --output results.xml --format junit
- name: Publish Test Report
uses: dorny/test-reporter@v3
if: success() || failure()
with:
name: Bruno Test Results
path: ${{ github.workspace }}/results.xml
reporter: java-junit
GitLab CI
api-tests:
image: usebruno/cli:latest
script:
- bru run --output results.xml --format junit
artifacts:
reports:
junit: results.xml
Running the CLI with Docker
Official images are published to Docker Hub (usebruno/cli) and GHCR (ghcr.io/usebruno/cli). The image's entrypoint is bru and its working directory is /bruno, so you mount your collection there and pass bru arguments after the image name.
# Run every request in the current collection directory
docker run -v $(pwd):/bruno usebruno/cli run
# Run a specific subfolder
docker run -v $(pwd):/bruno usebruno/cli run ./api-tests
# Run a single request and write a JUnit report
docker run --rm -v $(pwd):/bruno usebruno/cli run --reporter-junit results.xml
Two image variants are available:
| Variant | Base image | When to use |
|---|---|---|
| Alpine (default) | node:22-alpine |
The default — smallest and fastest to pull (use this unless you have a reason not to) |
| Debian | node:22-slim |
If you hit SSL/glibc compatibility issues |
docker pull usebruno/cli:latest # alpine, newest
docker pull usebruno/cli:3.3.0 # exact version, immutable
docker pull usebruno/cli:debian # debian variant
Note
On Windows native shells, replace
$(pwd)with${PWD}(PowerShell) or%cd%(CMD). Append--rmto anydocker runto auto-delete the container after the run finishes — handy for CI.
17. The Bru File Format
The .bru format is Bruno's plain-text markup for requests. You rarely need to write it by hand — the app generates it — but understanding it makes diffs readable and lets you hand-edit when convenient.
Block structure
A .bru file is a series of blocks. There are three kinds:
-
Dictionary blocks — key/value pairs inside
{ }:headers { content-type: application/json } -
Text blocks — free text inside
{ }(used for bodies, scripts, docs):body:json { { "username": "John" } } -
List blocks — a list of items inside
[ ]:tags [ regression smoke-test ]
Common blocks
| Block | Contents |
|---|---|
meta |
Request name, type (http/grpc/ws), and seq (display order) |
get / post / put / delete / patch / options / head / connect / trace / http |
The HTTP method block, carrying the url and request settings |
headers |
Request headers |
params:query / params:path |
Query and path parameters |
auth:* |
Authentication (see Authentication) |
body:* |
The request body (see Request Bodies) |
vars:pre-request / vars:post-response |
Variables computed before / extracted after |
assert |
Declarative assertions |
script:pre-request / script:post-response |
JavaScript scripts |
tests |
Scripted tests |
docs |
Markdown documentation for the request |
settings |
Per-request settings (see below) |
Conventions
- Disabling an entry: prefix a key with
~(for example~X-Debug: true) to keep it in the file but exclude it from the request. - Local variables: prefix a variable name with
@in avarsblock to make it local (not persisted). - File references: use
@file(/path/to/file)for file bodies and multipart file fields. - Content type annotations: append
@contentType(type)to pin a content type on a multipart or file field. - Multiline values: wrap a value in triple single quotes (
'''…''') for multi-line content.
Per-request settings
The settings block controls request behavior:
settings {
encodeUrl: true
timeout: 30000
followRedirects: true
maxRedirects: 5
}
| Setting | Meaning |
|---|---|
encodeUrl |
Whether to URL-encode the request URL |
timeout |
Request timeout in milliseconds (0 = no timeout; inherit = use the parent's) |
followRedirects |
Whether to follow HTTP redirects |
maxRedirects |
Maximum number of redirects to follow |
keepAliveInterval |
Keep-alive interval (where applicable) |
A complete example
meta {
name: Create User
type: http
seq: 3
}
post {
url: {{baseUrl}}/users
body: json
auth: bearer
}
headers {
Accept: application/json
}
auth:bearer {
token: {{accessToken}}
}
body:json {
{
"name": "Ada Lovelace",
"email": "[email removed]"
}
}
vars:post-response {
newUserId: res.body.id
}
assert {
res.status: eq 201
}
tests {
test("returns the created user id", function () {
expect(res.body).to.have.property("id");
});
}
docs {
Creates a new user. Requires a valid bearer token.
}
18. Troubleshooting
Each entry follows the pattern Symptom → Cause → Solution → Prevention.
The app shows a blank screen on launch
- Cause: The renderer failed to load (most common in development builds where the dev server isn't running; in production builds this indicates a bug).
- Solution: Restart Bruno. If you are running from source, start the web dev server first. If it persists with an official build, report it with logs at the project's issue tracker.
- Prevention: Use official release builds from the downloads page.
Error: The collection is not valid (neither bruno.json nor opencollection.yml found)
- Cause: You opened (or pointed the CLI at) a folder that is not a Bruno collection root.
- Solution: Open the folder that contains
bruno.jsonoropencollection.yml. For the CLI,cdinto the collection root before runningbru run. - Prevention: Run
bru runfrom the collection directory; open the collection's actual root folder in the app.
Error: Environment file not found: environments/<name>.bru
- Cause: The
--envname you passed does not match an environment file inenvironments/. - Solution: Check the exact file name under
environments/and pass that name (without extension) to--env. - Prevention: List your environments before a run; keep environment names consistent.
CLI exits with code 1 but requests "look fine"
- Cause: A test or assertion failed, or a request errored. Exit code
1means one or more requests, tests, or assertions failed. - Solution: Read the execution summary table the CLI prints, and inspect a generated report (
--reporter-html results.html) to see which check failed. - Prevention: Run locally with a reporter before pushing to CI.
SSL Certificate error: <code>. Try using --insecure if you trust this source.
- Cause: The server presented a certificate Bruno could not verify (self-signed, expired, or wrong hostname).
- Solution: If you trust the source, add
--insecure(CLI) or the equivalent app setting. For a private CA, supply the CA with--cacert <path>instead of disabling verification. - Prevention: Install your organization's CA into the system truststore, or reference it with
--cacert.
Workspace not found / Global environment requires a workspace
- Cause: You used
--global-envbut the collection isn't inside a workspace, or the workspace path couldn't be detected. - Solution: Ensure a
workspace.ymlexists in a parent directory, or pass--workspace-pathexplicitly. - Prevention: Keep collections that share global environments inside a workspace directory.
Variables in {{...}} aren't being substituted
- Cause: The variable isn't defined in any active scope, the environment isn't selected, or the name has a typo.
- Solution: Confirm the active environment, check the variable exists in some scope, and verify the exact name (names allow only letters, numbers,
-,_,.). - Prevention: Define shared values at the collection or environment level and reference them consistently.
Variable name: "<name>" contains invalid characters!
- Cause: A script tried to set a variable whose name contains characters other than alphanumerics,
-,_, or.. - Solution: Rename the variable to use only allowed characters.
- Prevention: Stick to simple identifier-style names.
Persistent environment variables must be strings
- Cause: You called
bru.setEnvVar(key, value, { persist: true })with a non-string value. - Solution: Convert the value to a string (for example
JSON.stringify(value)) before persisting, or omitpersistto keep it in-memory. - Prevention: Only persist string values; serialize structured data yourself.
A script works in the app but fails in the CLI (or vice versa)
- Cause: Different sandbox modes. The default is safe (QuickJS); some Node-specific APIs are unavailable there.
- Solution: Either rewrite the script to stay within the safe sandbox, or run with
--sandbox developerif you trust the collection. - Prevention: Prefer the inbuilt libraries and the
bru/req/resAPIs over raw Node APIs so scripts run identically everywhere.
Output directory <dir> does not exist
- Cause: A reporter path points into a directory that hasn't been created (CLI exit code
2). - Solution: Create the output directory first, or point the reporter at an existing one.
- Prevention:
mkdir -pthe reports directory in your CI step before runningbru.
File upload fails / file not found
- Cause: A
@file(...)reference points to a path that doesn't exist on the machine running the request. - Solution: Verify the file path is correct and present on the runner (in Docker/CI, ensure the file is inside the mounted volume).
- Prevention: Use paths relative to the collection where possible, and bind-mount the right directory in containers.
High memory use on very large collections / responses
- Cause: Large responses or big collection runs consume memory; this has been reported on some versions.
- Solution: Use the reporter
--reporter-skip-body/--reporter-skip-response-bodyflags to avoid retaining large bodies, run in smaller batches, and keep Bruno updated. - Prevention: Split very large collections and avoid storing huge responses in reports when you don't need them.
19. Frequently Asked Questions
Q: Does Bruno require an account or internet connection? No. Bruno is offline-only. There is no account and no cloud sync, and the project has stated it will not add cloud sync.
Q: Where are my collections stored?
In a folder you choose on your own filesystem. Each request is a .bru file; the collection is the folder.
Q: How do I share a collection with my team? Put the collection folder in a Git repository and have teammates clone it and open it in Bruno. That's the intended collaboration model.
Q: Is Bruno free? Yes — the desktop app and CLI are MIT-licensed and free. Some advanced features are available through paid commercial editions.
Q: Can I run my collections in CI?
Yes. The Bruno CLI (bru run) runs the same collections headlessly and exits non-zero on failure, with JSON, JUnit, and HTML reporters. Official Docker images make this easy.
Q: What protocols does Bruno support? HTTP/HTTPS, GraphQL, gRPC, WebSocket, and Server-Sent Events.
Q: What auth schemes are supported? Basic, Bearer, API Key, OAuth 2.0 (four grant types, with PKCE and auto-refresh), OAuth 1.0, Digest, AWS Signature v4, NTLM, and WSSE.
Q: How do I keep secrets out of Git?
Reference secrets via {{process.env.NAME}} and supply the actual values through a .env file at the collection root that you exclude from version control. The desktop app can also store secret values using your OS's secure storage.
Q: Can I import my Postman / Insomnia / OpenAPI collections? Yes. The app imports Postman, Insomnia, OpenAPI/Swagger, and WSDL. The CLI imports OpenAPI and WSDL.
Q: What's the difference between safe and developer sandbox modes? Safe mode (default) runs scripts in an isolated QuickJS engine with no host access. Developer mode runs them in a Node.js VM with full capabilities. Only use developer mode for collections you trust.
Q: Can scripts run other requests?
Yes — bru.runRequest(path) runs another request, bru.setNextRequest(name) controls chaining in collection runs, and bru.sendRequest(options) sends an ad-hoc request.
Q: What's the .bru vs .yml difference?
Both represent collections. .bru is Bruno's native markup; .yml is the OpenCollection YAML format. Bruno reads and writes both.
Q: How do I generate a curl command for a request?
Use Bruno's code generation feature to produce curl or language-specific snippets from any request.
Q: Does Bruno collect telemetry? Bruno is privacy-focused and offline-first. Optional product analytics may be present in the desktop app; the network policy restricts outbound connections accordingly. No collection data is sent anywhere.