browsebrowsebrowse
v0.3.0
Published
Headless Chrome CLI for coding agents. Screenshots, PDFs, rendered HTML/text and page JS from the shell — cold by default, optionally daemonised. Sibling of domdomdom, which does DOM work with no browser at all.
Maintainers
Readme
browsebrowsebrowse
Headless Chrome from the command line, built for coding agents to drive. Screenshots, PDFs, rendered HTML, page JS — and a real puppeteer escape hatch when the verbs run out. Browse, browse, browse!
bbb shot https://example.com out.png --full
echo 'getComputedStyle(document.querySelector(".card")).display' | bbb eval http://localhost:3000 --jsonPowered by chrome-headless-shell and puppeteer-core. No MCP server, no persistent connection, no framework — a CLI with --json output and honest exit codes, driven from a plain shell.
It is the sibling of domdomdom, which does DOM work with no browser at all. Read the next section before installing this one.
Which of the two do you want?
domdomdom is ~3.5x faster, needs no ~190MB engine on disk and leaves no process running. When both would work, use domdomdom.
| | domdomdom | bbb (this) |
| ------------------------------------------------------ | ------------------- | ------------------------------- |
| DOM queries, extraction, window.* smoke tests | ✅ ~0.25s | works, but wasteful |
| Screenshots, PDF, layout, getComputedStyle | ❌ no rendering | ✅ |
| Click, scroll, type, navigation flows | ❌ | ✅ |
| Streaming pages you need to see settle | ❌ | ✅ (--wait) |
| Cost | none | ~190MB engine on disk, ~0.87s cold / ~0.74s daemonised, ~140MB RSS idle |
Where those numbers come from. Measured 2026-09-01 on an Apple M2 (macOS 26.3.1, node 26.3.0, bun 1.4.0, deno 2.9.5, browsebrowsebrowse 0.2.0, domdomdom 0.5.0, Chrome 152.0.7977.64), median of 5 runs on a trivial page — domdomdom on an inline page, bbb on a data: page. Every other measurement in this file carries its own date; an older date means it has not been re-run since.
Two of them deserve a caveat. The ~3.5x is 0.87s / 0.25s on the easiest possible page, so it is a floor for both tools, not a throughput figure. And RSS grows with the pages you hold open: ~140MB idle after serve, ~168MB after one simple page, ~187MB after three heavy ones. An earlier version of this table said "~180MB RSS while daemonised", which was a loaded figure presented as an idle one.
For anything that needs the user's own logged-in browser session — their cookies, their tabs — neither tool is right; use a browser extension such as claude-in-chrome.
Install
npm install -g browsebrowsebrowse # or: bun add -g browsebrowsebrowseBoth browsebrowsebrowse and bbb are installed. Or run it with no install:
bunx browsebrowsebrowse shot https://example.com out.png
npx --yes browsebrowsebrowse shot https://example.com out.png
deno run -A npm:browsebrowsebrowse shot https://example.com out.pngRuntimes
bbb ships compiled ESM, which three runtimes execute. Each has its own way in, and all three are gated by bun run smoke:pack in CI.
| Runtime | Install | Run |
| ------- | ------- | --- |
| Node ≥ 22.12 | npm i -g browsebrowsebrowse | bbb … — the shipped bin's shebang is #!/usr/bin/env -S node, so this is the shebang path |
| Bun | bun add -g browsebrowsebrowse | bunx browsebrowsebrowse … — Bun's loader runs the file directly and never reads the shebang |
| Deno ≥ 2 | deno install -g -A npm:browsebrowsebrowse | deno run -A npm:browsebrowsebrowse … |
engines claims node >=22.12.0 and nothing else — puppeteer-core's own floor, asserted against its package.json by a test. That is deliberate and it is the honest claim: the shebang is the only thing the package controls, and it can only guarantee Node.
One real gap follows from that. On a Bun-only machine with no Node on PATH, bun add -g browsebrowsebrowse puts bbb on PATH but running it directly fails — the OS resolves the shebang's interpreter before Bun gets a say. Use bunx browsebrowsebrowse … there; it bypasses the shebang entirely and is covered by CI.
Deno needs neither Node nor the shebang: deno install -g writes its own #!/bin/sh shim that execs deno run -A npm:browsebrowsebrowse, so the package's shebang is never consulted. It installs one command per invocation, named after the package; for the short alias use deno install -g -A --name bbb npm:browsebrowsebrowse (both bins are the same file). Deno's node-compat carries the whole stack — spawning Chrome through node:child_process, the CDP websocket, and the daemon's node:net port probes — verified end to end against a real engine, not just asserted.
The engine
bbb needs a Chrome engine (chrome-headless-shell): ~95MB to download, ~190MB on disk once extracted. It installs one on first use at an interactive terminal, after printing what it is about to fetch. Anywhere non-interactive — CI, a pipe, a script — it exits 3 and prints the command instead:
SETUP ERROR: no Chrome engine installed.
Refusing to download ~95MB (~190MB on disk) unprompted in a non-interactive session.
Run: bbb engine install stableA surprise engine download in somebody's pipeline is a bug, not a convenience.
(Both figures are estimates rounded to the nearest 5MB, and every notice states both — at the moment you say yes, the transfer and the lasting disk cost are different questions. Measured 2026-09-01 for 152.0.7977.64 on mac-arm64: 93MB over the wire, 194MB extracted. They grow with Chrome.)
Engines live in ~/.cache/browsebrowsebrowse/engines/<version>/ — outside node_modules, so they survive reinstalls and one copy is shared by every project, global or local. This is the whole point of the engine verb: four tools each downloading their own Chrome is how a laptop loses a gigabyte.
bbb engine status # what's installed, and whether upstream has moved
bbb engine install [stable|<version>]
bbb engine update # explicit, always
bbb engine list
bbb engine prune # drop every engine but the current one
bbb engine pathNothing ever auto-updates. doctor and engine status fetch a few hundred bytes of version metadata from the Chrome-for-Testing feed, tell you if your engine has drifted, and print the command. They never fetch a binary.
Pin with --engine-version 152.0.7977.64 or BBB_ENGINE_VERSION. Skip the cache entirely by pointing CHROME_PATH at an existing Chrome — it must be the executable file, not the directory containing it.
Cold vs daemon
cold = clean room. daemon = session.
Cold is the default and needs no flag: a throwaway profile, no cookies, no history, browser closed when the command ends, no process left behind. Reproducible, and safe to put in CI.
bbb serve # persistent profile on a CDP port
bbb status
bbb stop # back to coldWhile a daemon is running, every command uses it automatically. There is no attach flag and no config file, because the failure mode of forgetting one — "why is it asking me to log in again?" — is exactly what the daemon exists to prevent.
Start one when a login has to survive between commands, or when a heavy page would otherwise be launched from scratch each time. On a trivial page the daemon saves less than you might expect — most of the residual is process startup, not the browser. Stop it when you are done; it holds ~140MB idle and grows towards ~190MB as you use it (see the cost table above).
CLI
bbb shot <url> [out.png] screenshot --full --w N --h N --wait <sel>
bbb pdf <url> [out.pdf] print to PDF
bbb html <url> serialised DOM after load
bbb text <url> visible text
bbb eval <url> run JS in the page (JS on stdin)
bbb run <script.mjs> [args] full puppeteer-core API
bbb serve | stop | status
bbb engine status | install | update | list | prune | path
bbb doctorA URL argument can be a full URL, a bare host (example.com → https, localhost:3000 → http) or a local file path (./dist/index.html).
Flags
| Flag | Effect |
| ------------------------ | --------------------------------------------------------- |
| --json | one line of { ok, result, logs, status } on stdout |
| --timeout <ms> | navigation/selector budget (default 30000) |
| --viewport <WxH> | page viewport (default 1440x900) |
| --w <n> / --h <n> | viewport width/height; beat --viewport |
| --full | full-page screenshot (shot) |
| --wait <selector> | wait for a selector instead of network idle |
| --fail | treat a non-2xx page as an error, like curl --fail |
| --user-agent <ua> | override navigator.userAgent |
| --engine-version <ver> | use a specific Chrome build for this run |
| --no-install | never download an engine; fail with the command instead |
| -h, --help | help |
Output contract
Human by default: result on stdout (strings verbatim, so bbb html url > page.html produces HTML), page console.* on stderr as [log]/[warn]/…, errors on stderr.
--json: exactly one line, nothing else.
{ "ok": true, "result": <any>, "logs": [{ "level": "log"|"warn"|"error"|"info"|"debug", "message": "..." }], "status": <number|null> }
{ "ok": false, "error": { "kind": "eval"|"timeout"|"setup"|"http", "message": "...", "stack": "..." }, "logs": [], "status": <number|null> }Exit codes: 0 ok · 1 eval error · 2 timeout · 3 setup/usage error · 4 HTTP error (--fail only).
All of this is deliberately identical to domdomdom, down to JS arriving on stdin rather than as an argument. Two tools that feel like one tool means an agent that learns either can drive both.
HTTP status, and why the exit code is not a fetch check
Every --json line carries status: the main document's final HTTP status, after redirects. It is null — not omitted — whenever there wasn't one: a local file, a data: URL, about:blank, or a verb that never navigates (serve, status, engine …, doctor). One shape, always.
Chrome reports 200 for a file: URL. That is nulled before it reaches the output: status answers "what did the server say", not "could Chrome read this", and --fail must not pass judgement on something that was never fetched.
Without --fail, a 404 is a success. It exits 0 with ok: true and the not-found page's content, because a non-2xx page is still a page — screenshotting an error page is a perfectly reasonable thing to want. The consequence is worth stating plainly: the exit code tells you whether the command worked, never whether the page was there.
echo 'document.title' | bbb eval https://example.com/nope --json
# {"ok":true,"result":"Page not found","logs":[],"status":404} exit 0
bbb shot https://example.com/nope out.png --fail --json
# {"ok":false,"error":{"kind":"http","status":404,"message":"HTTP 404 for https://example.com/nope"},"logs":[],"status":404} exit 4--fail is opt-in, and checks the status before the verb does its work — on a non-2xx your JS never runs and no screenshot is written. Same flag, same field, same codes in domdomdom.
eval
echo 'document.title' | bbb eval https://example.com --json
bbb eval http://localhost:3000 --json <<'JS'
const el = document.querySelector('.card')
const r = el.getBoundingClientRect()
return { w: r.width, h: r.height, display: getComputedStyle(el).display }
JSA single expression auto-returns. Multi-line code needs an explicit return. Top-level await works either way.
run
// flow.mjs
export default async ({ browser, page, args, goto, puppeteer }) => {
await goto(args[0])
await page.click('#open')
await page.type('#q', 'hello')
await page.waitForSelector('.results li')
return page.$$eval('.results li', els => els.map(e => e.textContent))
}bbb --json run flow.mjs http://localhost:3000 --depth 3A returned value prints as JSON. args is everything after the script path — bbb's own flags must come before it, since anything after belongs to the script. Prefer .mjs; a .ts script needs a runtime that handles types (Node ≥22.18, Bun, or Deno).
goto(url) resolves to { strategy, status }: how the navigation settled (wait-selector / networkidle2 / domcontentloaded) and the final HTTP status, or null. The last navigation's status is the one the command reports, and --fail applies to every one.
Streaming pages: the one gotcha
bbb navigates once with domcontentloaded and then waits for the network to go idle (at most two open connections, quiet for 500ms). That is right for an ordinary page and wrong for anything streaming — but not in the way you would expect.
The failure is fast, ok: true, and silently wrong. At domcontentloaded the page's script has not opened its EventSource yet, so the network is idle, networkidle2 is satisfied immediately, and the eval runs before any event has arrived. Measured 2026-08-26, 3/3, against a local SSE fixture pushing 5 items over 3 seconds, on bun 1.3.14 — i.e. before the current toolchain, and not re-run since:
| Command | Result | Time |
| --- | --- | --- |
| … \| bbb eval …/sse --json | {"ok":true,"result":0} | ~1.3s |
| … \| bbb eval …/sse --json --wait '[data-done]' | {"ok":true,"result":5} | ~3.8s |
Both exit 0. Nothing in the first distinguishes "there are no items" from "no time was given for any". An earlier version of this README and the skill said an SSE page "burns the whole --timeout" — it does not, and that is the more dangerous story, because someone told they will hit a timeout believes they will notice.
Name a selector instead. It skips the idle wait entirely, and is a real assertion about what rendered:
bbb shot http://localhost:3000/feed out.png --wait '[data-done]'The other streaming shape — a page holding many connections open, like chatty long-polling — is the one where networkidle2 genuinely never fires. There the idle timeout falls back to a short settle rather than failing, so you still get a screenshot; it just costs the full timeout first. --wait is the answer to both.
Two things that will bite you if you fork this
- Chrome must never be launched through a file symlink. It resolves
icudtl.datand its other resources relative to its own executable path; through a symlink it looks in the link's directory and dies withicudtl.dat not found in bundle. The resolverrealpaths every path it returns so no caller can forget. - A
CHROME_PATHpointing at a directory must be rejected, not spawned. Spawning a directory fails asEACCES, which reads like a permissions problem and sends you looking somewhere else entirely.
Both are encoded as tests, not as comments.
Environment
| Variable | Meaning |
| -------------------- | ----------------------------------------------------------- |
| CHROME_PATH | use this executable instead of the cache (never required) |
| BBB_CACHE_DIR | move the whole cache (engines, profile, manifests) |
| BBB_PORT | CDP port for the daemon (default 9333) |
| BBB_ENGINE_VERSION | pin an engine version |
| BBB_NO_INSTALL | never auto-install, same as --no-install |
| BBB_CHROME_ARGS | extra Chrome flags, space-separated |
Library
import { withSession, goto, loadConfig } from 'browsebrowsebrowse'
const cfg = loadConfig()
const { value } = await withSession(cfg, { viewport: { width: 1440, height: 900 }, timeout: 30000 },
async ({ page }) => {
await goto(page, 'https://example.com', { timeout: 30000 })
return page.title()
})Thin on purpose — the CLI is the product. For anything richer, bbb run hands you the real puppeteer objects.
Agent integration
browsebrowsebrowse was built for LLM agents to drive: --json plus stdin/stdout-only contracts mean it works behind a plain Bash tool with no MCP server, no persistent connection and no context overhead. The repo ships an Agent Skill at skills/browsebrowsebrowse/SKILL.md that teaches an agent when a real browser is warranted — most of that skill is about routing away to domdomdom.
Claude Code
/plugin marketplace add scruffymongrel/claude-plugins
/plugin install browsebrowsebrowse@scruffymongrelRestart Claude Code. The plugin does not put bbb on PATH — it ships the skill, not the binary — so install the CLI separately with npm i -g browsebrowsebrowse. The skill says so too.
Keeping the plugin and CLI in sync
browsebrowsebrowse installs as two separate artifacts from one repo at one version: this plugin, which ships the skill only (from the scruffymongrel marketplace, pinned to the plugin branch — see AGENTS.md for the channel-split invariant), and the npm package, which ships the bbb/browsebrowsebrowse binaries. They install and upgrade independently, so they can drift.
Upgrade both, as a pair:
- Plugin —
/plugin updatein Claude Code (opens the plugin manager; pickbrowsebrowsebrowse@scruffymongrelfrom the Installed tab), orclaude plugin update browsebrowsebrowse@scruffymongrelfrom the shell. Run/reload-plugins(or restart) to pick it up in the current session. - CLI —
npm i -g browsebrowsebrowse@latest/bun add -g browsebrowsebrowse@latest/deno install -g -A npm:browsebrowsebrowse@latest, reinstalling over the existing global link.
Which one is stale? bbb --version (or bbb doctor's JSON version field) reports the installed CLI's version directly; compare it against the plugin's version, visible from /plugin's Installed tab. The same behavioral check catches it too: if this skill describes a flag or verb bbb --help doesn't list, the CLI is behind — upgrade it from npm. If bbb --help/bbb doctor shows something this doc never mentions, the plugin is behind — update it through /plugin.
One direction only. The release workflow advances the plugin branch — the plugin channel — only after npm publish succeeds (see "Releasing" below and AGENTS.md). So npm is never behind the plugin; only the reverse can happen, and only because a user hasn't updated the plugin on their machine yet.
Quick fix: bunx browsebrowsebrowse, npx --yes browsebrowsebrowse, and deno run -A npm:browsebrowsebrowse always fetch latest by default, sidestepping CLI staleness entirely — reach for one of these when you're not sure which side has drifted.
Other agents (Cursor, Aider, Codex CLI, Copilot, …)
The skill follows the Agent Skills open standard — SKILL.md with YAML frontmatter. After installing, it lives at $(npm root -g)/browsebrowsebrowse/skills/browsebrowsebrowse/:
cp -r "$(npm root -g)/browsebrowsebrowse/skills/browsebrowsebrowse" <your-agent>/skills/For agents without skill support, paste this into your system prompt:
For screenshots, PDFs, layout/computed styles, clicking and typing, or streaming pages, use
bbb(browsebrowsebrowse):bbb shot <url> out.png,bbb pdf,bbb html,bbb text, andecho '<js>' | bbb eval <url> --json. Add--jsonand parse one line of{ok, result, logs, status}; exit codes are 0 ok / 1 eval / 2 timeout / 3 setup / 4 HTTP. A non-2xx page still returnsok: trueand exit 0 — readstatus(the final HTTP status,nullfor non-HTTP sources) or pass--fail. On a streaming page (SSE, htmx, long-poll) always pass--wait <selector>: without it the eval runs before the stream opens and returns an empty answer that looks successful. For DOM queries and extraction that need no rendering, usedomdomdominstead — it is much cheaper.
Development
bun install
bun run quality # tsc --noEmit + the coverage-gated unit tests
bun run test:integration # the browser-touching suite (needs an engine)
bun run build # compile dist/ (also runs via prepack)
bun run smoke:node # run the CLI from the checkout under Node
bun run smoke:pack # pack, install the tarball, run it under Node, Bun AND Denosmoke:pack is the one that matters for packaging: it packs the tarball, installs it into a scratch project, and runs the installed binary under all three runtimes — plus both bin aliases through the node_modules/.bin shebang path, with only Node on PATH. Testing the checkout alone is what let a broken Node install ship three times in the sibling project. Node and Deno skip with a notice when they aren't installed locally, and hard-fail instead when CI is set so a missing runtime can't pass silently.
Coverage is enforced at 100% lines and functions, but deliberately only over src/pure/ — the argument parser, the engine resolver, URL normalisation, version comparison and output shaping. The browser-touching code is covered by test/integration/, which drives a real Chrome and is outside the gate. Chasing 100% across a process-spawning, CDP-speaking codebase would mean testing mocks of Chrome instead of Chrome, and a green number built out of mocks is worse than an honest gap.
To run the integration suite without downloading anything, point it at a Chrome you already have:
CHROME_PATH=/path/to/chrome-headless-shell bun run test:integrationReleasing
Fully automated; no manual steps and no npm token.
bun run release patch|minor|majorscripts/release.mjs wraps that dispatch, and the wrapper is the point rather than the convenience: CI releases whatever is on origin/main, so it refuses unless local main and origin/main are identical — behind means publishing a commit you never ran, ahead means publishing without your unpushed work, and both used to be silent. On success it pulls the release commit back, which the bare gh workflow run release.yml -f bump=… never did; local main then sat a commit behind until someone noticed. The raw dispatch is still there as an escape hatch (see AGENTS.md), but it leaves the git pull --ff-only origin main to you.
CI runs the quality gate, the Node smoke test, the packed-tarball smoke test and the integration suite, then bumps the version, commits, tags, pushes and publishes to npm via Trusted Publishing (OIDC) with provenance. It refuses to run anywhere but main. release.yml and test.yml gate on the same checks on purpose — they drifted once and a release broke on a step PR CI had never run.
The same run builds the plugin branch, which is the Claude Code plugin channel. It isn't a copy of main: scripts/build-plugin-channel.mjs commits a tree of exactly .claude-plugin/, skills/, README.md and LICENSE, with no package.json and no lockfile — Claude Code installs dependencies into any plugin root that has both, and a skills-only plugin has no hooks or MCP servers that could ever load them.
Don't bump version in package.json by hand — CI owns it, and a manual bump double-bumps. Don't rename .github/workflows/release.yml either; npm's trusted publisher is keyed to the repo and the workflow filename. See AGENTS.md for the full set of release invariants.
License
MIT.
