Browser API
Getting tabs
Section titled “Getting tabs”tab = await browser.get("*example.com*") # URL globtab = await browser.get("9222:a1b2c3") # target IDtab = await browser.get("*app*", fresh=True) # only newly-appearing tabstab = await browser.get("*app*", timeout=10) # wait up to 10stab = await browser.get("*app*", ready="#root") # wait for element after attachtab = await browser.open("https://...") # open new tabtab = await browser.acquire("*pattern*") # get(), falling back to open() on a missawait browser.watch("*pattern*") # auto-attach current + future
browser.tabs # list[Tab] attachedawait browser.pages() # all Chrome targetsbrowser.patterns # active watch patterns (property)await browser.detach("*pattern*") # detach by patternawait browser.detach() # detach everythingbrowser.clear(target=) # clear captured data
await browser.connect(9223) # add another Chrome instanceawait browser.connect(profile="/path/to/profile") # port from DevToolsActivePortawait browser.disconnect() # unpin tabs, close all WebSocketsawait browser.disconnect(port=9222) # unpin + close one Chrome instancebrowser.acquire(pattern, *, open=None, ready=None, timeout=None) replaces the try-get/except-open dance a gist returning to one recurring site would otherwise hand-roll. open is the URL to navigate to on a miss — defaults to pattern itself when the pattern is already a real URL.
ready= parameter
Section titled “ready= parameter”Stores a selector or a JS expression on the Tab. Used by get(), open(), navigate(), reload(), and session recovery after HMR. It’s the one parameter that accepts either, so the shape decides:
- Selector → resolved and polled every 100ms. Anything starting with
.,#,[,data-,text=,role=orlabel=, anything containing:has-text(, and any bare name (main,my-app) — the same setclick()andwait_for()accept. - JS expression →
Runtime.evaluate, must return truthy. Anything else, which in practice means it has a dot or a call in it (window.ready,app.isLoaded()). - Default (no
ready=): waits fordocument.readyState === 'complete'.
The bare-name case is the one worth knowing: ready="main" and ready="my-app" are ordinary CSS, and are treated as such.
tab.ready_confirmed (read-only bool) says which case the last wait actually was: True when an explicit ready= was polled and satisfied, False on the readyState default — a best-effort guess that can return before a JS-heavy app has rendered anything.
Async methods
Section titled “Async methods”await tab.js(expr, *, await_promise=True, user_gesture=True) → AnyEvaluate JavaScript with REPL semantics. Top-level await works. Promise results are awaited by default. let/const can be redeclared across calls. Raises BrowserJSError on exceptions.
await tab.click(selector, *, button='left', click_count=1) → ReceiptMouse click: arrives (mouseMoved) then Input.dispatchMouseEvent (mousePressed + mouseReleased). The arrival is more than hover styling — Chromium’s native click synthesis can silently fail without it on some pages (elements involved in native drag detection are the confirmed case), the same reason Playwright’s own mouse implementation always moves before pressing. Produces isTrusted=true events. Auto-waits up to 2s for the element, then for it to be visible, enabled and stable. Resolution is strict: a selector matching more than one element (with no single visible winner) raises with a candidate digest instead of guessing. The returned Receipt names what the click actually hit — clicked: <button id="save">Save</button> — #save (412,133). When an unrelated element intercepts the point, a plain left-click switches to element.click() on the resolved target (a coordinate dispatch there can start a drag on a pan layer; the DOM click reaches the target the way assistive tech does — isTrusted=false, and the receipt says which path was taken). Modified clicks (right/double) keep the coordinate dispatch plus a warning.
type_text
Section titled “type_text”await tab.type_text(selector, text, *, delay_ms=0, press_enter=False) → ReceiptFocus element, select-all, type character-by-character, then verify the value landed. If the keystrokes changed nothing — the signature of a framework-controlled input reverting them — it falls back to the native prototype value setter plus bubbled input/change events and re-verifies. The Receipt says which path the text took. Auto-waits + strict resolution like click.
select_option
Section titled “select_option”await tab.select_option(selector, option) → ReceiptSelect an option in a dropdown. Native <select>: finds the option by label (else value) and sets it via the prototype setter + input/change events. Custom widgets (react-select-style): clicks the field, waits for a role=option matching the name (exact, then substring), clicks it. When the click focuses an element declaring aria-autocomplete, the option text is typed in first and resolution runs against the filtered listbox — which is what reaches an option a virtualized list hasn’t rendered. A miss lists the visible options’ accessible names.
await tab.hover(selector) → ReceiptMove the mouse over an element — or an (x, y) / 'x,y' point — and leave it parked there. :hover styling and mouseenter-revealed UI (menus, toolbars, tooltips) stay up for a following click, and an MCP observation’s changes: section reports what the hover revealed. The selector form resolves strictly + waits visible/stable like click. Coordinates are the route to visual-only targets whose accessible proxy sits elsewhere (SVG diagram nodes): hover has no element.click()-style fallback, so hovering the proxy parks on the proxy.
await tab.drag(source, to, *, steps=12, duration_ms=400, dwell_ms=150) → ReceiptMouse drag in one call: press on source, paced mouseMoved events with the button held (buttons=1), dwell at the drop point, release on to. Endpoints are selectors (strictly resolved, scrolled into view) or (x, y) tuples / 'x,y' strings. The first moves are 2 px micro-steps, so a small origin (a 10 px SVG port) arms its drag-slop threshold before the pointer leaves it — a plain distance/steps first jump exits the origin and the whole gesture reads as a stray click. dwell_ms holds the pointer at the drop point (repeated same-position moves, not a bare sleep) before releasing: a drop handler that debounces its own hit-detection on move events needs to see the pointer as hovered before a release there counts as a drop — pass 0 to skip the hold for gestures where it’s pure overhead (sliders, whole-node moves). A drop target that only appears once the drag starts is handled: if to doesn’t resolve up front, the drag begins and the selector is re-resolved mid-gesture. Covers pointer/mouse-event drag (SVG editors, sliders, drag libraries); native HTML5 draggable=true DnD rides a separate browser pipeline these events don’t start — an observation saying changes: none after dragging one is the tell. The Receipt names both endpoints and warns if the drop point was occluded.
tap / swipe / scroll
Section titled “tap / swipe / scroll”await tab.tap(selector_or_x, y=None) → Receiptawait tab.swipe(x1, y1, x2, y2, *, steps=10, duration_ms=300) → Noneawait tab.scroll(selector, dy=0, dx=0, *, steps=10, duration_ms=300) → NoneTouch events for mobile Chrome via ADB. scroll() is sugar over swipe() —
resolves selector to its center and swipes the opposite direction
(scrollBy semantics: positive dy scrolls down, positive dx scrolls right).
key / keys
Section titled “key / keys”await tab.key(key) → Noneawait tab.keys(keys, *, delay_ms=0) → Nonekey() dispatches one press: named keys ("Enter", "Backspace", "ArrowLeft", "F5", "Space"), printable characters ("a"), and modifier combos ("Ctrl+A", "Shift+Tab"; a trailing + is the plus key). Events carry the Windows virtual key code, so Chromium’s editing actions actually run — Backspace deletes, arrows move the caret — and Enter/Space carry their produced character so native button/form activation fires.
keys() presses a whole sequence in one call — one round trip for a keyboard flow instead of one per keypress — with delay_ms between presses for apps that debounce.
await tab.fetch(url, *, method='GET', body=None, headers=None) → dictIn-page fetch() — inherits cookies, session, CORS origin. Returns {"status": int, "ok": bool, "body": Any}. Body is auto-parsed as JSON whenever it’s valid JSON, regardless of content-type — plenty of real APIs mislabel JSON as text/plain or text/html. A non-JSON text body comes back as-is.
navigate / reload
Section titled “navigate / reload”await tab.navigate(url) → Noneawait tab.reload() → NoneBoth wait for the ready signal after page load.
await tab.close() → NoneCloses this tab (Target.closeTarget). Session cleanup follows from the resulting Target.targetDestroyed event, same as a user closing it.
await tab.tree(mode="aria", *, at=None, in_crop=None) → list[str]Accessibility snapshot as text lines. Crosses iframes. The default aria mode is Playwright’s LLM-oriented snapshot with [ref=eN] handles — each ref is usable as an aria-ref=eN selector in click/type_text until the next snapshot, navigation, or reattach. aria can’t see into a cross-origin iframe that shares the page’s process, because same-origin policy blocks its engine there. mode="ax" returns the raw CDP accessibility tree, which recurses into same-process iframes at any depth, cross-origin included, but has no refs.
at=(x, y) / 'x,y' hit-tests that point instead (ignores mode) and returns a small elided tree rooted at the nearest meaningful ancestor of the hit, rendered through the same engine as mode="aria" so its nodes carry real [ref=eN] handles too — for “what’s actually here” from a coordinate rather than a selector. Always also captures a screenshot cropped to the hit (its path is in the result’s screenshot line) — the tree’s text isn’t always enough to disambiguate visual-only state or near-identical rows. A hit landing on an iframe reports the redirect (target id + translated local coordinate) instead of a tree.
in_crop=<path> reinterprets at= as a pixel within an earlier seed’s screenshot instead of a page coordinate — the crop embeds its own translation, so acting on a point you see in that image needs no scale/offset math: pass it straight through and this seeds the page at the point it actually corresponds to.
screenshot
Section titled “screenshot”await tab.screenshot(*, full_page=False, force=False, path=None) → dictAlways writes a native-resolution PNG — to path if given, otherwise a 0600 file under $XDG_RUNTIME_DIR/repld/. Returns {path, width, height, bytes}. Raises if the capture exceeds the vision API’s token budget instead of silently shrinking it — pass force=True to send it natively anyway, or crop to a smaller region instead (browser_tree(at=...) does this). The budget check uses the PNG’s real pixel dimensions, not the CSS-pixel viewport size — they diverge by the device pixel ratio.
set_viewport
Section titled “set_viewport”await tab.set_viewport(width, height) → NoneEmulate a fixed viewport at deviceScaleFactor: 1 (Emulation.setDeviceMetricsOverride), so screenshot coordinates are page pixels with no multiplier math. browser_open’s viewport="1440x900" parameter calls this on open. Use a fresh tab per distinct size — re-overriding an already-overridden tab can leave clientWidth and innerWidth disagreeing.
wait_for / wait_for_idle
Section titled “wait_for / wait_for_idle”await tab.wait_for(selector, *, timeout=5.0) → Noneawait tab.wait_for_idle(*, timeout=5.0, quiet=0.5) → int # settle mspin / unpin / gates
Section titled “pin / unpin / gates”await tab.pin(reason='', guard_unload=True) → None # guard_unload=False for live-reload dev serversawait tab.unpin() → Noneawait tab.confirm(prompt) → boolawait tab.choose(prompt, options) → strawait tab.ask(prompt) → strset_files / expect_file_chooser / expect_auth / grant_permissions
Section titled “set_files / expect_file_chooser / expect_auth / grant_permissions”await tab.set_files(paths) → None # resolve an already-open chooserawait tab.expect_file_chooser(paths) → None # pre-arm before the action that opens oneawait tab.expect_auth(username, password) → None # pre-arm HTTP Basic/Digest authawait tab.grant_permissions(permissions, origin=None) → None # camera/mic/geolocation/etcA native <input type=file> click, an HTTP auth challenge, and a camera/mic/geolocation permission prompt are all OS-level UI that escapes CDP entirely by default — the click on a file input silently opens a real picker with no channel push, unhandled auth is cancelled, and permission prompts have nothing to click. These four resolve or pre-arm them through CDP instead: set_files answers a chooser already open (empty paths cancels it, same as declining); expect_file_chooser/expect_auth arm the answer before the triggering click. Downloads land in a fixed per-project directory rather than opening a native Save-As dialog.
await tab.cdp(method, **params) → dictRaw CDP passthrough.
cookies
Section titled “cookies”await tab.cookies() → list[dict]All cookies for this tab via Network.getCookies.
http_client
Section titled “http_client”await tab.http_client(*, base_url=None, **kwargs) → httpx.AsyncClientCopies this tab’s current cookies into a plain httpx.AsyncClient. Requires the http extra — repld browser loads it; a permanent install is uv tool install repld-tool[browser,http] (both extras: a tool install names the whole set, so [http] alone drops browser). Raises RuntimeError with that install hint if missing. base_url defaults to the tab’s own origin; extra kwargs pass through to httpx.AsyncClient. Use this once a site’s data lives behind cookie-authenticated JSON — the tab establishes the session, then every read after that is a plain concurrent-safe HTTP call, with none of tab.fetch()’s per-call CDP round trip or shared-tab-state races.
controls / invoke
Section titled “controls / invoke”await tab.controls() → dict | Noneawait tab.invoke(control, action, args=None) → dictcontrols() calls the page’s window.controls.describeAll(), returning the schema for every registered control — or None if the page exposes no window.controls. invoke() runs one action and returns {returned, stateBefore, stateAfter, duration}. See the controls guide for the protocol a page implements.
Sync query methods (DuckDB-backed)
Section titled “Sync query methods (DuckDB-backed)”All four take since=, and on all four it is epoch seconds — pass time.time(). The three underlying CDP clocks (wall-time seconds, Runtime.Timestamp milliseconds, Network.MonotonicTime from an arbitrary origin) are converted for you.
network
Section titled “network”tab.network(url=, method=, status=, type=, since=, include_assets=False) → RowsQuery captured requests. url uses LIKE matching (* → %). Assets excluded by default. Max 500 rows, newest-first.
console
Section titled “console”tab.console(level=, source=, since=) → RowsQuery console messages. Max 200 rows.
tab.sse(url=, event_name=, since=) → RowsQuery SSE (EventSource) messages. Each row: request_id, event_name, event_id, data, timestamp.
lifecycle
Section titled “lifecycle”tab.lifecycle(name=, since=) → RowsQuery Page.lifecycleEvent entries: DOMContentLoaded, load, networkIdle, etc.
request / body
Section titled “request / body”tab.request(request_id) → dict # full HAR entry (headers, timing, postData)tab.body(request_id) → dict # response body {"body": str, "base64Encoded": bool}row.body() → dict # shortcut on any network Rowtab.clear() → NoneMulti-browser
Section titled “Multi-browser”browser.connect(port) adds a Chrome instance to the pool — call it multiple times for multi-browser setups. Target IDs include the port prefix (42829:abc123 vs 43213:def456), so tab-scoped tools route to the right Chrome automatically.
await browser.connect(42829)await browser.connect(43213)await browser.watch("*localhost:5200*") # watches across bothbrowser.tabs # tabs from all instancesConnected ports and watch patterns persist across kernel restarts. On boot, repld prompts on the terminal ([Y/n], default yes) before reconnecting and re-watching — headless boot (--no-display) or non-tty stdin skips the restore entirely.
The dashboard’s Connections tab gives you the same connect/watch/disconnect controls from a browser instead of exec.
Console error push
Section titled “Console error push”Console errors and uncaught exceptions from watched tabs push as [console:error] channel messages the moment they happen — no polling:
[console:error] 9222:af5ae1: TypeError: Cannot read property 'x' of nullCross-tab duplicates within 2 seconds are collapsed into one follow-up message (... (×14 tabs)). Mute noisy patterns:
browser.suppress("[vite] failed to connect") # mute matching errorsbrowser.unsuppress("[vite] failed to connect") # un-mutebrowser.suppressed # list active patternsSuppress patterns persist across kernel restarts.
Body capture exemptions
Section titled “Body capture exemptions”get()/open() tabs capture response bodies through Fetch interception, which replays each captured response — and Chrome applies CORB/ORB more strictly to a replayed response than a natively-fetched one. A site that sends no CORS headers at all (an old embedded-device admin UI is the usual case) can see its own same-origin scripts blocked.
A second symptom class has no network-level signal at all: a page that just never renders, or throws a JS error with nothing in the console pointing at the network. The pause every intercepted request takes — round-tripping through the kernel’s event loop before Chrome may proceed — is itself enough latency to break a timing-sensitive page, even for a request whose body is never captured. Seen live: ExtJS 6’s eval-based class loader threw and the page stayed blank, on a target with zero CORS/CORB signal. If a page misbehaves only under repld with no network-level error, try no_capture before assuming it’s something else.
Exempt affected hosts from interception entirely:
browser.no_capture("*192.168.1.1*") # skip Fetch body capture for matching tab URLsbrowser.capture_ok("*192.168.1.1*") # remove the exemptionbrowser.no_capture_patterns # list active patternsPatterns persist across kernel restarts, like suppress. An exemption applies on the next attach — a tab that already has Fetch enabled keeps capturing until re-attached; await tab.disable_capture() is the retroactive per-tab knob. tab.network() / tab.body() still work on an exempted tab through the on-demand path — only the proactive capture is skipped.
Properties
Section titled “Properties”| Property | Type | Description |
|---|---|---|
tab.url | str | Current URL (cached — use tab.js("location.href") for live) |
tab.title | str | Page title (cached) |
tab.type | str | "page", "iframe", "service_worker", etc. |
tab.target_id | str | Short ID in {port}:{6-hex} format |
tab.capture_bodies | bool | Toggle Fetch body capture (True on get/open, False on watch) |
tab.ready_confirmed | bool | True if the last ready-wait satisfied an explicit ready=, not the readyState fallback |
tab.label | str | Human-readable identifier |
Selectors
Section titled “Selectors”| Pattern | Type |
|---|---|
.class, #id, [attr] | CSS |
[data-testid='name'] | CSS |
text=Submit | Exact text match |
role=button[name="Save"] | ARIA role + accessible name |
label=Username | Input by label |
placeholder=Search | Input by placeholder |
testid=x | data-testid shorthand |
tag:has-text('OK') | CSS + text filter |
aria-ref=e12 | Ref from tab.tree() snapshot |
getByRole('button', { name: 'OK' }) | Playwright locator call — strict-error suggestions paste as-is (also getByTestId/getByText/getByLabel/getByPlaceholder/locator) |
Every form resolves through a vendored build of Playwright’s InjectedScript engine, evaluated once per document in an isolated world, and pierces open shadow roots. role= computes real implicit ARIA roles and accessible names (the W3C accname algorithm), so labels, alt text and aria-labelledby all resolve; hidden elements are excluded from role= matches.
Resolution is strict: zero matches auto-waits then errors; one match is used, visible or not (a lone off-screen control behind a styled proxy is still the target); multiple matches are filtered by visibility, and unless exactly one visible element remains the call raises with a candidate digest — a preview and generated selector for each — so a wrong-element click is impossible rather than diagnosable. Input methods then wait for the element to be visible, enabled and stable before dispatching.
aria-ref= refs come from the last tab.tree() / browser_tree snapshot and die on the next snapshot, navigation, or reattach; a dead ref errors immediately with a fresh-snapshot hint rather than polling.