Tool reference: action tools
Action tools
Section titled “Action tools”All action tools return an ActionResult (text content; JSON-encoded), the same shape regardless of which action you used.
Failure origin. When ok:false, the result carries failure: { source, hint }. source is "browxai" (the context was torn down / detached / hit the anti-wedge deadline, not an app crash; re-open the session and retry), "app" (a real navigation/renderer failure, a genuine defect signal), or "unknown" (verify the session is still open via list_sessions before treating it as a defect). This exists because a browxai-side incognito-context teardown otherwise reads identically to “page crashed to about:blank” and produced expensive false CRITICAL defects. Never file an app-crash defect on a source:"browxai" failure.
Challenge detection. When an anti-bot gate is on the page, the result carries challenge: { kind, vendor, evidence[] }. kind is "interstitial" (the document IS the gate; the page you asked for was never served) or "widget" (a real page carrying a gated control, e.g. a Turnstile on a login form); vendor is "cloudflare", "anubis", or "unknown"; evidence[] lists the markers that matched (a cdn-cgi/challenge-platform script, a cf-mitigated: challenge response header, a 403/503 on the document, the document title, a .cf-turnstile[data-sitekey] element, an Anubis asset path). The block is absent when nothing matched, so a clean page costs no tokens. It does not change ok: a challenge on a completed action is reported, not failed. When an action’s deadline expires and markers are present, the error names the gate and points at await_human in place of the generic anti-wedge timeout.
Detection is not solving. browxai reports the gate; it does not clear one. Nothing reads this block to clear a gate. The default path is
await_human: a person clears the challenge in the live browser, then the run continues. Two off-by-default capabilities are separate seams that do not readchallenge:stealthpatches four fingerprint properties, andsolve_captchaundercaptchaforwards a captcha to a third-party solving service you configure. An interstitial that a real browser clears on its own (an Anubis proof-of-work gate typically resolves in about a second) needs nothing from you beyond a re-check.
Common per-call inputs (ACTION_OPTS)
Section titled “Common per-call inputs (ACTION_OPTS)”| Field | Default | Effect |
|---|---|---|
mode | "scoped_snapshot" | Shape of snapshotDelta. "none" omits the tree. "full" returns the whole post-action tree. "scoped_snapshot" (default) re-snapshots just the action’s element subtree + any newly-appeared regions (structure.appeared refs); falls back to the full tree if no scope refs exist; auto-promotes to "none" when no nav/structure change happened. "tree_diff" emits just the appeared-region subtrees (a full unified diff is still future work). |
maxResultTokens | 600 | Approximate cap for the elastic part (snapshotDelta.tree). Truncation is surfaced via warnings. |
Target shape (for tools that act on an element)
Section titled “Target shape (for tools that act on an element)”{ ref: string } OR { selector: string } OR { named: string } OR { coords: { x, y } }, exactly one. All four are first-class target shapes; choose by what the page lets you address:
ref: preferred for semantic UIs. Stable across snapshots, carries role+name+testId so Playwright auto-waiting + strict-match Just Works.selector: accepts theselectorHintstringsfind()emits plus arbitrary Playwright locator strings.named: mnemonic previously bound vianame_ref.coords: page coordinates{ x, y }in CSS pixels, viewport-relative. First-class for canvas, WebGL / three.js, painted UIs, and any surface where the agent locates targets visually (their own multimodal vision or geometric reasoning). Used byclick,hover,double_click,wait_for,scrollanddrop_files;fill,selectandchoose_optionstill require a resolved element. Coord-mode actions populateActionResult.element.hitwithelementFromPointevidence before+after (see below) so the action stays inspectable; for the full hit-stack + why a layer is/ isn’t hittable,point_probe({coords})first.
Optional contextRef: string scopes a selector to the subtree of a prior ref (row, card, panel). click({ selector: '[data-testid="row-action"]', contextRef: rowRef }) says “the action inside this row” without positional :nth chains. Mirrors find()’s contextRef; ignored when ref / named / coords is used.
Ref provenance and locator routing
Section titled “Ref provenance and locator routing”Every ref records the pass that discovered it: a11y (via the accessibility tree), dom (via the DOM walk), or both (the same element surfaced through both passes). The locator engine chooses by provenance so refs whose role is a bare tag (td, div, generic) still resolve to a real element instead of falling back to an ambiguous getByRole("td"). Priority order:
testId:[<attr>="<val>"]. Strongest signal; works for any provenance.- DOM-only refs with a
cssPath: the structural:nth-childpath captured at walk time. Used in place of role-locators when the only role is a bare tag. role + name:getByRole({ name }). Strong when the a11y pass produced a name.cssPathfallback: forboth-source refs whose a11y pass yielded no name.- Role only: last resort;
stability: "low"candidates land here.
Ambiguity guard on the acting path (click / hover). A ref built from a signal shared across repeated or hover-revealed items (e.g. one data-testid reused on every row’s edit button) would resolve via .first() to whatever instance is first in the DOM, a different visible element than the one you found, so the action silently lands at the wrong place. Before dispatching a click/hover on a ref, browxai checks the primary locator’s match count: if it is ambiguous (>1) and the ref carries the concrete structural path it was discovered as, the action re-resolves to that concrete element and adds a warnings entry saying so. If the concrete path no longer resolves, it keeps .first() but warns you to verify. Verify-before-dispatch: a loud “I re-resolved” beats a silent wrong-location action.
Named refs
Section titled “Named refs”For frequently-acted-on anchors across a long session, bind a mnemonic once and reference it from any action tool:
name_ref({ name, ref }): bind a name to a ref. Refs are stable across snapshots (element-key-based), so the binding survives navigation as long as the element persists.list_named_refs(): list all current name: ref bindings.- Then
click({ named: "voiceover_tab" }),fill({ named: "search_input", value: "…" }), etc.
navigate({ url,...opts })
Section titled “navigate({ url,...opts })”Goto a URL. Returns an ActionResult.
Example.
navigate({ url: "https://app.example.com/records" })// → ActionResult: { "ok": true,// "navigation": { "changed": true, "from": "about:blank",// "to": "https://app.example.com/records", "kind": "full_load" }, … }Target a deployed URL over a dev tunnel when you can. A cold dev tunnel (ngrok / cloudflared / framework --tunnel) routinely takes >15 s for first paint, well past the 5 s anti-wedge default, so the first navigate may return ok:false “anti-wedge timeout” while the page is, in fact, still loading. Treat navigate’s deadline as a soft signal, not a hard failure: on a timeout against a known-slow origin, follow with wait_for({ text }) (or a generous per-call timeoutMs on the navigate) and re-check before concluding the target is down. A deployed/static origin avoids the whole class; prefer it for calibration/QA runs.
click({ ref?|selector?|named?|coords?, button?, force?, dispatch?,...opts })
Section titled “click({ ref?|selector?|named?|coords?, button?, force?, dispatch?,...opts })”Click. Accepts all four target shapes. Prefer click over act_and_diff unless the change you need to see is a class or attribute edit. A stale ref errors: call snapshot or find again. button is "left" | "right" | "middle" (default left). Returns an ActionResult.element probe (stillAttached, focused, value, displayText, ownerControl, container) for ref/selector/named targets; coord targets populate element.hit (with before/after from elementFromPoint and focusChanged) in place of the locator-based fields.
Example.
click({ ref: "e42" }) // ref from snapshot/findclick({ selector: '[data-testid="row-action"]', contextRef: "e16" }) // the action *inside* this rowclick({ coords: { x: 512, y: 380 } }) // canvas / painted UI — point_probe firstHow the click reaches the element
Section titled “How the click reaches the element”Three levels, in the order to reach for them. Each one gives up a guarantee the level above it made, so stop at the first one that works.
1. Default, nothing set. Playwright’s actionability path (visible / stable / enabled / receives-events, then the mousedown hit-target interceptor). If the actionability budget expires, browxai retries once with force: true inside the remaining deadline and returns a warnings[] entry saying it did. Most “busy SPA” clicks are already covered here without you asking for anything.
2. force: true. Skips the actionability checks up front instead of waiting for them to fail first. The element is still resolved through the locator engine and scrolled into view. Reach for it on a target you have already verified with snapshot / find when you know the auto-recovery is going to fire anyway and you would rather not pay the actionability budget every call.
3. dispatch: "direct". Measures the target with a single CSS query in the page, then dispatches pointerdown, then mousedown, then pointerup, then mouseup, then click at its box centre through CDP. Skips the locator engine’s whole pre-dispatch path: resolution, actionability, scroll-into-view, the hit-target interceptor, and the retry loop around all of it. The events are trusted (isTrusted: true), so framework handlers fire exactly as they do for a real user.
Reach for dispatch: "direct" only after levels 1 and 2 have both failed on the same target. The shape it exists for: a view that replaces its own subtree while the main thread is saturated, where every resolution through the locator engine lands on a node that has already been re-rendered away, and the default path returns an anti-wedge timeout no matter how much budget you give it.
Do not reach for it as a general speed-up. It makes no visibility, stability, enabled, or receives-events guarantee, and it does not scroll, so a target outside the viewport is not clicked. Verify the target with find or point_probe first, and read element.hit on the result to see what was actually under the coordinate.
What it does not bypass: the browser still hit-tests the coordinate. An overlay above your target receives the click instead of it, and a disabled control fires nothing. See docs/threat-model.md §6 for the posture.
Constraints:
- Chromium-family engines only (chromium, android). On firefox / webkit / safari the call returns
ok:falsewithdirect-dispatch-needs-cdp, never a silent downgrade to the default path, because a page-JSMouseEventwould be untrusted and would be dropped by exactly the handlers this mode exists to reach. - The target must have a CSS form: a
selector, or arefcarrying a test attribute or the structural path it was discovered as. A role/name-only ref, or a ref minted inside a child frame, returnsok:falsewithdirect-dispatch-needs-css-target. - Resolves once, no retry. If nothing with a rendered box matches at the moment of measurement, the call fails and does not loop.
- Every direct dispatch returns a
warnings[]entry naming the coordinate and the checks it skipped, and populateselement.hit.before/.after. - Ignored for
coordstargets, which already dispatch through the same raw input path.
click({ selector: '[data-testid="send"]', dispatch: "direct" })// → ActionResult: { "ok": true,// "element": { "hit": { "before": { "tag": "button", "text": "Send" }, … } },// "warnings": ["click: dispatch:\"direct\" — measured the target once, then dispatched …"] }Post-action context probe
Section titled “Post-action context probe”When the action target is a ref/selector/named, element also carries delta-aware context for the logical thing that changed as well as the direct target. This eliminates the screenshot-to-confirm loop for combobox commits and row-level saves.
element.ownerControl: the logical owning control (combobox / listbox / radiogroup / labelled field wrapper) the action targeted. Walks up to 6 ancestors looking for a recognised owner. Surfaceslabel,displayTextBefore/displayTextAfter(innerText of the owner pre- and post-action, capped at 200 chars), andchanged: truewhen they differ. Use this to confirm “the combobox now displays X” without re-snapshotting.element.container: the repeated container (role=row/role=listitem/role=article/<tr>/<li>) the target lives inside. Surfaceskind,rowKey(first non-empty visible text within the row, capped at 80),rowText(concatenated row text, capped at 200), andchanged: truewhenrowTextdiffers pre-vs-post. Lets a row-level save confirm “the row’s visible state now reads …” in one round-trip.element.hit: coordinate-action evidence, populated forcoordstargets and forclick({dispatch:"direct"}).beforeandafterare{ tag, role, text, ancestorText }fromdocument.elementFromPoint(x, y)immediately before and after the action settles;focusChanged(coord targets only) flags whether the active element shifted. Lets canvas / WebGL coord actions stay inspectable, and lets a direct dispatch show what was actually under the coordinate it fired at.
A “did the click commit the right option?” check that holds across option shapes: element.ownerControl?.displayTextAfter?.includes(expectedLabel) && element.ownerControl.changed.
fill({ ref?|selector?, value,...opts })
Section titled “fill({ ref?|selector?, value,...opts })”Type into an input. The post-action element probe is the confirmation signal, with no follow-up snapshot/screenshot needed in the common case:
element.value: what’s actually in the DOM after the write. Not an echo of the requestedvalue. If the field is masked / capped / controlled, this differs from what you asked for.element.valueRequested: the string you asked us to type.value === valueRequestedmeans write landed as-asked; mismatch means the field rejected or transformed it.element.displayText: visible text of the closest labelled wrapper (role attr ordata-testid|test|cy|qa) up to 4 ancestors above. Surfaces the displayed state for controls that render the result outsideinput.value(chip-style selects, combobox displays, badge pickers, custom dropdowns that clear the underlying input on commit). Capped at 200 chars; omitted when no labelled wrapper was found.element.checked, for<input type=checkbox|radio>:true | false | "mixed"(indeterminate). Omitted for non-checkbox elements.
A confirmation check that holds across input shapes: value === valueRequested || displayText?.includes(valueRequested).
Example.
fill({ ref: "e4", value: "ada@example.com" })// → ActionResult.element: { "ref": "e4", "value": "ada@example.com",// "valueRequested": "ada@example.com", "displayText": "Email ada@example.com", … }// value === valueRequested ⇒ the write landed; no follow-up snapshot needed.fill_form({ fields, submit?,...opts })
Section titled “fill_form({ fields, submit?,...opts })”Fill N form fields atomically in one action window, with an optional final submit click. Replaces the fill / fill / fill / click round-trip pattern with one dispatch, covering ~80% of real form work in a single tool call. Same action-window envelope (navigation / structure / console / network / snapshotDelta) as a single fill, plus a per-field probe slot.
Args:
fields: non-empty array of{ ref?|selector?|named?|contextRef?, value }. Field targets accept the standard target shapes minuscoords(fill needs a real input element, not a viewport point).valuefollows the same secrets-substitution contract as the single-fieldfill: a<NAME>-shaped value triggers the secrets-registry materialisation at dispatch (capabilitysecrets); the recorded descriptor and per-field probe carry the alias, never the real value.submit: optional click target (ref/selector/named/contextRef). Clicked after every field has filled successfully.
Example.
fill_form({ fields: [ { ref: "e7", value: "Ada" }, { selector: '[data-testid="last-name"]', value: "Lovelace" }, { selector: '[data-testid="email"]', value: "ada@example.com" }, ], submit: { selector: '[data-testid="save"]' },})// → one ActionResult with `elements: [probe, probe, probe]` (dispatch order)// and `element` = the submit click's probe.Atomic pre-resolution. Every field’s target, and the submit target if supplied, is resolved BEFORE any DOM write lands. If any target fails to resolve (unknown ref, selector that matches zero nodes, scoped secret rejected because the field’s frame origin doesn’t match the scope, …), the call returns ok:false with a structured fieldResolution: [{ index, targetSummary, ok, error? }] block listing every field’s outcome, and NO partial fills happen. The agent gets a single “this form isn’t ready” signal instead of a half-filled form to recover from.
Sequential dispatch. Once resolution succeeds, fields are filled in array order via the same Playwright .fill() path the single-field primitive uses. The first per-field error stops the loop; later fields are reported as skipped on fillFailure: { atIndex, skipped: number[] } so the agent can see how far the dispatch got. Submit is skipped on any per-field error (no submitting a partially-filled form).
Per-field probes. The result carries elements: ElementProbe[] in dispatch order: the multi-target variant of the single-field element probe ({ value, valueRequested, displayText, ownerControl, container, … }). When a submit is supplied, element (singular) is the submit’s post-click probe so single-target consumers don’t have to feature-detect.
Failure envelope (atomic rejection):
{ "ok": false, "action": { "type": "fillForm", "value": "3 fields +submit" }, "error": "fill_form: atomic pre-resolution rejected the call — no fields were typed. Misses: [1] ref=e_missing: target resolved to zero DOM nodes — element no longer present", "fieldResolution": [ { "index": 0, "targetSummary": "ref=e7", "ok": true }, { "index": 1, "targetSummary": "ref=e_missing", "ok": false, "error": "target resolved to zero DOM nodes — element no longer present" }, { "index": 2, "targetSummary": "selector=[data-testid=\"phone\"]", "ok": true } ], "navigation": { "changed": false, "...": "..." }}Composes inside batch. Capability action.
press({ ref?|selector?, key,...opts })
Section titled “press({ ref?|selector?, key,...opts })”Press a key (Playwright key syntax: "Enter", "Control+A", …). If ref/selector is omitted, presses on the page. Example: press({ ref: "e4", key: "Enter" }).
shortcut({ keys, ref?|selector?, session?, timeoutMs? })
Section titled “shortcut({ keys, ref?|selector?, session?, timeoutMs? })”Dispatch a chord ("Control+C") or an ordered sequence (["Control+A","Control+C"]) and get handled-observability, which reports more than “keys were sent”. Optional ref/selector is focused first; else page-level. Returns { ok, keys, activeElement, events:[{type,key,defaultPrevented,target}], handled, clipboard?, clipboardNote? }:
eventsis captured by a fixed server-injected document listener (no agent JS) over the dispatch:keydown/copy/cut/paste, each withdefaultPreventedand a target summary.handled= a copy/cut/paste event fired or the apppreventDefault’d a keydown, i.e. the app actually responded, distinguishing “shortcut handled” from “selector/no-op”.- Clipboard (only when the off-by-default
clipboardcapability is enabled; observability works without it): the per-session clipboard model. Each session has its own buffer; the shared OS clipboard is touched only transactionally: at a copy/cut it captures the current selection into the session buffer and writes it out once; at a paste it writes this session’s buffer to the OS clipboard immediately before the keystroke (so concurrent sessions never paste each other’s content). browxai never reads the OS clipboard into a session (no cross-session/human clipboard bleed) and never touches it between commands. OS write is best-effort (osSync:false+ note when the platform tool, e.g.xclip, is absent). Same posture class aseval/network-body.
hover({ ref?|selector?|named?|coords?,...opts })
Section titled “hover({ ref?|selector?|named?|coords?,...opts })”Hover. Accepts the standard target shapes plus coords: {x, y} for visually-located targets. Example: hover({ ref: "e30" }) to reveal a row’s hover-only actions, then click the revealed control.
select({ ref?|selector?, values,...opts })
Section titled “select({ ref?|selector?, values,...opts })”selectOption on a <select>. Example: select({ ref: "e9", values: ["engineering"] }), where values match the <option> value attribute. For custom (non-native) dropdowns use choose_option instead.
upload_file({ ref?|selector?, name?, mimeType?, content?, path?, session? })
Section titled “upload_file({ ref?|selector?, name?, mimeType?, content?, path?, session? })”Set a file on a file <input> via Playwright setInputFiles (works on hidden inputs), the first-class alternative to injecting File/DataTransfer through eval_js. Target the input by ref/selector. File source is exactly one of: content (base64 inline, no filesystem read; pass name/mimeType) or path (resolved inside $BROWX_WORKSPACE only; a path escaping the workspace is rejected, so stage the file there first). Returns { ok, mode, name, bytes, mimeType?, target, fileCount } (bytes/target/fileCount for debugging a bad upload; mimeType set in content-mode). Gated by the off-by-default file-io capability. No agent JS.
Example.
upload_file({ selector: 'input[type="file"]', path: "fixtures/avatar.png" })// → { "ok": true, "mode": "path", "name": "avatar.png", "bytes": 18432,// "target": "input[type=\"file\"]", "fileCount": 1 }Drag-drop files from disk: drop_files({ ref?|selector?|named?|coords?, files, session? })
Section titled “Drag-drop files from disk: drop_files({ ref?|selector?|named?|coords?, files, session? })”Sibling to upload_file for drop-zone uploaders: modern SaaS file pickers that listen for dragenter / dragover / drop with a populated DataTransfer.files and never expose an <input type=file> for setInputFiles to drive. drop_files synthesizes the standard HTML5 drop sequence: builds an in-page DataTransfer populated with File objects constructed from the bytes the caller supplies, then dispatches dragenter, then dragover, then drop on the target element with realistic clientX / clientY (element box centre for ref/selector; literal coords). The Files type is registered on dataTransfer.types so apps that gate on it (React-DnD’s NativeTypes.FILE, e.g.) accept the drop.
Target the drop zone with the standard target shapes (ref / selector / named / coords). files[] carries one or more file entries; each entry is exactly one of:
{path, name?, mimeType?}: workspace-rooted file path. Resolved inside$BROWX_WORKSPACEonly (a path escaping the workspace is rejected, the same posture asupload_file’spathmode).namedefaults to the basename ofpath;mimeTypedefaults toapplication/octet-stream.{contents, name, mimeType?}: base64 inline. No filesystem read.nameis required;mimeTypedefaults toapplication/octet-stream.
Multiple entries land as a multi-file drop in a single sequence (one dragenter / dragover / drop triple with dataTransfer.files populated with all files), the way every real multi-file drop behaves. Returns { ok, target, files: [{name, mode, bytes, mimeType}], totalBytes, fileCount, eventsFired, dropDispatched, tokensEstimate }.
Example.
drop_files({ selector: '[data-testid="drop-zone"]', files: [{ path: "fixtures/report.csv", mimeType: "text/csv" }],})// → { "ok": true, "fileCount": 1, "totalBytes": 5120, "dropDispatched": true, … }In-page File construction. The page-side script is shipped inline per call via page.evaluate (not addInitScript): each drop is one-shot, the byte payload differs per call, and a boot-time injection would leak page-side identifiers across unrelated tools. Bytes ride the boundary as base64 (then atob + Uint8Array, then new File(...) in-page); Uint8Array over Playwright’s structured-clone boundary explodes into a per-byte object array (~10× larger on the wire). Gated by the off-by-default file-io capability, the same posture as upload_file. No agent JS.
pdf_save({ path?, format?, scale?, printBackground?, session? })
Section titled “pdf_save({ path?, format?, scale?, printBackground?, session? })”Print the current page to a workspace-rooted PDF via Playwright page.pdf() (CDP Page.printToPDF under the hood), the first-class alternative to screenshot-and-OCR or driving the browser’s print-to-file dialog through shortcut. The mirror of upload_file: file-io OUT instead of IN.
Defaults are what an agent reaching for “save the page as a PDF” expects without reading the docs: format:"A4", scale:1, printBackground:false (matches browser-print’s default; opt in when background colour / imagery matters for the artefact). path is resolved inside $BROWX_WORKSPACE only. A path escaping the workspace is rejected; omit it for a default pdfs/<sessionId>-<ts>.pdf. format accepts every Playwright paper preset (Letter/Legal/Tabloid/Ledger/A0 to A6). scale is bounded [0.1, 2.0] (Playwright’s CDP-layer clamp; out-of-band values are rejected up-front with a clearer error). Returns { ok, path, bytes, format, scale, printBackground }.
Chromium constraint. page.pdf() is Chromium-only, and every browxai session is Chromium so that’s fine. The tool layer refuses cleanly on attached (BYOB) sessions: driving PrintToPDF on a human’s own Chrome would surface a print dialog / mutate the human’s window state, so refusal lands before any Playwright call is made. Open a managed session (open_session({mode:"persistent"}) or {mode:"incognito"}) and re-run pdf_save against that. Capability action.
Example (canonical for the file-export family; page_archive / element_export / dom_export differ in format knobs and default dirs, same workspace-rooted path contract):
pdf_save({ path: "pdfs/invoice.pdf", printBackground: true })// → { "ok": true, "path": "/…/.browxai/pdfs/invoice.pdf", "bytes": 88231,// "format": "A4", "scale": 1, "printBackground": true }
page_archive({ format: "directory", path: "archives/checkout" })// → { "ok": true, "format": "directory", "path": "/…/.browxai/archives/checkout",// "sizeBytes": 18230412, "resourceCount": 64, "droppedCount": 2,// "warnings": ["archive output is UNMASKED — may carry credentials", …] }page_archive({ path?, format?, maxSizeMb?, session? })
Section titled “page_archive({ path?, format?, maxSizeMb?, session? })”Save the current page as a self-contained archive: HTML plus every linked resource the page references. The first-class alternative to screenshot-then-OCR for a faithful capture an adopter can re-open offline, grep through, or hand to another tool.
Two formats:
directory(default): writes<path>/index.htmlplus a<path>/assets/sidecar containing every fetched resource (images, fonts, scripts, stylesheets, CSS background images discovered viagetComputedStyle). The HTML’ssrc/hrefreferences are rewritten to relativeassets/<kind>/<file>paths so the directory opens directly in any browser. Best for large pages: no inline-data size cliff.single-file: one self-contained.htmlfile at<path>with every linked resource inlined as adata:URI. The MHTML-equivalent without the MIME-multipart format (which modern browsers no longer support well). One file to copy around, but browsers commonly struggle past ~150 MB; very large pages should preferdirectory.
Output path is resolved inside $BROWX_WORKSPACE only (path-traversal rejected, the same posture as pdf_save / dump_storage_state). Omit it for a default archives/<sessionId>-<ISO> (directory) or archives/<sessionId>-<ISO>.html (single-file). maxSizeMb caps the total archive (default 200). Resources past the budget land in droppedCount with a warning explaining which cap was hit. Returns { ok, format, path, sizeBytes, resourceCount, droppedCount, warnings[] }.
Resource fetching runs inside the page. The tool walks the DOM (document.querySelectorAll) to discover URLs and then await fetch(url, { credentials: 'include' }) from page context, so cookies / auth headers travel correctly. The flip side: page CSP connect-src applies: cross-origin fetches the policy refuses are caught, dropped, and surfaced in droppedCount + warnings[]. Cross-origin iframes are similarly unreachable and are dropped.
Caller must navigate + settle the page BEFORE calling page_archive. The tool captures document.documentElement.outerHTML once and does not inject its own wait, so pair it with a prior navigate (which waits for load) or a wait_for against the meaningful element.
Secrets-masking caveat (deliberate gap). The archive output is intentionally UNMASKED. Running the per-session egress masking layer over the bytes would corrupt the archive. Masking is literal-substring substitution, would break inline JSON state blobs, CSS, binary image bytes, and produce a file that no longer opens correctly. The warnings[] array always carries the caveat as its first entry. Treat the archive the same way you treat the output of dump_storage_state: it may carry credentials. See Threat model “Why archives aren’t masked”.
Gated by the off-by-default file-io capability (same posture as upload_file / downloads_capture): an archive write is a deliberate filesystem egress.
element_export({ ref, format?, intoDir?, maxSizeMb?, session? })
Section titled “element_export({ ref, format?, intoDir?, maxSizeMb?, session? })”Save the subtree under one ref as a self-contained snippet: outerHTML + page-wide stylesheets + every linked resource the subtree references. Sibling to page_archive, scoped to a single element instead of the whole document. The use case is “extract this component / card / table (markup, styles, images / fonts) to a directory I can grep, diff, or hand to another tool”.
Two formats:
directory(default): writes<intoDir>/element.htmlplus a<intoDir>/assets/sidecar containing every fetched resource (images, fonts, scripts, stylesheets, CSS background images discovered viagetComputedStyle). The HTML’ssrc/hrefreferences are rewritten to relativeassets/<kind>/<file>paths so the directory opens directly in any browser.single-file: one self-contained.htmlfile at<intoDir>with every linked resource inlined as adata:URI and the captured stylesheet text inlined in a<style>block. Same browser-engine soft-cap caveat aspage_archive(~150 MB).
The captured snippet is wrapped in a minimal standalone <html><head><style>…</style></head><body>…snippet…</body></html> document so it renders the way it did on the source page. CSS is collected page-wide via document.styleSheets[].cssRules. A stylesheet’s rules may target the subtree from afar, so we keep them all. Cross-origin stylesheets the page can’t read (browser security: the page lacks CORS access to cssRules) end up missing from the export; the count is surfaced in warnings[] so the adopter knows the snippet may render differently than the source page.
Resource discovery walks only the element subtree (not the whole document) for [src] / [href] / background-image: url(...). Same in-page await fetch(url, { credentials: 'include' }) posture as page_archive: cookies / auth headers travel correctly, but page CSP connect-src applies: refused fetches are caught, dropped, and surfaced in droppedCount + warnings[].
ref must come from a prior snapshot() / find(). A stale or fabricated ref is a structured error, not a silent miss. intoDir is resolved inside $BROWX_WORKSPACE only (path-traversal rejected). Omit it for a default elements/<sessionId>-<ISO>-<ref> (directory) or elements/<sessionId>-<ISO>-<ref>.html (single-file). maxSizeMb caps the total export (default 50, smaller than page_archive’s 200, since a snippet is meant to be a slice). Returns { ok, format, ref, path, sizeBytes, resourceCount, droppedCount, warnings[] }.
Judgment call: iframe contents. The same-document subtree walk picks up an <iframe> element’s own src attribute (best-effort, treated as other), but never enters the iframe’s contentDocument. Cross-origin iframes are unreachable for the same reason the page can’t read cross-origin stylesheets; same-origin iframes could in principle be walked, but the discovered subtree’s outerHTML already terminates at the iframe boundary, and there’s no faithful way to splice the inner document’s HTML in without diverging from “this is what the element subtree actually is”. Adopters who need an iframe interior should navigate into it as its own page and call page_archive.
Secrets-masking caveat (deliberate gap). Same posture as page_archive. The export is intentionally UNMASKED: running the per-session egress masking layer over the bytes would corrupt the file (literal-substring substitution breaks inline JSON state blobs, CSS, binary image bytes, and produces a file that no longer opens correctly). The warnings[] array always carries the caveat as its first entry. Treat the export the same way you treat the output of page_archive / dump_storage_state: it may carry credentials.
Caller must navigate + settle the page BEFORE calling element_export. The tool captures the element subtree once and does not inject its own wait. Gated by the off-by-default file-io capability.
dom_export({ format?, includeShadow?, path?, session? })
Section titled “dom_export({ format?, includeShadow?, path?, session? })”Full-document DOM dump. The structural sibling of element_export for cases where the agent needs the whole tree (every element + every attribute), where element_export covers one subtree’s renderable slice.
Two formats:
html(default):document.documentElement.outerHTMLwritten verbatim to a workspace-rooted.htmlfile. Important: the platform serializer does NOT include shadow-DOM content (open OR closed), even for elements that have one. Web Component interiors are invisible toouterHTML. The result envelope surfaces this inwarnings[]whenever custom elements are detected.jsonl: one JSON object per line, depth-first walk:{ tag, role?, attrs, text?, ref?, depth }. A grep-friendly serialization for cases where the agent needs to scan structure without parsing HTML.attrsis a flat attribute-name to value map.textis set only for nodes whose direct text content is non-empty (whitespace-trimmed), and direct so a deeply-nested phrase isn’t smeared across every ancestor.refechoes adata-browx-refattribute if the agent annotated the DOM; refs are NOT minted by this tool.
Shadow-DOM traversal. includeShadow:true (the default, jsonl mode only) descends into every open shadow root (Element.shadowRoot when not null). Closed shadow roots are inaccessible by web-platform design: shadowRoot returns null and the tree behind them is genuinely unreachable from any tool. The warnings[] array surfaces the closed-shadow limitation when custom elements are present in the document, so the adopter doesn’t wonder where a Web Component’s interior went.
path is resolved inside $BROWX_WORKSPACE only (path-traversal rejected, the same posture as pdf_save / page_archive). Omit it for a default dom-dumps/<sessionId>-<ISO>.html or dom-dumps/<sessionId>-<ISO>.jsonl. Returns { ok, format, path, sizeBytes, nodeCount, shadowRootCount, warnings[] }.
Secrets-masking caveat (deliberate gap). Same posture as page_archive. The dump is intentionally UNMASKED: running the per-session egress masking layer over the bytes would corrupt inline JSON state blobs and break the file. The warnings[] array always carries the caveat as its first entry.
Caller must navigate + settle the page BEFORE calling. Gated by the off-by-default file-io capability.
Download capture: downloads_capture / download_get
Section titled “Download capture: downloads_capture / download_get”The reverse direction of upload_file: intercept page-initiated downloads,
persist the artifact at a workspace-rooted path, and hand the bytes back to the
agent. Per-session, off by default, no new capability, and the same off-by-default
file-io posture as upload_file.
The pipeline is two tools plus an additive field on every ActionResult:
downloads_capture({on:true}): turn capture on for the session.- Run the action that triggers the download (
click({ref})on a download link, anavigate(...)that returnsContent-Disposition: attachment, etc.). Every download fired during the action window lands onActionResult.downloads[]with anid, the (sanitised)suggestedFilename,mimeType(best-effort, extension-inferred),sizeBytes, and a workspace-rootedpath. download_get({id}): return the bytes (base64) for one capture. PasspathOnly:trueto skip the payload and just get the metadata + path (useful for very large artefacts an agent only needs to hand off by path).
Captured artifacts live at $BROWX_WORKSPACE/.downloads/<sessionId>/<prefix>-<sanitised-name>
(per-session subdir, prefix disambiguates concurrent downloads). The
page-supplied filename is sanitised before composing the on-disk name:
path separators stripped, leading dots stripped, NUL/control bytes stripped,
length-capped, all-stripped names fall back to "download". The raw
page-supplied filename is preserved on the entry as rawSuggestedFilename when
sanitisation diverged.
When capture is OFF (the default), every download is silently discarded by cancelling Playwright’s temp artifact. Sessions that never opt in leave no on-disk trace, preserving the no-trace contract.
downloads_capture({ on, clear?, session? })
Section titled “downloads_capture({ on, clear?, session? })”on: boolean: turn capture on or off.clear?: boolean: when toggling off, ALSO delete every previously-captured file on disk. No-op whenon:true.- Returns
{ ok, captureOn, storageDir, captured: [{id, suggestedFilename, sizeBytes, path, mimeType?}], tokensEstimate }.
download_get({ id, pathOnly?, session? })
Section titled “download_get({ id, pathOnly?, session? })”id: string: download id fromActionResult.downloads[].id.pathOnly?: boolean: omit the base64 payload, return only path + metadata. The file is not read.- Returns
{ ok, id, suggestedFilename, mimeType?, sizeBytes, path, content?: base64, tokensEstimate }. - A file over 16 MiB is not returned inline. The call fails with an error that names the limit and the saved path, and
pathOnly:truestill returns the path.
Example (the whole pipeline).
downloads_capture({ on: true })click({ ref: "e51" }) // the export button — the download lands on ActionResult.downloads[]download_get({ id: "d1", pathOnly: true })// → { "ok": true, "id": "d1", "suggestedFilename": "report.pdf",// "mimeType": "application/pdf", "sizeBytes": 18420,// "path": "/…/.browxai/.downloads/default/1716…-report.pdf" }Gated by the off-by-default file-io capability. Per-session capture state
isn’t persisted across close_session/open_session; a fresh session starts
with capture off.
Asset export: asset_export
Section titled “Asset export: asset_export”downloads_capture only sees what the page chose to download (<a download>
links, Content-Disposition: attachment, programmatic download events).
Plenty of useful artifacts never trigger a download. Every image, font, video,
audio clip, stylesheet, and script the page actually rendered came in through
the regular HTTP fetch pipeline and lives in the session’s always-on network
ring. asset_export filters that ring and persists matching responses to a
workspace-rooted directory in a single call, the first-class alternative to
scraping <img src> / <link href> from the DOM and re-fetching each one
through eval_js.
asset_export({ filter, intoDir?, maxCount?, maxBytes?, session? })
Section titled “asset_export({ filter, intoDir?, maxCount?, maxBytes?, session? })”filter: { mime?: string[], urlPattern?: string, minBytes?: number, maxBytes?: number, status?: number[] }: applied to every entry in the session’s network ring:mime: substring match against the captured responseContent-Type(case-insensitive, any one match wins;["image/", "video/"]).urlPattern: RegExp source matched case-insensitively against the URL ("\\.(woff2?|ttf|otf)$"). Invalid regex returns a structured error.minBytes/maxBytes: bound the encoded response size, only enforced when the renderer reported a byte count.status: allow-list of HTTP status codes. Default: 2xx (200..299).
intoDir?: output directory. Resolved inside$BROWX_WORKSPACE, and an escape is rejected. Default:assets/<sessionId>-<ISO>/.maxCount?: per-call file count cap. Default 10000; clamped to a hard ceiling of 50000.maxBytes?: per-call total byte cap. Default 500 MiB; clamped to a hard ceiling of 2 GiB.- Returns
{ ok, intoDir, totalCount, matchedCount, persistedCount, droppedCount, manifest: [{url, mime?, status?, sizeBytes, savedAs}], warnings, tokensEstimate }. The manifest is also written to<intoDir>/_manifest.json.tokensEstimatesizes the result envelope (the manifest blob), not the exported files.
Filenames are derived from the URL path basename, percent-decoded, and
sanitised: no path separators, no NUL/control bytes, no leading dots,
length-capped, all-stripped names fall back to "asset". Two responses with
the same basename are collision-resolved with a -N suffix
(logo.png, logo-1.png, …). Files are created exclusively: a name that
already exists in intoDir, from an earlier export or anything else, is never
replaced and moves on to the next free -N name.
Example.
asset_export({ filter: { mime: ["image/"], minBytes: 10000 }, intoDir: "assets/hero-images" })// → { "ok": true, "intoDir": "/…/.browxai/assets/hero-images", "totalCount": 96,// "matchedCount": 14, "persistedCount": 12, "droppedCount": 2,// "manifest": [{ "url": "https://cdn.example.com/img/hero.webp", "mime": "image/webp",// "status": 200, "sizeBytes": 48210, "savedAs": "hero.webp" }, …],// "warnings": [] }CORS caveat. The renderer discards response bodies fairly quickly. When
CDP Network.getResponseBody returns “not available” the tool falls back to
an in-page fetch() against the original URL. Same-origin URLs work. Cross-
origin URLs without permissive CORS headers will reject, so they land in
droppedCount with a warning, never a crash.
Gated by the off-by-default file-io capability, the same posture as
download_get.