Bruno — Git-Friendly API Client
Overview
Bruno is an open-source API client that stores collections as plain text files in a folder, so they live in the Git repository next to the code: versioned, reviewable in pull requests, with no account and no cloud sync (unlike Postman). The desktop app edits the files; the bru CLI runs them in a terminal or in CI.
A collection uses one of two file formats, never both:
- OpenCollection YAML (
.yml, root file opencollection.yml) — the default for new and imported collections since Bruno 3.1.
- Bru (
.bru, root file bruno.json) — the original format, still supported. The desktop app converts a Bru collection with Migrate to YML (Bruno 4.1 and later).
Instructions
Installation
npm install -g @usebruno/cli # CLI, Node.js 20+
bru --version # 4.2.0
brew install bruno # desktop app, macOS
winget install Bruno.Bruno # desktop app, Windows
flatpak install flathub com.usebruno.Bruno # desktop app, Linux
Other installers are on https://www.usebruno.com/downloads. The CLI is also published as the Docker image usebruno/cli (mount the collection at /bruno).
Collection Structure
orders-api/
├── opencollection.yml # Collection root (Bru format: bruno.json + collection.bru)
├── .env # Local secrets, listed in .gitignore
├── environments/ # local.yml, staging.yml
├── auth/
│ ├── folder.yml # Folder settings (Bru format: folder.bru)
│ └── login.yml
├── users/ # list-users.yml
└── orders/ # create-order.yml
The root file opencollection.yml needs only opencollection: 1.0.0 and an info: block with the collection name.
Request File (YAML)
# auth/login.yml
info:
name: Login
type: http
seq: 1
tags:
- smoke
http:
method: POST
url: "{{baseUrl}}/api/auth/login"
body:
type: json
data: |-
{
"email": "{{smokeEmail}}",
"password": "{{smokePassword}}"
}
auth: inherit
runtime:
scripts:
- type: after-response
code: |-
if (res.status === 200) {
bru.setVar("authToken", res.body.token); // runtime variable for later requests
}
- type: tests
code: |-
test("login returns a token", () => {
expect(res.status).to.equal(200);
expect(res.body.token).to.be.a("string");
expect(res.body.user.email).to.equal(bru.getEnvVar("smokeEmail"));
});
Script types are before-request, after-response and tests. Tests use Chai's expect. {{variable}} is replaced in the URL, headers and body but not inside scripts: read values there with bru.getEnvVar() or bru.getVar().
Bru File Format
The same request in an older .bru collection. Bru has no comment syntax: a # line at the top of a file is a parse error and the CLI skips the file.
meta {
name: Login
type: http
seq: 1
}
post {
url: {{baseUrl}}/api/auth/login
body: json
auth: none
}
body:json {
{
"email": "{{smokeEmail}}",
"password": "{{smokePassword}}"
}
}
script:post-response {
if (res.status === 200) {
bru.setVar("authToken", res.body.token);
}
}
tests {
test("login returns a token", () => {
expect(res.status).to.equal(200);
expect(res.body.user.email).to.equal(bru.getEnvVar("smokeEmail"));
});
}
Environments
One file per environment in environments/. A value written as {{process.env.NAME}} comes from the shell or from a .env file at the collection root. A variable marked secret keeps its value outside the file (the desktop app stores it encrypted on the machine), so the CLI must receive it with --env-var.
# environments/local.yml
name: local
variables:
- name: baseUrl
value: http://localhost:3000
- name: smokeEmail
value: maria.keller@northwind-logistics.com
- name: smokePassword
value: "{{process.env.SMOKE_USER_PASSWORD}}"
- name: apiSecret
secret: true
In a Bru collection the same file is environments/local.bru: a vars { … } block with one baseUrl: http://localhost:3000 pair per line (a block written on a single line is a parse error) and a vars:secret [ apiSecret ] list for secret names.
Scripting
# orders/create-order.yml — sign the request before it is sent
info:
name: Create order
type: http
seq: 1
http:
method: POST
url: "{{baseUrl}}/api/orders"
headers:
- name: X-Timestamp
value: "{{timestamp}}"
- name: X-Signature
value: "{{signature}}"
runtime:
scripts:
- type: before-request
code: |-
const CryptoJS = require("crypto-js");
const timestamp = Date.now().toString();
const signature = CryptoJS.HmacSHA256(timestamp, bru.getEnvVar("apiSecret")).toString();
bru.setVar("timestamp", timestamp);
bru.setVar("signature", signature);
Scripts run in Safe Mode by default (CLI 3.0 and later). Safe Mode offers require() for the bundled libraries chai, crypto-js, uuid, nanoid, moment, axios, jsonwebtoken, tv4, ajv, atob and btoa. Node built-ins such as crypto and fs, npm packages, lodash, xml2js and node-fetch need Developer Mode: bru run --sandbox=developer.
Useful calls: bru.setVar / bru.getVar (runtime variables), bru.getEnvVar / bru.setEnvVar, bru.getProcessEnv("NAME"), bru.runner.skipRequest(), bru.runner.stopExecution(), req.setHeader(name, value), res.status, res.body, res.getHeader(name).
CLI for CI/CD
cd orders-api # bru run works only at the collection root
bru run --env local # whole collection
bru run auth --env local # one folder (add -r to include subfolders)
bru run auth/login.yml --env local # one request
bru run --env local --tags=smoke # only requests tagged "smoke"
# Pass a secret variable from the shell
bru run --env staging --env-var apiSecret="$ORDERS_API_SECRET"
# Stop at the first failure and write reports (the reports/ directory must already exist)
bru run --env staging --bail \
--reporter-junit reports/results.xml --reporter-html reports/results.html
# Create a collection from an OpenAPI file (add --collection-format bru for .bru files)
bru import openapi --source openapi.yaml --output ./orders-api --collection-name "Orders API"
bru run exits with a non-zero code when a request gets no response or a test or assertion fails; a 4xx or 5xx response alone does not fail the run. The older --output results.xml --format junit pair still works but is deprecated in favour of the --reporter-* options.
Examples
Example 1: Add an authenticated request and run the collection
User: "Add a request to our Bruno collection that lists users with the token from login, then run everything locally."
# users/list-users.yml
info:
name: List users
type: http
seq: 1
http:
method: GET
url: "{{baseUrl}}/api/users"
auth:
type: bearer
token: "{{authToken}}"
runtime:
assertions:
- expression: res.status
operator: eq
value: "200"
- expression: res.body.data
operator: isArray
cd orders-api # SMOKE_USER_PASSWORD is set in the shell or in .env
bru run auth users --env local
Result: the login request stores authToken and the next request sends it as a Bearer token. Running bru run users --env local alone returns 401, because nothing has set authToken.
auth/login (200 OK) - 12 ms
Tests
✓ login returns a token
users/list-users (200 OK) - 2 ms
Assertions
✓ res.status: eq 200
✓ res.body.data: isArray
Example 2: Run the collection in GitHub Actions against staging
User: "Run our Bruno tests on every pull request and show the results in CI."
# .github/workflows/api-tests.yml
on: pull_request
jobs:
bruno:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: "24"
- run: npm install -g @usebruno/cli
- name: Run collection
working-directory: orders-api
env:
SMOKE_USER_PASSWORD: ${{ secrets.SMOKE_USER_PASSWORD }}
ORDERS_API_SECRET: ${{ secrets.ORDERS_API_SECRET }}
run: |
mkdir -p reports
bru run --env staging \
--env-var apiSecret="$ORDERS_API_SECRET" \
--reporter-junit reports/results.xml
- uses: actions/upload-artifact@v6
if: ${{ !cancelled() }}
with:
name: bruno-report
path: orders-api/reports/results.xml
Result: the job fails when any test or assertion fails, and results.xml holds one <testsuite> per request with a <testcase> for every test and assertion.
Guidelines
- Git-first workflow — Store Bruno collections in your repo next to application code; review API changes in PRs
- Environment files for config — Use environments for base URLs; keep credentials out of them with
{{process.env.NAME}} or secret variables, and add .env to .gitignore
- Secrets in the CLI — Secret variables are stored by the desktop app, not in the collection, so a CLI run sees them empty unless
--env-var name=value supplies them
- Test assertions — Write tests in every request; run them in CI to catch API regressions
- Script chaining — Use
bru.setVar() in after-response scripts to pass data between requests (token → subsequent calls); the request that sets a variable has to run first
- Folder organization — Mirror your API structure (auth/, users/, orders/); a folder can carry its own auth, variables and scripts in
folder.yml (folder.bru)
- One format per collection — Do not mix
.bru and .yml requests; after migrating, change .bru paths passed to bru.runRequest() to .yml
- Safe Mode first — Use
--sandbox=developer only for collections you trust: Developer Mode scripts can read files and run system commands
- CI/CD integration — Run
bru run --env staging after deployment to verify the API contract; the exit code fails the pipeline. In CLI 4 the JUnit classname is the request's path in the collection, not its URL
- Reports can leak — The JSON report records request and response headers and bodies, including a login password and the returned token; add
--reporter-skip-all-headers --reporter-skip-body before publishing JSON or HTML reports as CI artifacts