Skip to content

Browser guide

repld’s browser integration attaches to your real Chrome via CDP. No headless automation profile — you log in normally, and the agent sees your traffic.

Chrome 140 or newer. Start it with remote debugging:

Terminal window
google-chrome --remote-debugging-port=9222

Run the kernel via the browser subcommand — it re-execs under uv run with the browser and http extras (duckdb, websockets, pillow, httpx) for this invocation, so browser tools work without adding anything to your project’s dependencies:

Terminal window
repld browser

For a permanent global install instead:

Terminal window
uv tool install repld-tool[browser,http]

Name both extras. http is only httpx, which tab.http_client() needs, but a uv tool install names the whole set of extras — running it later with [http] alone reinstalls without browser.

The three browser packages are required and all three are imported eagerly, so a two-of-three install doesn’t degrade to “everything but screenshots” — the whole extra reads as absent and no browser object appears in the kernel.

Chrome’s port defaults to 9222. If yours listens elsewhere, set REPLD_CHROME_PORT in the kernel’s environment — browser.connect() with no port, and every browser.get() / open() / watch() that connects lazily, read it — or pass the port to browser.connect() explicitly.

tab = await browser.get("*example.com*") # find by URL glob
tab = await browser.open("https://...") # open new tab
await browser.watch("*pattern*") # auto-attach matching tabs

get() returns a Tab object. The glob matches against the tab URL — * is a wildcard. If no tab matches, it raises TabNotFoundError (from repld.browser), a RuntimeError subclass — catch the specific one, so a CDP or ready-signal failure isn’t swallowed as “no such tab”.

browser.tabs # list of attached Tab objects
await browser.pages() # all Chrome targets (attached or not)
await browser.detach() # detach everything

Every mutation — click, type_text, navigate — settles before returning, then reports what changed:

  • Changes — an AX-tree diff of the mutation itself: what appeared, disappeared, or changed state (+ button 'port' ×8, ~ button 'Save' [disabled] → [none]). A presentational reveal the AX tree can’t see (SVG ports mounting on hover) falls back to a dom: +N −M elements line; changes: none means the page visibly ignored the action.
  • Accessibility tree — the page’s semantic structure
  • Network delta — requests fired since the last observation
  • Console delta — log messages and errors

This is what makes repld’s browser different from Playwright: the agent sees exactly what its action changed, in one round-trip.

The typical workflow: interact with a page, then inspect the traffic.

tab = await browser.get("*dashboard.example.com*")
await tab.click("text=Export")
# what did that click do?
reqs = tab.network(url="*api*")
# → [<Request POST /api/exports → 201 (340ms, 1.2KB)>]
# inspect the request
entry = tab.request(reqs[0].request_id)
# → {request: {headers: {...}, postData: "..."}, response: {...}}
# get the response body
body = tab.body(reqs[0].request_id)

tab.fetch() runs a fetch() inside the browser — inheriting cookies, session, CORS origin:

data = await tab.fetch("/api/accounts")
# → {"status": 200, "ok": True, "body": [...]}
await tab.fetch("/api/orders", method="POST", body={"status": "open"})

This is the bridge between browser-as-explorer and browser-as-API-client.

All interaction methods (click, type_text, select_option, tap, wait_for) share the same selector syntax:

PatternTypeNotes
.class, #id, [attr]CSS
[data-testid='name']CSSRecommended for own code
text=SubmitTextExact text match
role=button[name="Save"]ARIAReal accessible-name computation (accname)
label=UsernameLabelInput by associated label
aria-ref=e12Snapshot refFrom tab.tree() — valid until the next snapshot or navigation

Selectors resolve through Playwright’s injected engine (vendored, evaluated once per document in an isolated world) and pierce open shadow roots. Resolution is strict: a selector matching several elements with no single visible winner fails with a candidate list instead of silently picking one — and every click/type_text returns a receipt naming what it actually hit, so a misdirected action is visible in the same call rather than after a screenshot.

Guard a tab from accidental navigation:

await tab.pin("admin session — don't close")

This injects a floating pill UI with a beforeunload guard.

Driving a live-reload dev server (Vite, Astro, etc.) instead of a hosted app? Pin with the guard off:

await tab.pin("dev server — repld integration", guard_unload=False)

The default guard’s beforeunload handler fires on any unload, same-origin included, so it blocks the framework’s own HMR full-page reload behind a native confirm dialog that no CDP driver can dismiss — every call against that tab then times out, indistinguishable from a genuinely hung page. This isn’t a rare edge case: it’s the default outcome of pinning a tab you’re actively iterating against.

Same-origin navigation self-heals — a reload re-injects the pill automatically — but a cross-origin navigation drops the pin entirely (pushed to the channel as pin_lost); call pin() again after landing on the new origin.

Gates route human decisions through the pill:

ok = await tab.confirm("Delete all draft orders?")
choice = await tab.choose("Which environment?", ["staging", "production"])

A click on a real <input type=file> opens the OS’s own file picker, which sits outside CDP entirely — no channel push, just a page that silently stalls. tab.expect_file_chooser(paths) arms the answer before the triggering click; tab.set_files(paths) resolves one already open. tab.expect_auth(username, password) pre-arms an HTTP Basic/Digest prompt (unhandled otherwise, cancelled by default), and tab.grant_permissions([...]) pre-authorizes camera/mic/geolocation for an origin instead of leaving a permission dialog with nothing to click. Downloads land in a fixed per-project directory rather than a native Save-As dialog. See the browser reference for the full signatures.

Watched tabs push console errors and uncaught exceptions to the channel the instant they happen — no polling. Duplicate errors firing across tabs within 2 seconds collapse into one follow-up message. Mute a noisy pattern (a dev-server HMR warning, a third-party script) with browser.suppress("substring"); browser.unsuppress(...) un-mutes, browser.suppressed lists active patterns.

CDP’s Emulation.setDeviceMetricsOverride works for one-shot mobile screenshots, but reapplying a different override on the same tab can leave document.documentElement.clientWidth and window.innerWidth disagreeing — a state real browsers never produce. Prefer a fresh tab per distinct viewport size, and verify clientWidth === innerWidth before trusting the capture.

For definitive results, connect to a real device over ADB instead of emulating:

Terminal window
adb forward tcp:9333 localabstract:chrome_devtools_remote
mobile = await browser.connect(9333)
tab = mobile.tabs[0]

This sidesteps emulation entirely — touch events, viewport metrics, and screenshots all reflect the actual hardware.