| name | bunwv |
| description | Headless browser testing via persistent Bun.WebView session. Use when testing frontend UI, verifying page content, filling forms, clicking buttons, taking screenshots, or interacting with local or staging web applications. Triggers on phrases like "test the page", "check the UI", "browse to", "fill the form", "click the button", "take a screenshot". |
bunwv
Headless browser automation using a persistent WebView session. The daemon keeps a single WebView instance alive so page state (DOM, modals, forms, SPA routes, scroll position) persists across commands.
Agent-first usage patterns
bunwv is designed for AI agents driving it via discrete tool calls. A few contracts to rely on:
- Successful verbs print nothing on stdout and exit 0.
click, type, navigate, press, scroll, scroll-to, clear, submit, resize, back/forward/reload, close, exists, wait-for, wait-for-gone, cdp-subscribe, cdp-unsubscribe all follow this. Read verbs (status, evaluate, events, console, cdp, cdp-subscriptions, screenshot, sessions) print their result.
- Errors are JSON on stderr with a stable exit code. Branch on exit code, not stderr text:
0 ok, 1 generic, 2 usage, 3 timeout, 4 element-not-found, 5 daemon-unreachable, 6 batch-partial (only in batch --keep-going).
console.error/console.warn auto-surface during verbs. If the page logs an error while a verb runs, bunwv prints {"console":[…]} to stderr. You see the failure without a second call.
- Cursor-pull for events.
events --since <seq> returns entries newer than the cursor plus a new cursor. Keep the cursor across turns; refetch after actions. If the buffer evicted older entries, the response includes "truncated":true,"oldest":<seq>.
- File paths for binary output.
bunwv screenshot writes bytes to /tmp/bunwv-screenshot-<session>.jpg (JPEG @ q80) by default and prints the path on stdout. Use the Read tool on that path to see the image.
--json for uniform envelopes. Any command with --json returns {ok, data?, error?, exitCode} as a single JSON line. Use it when you prefer one shape over terse output.
- Flexible flag syntax.
--flag value, --flag=value, and repeated flags (e.g. --mod Shift --mod Control) all work. Flags may appear before or after the command: bunwv --json status and bunwv status --json are equivalent.
BUNWV_SESSION env var — set it once and --session becomes optional.
Commands
Run all commands with bunwv <command> (installed globally via bun install -g @naticha/bunwv).
bunwv start [--width N] [--height N] [--data-store PATH] [--idle-timeout ms]
[--backend webkit|chrome] [--chrome-path PATH] [--chrome-argv '[json]']
[--chrome-url <ws-url>] [--url <initial-url>]
bunwv navigate <url>
bunwv click --selector <css> | --text <text> | --at <x,y>
[--text-match exact|contains|regex] # default: contains (trimmed)
[--button left|right|middle] [--count 1|2|3]
[--mod Shift] [--mod Control] [--mod Alt] [--mod Meta]
[--timeout ms]
bunwv exists <selector> # silent; exit 0 if present, 4 if not
bunwv type <text>
bunwv press <key> [--mod Shift] [--mod Control] ...
bunwv clear <selector>
bunwv submit [--form <selector>] [--button <text>]
bunwv scroll <dx> <dy>
bunwv scroll-to <selector> [--block start|center|end|nearest] [--timeout ms]
bunwv screenshot [--format png|jpeg|webp|avif|heic] [--quality 0-100]
[--max-width N] [--max-height N]
[--placeholder | --metadata]
[--encoding blob|buffer|base64|shmem] [--out <path>|-]
bunwv image <input> [--out <path>|-] [--format ...] [--quality N]
[--resize WxH | --max-width N | --max-height N]
[--rotate 90|180|270] [--flip] [--flop] [--metadata] [--placeholder]
bunwv evaluate <expression>
bunwv console [--clear] [--since <seq>] # terse "<seq> [<level>] <message>", cursor-based
bunwv events [--since <seq>]
bunwv cdp <method> [--params '{}']
bunwv cdp-subscribe <CDP.event> [<CDP.event> ...]
bunwv cdp-unsubscribe <CDP.event> [<CDP.event> ...]
bunwv cdp-subscriptions
bunwv wait-for <selector> | --url <substring> | --title <substring>
[--timeout ms]
bunwv wait-for-gone <selector> | --url <substring> | --title <substring>
[--timeout ms]
bunwv batch [--file <path>] [--keep-going] # stdin NDJSON of JSON arrays
bunwv status [--json]
bunwv resize <width> <height>
bunwv back / forward / reload
bunwv sessions
bunwv close [--all]
bunwv help
All commands accept --json (opt-in envelope), --session <name> (or BUNWV_SESSION env var), and the flexible flag syntax described above.
Default viewport is 1920x1080 for readable screenshots.
Session Management
Sessions are named and isolated. Each session runs its own daemon on a separate Unix socket. The default session is named default; override with --session <name> or the BUNWV_SESSION env var.
bunwv start # starts "default" session
bunwv start --session cmais # separate "cmais" session
BUNWV_SESSION=cmais bunwv navigate http://localhost:3000
bunwv sessions # list all running sessions
bunwv close --session cmais # stop a specific session
bunwv close --all # stop every running session
Auto-shutdown: Daemons exit after 30 minutes of inactivity. Override with --idle-timeout:
bunwv start --idle-timeout 3600000 # 1 hour
bunwv start --idle-timeout 0 # never auto-shutdown
Reuse detection: bunwv start on an existing session prints the current URL and exits 0.
Best practice: Run bunwv sessions at the start of a conversation to check for orphaned daemons. Close any you don't need with bunwv close --all.
Core Interaction Loop
Look, then act, then look again. A canonical single-turn loop:
- Start the daemon:
bunwv start (no-op if already running)
- Navigate:
bunwv navigate http://localhost:3000
- Screenshot:
bunwv screenshot — prints /tmp/bunwv-screenshot-<session>.png to stdout
- Read the screenshot with the Read tool
- Act:
bunwv click --selector "button.submit" (or --text)
bunwv wait-for --url "/next" (or wait-for "<selector>") before the next screenshot
- Screenshot again to verify
bunwv close when the task is done
For multi-step flows, prefer bunwv batch (see below) — it runs the whole sequence in one process and returns an NDJSON transcript you can inspect.
Clicking Elements
click is polymorphic — use exactly one of --selector, --text, or --at:
bunwv click --selector "button.submit"
bunwv click --text "Sign In" # default: trimmed contains match
bunwv click --text "Sign In" --text-match exact
bunwv click --text "^Sign.+In$" --text-match regex
bunwv click --at 100,200
Modifiers, button, and click count are orthogonal:
bunwv click --selector "#ctx" --button right # context menu
bunwv click --selector ".item" --count 2 # double-click
bunwv click --selector "a" --mod Shift # shift+click
bunwv click --selector "a" --mod Meta --mod Shift # cmd+shift+click
bunwv click --selector "button" --timeout 60000 # longer actionability wait
--text defaults to trimmed substring match (case-sensitive). Use --text-match exact for strict equality or --text-match regex for a regex pattern. --selector and --text both produce native isTrusted: true events with the actionability wait; --at skips the wait.
Clearing and Editing Input Fields
Do NOT use Cmd+A / Backspace to clear React inputs — it doesn't update React state. Use clear:
bunwv clear "input[name='email']"
bunwv click --selector "input[name='email']"
bunwv type "new-value@example.com"
Always clear then click then type when editing existing input values.
Waiting for Elements, URLs, or Titles
Use wait-for after actions that trigger page changes:
bunwv click --text "Save Changes"
bunwv wait-for-gone "[role='dialog']" # wait for modal to close
bunwv screenshot
bunwv click --text "Edit"
bunwv wait-for "[role='dialog']" # wait for modal to appear
bunwv wait-for --url "/dashboard" # wait until URL contains substring
bunwv wait-for --title "Home" # wait until <title> contains substring
--url polls location.href and --title polls document.title. Exactly one of <selector>, --url, --title is required. Default timeout 10s.
Checking Existence
Use exists as a cheap probe (silent; exit 0 present, 4 missing):
bunwv exists "[data-loaded]"
if [ $? -eq 0 ]; then ... fi
Prefer exists over evaluate "!!document.querySelector(...)" — fewer tokens, clearer contract.
Extracting Page Data
evaluate prints the result as a JSON literal — strings keep their quotes, numbers don't, objects arrive as structured JSON:
bunwv evaluate "document.title" # "Example"
bunwv evaluate "document.querySelectorAll('.error').length" # 3
bunwv evaluate "[...document.querySelectorAll('h2')].map(h => h.textContent)"
Statements (const, let, if, etc.) are auto-wrapped in an IIFE.
Submitting Forms
Use submit instead of clicking the submit button — it uses form.requestSubmit(), which React forms accept (JS .click() produces isTrusted:false which many React handlers ignore):
bunwv submit # first form on page
bunwv submit --button "Save Changes" # submit via a specific button
bunwv submit --form "form.edit-quote" # target a specific form
After submitting, wait for the resulting DOM change:
bunwv submit --button "Save Changes"
bunwv wait-for-gone "[role='dialog']"
bunwv screenshot
Filling Forms
Click the input first, then type. Use Tab to move between fields:
bunwv click --selector "input[name='email']"
bunwv type "user@example.com"
bunwv press Tab
bunwv type "password123"
bunwv submit --button "Sign In"
Credentials go in .env (Bun auto-loads it). The shell expands $VAR in CLI args:
bunwv type "$TEST_EMAIL"
Persistent Auth
Use --data-store to preserve cookies and localStorage across daemon restarts:
bunwv start --data-store ./bunwv-session
Log in once; future sessions stay authenticated.
Debugging with Console Capture
Page console output is captured automatically. console.error/console.warn entries that fire during a verb are printed to stderr alongside the verb's response. To pull the full buffer:
bunwv console # terse: "<seq> [<level>] <message>", one per line
bunwv console --clear # print then clear
bunwv console --since 42 # only entries with seq > 42 (matches events cursor model)
bunwv --json console # {messages:[…], cursor, truncated?, oldest?}
Terse output escapes \n and \r in the message so each entry stays on one line. Empty buffer prints nothing (exit 0). Advance --since by using the max seq you saw (first field of each line). Use --json when you need raw message text (e.g. multi-line stack traces) or the truncation signal.
Navigation and CDP Events
Navigation events and subscribed CDP events land in a ring buffer. Pull them with a cursor:
bunwv events # full buffer, prints {events, cursor}
bunwv events --since 42 # only events with seq > 42
Subscribe to CDP events (Chrome backend only; enable the domain first). Multiple types per call:
bunwv cdp Network.enable
bunwv cdp-subscribe Network.responseReceived Network.requestWillBeSent
bunwv navigate https://example.com
bunwv events --since 0 # inspect events
bunwv cdp-unsubscribe Network.responseReceived Network.requestWillBeSent
bunwv cdp-subscriptions # list active subscriptions, one per line
If the buffer evicted older entries, events returns "truncated":true,"oldest":<seq>.
Screenshot Options
Defaults write a JPEG (quality 80) to a session-scoped file and print its path. JPEG is the default because it's typically 5–15× smaller than a same-viewport PNG, which makes the screenshot loop dramatically cheaper.
bunwv screenshot # /tmp/bunwv-screenshot-<session>.jpg (JPEG @ q80)
bunwv screenshot --format png # opt back into PNG for pixel-exact comparisons
bunwv screenshot --format webp --quality 70 # smaller again at the cost of decode speed
bunwv screenshot --max-width 1024 # cap longest side; aspect preserved; never upscales
bunwv screenshot --max-width 800 --max-height 600 # bound both axes
bunwv screenshot --out shot.jpg # write to a specific path
bunwv screenshot --out - # bytes to stdout
bunwv screenshot --encoding base64 # base64 string to stdout
Cheap previews when you don't need full pixels:
bunwv screenshot --metadata # {"width":1920,"height":1080,"format":"png"} — size-check before Reading
bunwv screenshot --placeholder # data:image/png;base64,... (tiny blur-up preview, no file)
--placeholder and --metadata print structured output on stdout instead of writing a file, and are mutually exclusive with each other. They cannot be combined with --encoding shmem.
Platform notes: --format avif and --format heic are encode-supported on macOS/Windows running on Apple Silicon only. On other platforms a structured error is returned. Use jpeg/webp/png for portable code.
--encoding shmem (Kitty terminal) prints {name, size} and leaves the POSIX shm segment for the caller to unlink. It bypasses the Bun.Image pipeline, so --max-width/--max-height/--placeholder/--metadata are rejected when combined with it.
Transforming arbitrary images: bunwv image
For local image files outside the screenshot loop (uploads, downloaded assets, fixtures), bunwv image runs Bun.Image directly in the CLI process — no daemon required, no session needed.
bunwv image input.png # input.jpg (jpeg @ q80, same dir)
bunwv image input.png --out small.webp --max-width 512 # webp, capped at 512 wide, aspect preserved
bunwv image input.png --resize 200x200 # explicit resize (may distort if aspect differs)
bunwv image input.png --rotate 90 # rotate 90/180/270
bunwv image input.png --flip # vertical flip; --flop is horizontal
bunwv image input.png --metadata # {"width","height","format"} JSON on stdout
bunwv image input.png --placeholder # data: URL on stdout
Output format is inferred from the --out extension when --format isn't given; otherwise defaults to JPEG. If --out is omitted, the result is written next to the input with the new extension.
Chrome Backend & CDP
macOS defaults to WebKit; Linux/Windows auto-use Chrome. Override anywhere:
bunwv start --backend chrome
bunwv start --chrome-path /path/to/chromium
bunwv start --chrome-argv '["--headless=new"]'
bunwv start --chrome-url ws://127.0.0.1:9222/devtools/browser/<id> # attach to an existing Chrome
Raw CDP calls (Chrome only):
bunwv cdp "Page.getLayoutMetrics"
bunwv cdp "Runtime.evaluate" --params '{"expression": "1+1"}'
bunwv cdp "Emulation.setDeviceMetricsOverride" --params '{"width":375,"height":812,"deviceScaleFactor":2,"mobile":true}'
CDP is unavailable with the WebKit backend.
Debugging a crashing backend
Route the backend process stdio to the daemon's stdio (human-debug only; agents never need these):
bunwv start --backend chrome --chrome-stderr inherit
bunwv start --webkit-stderr inherit
Batch mode
bunwv batch executes many commands in a single process — one socket round-trip per verb, no per-command Bun startup. Each stdin line is a JSON array of args; each response is an NDJSON envelope on stdout. Flags on batch (e.g. --session) inherit into every line unless that line specifies its own.
$ cat <<'EOF' | bunwv batch --session cmais --keep-going
["navigate","http://localhost:3000/login"]
["click","--selector","input[name='email']"]
["type","me@example.com"]
["press","Tab"]
["type","hunter2"]
["submit","--button","Sign In"]
["wait-for","--url","/dashboard"]
["screenshot"]
EOF
{"argv":[...],"ok":true,"exitCode":0}
{"argv":[...],"ok":true,"exitCode":0}
...
--keep-going runs the full list even if one line fails; the process exits 6 (batch-partial) if any failed, 0 if all succeeded, or the failing line's exit code when --keep-going is off. --file <path> reads from a file instead of stdin.
stdout fields contain the command's terse output (e.g. "\"Example Domain\"" for evaluate); stdoutBytes is base64 for binary outputs like screenshot --out -.
Error Recovery
If a command fails or times out:
- Screenshot to see the page state
bunwv console to see any captured errors
bunwv events --since 0 to see navigation/CDP events
bunwv evaluate to inspect the DOM
- If the daemon is unreachable (exit 5),
bunwv start — the data store preserves auth
Socket permissions
Each session's Unix socket (/tmp/bunwv-<session>.sock) and PID file are chmod 0600 — only the user who started the daemon can talk to it. On shared machines (containers, build boxes) this prevents other local users from driving your browser session.
Known Limitations
- macOS: WebKit default (no deps). Linux/Windows: Chrome auto-detected. Override with
--backend.
click --selector / --text auto-wait for actionability (visible, stable, unobscured); WebView default 30s, override with --timeout.
--text default is trimmed substring (contains). Use --text-match exact|regex to change.
--at skips the actionability wait — requires knowing exact coordinates. Use evaluate + getBoundingClientRect() when CSS/text don't work.
clear is required for React inputs — Cmd+A/Backspace don't update React's internal state.
- CDP is Chrome-only. WebKit rejects
bunwv cdp and bunwv cdp-subscribe.
- Events buffer: 1000 entries or 10 MB, whichever first. Older entries drop silently;
events --since reports truncated when you missed any.