Tool reference: action tools, continued
Action tools, continued
Section titled “Action tools, continued”Storage-state: three layers
Section titled “Storage-state: three layers”Bulk state alone isn’t enough (the @playwright/mcp lesson): agents constantly need to read a single cookie (“am I logged in?”) or set one (“opt-out=1”) without round-tripping a full blob. Three layers ship together; no parallel implementations.
Capability split: reads (*_get, *_list, dump_storage_state,
auth_list) under read; writes (*_set, *_delete, *_clear,
inject_storage_state, auth_save, auth_load, auth_delete) under
action. No new capability gate to enable.
Sensitive data: cookie values may carry credentials. With the secrets
capability on, registered values are masked in tool results (cookies_get,
cookies_list, the dump_storage_state blob). A file written by
dump_storage_state({path}) or auth_save keeps the values as the browser
holds them. Treat dumps and saved named states as sensitive.
Layer 1: bulk
Section titled “Layer 1: bulk”dump_storage_state({ path?, session? })
Section titled “dump_storage_state({ path?, session? })”Wraps Playwright’s BrowserContext.storageState(), returning {cookies, origins:[{origin, localStorage}]}. Always returns the blob inline; with path, also writes the JSON to a workspace-rooted file (path-traversal rejected; must resolve under $BROWX_WORKSPACE). Read-only.
inject_storage_state({ state, mode?, session? })
Section titled “inject_storage_state({ state, mode?, session? })”Apply a bulk state to the current session’s context. state accepts an inline blob OR a workspace-rooted JSON path. Two modes:
replace(default): uses Playwright’ssetStorageState, which clears the context’s existing cookies / localStorage / IndexedDB before applying. Clean swap.merge: adds cookies viaaddCookieswithout clearing AND merges localStorage for the currently-loaded origin only (other origins in the blob are skipped and returned inoriginsSkipped, since localStorage is page-bound, not context-bound).
For per-session seeding at creation, prefer open_session({storageState | authState}), the Playwright-native primitive on incognito mode, which avoids a clear-then-apply cycle on a fresh context.
Layer 2: granular CRUD
Section titled “Layer 2: granular CRUD”Cookies (context-scoped, no navigation required):
cookies_get({ name, url?, session? })returns{cookie | null}cookies_list({ urls?, session? })returns{count, cookies}(Playwright’s URL-filter is honoured)cookies_set({ name, value, url?|domain+path, expires?, httpOnly?, secure?, sameSite?, session? }): Playwright’saddCookiesrequires eitherurl(recommended, since it derives domain/path/secure) OR bothdomainANDpath; one form must be supplied.cookies_delete({ name, url?|domain+path?, session? }): narrow by url (derives domain/path) or explicit values; idempotent.cookies_clear({ session? }): wipes ALL cookies in the context. localStorage/sessionStorage untouched.
localStorage / sessionStorage (origin-scoped, page-bound; see caveat below):
localstorage_get/sessionstorage_get({ key, session? })returns{value, origin}localstorage_list/sessionstorage_list({ session? })returns{count, entries:[{key,value}…], origin}localstorage_set/sessionstorage_set({ key, value, session? })localstorage_delete/sessionstorage_delete({ key, session? })localstorage_clear/sessionstorage_clear({ session? })
Origin caveat (loud).
localStorageandsessionStorageare origin-scoped and tied to the current page: the session MUST be navigated to the target origin before any of these tools work. Onabout:blankor a different origin the call rejects with an explicit “navigate first” hint. This is the same constraint Playwright’sstorageState()operates under (each origin’s localStorage is captured per-origin).sessionStorageis additionally NOT included indump_storage_state(Playwright’s bulk capture is intentionally cookies+localStorage only); to checkpoint sessionStorage, use the granular tools directly.
Example (canonical for the CRUD families; sessionstorage_*, caches_*, and idb_* follow the same get/list/set/delete/clear pattern on their own keys):
cookies_get({ name: "session_id", url: "https://app.example.com" })// → { "cookie": { "name": "session_id", "value": "…", "domain": ".example.com",// "path": "/", "httpOnly": true, "secure": true } } // or { "cookie": null }
cookies_set({ name: "opt-out", value: "1", url: "https://app.example.com" })
// localStorage is page-bound: navigate to the origin first.localstorage_set({ key: "feature-flag", value: "on" })localstorage_get({ key: "feature-flag" })// → { "value": "on", "origin": "https://app.example.com" }
idb_get({ dbName: "app-db", storeName: "drafts", key: "draft-7" })// → { "found": true, "value": { "title": "Q2 report", "updatedAt": "2026-06-11T…" } }Layer 3: named auth-states
Section titled “Layer 3: named auth-states”Wraps layer 1 with workspace-rooted JSON files at $BROWX_WORKSPACE/.auth-states/<name>.json. Names are restricted to letters / digits / ._- (no separators, no ..). No parallel implementation; these call into the bulk layer under the hood.
auth_save({ name, session? })returns captures the session’s current storage state into the named slot. Overwrites an existing slot of the same name.auth_load({ name, session? })returns loads the named slot AND applies it to the session (replace semantics, the same asinject_storage_state({mode:"replace"})). For SEEDING at creation, preferopen_session({authState:"<name>"}).auth_list()returns{count, slots:[{name, path, bytes, modifiedAt}…]}auth_delete({ name })returns{ok, existed}(idempotent).
Example (log in once, reuse everywhere).
// After driving the login flow once:auth_save({ name: "alice" })// Any later run starts already logged in:open_session({ session: "fresh", mode: "incognito", authState: "alice" })Cache API CRUD
Section titled “Cache API CRUD”Sibling of cookies / web-storage CRUD on the W3C Cache API (window.caches),
which Service Workers populate for offline-first apps. Origin-scoped and
page-bound (same posture as localStorage: navigate the session to the
target origin first; on about:blank or a different origin the call rejects
with a navigation hint). Reads under read; writes under action. No
synthetic IDs; each entry is keyed by its (cacheName, url) pair.
caches_list_storages({ session? })returns{count, names:[…], origin}(caches.keys()).caches_list({ cacheName, urlPattern?, session? })returns{count, entries:[{url, method}], origin, cacheName}.urlPatternis a case-sensitive substring filter on each entry’s request URL (no regex; post-filter the result for richer matching).caches_get({ cacheName, url, session? })returns{found:true, kind, contentType, status, headers, byteLength, cacheName, url, origin}. The response body rides the off-by-defaultnetwork-bodycapability, asnetwork_bodydoes. With it, text-like content-types (text/*,application/json|javascript|xml|x-www-form-urlencoded, anything withcharset=) addtext(kind:"text") and everything else addscontentBase64(kind:"binary"). Without it the body is left out and the result carriesbodyOmitted:true,requiredCapability:"network-body"and ahint.{found:false}when no entry matches the URL.caches_put({ cacheName, url, response:{ status?, headers?, body? | contentBase64? }, session? }): auto-opens (= creates) the named cache storage.response.bodyis a UTF-8 string; for binary content passresponse.contentBase64instead. The two are mutually exclusive. Defaultstatus200.caches_delete({ cacheName, url, session? })returns{ok, existed}(idempotent).caches_clear({ cacheName, session? })returns{ok, cleared:N}(cache storage itself remains).caches_delete_storage({ cacheName, session? })returns{ok, existed}; drops the whole storage.
IndexedDB CRUD
Section titled “IndexedDB CRUD”Sibling of cookies / web-storage / Cache API CRUD on the W3C IndexedDB API.
Origin-scoped and page-bound (same caveat as above). Reads under read;
writes under action. No synthetic IDs; each entry is keyed by its
(dbName, storeName, key) triple.
idb_list_databases({ session? })returns{count, databases:[{name, version}], origin, supported}. UsesindexedDB.databases()(Chromium-family);supported:falseon engines without it, though you can still driveidb_list_stores({dbName})if you know the database names.idb_list_stores({ dbName, session? })returns{count, stores:[…], dbName, version, origin}. Read-only: does NOT trigger an upgrade, so it only sees stores that already exist.idb_get({ dbName, storeName, key, session? })returns{found:true, value}or{found:false}. Keys: IDB accepts strings, numbers, dates, and arrays as keys; all four shapes round-trip through JSON cleanly (Dates as ISO strings). Values: IDB stores structured-clonable values (Blob/ArrayBuffer/Map/Set/Date), but this tool returns over MCP’s JSON-only transport, so non-JSON-serialisable values surface as a structured error and are never dropped silently; the platform value is preserved IN the store and only the over-the-wire return is bounded. For binary payloads, store them base64-encoded at the app level.idb_put({ dbName, storeName, key, value, session? }): the object store MUST already exist (store creation requires an IDB upgrade transaction, which is the app’s schema concern; this tool refuses with a clear hint instead of silently creating). If the store uses an in-line keyPath,keyis ignored (the keyPath read offvalueis authoritative); otherwisekeybecomes the out-of-line primary key.idb_delete({ dbName, storeName, key, session? }): idempotent (same shape whether or not a record was there).idb_clear({ dbName, storeName, session? }): clears every record from the store; the store itself remains.
open_session({... storageState?, authState? }) extension (additive)
Section titled “open_session({... storageState?, authState? }) extension (additive)”open_session now optionally seeds the new context with a storage state at creation. Mutually exclusive: pass one or the other:
storageState: inline blob (as returned bydump_storage_state) OR a workspace-rooted JSON path.authState: name of a slot fromauth_save.
Per-mode semantics:
- incognito: Playwright-native primitive (
browser.newContext({storageState})). Cheapest path; preferred for “open a fresh browser already logged in as X.” - persistent (managed): Playwright’s
launchPersistentContextdoesn’t acceptstorageStateat creation (the profile’s state lives on disk). The session post-seeds viasetStorageState, which clears the profile’s existing cookies / localStorage / IndexedDB first. Loud-warned. Use incognito instead if you don’t want to touch a persistent profile. - attached (BYOB): ignored with a warning. The consumer’s Chrome is not-owned; use
inject_storage_stateexplicitly if you really mean to overwrite the attached browser’s state.
Per-session artifacts: artifact_save / artifact_get / artifact_list
Section titled “Per-session artifacts: artifact_save / artifact_get / artifact_list”Session-scoped workspace KV. First-class save/get/list of string or binary
payloads: the “build your own library over time” loop, and a far better fit
for raw bytes than round-tripping blobs through ref-typed name_ref /
name_region. Three primitives, no new capability: artifact_save is
action (writes a file); artifact_get / artifact_list are read.
Artifacts live at $BROWX_WORKSPACE/.artifacts/<sessionId>/<name>. Names are
restricted to letters / digits / ._- only: no path separators, no .., no
leading dot. Workspace-escape is rejected.
Capacity caps (per session): 200 entries AND 50 MiB total. Past
either cap the oldest-write entry is evicted to make room, so a runaway
loop can’t exhaust the disk. Both caps are documented constants
(ARTIFACT_MAX_ENTRIES / ARTIFACT_MAX_BYTES in src/session/artifacts.ts).
Retention. Per-session. The on-disk subdir is wiped on close_session;
artifacts don’t survive teardown. Sessions that never wrote an artifact never
create the dir.
Encoding. Text by default. Pass encoding:"base64" to save or get binary
payloads. artifact_get returns the same encoding the caller asks for
(round-trip-faithful for both text and binary).
artifact_save({ name, content, encoding?, session? })
Section titled “artifact_save({ name, content, encoding?, session? })”name: string:/[A-Za-z0-9._-]+/only; no separators, no.., no leading dot.content: string: payload. Text by default; passencoding:"base64"for binary.encoding?: "utf8" | "base64": defaults to"utf8".- Returns
{ ok, name, size, mtime, path }. Overwrites an existing same-named artifact.
artifact_get({ name, encoding?, session? })
Section titled “artifact_get({ name, encoding?, session? })”name: string: as passed toartifact_save.encoding?: "utf8" | "base64": return shape; defaults to"utf8".- Returns
{ ok, name, content, size, mtime, encoding }. Throws if the name is unknown in this session.
artifact_list({ session? })
Section titled “artifact_list({ session? })”- Returns
{ ok, count, artifacts: [{ name, size, mtime }] }(sorted by name asc).
Example.
artifact_save({ name: "scrape-page1.json", content: "{\"rows\":[…]}" })// → { "ok": true, "name": "scrape-page1.json", "size": 1840, "mtime": 1765540264000, "path": "/…" }artifact_get({ name: "scrape-page1.json" })// → { "ok": true, "name": "scrape-page1.json", "content": "{\"rows\":[…]}", "encoding": "utf8", … }choose_option({ ref?|selector?|named?, option, exact?,...opts })
Section titled “choose_option({ ref?|selector?|named?, option, exact?,...opts })”Pick an option in a custom combobox / listbox / menu by visible text. Generic primitive for controls that aren’t native <select>: the kind that open a portal listbox on click and commit on option click. The target (ref/selector/named) is the trigger (the combobox itself); option is the visible text of the option to commit. Behaviour:
- If
aria-expanded !== "true"on the trigger, click the trigger to open the control. - Find a visible option element matching
option: triesgetByRole("option"), thengetByRole("menuitem"), thengetByText; first attempt with non-zero count wins. - Click the resolved option element.
- Return the probe on the trigger:
element.ownerControl.displayTextAftershows the committed selection.
exact defaults to true (option text must match exactly). Set false to allow substring. Does not simulate type-and-press-Enter, which is prone to picking the wrong option in dense lists.
Example.
choose_option({ ref: "e30", option: "Engineering" })// → ActionResult.element.ownerControl:// { "label": "Department", "displayTextBefore": "Select…",// "displayTextAfter": "Engineering", "changed": true }plan({ query, verb, verbArgs?, contextRef?, confidenceFloor?, ttlMs?, session? }) / execute({ descriptor,...opts })
Section titled “plan({ query, verb, verbArgs?, contextRef?, confidenceFloor?, ttlMs?, session? }) / execute({ descriptor,...opts })”Separate intent capture from dispatch. plan resolves a natural-language query against the live tree (same ranker as find()), picks the top candidate, validates the verb’s args, and returns a serialisable ActionDescriptor; no action runs. Hand it back verbatim to execute to dispatch; cache it for replay; or inspect evidence and refuse to dispatch when the stability is too low. This is browxai’s caching + self-healing substrate (the agent can re-execute a stored descriptor across runs, detect “ref-gone” / “expired” structurally, and re-plan only when needed).
execute runs the action for real. The recorded intent is the value here.
Verbs: click, fill, hover, press, select (single-target verbs only; navigate/scroll/wait_for/choose_option either don’t need a ranked candidate or expand into multiple action-window dispatches and stay as their own primitives).
ActionDescriptor shape (returned by plan):
id: opaque uuid for this descriptor (caches key on it).ref: the bound element ref. SameeNnamespace assnapshot/find/name_ref; there is no parallel id system. A named ref is an alias for aneN; a descriptor that targetse7and aname_ref({name:"play_btn",ref:"e7"})refer to the same element.verb: the action verb (one of the five above).args, verb-specific:valuefor fill,keyfor press,valuesfor select,button(optional) for click.evidence:{ query, selectorHint, selectorTier, stability, role, name?, testId?, score, actionable, warnings, alternatives[≤4] }, the audit trail.warningscarries any low-confidence / no-visible-candidate signal from the underlyingfind(); the caller can refuse to dispatch on that signal alone.expiresAt: epoch-ms past whichexecuterefuses to dispatch. Defaultnow + 60000(1 min);ttlMsoverrides, clamped to[1000, 1800000](1s..30min).
execute refusal modes (no action runs, descriptor is rejected up front):
reason: "expired": pastexpiresAt. Re-plan.reason: "ref-gone": the ref is no longer in the session’s registry (e.g. a navigation evicted it). Re-plan.reason: "invalid": descriptor shape is malformed (bad verb, missing fields, missing required arg).
On a successful dispatch, execute returns { ok: true, result: <ActionResult>, tokensEstimate }. The inner ActionResult is the same shape calling the verb’s tool directly would return.
Example.
plan({ query: "the Save button in the editor toolbar", verb: "click", ttlMs: 300000 })// → { "id": "f3a1…", "ref": "e42", "verb": "click", "args": {},// "evidence": { "selectorHint": "[data-testid=\"save-btn\"]", "stability": "high",// "actionable": true, "score": 17, "warnings": [], "alternatives": […] },// "expiresAt": 1765540564000 }
execute({ descriptor: { /* the object above, verbatim */ } })// → { "ok": true, "result": { /* the click's ActionResult */ }, "tokensEstimate": 212 }// or refusal: { "ok": false, "reason": "ref-gone" } → re-plan.Capability gating: plan is read (it only ranks candidates). execute is action AND the underlying verb’s capability is enforced: a descriptor with verb:"click" denied with the action capability disabled surfaces as click denied, not a generic “execute denied”. byob_action confirm-hooks apply the same way: a policy that blocks click also blocks execute of a click descriptor.
wait_for({ ref?|selector?|named?|coords? | text?, timeoutMs?,...opts })
Section titled “wait_for({ ref?|selector?|named?|coords? | text?, timeoutMs?,...opts })”Wait until an element is visible, or until visible text appears anywhere on the page: the SPA-readiness gate real apps need after a reload/nav. Pass exactly one of a target or text; neither is a clear error. Substring match: case-insensitive, whitespace-trimmed (Playwright getByText default; a short token inside a longer string matches), visible-only. No arbitrary-JS predicate mode by design: “poll an in-page condition until truthy” stays eval_js’s domain (gated behind the eval capability; browxai keeps a single arbitrary-JS loophole).
Example. wait_for({ text: "Dashboard", timeoutMs: 10000 }) after a login submit; ok:false here is a real signal (the page never reached the dashboard), not an error to retry blindly.
go_back({...opts }) / go_forward({...opts })
Section titled “go_back({...opts }) / go_forward({...opts })”History navigation.
tab_visibility({ state, holdMs?, session? })
Section titled “tab_visibility({ state, holdMs?, session? })”Background or foreground the session’s tab, the only way to reproduce the bug class that only fires when the tab is hidden: throttled setTimeout, paused requestAnimationFrame (framework enter/animation hooks never run), and an on-return visibilitychange/focus handler that replays stale state. browxai otherwise keeps the driven tab foreground, so agentic QA scores these flows PASS while they’re broken.
state: "background": overridesdocument.visibilityState/hiddenand dispatchesvisibilitychange(+blur), and best-effort takes front focus away from the page (a blank scratch page in the same context is brought to front) so real timer/rAF throttling applies. The synthetic flip is deterministic everywhere; real throttling is best-effort and may not occur under headless. The result’srealBackgroundingandnotesay which you got (named, never silently assumed).state: "background"withholdMsis the headline form: background, then hold hiddenholdMs, then auto-foreground, reproducing the background-to-foreground transition in one call. Returnsstate:"foreground"+heldMs.state: "foreground": restores visibility (+focus) and re-focuses the tab.- No agent JS (server-injected fixed script, same posture as the sampler / overlay-hide). Capability:
navigation.
Example. tab_visibility({ state: "background", holdMs: 5000 }) returns { ok, state: "foreground", heldMs: 5000, realBackgrounding, note }, reproducing the background-then-return transition in one call.
Device emulation: set_locale / set_timezone / set_geolocation / set_color_scheme / set_reduced_motion / set_user_agent / grant_permissions
Section titled “Device emulation: set_locale / set_timezone / set_geolocation / set_color_scheme / set_reduced_motion / set_user_agent / grant_permissions”Seven sibling primitives (deliberately not a bundled emulate({...})), each setting ONE Playwright/CDP knob on the live session. Capability: action. Per-session state persists across navigation and new tabs in the same context. See the Device / viewport table in § Sessions for the at-a-glance summary including the mid-session mechanism per tool and the reset sentinel.
Example (canonical for the family; each sibling sets its one knob the same way):
set_timezone({ timezoneId: "America/New_York" })// later: set_timezone({ timezoneId: null }) // clear the override
// Geolocation is permission-gated — pair the two:grant_permissions({ permissions: ["geolocation"], origin: "https://maps.example.com" })set_geolocation({ latitude: 40.7128, longitude: -74.006 })Every emulation-tool result returns:
{ "ok": true, "session": "default", "applied": { /* the field(s) just set */ }, "state": { "locale": "en-US" | null, "timezoneId": "America/New_York" | null, "geolocation": { "latitude": 40.7, "longitude": -74, "accuracy": 0 } | null, "colorScheme": "dark" | null, "reducedMotion": "reduce" | null, "userAgent": "Bot/1.0" | null, "permissions": { "": ["geolocation"], "https://example.com": ["clipboard-read"] } }, "warnings": [ /* e.g. BYOB CDP-persistence, geolocation-without-grant */ ], "tokensEstimate": 312}set_locale({ locale | null, session? })
Section titled “set_locale({ locale | null, session? })”Override navigator.language, Intl.* defaults, and the Accept-Language header. Pass locale: null (or omit) to clear. Runtime mutation goes through CDP Emulation.setLocaleOverride because Playwright’s BrowserContext.locale is creation-time-only; the CDP equivalent takes effect immediately on existing pages.
set_timezone({ timezoneId | null, session? })
Section titled “set_timezone({ timezoneId | null, session? })”Override the session’s IANA timezone (Date, Intl.DateTimeFormat). Pass timezoneId: null to clear. Runtime mutation via CDP Emulation.setTimezoneOverride for the same reason as set_locale.
set_geolocation({ latitude, longitude, accuracy?, session? })
Section titled “set_geolocation({ latitude, longitude, accuracy?, session? })”Override the HTML5 Geolocation reading. Mutates a live context via Playwright’s context.setGeolocation(). Pass latitude: null (or no coords) to clear. navigator.geolocation is gated on the geolocation permission; pair with grant_permissions({ permissions: ["geolocation"] }) for the relevant origin. When no geolocation grant is recorded for the session, the result includes a warning naming the missing grant.
set_color_scheme({ scheme, session? })
Section titled “set_color_scheme({ scheme, session? })”Override prefers-color-scheme for the session via Playwright’s page.emulateMedia. scheme: "light" | "dark" | "no-preference"; "no-preference" clears the override. CSS media queries re-evaluate immediately.
set_reduced_motion({ on, session? })
Section titled “set_reduced_motion({ on, session? })”Override prefers-reduced-motion. on: true → "reduce", on: false → "no-preference" (clears). Mutates a live page via page.emulateMedia. Useful when an animation-heavy page is unstable to drive, or to verify a reduced-motion code path.
set_user_agent({ userAgent | null, session? })
Section titled “set_user_agent({ userAgent | null, session? })”Override the User-Agent string (HTTP header and navigator.userAgent). Pass userAgent: null to clear. Runtime mutation via CDP Network.setUserAgentOverride (Playwright’s context.userAgent is creation-time-only). Updates both surfaces in one call.
grant_permissions({ permissions, origin?, session? })
Section titled “grant_permissions({ permissions, origin?, session? })”Grant browser permissions for the session. A grant of notifications, midi, midi-sysex, payment-handler, background-sync or a sensor (or a name outside the supported list) that the session’s permission policy holds on ask-human is refused with requiredCapability: "human-gate-override" unless the operator enabled it, because the browser answers those from its native grant state and the grant would skip the human prompt. Clearing grants and granting camera, microphone, geolocation or clipboard are not refused: the page-side wrappers on their main entry points (getUserMedia, getCurrentPosition / watchPosition, the navigator.clipboard methods) still ask the human. Legacy webkitGetUserMedia and the native method reached through its prototype are not wrapped, so a native grant of those names can still skip the prompt there. Chromium permission names: geolocation, notifications, clipboard-read, clipboard-write, camera, microphone, midi, background-sync, accelerometer, gyroscope, magnetometer, ambient-light-sensor, payment-handler, …. Mutates a live context via Playwright context.grantPermissions. Optionally scope to a specific origin; otherwise grants for the current page’s origin. Re-granting for the same origin REPLACES the prior set (Playwright semantics). Pass permissions: [] (or omit) to clear ALL grants. Playwright does not expose per-origin revocation, so clearing is context-wide; the result names this in note whenever origin was passed alongside an empty permissions.
Persistence & reset semantics
Section titled “Persistence & reset semantics”- New tabs in the same context inherit every override. The registry installs a
BrowserContext.on("page")listener that re-runs every set knob on the freshly-attached page (each new tab gets its own CDP session for the CDP-routed overrides). - Re-applying the same primitive with a different value REPLACES the prior value for that knob (mirrors Playwright/CDP semantics for all 7).
- Reset sentinels are per-tool, listed in the § Sessions table: typically
nullfor the optional fields,[]for permissions,"no-preference"for the twoemulateMediaknobs.
BYOB / attached-mode caveat
Section titled “BYOB / attached-mode caveat”When the session is mode:"attached", the locale / timezone / UA overrides go in via CDP to a Chrome browxai does NOT own. CDP doesn’t revoke these on detach: the human’s Chrome will keep them until it navigates or restarts. Every emulation tool’s warnings includes a one-line note to this effect for attached sessions. (Geolocation / colour scheme / reduced motion / permissions are mutated via Playwright on the attached context; the same caveat applies as a defensive default, even though those mechanisms are scoped slightly differently.)
scroll({ ref?|selector?|named?|coords?, to?, by?, intoView?,...opts })
Section titled “scroll({ ref?|selector?|named?|coords?, to?, by?, intoView?,...opts })”One general scroll primitive (capability: navigation):
- No target: scroll the window. Pass
to: "top"|"bottom"|"left"|"right"orby: { x?, y? }(CSS px;+y= down,+x= right). ref/selector/namedtarget, noto/by: scroll that element into view (scrollIntoViewIfNeeded), the lazy-load / virtualised-list case.- element target +
to/by: scroll within that container (e.g. anoverflow:autopanel).intoView:falseis implied; setintoView:trueto force into-view even withto/by. coordstarget: wheel-scroll at that point (mouse.wheel), for canvas / map / WebGL panning.
Returns an ActionResult. Scroll commonly triggers infinite-scroll XHRs and DOM growth, so network / structure / snapshotDelta on the result show what loaded. No-op calls (no target and no to/by) return a clear error and never do nothing silently.
Scroll geometry: the result’s element.scroll carries the post-scroll metrics of the relevant scroller: { x, y, scrollWidth, scrollHeight, clientWidth, clientHeight, atTop, atBottom }. Container-mode reports the scrolled element; window / into-view / wheel-at report the document scroller. Lets you assert “the older page prepended” (scrollHeight grew between two scrolls), “pinned to bottom” (atBottom), “reached the top loader” (atTop) without eval_js. set_viewport’s result carries it too (post-resize document geometry).
Example.
scroll({ to: "bottom" }) // window → triggers the infinite-scroll fetchscroll({ ref: "e60" }) // bring a lazy-loaded row into viewscroll({ ref: "e22", by: { y: 400 } }) // scroll *within* an overflow:auto panelscroll({ coords: { x: 512, y: 380 }, by: { y: 240 } }) // wheel at a point — canvas / map panningbatch({ calls, stopOnError? })
Section titled “batch({ calls, stopOnError? })”Run a sequence of tool calls server-side and return their results as one response. Eliminates round-trip overhead for known-safe sequences (fill several fields then submit; navigate, then wait_for, then snapshot). Each inner call dispatches through the same handlers as a top-level call: capability gating, confirmation hooks, and ActionResult shape are unchanged.
calls:Array<{ tool: string; args?: object; label?: string; expect?: object }>. 1 to 32 entries.stopOnError: defaultstrue. Whentrue, the first inner failure halts the batch. Whenfalse, every call is attempted and individual results carry their ownok/error.
Each call may optionally carry:
label: opaque free-form string echoed verbatim in the corresponding result entry. Useful in long batches ("set type","set initiative","save row").expect: post-call assertions on the inner ActionResult’s element probe. Failing any assertion marks the callok: falsewitherror: "expect failed: …"and respectsstopOnError. Predicates:valueEquals,displayTextIncludes,controlDisplayTextIncludes,containerTextIncludes,controlChanged. Minimal predicate set, not an assertion DSL.
Returns { completed, failedAt, results }:
completed: how many entries the loop produced (≤calls.length).failedAt: index of the first failed call, ornullif all succeeded.results:Array<{ tool, ok, result?, error? }>, one per executed call.resultcarries the parsed inner-response JSON.
Whitelist (allowed inner tools): navigate, click, fill, fill_form, press, hover, select, choose_option, scroll, wait_for, go_back, go_forward, snapshot, find, text_search, screenshot, console_read, network_read, eval_js, list_named_refs, name_ref, find_feedback, approve_actions, list_approvals, get_config, list_sessions. Excluded: batch (no nesting), await_human (would block the whole batch), recording-control tools.
Example.
batch({ calls: [ { tool: "navigate", args: { url: "https://app.example.com/login" } }, { tool: "wait_for", args: { text: "Sign in" }, label: "page ready" }, { tool: "fill", args: { selector: "input[name=email]", value: "ada@example.com" } }, { tool: "click", args: { selector: "button[type=submit]" }, label: "submit", expect: { containerTextIncludes: "Welcome" } }, ],})// → { "completed": 4, "failedAt": null,// "results": [{ "tool": "navigate", "ok": true, "result": {…} }, …] }flake_check({ calls, n, stopOnAllGreen? })
Section titled “flake_check({ calls, n, stopOnAllGreen? })”Run the same call sequence N times and report what shifted between runs, for diagnosing intermittent CI flakes before you start chasing them through logs. Same inner-call shape and whitelist as batch (the inner runner is batch’s dispatch loop); capability gating, confirm hooks, and ActionResults are unchanged. Each repetition runs with stopOnError: false internally so a mid-sequence failure does NOT hide the variance picture for later steps. The whole point of flake-check is knowing that step 4 sometimes fails AND that step 5 then also fails differently.
calls: same shape asbatch.calls(whitelist, optionallabel+expect). 1 to 32 entries.n: repetitions, bounded[3, 20]. Fewer than 3 can’t surface intermittent flakes; more than 20 burns server time without sharpening the picture.stopOnAllGreen: when set toK, short-circuit onceKconsecutive runs are all-green. Off by default.
Returns { runsCompleted, allGreen, shortCircuitedAfter?, steps, firstDivergence, cachedResolvers, runs }:
steps[]: per-step roll-up{ step, tool, label?, runs, ok, successRate, errors[], signatures[] }.errorsis the deduped distinct-error list (capped at 8; anything noisier is itself the finding).signaturesis the distinct-resolution-signature list: forplan/findsteps,<ref>::<selectorHint>; for boundclick/fill/...calls, the suppliedref/selector/named. One signature = the step landed identically across every run.firstDivergence: the earliest step (0-based) whereokdiffered across the runs that reached it, ornullwhen every run agreed per step (all-green and all-red both count as agreement; agreement IS the finding).cachedResolvers[]: the self-heal artifact. For each step where every reaching-this-step run agreed AND succeeded, a{ step, tool, label?, ref?, selectorHint?, descriptor?, agreedRuns }entry the caller can hand back as a hint on the next run.plansteps carry the fulldescriptorprojection (mirrors theActionDescriptorshape so a follow-upexecute()can consume it after re-snapshotting);findsteps carry the top-candidate ref +selectorHint; bound steps carry the input target. Steps with no extractable target (coords) yield no entry.runs[]: the per-runBatchReportechoes so the caller can drill into individual failures.
Capability action (the calls dispatch through the batch handler map; each inner tool’s own gateCheck still fires). Same whitelist as batch; nested flake_check and batch are rejected.
Example.
flake_check({ n: 5, calls: [ { tool: "navigate", args: { url: "https://app.example.com/records" } }, { tool: "find", args: { query: "the New Record button" }, label: "locate" }, { tool: "click", args: { selector: "[data-testid=\"new-record\"]" }, label: "open form" }, ],})// → { "runsCompleted": 5, "allGreen": false, "firstDivergence": 2,// "steps": […, { "step": 2, "tool": "click", "runs": 5, "ok": 3, "successRate": 0.6,// "errors": ["target resolved to zero DOM nodes — …"], "signatures": […] }],// "cachedResolvers": [{ "step": 1, "tool": "find", "ref": "e9",// "selectorHint": "[data-testid=\"new-record\"]", "agreedRuns": 5 }], … }ActionResult shape
Section titled “ActionResult shape”{ "ok": true, "action": { "type": "click", "ref": "e42", "selector": "role=button[name=\"Save\"]" },
// `offOrigin` is present ONLY when the action requested a URL and the landed // host differs — an auth bounce, a challenge interstitial, a captive portal. // Hosts are compared, not origins, so an http→https upgrade stays quiet; a // www/apex or subdomain hop fires, because it moves cookie scope. Check it // before trusting a snapshot: a signed-out inbox request that lands on a // marketing page is still `ok:true`, and this is the field that says so. "navigation": { "changed": true, "from": "...", "to": "...", "kind": "full_load" | "spa" | "hash" | null, "offOrigin": { "requested": "https://mail.example.com", "landed": "https://www.example.com" } }, "structure": { "appeared": [{ "role": "dialog", "name": "Confirm order", "ref": "e88" }], "removed": [], "newTabs": [{ "url": "...", "title": "..." }] }, "console": { "errors": [/* strings */], "warnings": 0 }, "pageErrors": [/* uncaught-exception messages */], "element": { "ref": "e42", "stillAttached": true, "focused": false, "value": "Engineering", "valueRequested": "engineering", "displayText": "Engineering ×", "checked": null },
"snapshotDelta": { "mode": "scoped_snapshot", // see Common per-call inputs "scope": "full", // a future release narrows this to the actual changed region "tree": "<compact a11y snapshot of the page>", "truncated": false }, "network": { "summary": { "total": 3, "byType": { "xhr": 2, "document": 1, "other": 6 }, "failed": 0 }, "requests": [ { "method": "POST", "url": "/api/orders", "status": 200, "type": "Fetch", "ms": 142 } ], "mutations": [ // bounded write-summary; keys only, never values { "method": "POST", "urlPattern": "https://api.example.com/v1/records", "status": 200, "ok": true, "durationMs": 142, "responseShape": ["id", "date", "type", "task"] } ] },
// dialogs fired during the action window — absent when none. Independent of // `ok`: under accept/dismiss/accept-prompt-with the dialog is handled and the // action proceeds; under `raise` (default) the page is dismissed server-side // AND `ok` is flipped to false with `failure:{source:"app", hint:"…"}`. "dialogs": [ { "kind": "confirm", "message": "Delete this record?", "handledAs": "accepted" } ],
// downloads captured during the action window — absent when per-session // download capture (`downloads_capture({on:true})`) hasn't been turned on, // or when no download fired. Each entry's `path` is workspace-rooted under // `$BROWX_WORKSPACE/.downloads/<sessionId>/`. Read the bytes back with // `download_get({id})`. Capability `file-io`. "downloads": [ { "id": "d1", "suggestedFilename": "report.pdf", "mimeType": "application/pdf", "sizeBytes": 18420, "path": "/Users/.../.browxai/.downloads/default/1716..-rep.pdf" } ],
"tokensEstimate": 180, "warnings": [], "error": null}Session pre-approvals
Section titled “Session pre-approvals”approve_actions({ scopes, ttlSeconds? })
Section titled “approve_actions({ scopes, ttlSeconds? })”Capability: self-approval, off by default. Without it the call returns the standard gate refusal (requiredCapability: "self-approval") and grants nothing. Absent from tools/list when the server runs with BROWX_CONFIG_READONLY=1.
Pre-approves confirm-required scopes for a TTL window, so the confirm hooks for those scopes pass without asking the human. The hooks exist to stop the agent’s own actions until someone says yes; this tool lets the agent say yes itself. That is why the operator opts in at server start and the agent cannot.
If an action came back
policy: …blocked: a confirm hook held it. It is a policy gate, not a selector failure, so don’t mark the feature unverified. The ways through are the operator’s: a human answers__browx.confirm(true)from the DevToolsbrowxaiconsole context, the operator drops the hook fromBROWX_CONFIRM_REQUIRED, or the operator enablesself-approvalsoapprove_actionsworks.
Pattern, with self-approval enabled:
- At session start, the client calls
approve_actions({ scopes: ["byob_action"], ttlSeconds: 600 }). - Action tools that would have hit the BYOB confirm hook pass within the TTL window.
- Each grant and each consume is logged for audit; the human channel still answers when no live grant covers the scope.
Scopes match BROWX_CONFIRM_REQUIRED vocabulary: navigate_off_allowlist, byob_action, file_download, file_upload. ttlSeconds defaults to 3600 (1 hour); hard cap 86400 (24h). Re-granting an existing scope resets its TTL. If one hook is all you want gone, removing it from BROWX_CONFIRM_REQUIRED is narrower than enabling self-approval.
Example.
// BROWX_CAPABILITIES=read,navigation,action,human,byob-attach,self-approvalapprove_actions({ scopes: ["byob_action"], ttlSeconds: 600 })// → actions that would hit the BYOB confirm hook pass for 10 minlist_approvals()
Section titled “list_approvals()”Audit helper. Returns live grants: { scope, sessionId?, grantedAt, expiresAt, uses, remainingMs }. sessionId is present on a grant the operator channel made for one session; approve_actions grants and operator workspace grants have none and cover every session. An operator grant is never wider than the workspace.