Bowser
A Bun-native, drop-in command-compatible alternative to Microsoft playwright-cli for AI agents. Same commands, same flag syntax, same snapshot YAML — replace playwright with bowser and existing playwright-cli skills work unchanged.
Built on Bun.WebView (new in Bun 1.3.12), it drives the native WebKit engine on macOS, so there is no browser to download: nothing to install beyond Bun itself. bowser runs on macOS only. If you need Chromium, or Linux or Windows, use playwright-cli.
Why
What sets it apart from playwright-cli:
- Bun-native. Runs on Bun, no Node or Playwright install.
- Fewer tokens than playwright-cli. In the agent benchmark (10 browser tasks, 3 runs each, Claude Sonnet 5.5, 2026-10-21), an agent used 413k tokens and $0.41 with bowser against 445k and $0.51 with playwright-cli, summed over the per-task medians. Both passed all 30 runs.
- Persistent sessions. Each named session keeps a long-lived browser process so multi-step flows survive between commands.
Install
bowser runs on macOS only (it needs WebKit, which Bun.WebView provides only there).
# From npm
npm install -g @drakulavich/bowser-cli
# ...or directly from source
git clone https://github.com/drakulavich/bowser.git
cd bowser
bun install
bun link # exposes `bowser` on $PATH
Bun ≥ 1.4.4 must be on your PATH: the npm package runs src/cli.ts with the bun it finds there.
npm does not enforce that, so on an older Bun a command that would start a session fails with the
error "bowser requires Bun >=1.4.4 (found )" (exit 1).
bowser is distributed only through npm and from source; releases no longer attach binaries. If you
used a release binary, run bowser close --all with it, delete it, then
npm i -g @drakulavich/bowser-cli.
On another platform, any command that would start a session fails with the error "bowser requires macOS (WebKit)" (exit 1).
Upgrading
Open sessions survive an upgrade. The first command after it finds the daemon the old bowser
started, stops it, and opens the session again with its profile and its page. It then runs as
asked, and prints one line on stderr first:
"bowser: session '' restarted for bowser (was ): page reloaded at ; refs cleared,
run 'bowser snapshot'". A session opened without --persistent or --profile adds "cookies and
storage were lost (no --persistent)": those lived only in the old daemon. A ref from before the
upgrade fails with "is from a page that is no longer loaded", so take a new snapshot. The page is the
one the old daemon showed, and the session keeps the BOWSER_OP_TIMEOUT_MS it was opened with. A
session started by bowser 0.12 or older gets the default 30 s instead, whatever the first command after
the upgrade sets. The restart has its own budget, so that first command can take up to twice the budget,
plus a few seconds to stop the old daemon.
From 0.12.0 on, a running bowser mcp server hands its calls over to the installed version on the
first tool call after the upgrade, and the restart line comes first in that call's text. Its tool list stays the
one the client got when it connected, so a new command shows up once the client restarts the
server. A server started by 0.11 or older can't hand over: restart it once after the upgrade.
A daemon newer than the bowser you run (after a downgrade) is still refused: "session '' is running bowser (this is ); run 'bowser close -s ', then open it again" (exit 1).
Screenshots
Screenshots are written as PNG files. bowser screenshot --filename out.png writes
to out.png (relative paths resolve against your current directory); without
--filename it writes screenshot-<session>.png, auto-incrementing (-1, -2, …)
if that file already exists. The reply names the absolute path written and the PNG's size in pixels
(wrote /…/out.png (1600×1200), {"ok":true,"filename":"/…/out.png","width":1600,"height":1200});
snapshot --filename names the path too. The size follows the display's pixel ratio: an 800×600 viewport
is 1600×1200 on a Retina display and 800×600 on one at ratio 1. --scale=N fixes it at
innerWidth·N × innerHeight·N by scaling the capture down, or fails with exit code 1 when N is above
the display's ratio, since bowser cannot render more pixels than the display has. A capture is the
viewport, as in playwright-cli without --full-page: bowser has no full-page or
element-bounded screenshots (Bun.WebView captures only the viewport). Use resize to capture more.
WebKit cannot capture a viewport whose pixels fill 4 GiB. On a Retina display (pixel ratio 2)
that is reached only near resize's maximum: resize 16384 16384 cannot be captured,
resize 16384 16383 can. screenshot then fails with exit code 1 and names the size to resize to.
Before it captures, screenshot waits up to 5 s for the web fonts the page itself has asked for (not those
of its iframes), such as a font first used by text a script added after the page loaded. If they are still loading after 5 s it captures
anyway and adds ; fonts still loading to the reply ("fontsLoading":true under --json): the image may
show text in a fallback font or none at all, so retry, or check that the font URL answers.
Quickstart
bowser open https://example.com # navigate, save state
bowser snapshot # the page's aria tree, with [ref=eN]
bowser click e3 # click a ref
bowser fill e5 "[email protected]" # fill a form field
bowser press Enter # submit
bowser screenshot --filename=shot.png # capture
bowser close # end session
Each session runs one persistent browser process (spawned lazily on first command, addressed over a Unix socket). Commands attach, run, and detach — so typed text, modals, dynamic DOM, cookies, and auth all survive across invocations. Session state lives under ~/.bowser/sessions/<name>/. Several commands started at once on a new session still share one browser.
If the page's web process crashes, WebKit relaunches it and reloads the page once. That reload looks like the page reloading itself, so bowser does not report it: page state is gone, and old refs fail with not found in the current page snapshot. If the process dies again, WebKit does not reload the page, and every page command fails with the page crashed (its web process exited); run 'bowser reload' or 'bowser goto <url>' (exit 2). bowser does not reload it for you; reload, goto or open <url> recover it.
If a session's browser exits (it crashed, or was killed), every command but open and close fails with session '<name>' is not open (its browser exited); run 'bowser open' (exit 1), or gives the recorded bowser open --persistent / bowser open --profile=<dir> command when session state records a profile; paths are quoted when needed. State from an older bowser has no profile record, so bowser falls back to checking the default profile directory. This avoids quietly starting an empty browser. bowser open (with the same profile option again, when one was recorded) starts it anew; bowser close clears it. A session that never ran a browser still starts one on its first command.
Multiple sessions
bowser -s=login open https://app.example.com/login
bowser -s=login fill e1 "[email protected]"
op read op://vault/app/password | bowser -s=login fill e2 --stdin
bowser -s=login click e3
Commands on one session run one at a time: when several clients (parallel shell calls, two MCP servers) send commands to the same session, each waits its turn and runs whole.
Persistent profiles
A session's browser store is in memory by default: close (or a crash) loses its logins. A persistent profile is the way to keep cookies, and so logins, between sessions: bowser has no cookie commands, and state-save/state-load carry only localStorage. Open the session with --persistent to keep cookies, localStorage and IndexedDB on disk, like playwright-cli open --persistent:
bowser -s=app open https://app.example.com/login --persistent # profile in ~/.bowser/profiles/app/
bowser -s=app close # the profile stays
bowser -s=app open https://app.example.com --persistent # still logged in
bowser -s=work open https://app.example.com --profile=./profiles/work # a directory of your choice
- The profile lives outside
~/.bowser/sessions/<name>/, socloseleaves it alone. Delete it withrm -rf ~/.bowser/profiles/<name>(or your--profiledirectory). - The store is chosen when the session's browser starts.
open --persistenton a session that is already running with another store fails with exit 1; runbowser closefirst.openwithout the flag reuses a running persistent session. - One profile directory serves one running session at a time; sharing it between two is unsupported.
- A persistent store needs macOS 15.2 or later.
Dialogs
bowser answers every alert, confirm and prompt the moment it opens, so no command ever waits on a dialog. Without an answer set, the dialog is dismissed: confirm returns false and prompt returns null. To accept one, set the answer before the action that opens it:
bowser dialog-accept # the next dialog is accepted (confirm → true)
bowser click e7 # the click that opens it
bowser dialog-accept "Ann" # a prompt gets "Ann"; with no text, its own default value
bowser click e8
### Modal state
- ["prompt" dialog with message "Your name?"]: accepted
The command that caused the dialog reports it under ### Modal state. With --json it reports it as "dialogs": [...]. If the command fails, the report follows its error on stderr and the exit code is unchanged. dismissed (run dialog-accept before the action to accept it) means no answer was set. An answer covers one dialog and is dropped when the page navigates.
Differences from playwright-cli:
-
In
playwright-cli, the dialog stays open anddialog-acceptanswers it after the action. In bowser,dialog-acceptafter the action prepares the next dialog. It does not answer the one already reported. -
WebKit has no dialog events, so bowser replaces
window.alert/confirm/promptin the page, and in every same-origin iframe it can reach, before it acts there. A dialog in such an iframe is reported like the page's own and takes the prepared answer. A dialog that fires between commands (a timer) is reported by the next command that prints dialogs, even when that command leaves the page (reload,goto,open,go-back,press Enteron a form). -
A function the page defined itself (
window.confirm = m => …) is left alone and runs, as inplaywright-cli; its calls are not dialogs and are not reported. -
A page function made with
.bind()from the browser's own (window.confirm = confirm.bind(window)) looks native to bowser: the shim replaces it, and the dialog is reported. -
Not reported, and dismissed by WebKit:
- a dialog the page opens while loading, before bowser's first command on that document;
- a dialog in a cross-origin iframe, or in an iframe that loaded after bowser's last command;
- a dialog opened through a reference the page saved at load time (
const c = window.confirm), including a page wrapper around it (window.confirm = m => c(m)).
In each of these cases a prepared answer stays set until the next dialog bowser sees or a navigation.
-
A dialog whose handler then navigates the page (
if (confirm('Leave?')) location = …) is answered but not reported, because the report leaves with the old page. -
beforeunloadis not handled.
Snapshot output
snapshot prints the page's full accessibility tree in playwright-cli's format: headings, text, state such as [checked] or [active], /url and /placeholder props, and an eN ref on every visible element, not only the interactive ones. This is the todo fixture in tests/fixtures/:
### Page
- Page URL: http://localhost:52047/todo-app.html
- Page Title: Bowser Todo
### Snapshot
```yaml
- generic [active] [ref=e1]:
- heading "Todos" [level=1] [ref=e2]
- generic [ref=e3]:
- textbox "New todo" [ref=e4]:
- /placeholder: What needs doing?
- button "Add" [ref=e5] [cursor=pointer]
- list "Todo list" [ref=e6]:
- listitem [ref=e7]: No todos yet
- generic [ref=e8]:
- generic [ref=e9]: 0 items left
- button "Clear completed" [ref=e10] [cursor=pointer]
```
A ref stays the same across snapshots of one document while the element's role and name do not change; new elements get the next free number, so gaps are normal. After fill e4 "buy milk" and click e5, the list above becomes listitem [ref=e11] holding checkbox "Toggle buy milk" [ref=e12], and e5, e10 still name the same buttons. A navigation or reload starts again at e1.
Password field values are never shown: a filled <input type="password"> prints as textbox "Password" [ref=e3] with no value, state.json does not store it, and it adds nothing to another element's name. This is a deliberate difference from playwright-cli, which prints the value.
An action on a ref whose element a re-render removed fails at once with ref 'eN' not found in the current page snapshot. Try capturing new snapshot. and exit code 1, as in playwright-cli. A ref from another page (from before a navigation or reload, or from the page you left before go-back) fails the same way with ref 'eN' is from a page that is no longer loaded; take a new snapshot. Run snapshot again and use the new refs. A pushState or #hash change keeps the page, and its refs keep working.
An action on a ref whose element changed its role or name since the snapshot (a Buy A button that relabelled itself) also fails, exit code 1, before it touches the page: ref 'e2' now points to button "Delete account", not button "Buy A"; take a new snapshot. click and fill on a ref whose centre another element covers (a backdrop, or a + button the page put where Add was) fail the same way and name what covers it: ref 'e2' (button "Add to cart") is covered by generic <div> at its click point; take a new snapshot or close what covers it. A playwright-cli frame ref such as f1e3 fails with 'f1e3' is a playwright-cli frame ref; bowser does not snapshot iframe contents.
--depth=N prints N levels below the first line: a node at the limit drops its children but keeps its inline text value and its prop lines (/url, /placeholder). --depth=0 or no flag prints the whole tree. Iframe contents and shadow DOM are not walked: an iframe prints as a leaf with a ref.
JSON output for agent pipelines
--json snapshot prints {"snapshot": "<tree>"}: the tree text alone, without the ### Page wrapper. snapshot --filename=f writes the ### Page text to f, with or without --json, and answers wrote /…/f ({"ok":true,"filename":"/…/f"} under --json, with "dialogs" when a dialog was answered).
bowser --json snapshot | jq -r .snapshot | grep 'button'
Command reference
With --json, and so over MCP, click, fill, hover, select, check and uncheck name the element the ref pointed to, with the role and name its snapshot gave: click e724 answers {"ok":true,"ref":"e724","element":{"role":"button","name":"Add to cart"},"url":"…"}.
Global flags: -s=<name> / --session=<name>, --json, -h/--help, --version (prints the version and runs nothing). A session name uses letters, digits, ., _ and -, and does not start with . or -. It is at most 255 characters, or fewer under a very long HOME: <HOME>/.bowser/sessions/<name>/pid.<pid>.tmp must fit Bun's 1016-character path limit. A longer name is a usage error that gives the limit.
bowser <command> --help (or -h anywhere before --) prints that command's usage, arguments and
flags and runs nothing; bowser mcp --help does not start the server. After --, --help is text
like any other argument.
A command given more arguments than it takes fails before it runs, with
usage: too many arguments for '<cmd>': expected <n>, received <m> and exit code 1. Quote an
argument that has spaces: bowser eval "1 + 1", bowser fill e4 "hello world". Words after --
count too, so fill e1 -- a b is too many. bowser mcp with any extra word fails the same way and
does not start the server.
A command given fewer arguments than it requires fails the same way, with its usage line:
bowser select e3 fails with usage: bowser select <ref> <value> and exit code 1. An empty
argument is a value, as in playwright-cli: bowser select e3 "" selects the option whose value
is empty, and bowser localstorage-set k "" stores an empty string.
MCP bridge
bowser mcp runs a Model Context Protocol server over stdio, exposing every browser command as an MCP tool — so MCP clients (Claude Desktop, etc.) can drive the browser without shelling out. Each tool maps 1:1 to a CLI command and takes an optional session argument; outputs are the same JSON as --json mode.
Arguments are checked against the tool's input schema. A wrong type (session: 42, an object where a string belongs, persistent: "false") or a key the tool does not have is refused with an isError result starting usage:, and nothing runs. Where the schema says string, a finite number is also taken (resize {width: 800, height: 600}), and null is the same as leaving the argument out.
Register it in an MCP client config (e.g. claude_desktop_config.json):
{
"mcpServers": {
"bowser": { "command": "bowser", "args": ["mcp"] }
}
}
Notes:
- The server is a thin client of the same per-session daemons the CLI uses; the first tool call on a fresh session spawns one.
- Relative file paths (
screenshot,snapshotfilename,state-save,state-load) resolve against the server's working directory. If the client starts it from/or another directory it cannot write, the server uses<os.tmpdir()>/bowser-mcpinstead ($TMPDIR/bowser-mcp). Replies name the absolute path written. An absolute path is used as given. - The protocol is hand-rolled (newline-delimited JSON-RPC) with zero runtime dependencies. It speaks MCP
2025-11-25,2025-06-18and2024-11-05:initializeanswers with the client's version when it is one of these, and with2025-11-25otherwise. The stateless2026-07-28revision is not implemented; itsserver/discoverprobe gets-32601 Method not found, on which a client falls back toinitialize. initialize,ping,tools/listand notifications are answered at once, even while tool calls run.- A JSON-RPC batch (an array) or a line that is not a JSON object gets one
-32600 Invalid Requesterror withid: null; nothing in a batch runs. MCP2025-06-18has no batching. - A request without a string
methodgets-32600 Invalid Requestwith itsid, and atools/callof a tool the server does not have gets-32602 Unknown tool. A response from the client (a message withresultorerror) gets no answer. - Tool calls for different sessions run concurrently; calls for the same session run one at a time, in the order they arrived. Responses may therefore arrive out of order (JSON-RPC matches them by
id). - The server exits when stdin closes and every running call has answered. A client that stops reading stdout is gone: the server exits 0 at its next response, and calls still queued never run. A browser operation already running finishes in its daemon.
notifications/cancelleddrops the call: one still queued behind its session never runs, and a running one finishes but its result is discarded. Either way no response is sent for it, per the MCP spec. A browser operation that has already started is not undone — a cancelledclickmay still have clicked, and the session's next call waits for it to finish.
How it works
bowser openspawns a per-session daemon holding aBun.WebView, navigates, and saves{url, title}to~/.bowser/sessions/<name>/state.json.bowser snapshotruns a snapshot script in the page that walks the DOM into an aria tree (roles, names, text, refs) and keeps a reference to each ref's element in the page;src/snapshot.tsrenders that tree as YAML. Refs are persisted with the CSS path each element had (#idwhen safe, otherwise annth-of-typechain), e.g.e3→html > body > button:nth-of-type(2).bowser click e3resolves the ref from state, then in the page: the snapshot script kept a reference to each ref's element, so the command checks that element is still in the document and computes its CSS path afresh (a stale ref fails here, before anything is clicked). It then dispatches the click via the daemon, usingBun.WebView's built-in actionability auto-wait — no polling, no hard-coded timeouts.
bowser's scripts run in the page's own JavaScript world, and WebKit returns their results through the page's JSON.stringify. bowser sets aside the builtin patches that legacy libraries such as Prototype.js install, and it refuses a page that replaced JSON.stringify. It does not defend against a page written to deceive automation: such a page can forge any check made from inside it.
Environment variables
Tests
bun run check # typecheck, lint, then unit tests
bun run typecheck # tsc
bun run lint # Biome (import cycles, layer rules) and Knip (unused files, exports)
bun test # unit + command tests with a fake daemon
BOWSER_E2E=1 bun test # + end-to-end on WebKit
BOWSER_E2E=1 BOWSER_E2E_NET=1 bun test # + live-internet e2e (GitHub search)
End-to-end examples included:
tests/e2e.test.ts— open/snapshot/click on adata:URL (no network)tests/e2e-todo.test.ts— a local todo app served byBun.serve: add three todos, toggle one, clear completed. Proves the daemon keeps state across commands.tests/e2e-webkit.test.ts— every command driven on WebKit, with page state read back viaevalafter each one.tests/e2e-compat.test.ts— diffs bowser againstplaywright-clion the todo flow, asserting bowser's refs are a subset of playwright-cli's tree; skips without playwright-cli's WebKit installed.tests/e2e-search.test.ts— live web: search GitHub for OpenClaw, find the repo link, type into the search box and press Enter.
Roadmap
- Persistent session daemon over Unix socket
- playwright-cli command compatibility for the core agent loop
- Snapshot nesting honoring
--depth=N - Full aria tree in
playwright-cli's snapshot format - Storage commands (
localstorage-*,sessionstorage-*,state-save/load)-
localstorage-{list,get,set,delete,clear} -
sessionstorage-{list,get,set,delete,clear} -
state-save/state-load— Playwright-compatiblestorageStateJSON (per-origin localStorage; no cookies, useopen --persistent)
-
- Tab management (
tab-list/tab-new/tab-select/tab-close) — deferred:Bun.WebViewcan't reach popups yet (window.openreturnsnullon WebKit), so tabs would leave out their main use; see the refactor spec - Network mocking (
route,unroute) - Tracing / video / PDF output
-
markdown: the page, or one element, as Markdown -
eval,run-code -
resize -
dialog-accept/dismiss— the answer is set before the action; see Dialogs - MCP bridge subcommand for non-CLI clients (
bowser mcp) - Agent skill published to agentskills.io
Migrating from 0.1.0
The 0.2.0 release is a clean break: snap → snapshot, @e3 → e3, --session → -s=, session list → list. See CHANGELOG.md for the full migration table.
License
MIT