Let an AI/code agent drive a real (or isolated) Chrome from the terminal over CDP — open pages, click, type, fill forms, scrape, screenshot — and author userscripts the browser runs on matching sites.
One installable package, two CLIs that work together:
browserwright-daemon(Layer 1) — resolves a Chrome CDP WebSocket URL and proxies it. Backends:env / cdp / extension. Alsolaunch-chrometo spawn an isolated Chrome.browserwright(Layer 2) — the agent-facing surface: sessions, heredoc scripting with pre-imported primitives, reusable site tasks, memory, and userscript management.
.
├── src/browserwright/ the package
│ ├── … Layer 2 — sessions / primitives / site skills / memory / userscripts
│ └── daemon/ Layer 1 — CDP URL resolver + proxy (env/cdp/extension backends)
├── chrome-extension/ unpacked relay extension for the `extension` backend
├── skill/ Agent skill bundle (symlinked into Claude Code, Codex, and Pi)
├── tests/{skill,daemon}/ test suites
└── docs/ deeper docs (architecture.md, daemon.md, session-workspaces.md, …)
Contributing or hacking on this repo? Start at AGENTS.md — it points to ONBOARD.md (clone → install → test loop), docs/architecture.md (architecture orientation), TESTING.md (test-suite map), and RELEASING.md (releases + updating the global install). This README covers the end-user path only.
- macOS or Linux
- Python 3.11 (
brew install python@3.11/pyenv install 3.11) - Chrome / Chromium (any flavor)
~/.local/binon$PATHuv— manages the venv, lockfile, and Python toolchain (uvcan fetch Python 3.11 itself)
The distribution target is one PyPI package named browserwright. A global tool
install should expose both CLIs:
pipx install browserwright
# or
uv tool install browserwrightAfter that, install the one global daemon service:
browserwright-daemon install
browserwright-daemon status
browserwright-daemon doctorOn macOS, browserwright-daemon install registers the daemon as a LaunchAgent
at ~/Library/LaunchAgents/com.browserwright-daemon.plist, starts it on login,
and keeps the single daemon socket at:
${XDG_RUNTIME_DIR:-/tmp}/browserwright-daemon.sockLinux currently does not auto-install a service; run browserwright-daemon serve
from your own systemd-user unit or process supervisor.
The browser extension is intentionally not part of the PyPI packaging contract: it ships through the Chrome Web Store and installs from there like any other extension (auto-updating, no Developer mode):
https://chromewebstore.google.com/detail/browserwright-daemon-rela/okgnalaalckoaeledbjhpjiccmcdceeb
Developers working on the extension itself can load the repo's
chrome-extension/ directory unpacked (chrome://extensions → Developer mode
→ Load unpacked); the GitHub Release extension artifact and mise run upgrade-global are the fallback distribution path for machines that want a
specific pinned version without the store.
The agent skill bundle remains a thin shell around the installed CLI. For PyPI installs, the intended stable contract is:
browserwright --print-skillAgents should read the runtime guide generated by the installed package version, so the skill instructions stay version-locked to the CLI that actually runs.
The global install is considered healthy when these pass:
browserwright version check
browserwright-daemon version check
browserwright-daemon doctorIf the daemon was already running when you upgraded the global tool, restart it:
browserwright-daemon restartIf it is running in the foreground instead of as the macOS LaunchAgent:
browserwright-daemon stop
browserwright-daemon serveCutting a release, updating a machine's global install (mise run upgrade-global), the extension reload flow, and the dev-link workflow are all
documented in RELEASING.md and ONBOARD.md.
# Start an isolated Chrome (own profile dir, won't touch your daily Chrome)
browserwright-daemon launch-chrome --port 9333 --profile bs-smoke --persistent --json
# Drive it
BD_PORT=9333 BD_BACKEND=cdp browserwright <<'PY'
page.goto("https://example.com", wait_until="load")
print(f"URL: {page.url}")
print(f"Title: {page.title()}")
PY
# expected:
# URL: https://example.com/
# Title: Example DomainClean up: kill <pid> (the launch-chrome --json output includes the pid).
A session is the isolation key that lets multiple agents drive browsers without colliding. Create one, then every later call carries its id (via --session or BD_SESSION):
sid=$(browserwright session new --backend=extension)
BD_SESSION=$sid browserwright <<'PY'
page.goto("https://news.ycombinator.com", wait_until="load")
print(page.title())
PY
browserwright whoami --session=$sid
browserwright session end --session=$sidA bare heredoc with no session/BD_PORT context exits 2 with guidance — the daemon is never silently shared.
One global daemon serves every session (fixed socket browserwright-daemon.sock; no per-instance name). The session's backend is fixed at session new and never changes. On extension the session's "browser" is a Chrome tab group (named after the session) inside the user's real Chrome; session end closes the whole group. On cdp the daemon launches and owns a dedicated, isolated Chrome (profile bs-s<id>) that dies with the session. Isolation caveat: cdp sessions get isolated profiles (separate cookies/storage), but extension tab groups isolate only the tab set — all extension sessions share the user's one profile, so they share cookies/login/origin storage with each other and with the user.
# (a) Inline heredoc — one-off scripts; drive the injected Playwright `page`
BD_SESSION=$sid browserwright <<'PY'
page.goto("https://news.ycombinator.com", wait_until="load")
print(page.title())
print(snapshot()) # [ref=eN] aria tree → page.locator("aria-ref=eN")
PY
# (b) Solidified task — reusable, pre-saved flow under ~/.browserwright/site-skills/<host>/tasks/
browserwright list-tasks
browserwright list-tasks --query="search the web"
browserwright task wikipedia.org/lookup --title="Wikipedia"| Scenario | Backend | How |
|---|---|---|
| Your daily Chrome (logged-in / personal) (default for "use my browser") | extension |
browserwright session new --backend=extension … — load chrome-extension/ once, connect via the daemon's relay; zero popups |
| Scripts / iterative work in throwaway profiles | cdp + isolated Chrome |
browserwright-daemon launch-chrome --port 9333 --profile bs-dev + BD_PORT=9333 BD_BACKEND=cdp |
| Fingerprint browser (AdsPower / MultiLogin / 比特浏览器) | cdp |
point BD_PORT at the tool's exposed port |
| Externally-owned CDP endpoint (anti-detect / cloud browser) | cdp attach |
browserwright session new --backend=cdp --attach=ws://… --name=… (attach-owned — never closed on session end). Repeat for as many profiles as you need; one daemon serves them all |
Interactive wizard: browserwright install — walks the decision tree and writes your pick.
Scaling env to N profiles: one daemon has one shared upstream, so drive N
external profiles with N isolated daemons — each with its own XDG_RUNTIME_DIR
(distinct socket), --facade-port, and BD_CDP_WS, one env session apiece.
See docs/session-workspaces.md §"Env Backend".
Reaching the facade from another machine (Tailscale/LAN): the Playwright
facade binds 127.0.0.1 by default and is never exposed off-box unless you
opt in. Pass --facade-host <tailnet-ip> (or BD_FACADE_HOST / facade_host
in config.toml; 0.0.0.0 to bind all interfaces) and a remote client can
connect_over_cdp("http://<tailnet-ip>:19990/cdp") — the facade's
/json/version bootstrap rewrites the advertised webSocketDebuggerUrl from
the request's Host header, so the ws URL points back at the address the
client actually used. No auth is added, so only bind an interface you trust
(a Tailscale IP is private to your tailnet).
Remote clients own one tab group each (extension backend, ADR-0010): a
connection without ?session= is auto-scoped to its own private tab group —
<label>-BWauto-<hex>, where ?label= sets the prefix (default anon). It
can only see and operate the tabs in that group; the group is created on first
use, closed when the connection drops (ws heartbeat + 15-min orphan reaper),
and never reused across connections. Add ?label= so the group title tells
you who owns it: connect_over_cdp("ws://<tailnet-ip>:19990/cdp?label=hermes").
Author Tampermonkey-style scripts the extension backend injects on matching sites:
browserwright userscript push ./greet.user.js --verify
browserwright userscript list
browserwright userscript toggle <id>
browserwright userscript logs <id>
browserwright userscript remove <id>Browser driving is real synchronous Playwright. Every heredoc gets the following names injected, already connected through the daemon's Playwright CDP facade:
page— a PlaywrightPagebound to the session's current tab, reused across heredocs. Navigate it in place (page.goto,page.locator,page.fill,page.click, …); neverpage.close().context— the PlaywrightBrowserContext.context.new_page()opens a real second tab (in the session's tab group on extension) — thengoto()it; manage several tabs with thetabs()/switch_tab()primitives.snapshot()— a first-party AI aria snapshot; each node carries a[ref=eN]you act on viapage.locator("aria-ref=eN"). Prefer this over screenshots; re-snapshot()after each action (observe → act → observe).
Tabs are workstreams, not steps: keep a tab for a page you will return to
(its state — scroll, form input, JS state — survives), navigate in place when
you are passing through, and never close the browser/context (those are the
user's real tabs). switch_tab("url-substring") moves the session's current
tab (the one page is bound to) between open tabs.
Non-browser helpers (also pre-imported):
- HTTP (no browser, for static pages):
http_get(url) - Tabs:
tabs,switch_tab(session tab management, see above) - Memory:
remember,remember_global,remember_preference,memory_read - Site-skills / tasks:
list_site_skills,load_site_skill,run_task,bootstrap_site
Full catalogue and guidance in skill/SKILL.md.
browserwright-daemon doctor # which backends are live, why each is/isn't usable
browserwright-daemon status --json # daemon liveness + endpoint + facade port
browserwright doctor # skill-side healthThe skill/ directory is an agent skill bundle. Release install symlinks it into Claude Code, Codex, and Pi skill directories, and each symlink points at the active immutable release copy. The bundle is a stable shell; it tells the agent to run browserwright --print-skill for the version-locked runtime guide. Prompts like "open example.com and screenshot it", "scrape the HN front page", or "write me a userscript that …" trigger it automatically.
rm ~/.local/bin/browserwright ~/.local/bin/browserwright-daemon
rm ~/.claude/skills/browserwright
rm ~/.codex/skills/browserwright
rm ~/.pi/agent/skills/browserwright
rm -rf .venv
rm -rf ~/.cache/browserwright-daemon ~/.browserwrightAGENTS.md— entry point for contributors and code agentsdocs/architecture.md— architecture tour + isolated local devdocs/session-workspaces.md— the session workspace / isolation modeldocs/daemon.md— backend internals, env vars,config.tomldocs/skill.md— the Layer 2 agent-facing surfaceTESTING.md— map of the test suites and how to run themdocs/archive/browser-connection.md— why this stack exists (CDP discovery paths, Chrome 144+ popup mechanics)
GNU AGPL-3.0 — copyleft: modifications must stay open source, including when offered as a network service. If you build on this code, your derivative must be AGPL too. See LICENSE for the full terms.