20.07.2026 • 19 min read

Bruno | Git-Friendly API Client with .bru Files & bruno-cli

Cover Image

Introduction 🎯

There’s an unspoken tension in every API development team: Postman works great for individuals, but collaboration is a mess.

You export a collection as JSON, commit it to the repo, and the next pull request shows a diff of 2000 lines because Postman reordered your headers. Someone changes an endpoint on their machine but forgets to export. The “canonical” collection lives on a cloud workspace that half the team can’t access.

Bruno eliminates that tension entirely. It’s an open-source API client built around a simple idea: API collections are plain-text files stored on your filesystem, versioned with Git, and reviewable in pull requests. No cloud account required. No proprietary binary format. No export step.

# A Bruno collection is a folder with .bru files — that's it
tree api-collection/
api-collection/
├── bruno.json          # Collection metadata
├── users/
   ├── get-users.bru   # Plain-text request definition
   ├── create-user.bru
   └── delete-user.bru
└── environments/
    └── local.bru

And the CLI counterpart — bruno-cli — runs those same collections in CI/CD with test reports, data-driven iteration, and JUnit output.

If you’ve ever wished Postman collections could be as easy to review as source code, this article is for you. We’ll cover the .bru format, the bru CLI, scripting, assertions, CI/CD integration, and a head-to-head comparison with Postman/Newman and Hurl. Let’s build. 🚀


Part 1: What Makes Bruno Different?

1.1 The Manifesto

Bruno’s manifesto is worth reading — it’s refreshingly direct:

Bruno is a closed source API client that is also open source… wait, what?

Bruno is an open-source API client. It does not store your API collections in the cloud. Your data stays on your computer, under your control.

Key principles:

PrincipleBrunoPostman
Data storageLocal filesystem (.bru files)Cloud workspace
Account required❌ Never✅ Required for sync
Collection formatPlain text, human-readableProprietary JSON
Git diff friendly✅ Yes — each request is a separate file❌ Single JSON blob
Offline-first✅ Full functionality offline⚠️ Limited without account
Open source✅ MIT license❌ Closed source
Free forever✅ No paid tiers for core features⚠️ Paid tiers for team features

1.2 Plain-Text Collections vs JSON Blobs

This is the core distinction. Compare how Postman and Bruno store a simple GET request:

Postman (excerpt from a collection JSON):

{
  "info": {
    "name": "My API",
    "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
  },
  "item": [
    {
      "name": "Get Users",
      "request": {
        "method": "GET",
        "header": [
          {
            "key": "Authorization",
            "value": "Bearer {{token}}",
            "type": "text"
          }
        ],
        "url": {
          "raw": "https://api.example.com/users?page=1",
          "protocol": "https",
          "host": ["api", "example", "com"],
          "path": ["users"],
          "query": [
            { "key": "page", "value": "1" }
          ]
        }
      }
    }
  ]
}

Bruno (.bru file — one file per request):

get {
  url: https://api.example.com/users?page=1
}

headers {
  Authorization: Bearer {{token}}
}

That’s it. A .bru file is a simple DSL with labelled sections (get, headers, body, tests, script:pre-request, etc.). Each request gets its own file. Git shows exactly what changed — a new header, a modified URL — not a reformatted JSON blob.


Part 2: The .bru File Format

2.1 Anatomy of a .bru File

A .bru file uses labelled sections to describe an HTTP request and its associated scripts:

# File: users/get-users.bru

get {
  url: https://api.example.com/users
}

headers {
  Authorization: Bearer {{auth_token}}
  Accept: application/json
}

query {
  page: 1
  limit: 20
}

Available section types:

SectionPurpose
get / post / put / patch / delete / head / options / patchHTTP method + URL
headersRequest headers (key: value)
queryURL query parameters (key: value)
bodyRequest body (JSON, text, XML, form-data, etc.)
paramsPath parameters
authAuth type and configuration
script:pre-requestJavaScript to run before the request
script:post-responseJavaScript to run after the response
testsDeclarative assertions
docsMarkdown documentation for the request
varsRequest-level variables
assertDeclarative assertion rules (GUI-based)

2.2 Request Examples

GET with query params:

get {
  url: https://api.github.com/users/usebruno
}

headers {
  Accept: application/vnd.github.v3+json
}

POST with JSON body:

post {
  url: https://api.example.com/auth/login
}

body:json {
  {
    "email": "user@example.com",
    "password": "securepass123"
  }
}

headers {
  Content-Type: application/json
}

POST with form-data:

post {
  url: https://api.sms.net.bd/sendsms
}

body:form-urlencoded {
  api_key: {{sms_api_key}}
  msg: Your OTP is 123456
  to: 8801800000000
}

headers {
  Content-Type: application/x-www-form-urlencoded
}

PUT with path params:

put {
  url: https://api.example.com/users/{{user_id}}
}

params {
  user_id: 42
}

body:json {
  {
    "name": "Updated Name",
    "email": "updated@example.com"
  }
}

2.3 Adding Tests and Assertions

Tests live in the tests section using standard Chai-style assertions:

post {
  url: https://api.example.com/auth/login
}

body:json {
  {
    "email": "user@example.com",
    "password": "securepass123"
  }
}

tests {
  test("should return 200", function() {
    expect(res.status).to.equal(200);
  });

  test("should return auth token", function() {
    expect(res.body.token).to.be.a('string');
    expect(res.body.token).to.not.be.empty;
  });

  test("should have refresh token", function() {
    expect(res.body.refreshToken).to.be.a('string');
  });
}

2.4 Declarative Assertions (No-Code)

For simpler cases, Bruno also supports declarative assertions via the GUI, stored in the .bru file as assert sections:

assert {
  res.status: eq 200
  res.body.status: eq success
  res.body.id: isNotEmpty
  res.headers['content-type']: contains application/json
  res.responseTime: lt 1000
}

Available operators: eq, neq, gt, gte, lt, lte, contains, matches, startsWith, endsWith, isEmpty, isNotEmpty, isNull, isTruthy, isFalsy, isNumber, isString, isBoolean, isArray, isJson.


Part 3: Bruno CLI (bruno-cli)

The CLI is where Bruno shines for automation. You write the collection in the desktop app (or by hand), commit it to Git, and bru run in CI/CD with zero adjustments.

3.1 Installation

# Global install via npm
npm install -g @usebruno/cli

# Verify
bru --version
# → 3.4.2

Or via Docker:

docker run --rm -v $(pwd):/collection usebruno/bruno-cli:latest run

3.2 Running a Collection

# Run the entire collection (from the collection directory)
cd collections/my-api
bru run

# Run with an environment
bru run --env local

# Run a single request file
bru run login.bru

# Run a folder
bru run users/

# Run multiple files
bru run login.bru users/create-user.bru

3.3 Output

01-get-users (200 OK) - 234 ms
02-create-user (201 Created) - 412 ms
03-get-user-by-id (200 OK) - 156 ms
04-delete-user (204 No Content) - 89 ms

Tests
 get-users: should return list of users
 create-user: should create user with valid data
 delete-user: should return 204

📊 Execution Summary
┌───────────────┬──────────────────────────┐
 Metric         Result
├───────────────┼──────────────────────────┤
 Status PASS
 Requests 4 (4 Passed, 0 Failed)   │
 Tests          3/3
 Assertions          12/12
└───────────────┴──────────────────────────┘

3.4 Key CLI Options

# Environment & variables
bru run --env staging                    # Use a named environment
bru run --env-var baseURL=https://staging.example.com   # Override at runtime
bru run --env-file ./environments/ci.bru # Explicit env file path

# Filtering
bru run --tests-only                     # Only requests with tests/assertions
bru run --tags=smoke,sanity              # Filter by tag (include)
bru run --exclude-tags=slow,draft        # Filter by tag (exclude)
bru run --bail                           # Stop on first failure

# Parallel execution
bru run --parallel                       # Run requests in parallel

# Data-driven testing
bru run --csv-file-path ./test-data.csv  # Iterate over CSV rows
bru run --json-file-path ./data.json     # Iterate over JSON array
bru run --iteration-count 3              # Repeat N times

# Reporting
bru run --reporter-html report.html      # HTML report
bru run --reporter-json results.json     # JSON report
bru run --reporter-junit results.xml     # JUnit XML (for CI)

# Security
bru run --sandbox=developer              # Enable developer mode (v3+)
bru run --insecure                       # Skip TLS verification
bru run --delay 500                      # Add delay between requests

3.5 Docker Integration

# Pull the CLI image
docker pull usebruno/bruno-cli:latest

# Run a collection from Docker
docker run --rm \
  -v $(pwd):/collection \
  usebruno/bruno-cli:latest \
  run --env ci --reporter-junit /collection/results.xml

Part 4: Scripting & Request Chaining

4.1 Pre-Request Scripts

Execute JavaScript before a request to set up dynamic data, compute signatures, or fetch tokens:

post {
  url: https://api.example.com/data
}

script:pre-request {
  // Generate a timestamp-based signature
  const timestamp = Date.now();
  const signature = CryptoJS.HmacSHA256(
    `data${timestamp}`,
    bru.getVar("apiSecret")
  ).toString();

  bru.setVar("timestamp", timestamp);
  bru.setVar("signature", signature);
}

headers {
  X-Timestamp: {{timestamp}}
  X-Signature: {{signature}}
}

4.2 Post-Response Scripts

Capture data from one response to use in subsequent requests — the foundation of request chaining:

post {
  url: https://api.example.com/auth/login
}

body:json {
  {
    "email": "{{email}}",
    "password": "{{password}}"
  }
}

script:post-response {
  // Save the auth token for downstream requests
  bru.setVar("authToken", res.body.token);
  bru.setVar("refreshToken", res.body.refreshToken);
  bru.setVar("userId", res.body.user.id);
}

4.3 Full Chaining Example

# 1. login.bru — authenticate and capture token
post {
  url: https://api.example.com/auth/login
}

body:json {
  { "email": "{{email}}", "password": "{{password}}" }
}

script:post-response {
  bru.setVar("accessToken", res.body.token);
  bru.setVar("userId", res.body.user.id);
}

tests {
  test("login successful", function() {
    expect(res.status).to.equal(200);
  });
}
# 2. create-post.bru — use captured token + userId
post {
  url: https://api.example.com/posts
}

headers {
  Authorization: Bearer {{accessToken}}
}

body:json {
  {
    "title": "My Post",
    "content": "Hello from Bruno!",
    "authorId": "{{userId}}"
  }
}

script:post-response {
  bru.setVar("postId", res.body.id);
}

tests {
  test("post created", function() {
    expect(res.status).to.equal(201);
    expect(res.body.id).to.be.a('string');
  });
}
# 3. verify-post.bru — confirm the post exists
get {
  url: https://api.example.com/posts/{{postId}}
}

headers {
  Authorization: Bearer {{accessToken}}
}

tests {
  test("post exists and authored by correct user", function() {
    expect(res.status).to.equal(200);
    expect(res.body.authorId).to.equal(bru.getVar("userId"));
  });
}

Run them all: bru run login.bru create-post.bru verify-post.bru

4.4 Available Scripting APIs

APIPurpose
bru.getVar("name")Read a variable
bru.setVar("name", value)Write a variable
bru.getEnvVar("name")Read an environment variable
bru.setEnvVar("name", value)Write an environment variable
req / resRequest / response objects
console.log()Debug output (visible in Timeline)
CryptoJSBuilt-in cryptographic library
axiosBuilt-in HTTP client (for nested calls)
chaiBuilt-in assertion library
promptPrompt user for input (GUI only)

Part 5: Bruno vs Postman/Newman

This is the comparison most readers care about. Let’s be honest about where each tool excels.

5.1 Format & Collaboration

AspectBrunoPostman + Newman
Collection formatPlain-text .bru files (one per request)Proprietary JSON (whole collection in one file)
Git diff✅ Clean, per-file diffs❌ Massive single-file diffs
Code review✅ PRs show exact request changes⚠️ Near impossible to review
Offline✅ Fully offline, always⚠️ Cloud workspace required for sync
Export step❌ Never needed — files are on disk✅ Required before commit

5.2 Feature Comparison

FeatureBruno (Desktop + CLI)Postman (Desktop)Newman (CLI)Hurl
Pricing✅ Free & open source⚠️ Free tier, paid teams✅ Free✅ Free & open source
Desktop app✅ Native (Electron)✅ Native (Electron)❌ CLI only❌ CLI only
Collection format.bru (plain text)JSON (proprietary)JSON.hurl (plain text)
One file per request✅ Yes❌ Single blob❌ Single blob✅ Yes (can split)
Git-native✅ Designed for Git❌ Postman sync❌ Postman sync✅ Designed for Git
GraphQL✅ Yes✅ Yes✅ Yes✅ Yes
gRPC✅ Yes✅ Yes
WebSocket✅ Yes✅ Yes
SOAP✅ Yes✅ Yes✅ Yes✅ Yes
Pre-request scripts✅ JS sandbox✅ JS sandbox✅ JS sandbox❌ No scripting
Post-response scripts✅ JS sandbox✅ JS sandbox✅ JS sandbox❌ No scripting
Request chainingbru.setVar()pm.variables.set()✅ Same as Postman✅ Captures
Declarative assertions✅ GUI + assert section✅ GUI + pm.test()✅ Same as Postman✅ Built-in DSL
Data-driven testing✅ CSV/JSON files✅ CSV/JSON files✅ CSV/JSON files❌ Manual
CI/CD reports✅ HTML, JSON, JUnit⚠️ Newman only✅ HTML, JSON, JUnit✅ HTML, JSON, JUnit
Parallel execution--parallel⚠️ Collection Runner✅ Parallel--parallel
Docker support✅ Official image✅ Official image✅ Official image
OpenAPI importbru import openapi✅ Import
Cloud account❌ Not required✅ Required for sync❌ Not required❌ Not required
Environment variables.bru env files✅ JSON env files✅ JSON env files--variable
Proxy support✅ Yes✅ Yes✅ Yes✅ Yes
VS Code extension✅ Bruno VS Code ext
Secret management✅ Dotenv + secret providers✅ Postman Secrets✅ Same
OAuth 2.0 flow✅ Built-in✅ Built-in
AI/agent integration✅ Claude, Codex, Cursor, Copilot✅ Postman AI

5.3 When to Use What

Choose Bruno when:

  • You want API collections versioned in Git with meaningful diffs
  • You value offline-first operation with no cloud dependency
  • You need both a desktop app and a CI/CD CLI with identical behaviour
  • Your team reviews API changes in pull requests
  • You want multiple protocol support (REST, GraphQL, gRPC, WebSocket, SOAP)

Choose Postman/Newman when:

  • Your team is already deeply invested in Postman workspaces and sync
  • You need Postman’s advanced monitoring and public API network
  • You rely on Postman’s built-in mock servers and documentation hosting
  • You need team workspaces with role-based access control

Choose Hurl when:

  • You want the fastest possible CLI-only API testing tool
  • You don’t need a desktop app at all
  • Your tests are simple request → assert chains
  • You want a single binary with no npm/Node.js dependency

Part 6: Migration from Postman

Bruno provides built-in tools to migrate from Postman:

# Import a Postman collection (via the desktop app)
# File → Import → Select Postman Collection

# Or import via CLI
bru import postman --source ./postman-collection.json --output ./my-api

The Scripts Translator helps convert Postman’s pm.* API to Bruno’s bru.* API:

PostmanBruno
pm.variables.get("key")bru.getVar("key")
pm.variables.set("key", val)bru.setVar("key", val)
pm.environment.get("key")bru.getEnvVar("key")
pm.environment.set("key", val)bru.setEnvVar("key", val)
pm.response.coderes.status
pm.response.json()res.body
pm.expect().to.equal()expect().to.equal() (Chai)

Part 7: CI/CD Integration

7.1 GitHub Actions

Bruno has an official GitHub Action:

# .github/workflows/api-tests.yml
name: API Tests

on:
  push:
    branches: [main]
  pull_request:

jobs:
  bruno:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: usebruno/bruno-cli-action@v1
        with:
          working-directory: collections/my-api
          command: >
            run
            --env ci
            --reporter-html ../../reports/bruno-report.html
            --reporter-junit ../../reports/bruno-results.xml

      - name: Upload test report
        if: ${{ !cancelled() }}
        uses: actions/upload-artifact@v4
        with:
          name: bruno-report
          path: reports/bruno-report.html

The action exposes outputs:

- name: Bruno API Tests
  id: bruno
  uses: usebruno/bruno-cli-action@v1
  with:
    working-directory: collections/my-api
    command: 'run --env ci'

- name: Check results
  run: |
    echo "Passed: ${{ steps.bruno.outputs.passed }}"
    echo "Failed: ${{ steps.bruno.outputs.failed }}"
    echo "Total: ${{ steps.bruno.outputs.total }}"

7.2 Manual CI/CD (No Action)

steps:
  - uses: actions/checkout@v4

  - name: Install Bruno CLI
    run: npm install -g @usebruno/cli

  - name: Run API tests
    run: |
      cd collections/my-api
      bru run --env ci \
        --reporter-html ../../reports/report.html \
        --reporter-junit ../../reports/results.xml

7.3 GitLab CI

api-tests:
  image: node:20
  before_script:
    - npm install -g @usebruno/cli
  script:
    - cd collections/my-api
    - bru run --env ci --reporter-junit results.xml
  artifacts:
    reports:
      junit: collections/my-api/results.xml

7.4 Jenkins

stage('API Tests') {
    agent {
        docker { image 'usebruno/bruno-cli:latest' }
    }
    steps {
        sh 'cd collections/my-api && bru run --env ci --reporter-junit results.xml'
    }
    post {
        always {
            junit 'collections/my-api/results.xml'
        }
    }
}

7.5 Docker in CI

# GitLab CI with Docker executor
api-tests:
  image: usebruno/bruno-cli:latest
  script:
    - cd collections/my-api
    - bru run --env ci --reporter-junit results.xml

Part 8: Environments & Variables

8.1 Environment Files

Bruno stores environments as .bru files in an environments/ directory:

# environments/local.bru
vars {
  baseURL: http://localhost:3000
  authToken: test-token-local
}

# environments/ci.bru
vars {
  baseURL: https://staging-api.example.com
  authToken: ""
}

8.2 Variable Precedence (lowest to highest)

  1. Collection-level defaults (in bruno.json)
  2. Environment variables (.bru env files)
  3. Folder-level variables
  4. Request-level vars section
  5. Script-set variables (bru.setVar())
  6. CLI --env-var overrides

8.3 Dynamic Variables

Built-in dynamic variables:

VariableDescriptionExample Output
{{$guid}}UUID v4550e8400-e29b-41d4-a716-446655440000
{{$timestamp}}Unix timestamp in ms1721462400000
{{$isoTimestamp}}ISO 8601 timestamp2026-07-20T12:00:00.000Z
{{$randomInt}}Random integer42

Part 9: The OpenCollection YAML Format

Bruno now offers a second, YAML-based collection format called OpenCollection alongside the classic .bru format. It’s an open specification using standard YAML — same Git-friendly benefits, broader tooling compatibility:

# opencollection.yml
version: "1.0"
name: My API Collection
requests:
  - method: GET
    url: https://api.example.com/users
    headers:
      Authorization: Bearer {{token}}
    tests:
      - expression: res.status
        operator: equals
        value: 200
      - expression: res.body
        operator: isNotEmpty

The OpenCollection YAML specification is an open standard, meaning other tools can read and write it too.

To import an OpenAPI spec as OpenCollection YAML:

bru import openapi \
  --source https://petstore3.swagger.io/api/v3/openapi.json \
  --output ./petstore-api \
  --collection-format opencollection

Part 10: Bruno vs Hurl — A Nuanced Comparison

Since we covered Hurl in a previous article, it’s worth a specific comparison:

10.1 Philosophy

DimensionBrunoHurl
ApproachDesktop app + CLICLI only
LanguageJavaScript (scripts), DSL (requests)Plain-text DSL
Learning curveLow (GUI) + Medium (CLI)Low (simple cases)
Use caseFull API development lifecycleTesting & automation only
DependenciesNode.js (CLI), Electron (desktop)Single Rust binary

10.2 When Bruno Wins

  • You need a GUI for exploration during development
  • You need scripts (pre-request, post-response) for complex workflows
  • You need multiple protocols (gRPC, WebSocket, GraphQL)
  • You want a unified tool from development → testing → CI/CD

10.3 When Hurl Wins

  • You want the lightest possible CI dependency (no Node.js)
  • Your tests are straightforward request → assert chains
  • You prefer Hurl’s built-in assertion DSL over JavaScript
  • You need the fastest execution (Rust vs Node.js)

10.4 Side-by-Side: Same Test in Bruno vs Hurl

Bruno (login.bru):

post {
  url: https://api.example.com/auth/login
}

body:json {
  { "email": "user@example.com", "password": "pass" }
}

script:post-response {
  bru.setVar("token", res.body.token);
}

tests {
  test("status is 200", function() { expect(res.status).to.equal(200); });
  test("has token", function() { expect(res.body.token).to.be.a('string'); });
}

Hurl (login.hurl):

POST https://api.example.com/auth/login
{ "email": "user@example.com", "password": "pass" }

HTTP 200
[Asserts]
jsonpath "$.token" exists
[Captures]
token: jsonpath "$.token"

Both are valid approaches. Bruno gives you JavaScript power; Hurl gives you zero-dependency simplicity.


Part 11: Bruno in a Real Workflow

Let’s put it all together with a realistic workflow:

Project Structure

project-root/
├── collections/
   └── my-api/
       ├── bruno.json                 # Collection manifest
       ├── auth/
   ├── login.bru
   └── refresh-token.bru
       ├── users/
   ├── create-user.bru
   ├── get-users.bru
   ├── get-user.bru
   └── delete-user.bru
       ├── payments/
   ├── create-payment.bru
   ├── execute-payment.bru
   └── refund.bru
       └── environments/
           ├── local.bru
           ├── ci.bru
           └── staging.bru
└── .github/
    └── workflows/
        └── api-tests.yml

Developer Workflow

  1. Develop: Open the collection in Bruno Desktop, explore endpoints, write tests
  2. Commit: git add collections/my-api/ && git commit -m "add payment endpoints"
  3. Review: PR shows per-file diffs — teammates review the .bru file changes
  4. CI: bru run --env ci executes the collection on every push
  5. Debug: Failed tests produce JUnit reports viewable in CI artifacts

Commands Cheat Sheet

TaskCommand
Run all testsbru run
Run with environmentbru run --env local
Run a specific folderbru run payments/
Run a single requestbru run auth/login.bru
Run with test-only filterbru run --tests-only
Generate HTML reportbru run --reporter-html report.html
Save JUnit for CIbru run --reporter-junit results.xml
Override a variablebru run --env-var baseURL=https://staging.example.com
Run with CSV databru run --csv-file-path data.csv
Run in parallelbru run --parallel
Import OpenAPI specbru import openapi --source ./spec.yaml --output ./collection

Part 12: Best Practices

12.1 Organise Collections

  • One file per request — Bruno’s superpower is per-file diffs
  • One folder per resourceusers/, payments/, auth/
  • Name files clearlycreate-user.bru, not req1.bru
  • Keep environments separate — never hardcode a URL or API key in a request

12.2 Writing Tests

  • Test status codes firstexpect(res.status).to.equal(200)
  • Test response shape — not just “it didn’t error”
  • Use script:pre-request for dynamic setup, not for logic that belongs in tests
  • Use script:post-response for chaining, not for assertions
  • Keep assertions declarative when possible (the assert section)

12.3 CLI in CI

  • Always use --reporter-junit for CI tool integration
  • Use --bail in pre-merge checks to fail fast; omit it in full test suites
  • Use --tags to separate smoke tests (--tags=smoke) from full regression
  • Use --env-var for secrets — never commit real credentials to env files
  • Pin your CLI version: npm install -g @usebruno/cli@3.4.2

12.4 Git Workflow

  • Review .bru files in PRs — each file is small enough for meaningful review
  • Use GitHub PR comments on specific lines of request definitions
  • Don’t commit generated OpenAPI imports without reviewing them
  • Use .gitignore for local environment overrides if needed

Conclusion

Bruno solves a problem that Postman has ignored for years: API collections should be as easy to version, review, and collaborate on as source code.

By storing each request as a plain-text .bru file on your filesystem, Bruno makes Git the collaboration layer — not a proprietary cloud workspace. Your team reviews API changes in PRs alongside code changes. Your CI pipeline runs the same collections without any export step, cloud account, or API key.

Why adopt Bruno:

  • Git-native — per-file diffs, meaningful PR reviews, no export step
  • Open source & free — MIT license, no paid tiers, no account required
  • Offline-first — your collections are on your disk, always
  • Desktop + CLI — one format for development and automation
  • Multiple protocols — REST, GraphQL, gRPC, WebSocket, SOAP, all in one tool
  • Scriptable — pre-request and post-response JavaScript with full API
  • CI-ready — JUnit reports, Docker image, GitHub Action, data-driven testing

Is it a complete Postman replacement? For many teams — yes. If your primary need is designing, testing, and automating API calls with Git-based collaboration, Bruno does everything Postman does and does the collaboration part better. The only gaps are Postman’s cloud-hosted features (monitors, public API network, team workspaces) — and if you don’t need those, Bruno is the better choice.

Try it: install the desktop app from usebruno.com, clone a collection from bruno-collections, and experience what API development looks like when your tools respect your filesystem. 🚀


Further Reading