The string you pass to run() executes as an async function inside the worker's
sandbox. This page documents what that code can use: the globals, the helpers,
the return-value handling, and the parts of the Playwright API that are
deliberately removed.
await bw.run(`
await page.goto('https://news.ycombinator.com');
const titles = await page.locator('.titleline > a').allInnerTexts();
return titles.slice(0, 5);
`);- A single trailing expression is returned automatically:
bw.run("return page.title()")andbw.run("page.title()")are equivalent. - A multi-statement block must use
return. - The value becomes a bounded JSON-safe summary. Playwright handles (
Page,Locator) are summarized rather than serialized whole — aPagebecomes{type: "Page", pageId, url, title, closed}. - Arrays, Maps, Sets, and object properties are limited to the first 200
entries; nested containers beyond depth eight become
"[Max depth]". These reductions do not themselves add atruncatedmarker. Aggregate inside the snippet or return explicit bounded chunks when completeness matters. - If the summarized result still exceeds the output-size limit, it is spilled
to a file and replaced with
{truncated: true, preview, fullOutputPath}. That file contains the bounded summary, not the original unbounded value.
betterwright run prints compact JSON when stdout is piped, preserving every
field while avoiding repeated indentation in agent observations. Terminal output
is indented by default; pass --pretty to retain indentation in a pipe or file.
| Global | Description |
|---|---|
page |
The current page. Always points at the active page for this session. |
pages |
Live array of open pages in this session. |
openPage(url?, options?) |
Open a new page, optionally navigating. Returns the page. |
usePage(idOrIndexOrPage) |
Make another page current; accepts a pageId, an index, or a page object from openPage/pages. |
closePage(idOrIndexOrPage?) |
Close a page (the current one if omitted); accepts the same handles as usePage. |
context |
The Playwright BrowserContext, with mutating methods removed (see below). |
Pages persist across run() calls within the same session, so an agent can
open a tab in one step and act on it in the next. Popups and
target=_blank links are adopted automatically and appear in pages.
Sandbox navigation (page.goto, frame.goto, page.reload, page.goBack,
page.goForward, and openPage(url)) defaults to waitUntil: 'domcontentloaded'.
Unlike Playwright's load default, this does not wait for every image or other
subresource. Wait for the relevant locator before reading dynamic results.
Explicit waitUntil and timeout options are preserved; use
{waitUntil: 'load'} when the task requires all load-event resources.
waitForLoadState() and setContent() retain their Playwright behavior.
By default, idle headless pages are parked after a short delay between calls:
their timers, animation frames, and animation timelines pause, then resume
before the next execution. Pass parkBackgroundPages: false to the client
or set BETTERWRIGHT_PARK_BACKGROUND_PAGES=0 when the application must keep
progressing in the background. Headed sessions, sessions with a live view,
and actively recording pages are not parked. Worker restarts discard the
session's in-memory state and live page handles; see
timeouts and the worker.
// Work two tabs at once
const [a, b] = await Promise.all([
openPage('https://example.com'),
openPage('https://example.org'),
]);
return { a: await a.title(), b: await b.title() };recording.start(options?) records the current tab to a session MP4 artifact by default.
An explicit .webm filename selects VP8/WebM.
recording.status() returns progress, recording.stop() finishes the video,
and recording.restart(options?) finishes the previous take before starting another.
Only one recording can run per session. Capture defaults to 60 FPS and preserves
page state and elapsed time. FFmpeg is required only when recording.
See Record a browser session for options, limits, and CLI commands.
return recording.start({ name: "demo.mp4", fps: 60 });snapshot(options?) returns an accessibility-tree snapshot of the current page —
a compact, model-friendly view far cheaper than full HTML. The header line
carries the page id, URL, and title. Every element carries a [ref=eN] marker
you can act on directly with an aria-ref locator, so there is no need to
reverse-engineer a CSS selector from the snapshot:
return await snapshot({interactive: true}); // "page page-1 https://… \"Sign in\"\n- button \"Sign in\" [ref=e12]…"
// then, in a later run:
await human.click(page.locator('aria-ref=e12'));The tree covers the whole page, not just the viewport: off-screen elements are
included (locator actions scroll to their target on their own, so never scroll
just to read), and child-iframe contents appear inline with frame-qualified
refs like f1e2 that work in aria-ref locators exactly like main-frame refs.
Refs are assigned fresh on every snapshot and go stale when the page changes — re-snapshot after navigations or re-renders before using one, and never guess a ref you have not seen in the current snapshot.
Filled <input type="password"> values are replaced with [redacted] in the
snapshot (username and other fields read normally), so a routine read never
pulls a just-typed or extension-filled secret into model context.
The tree is compressed before it reaches the model: bare generic wrappers
are unwrapped, text-only paragraphs and small text-only containers collapse to
single lines, refs and cursor hints that cannot serve as action targets are
dropped, nameless images disappear, names are capped at 100 characters, and
link /url lines are omitted (pass {urls: true} to keep them, or read an
href via page.locator('aria-ref=eN').getAttribute('href')). This typically
halves the size of a real page's tree without losing anything actionable.
| Option | Default | Notes |
|---|---|---|
interactive |
false |
Keep actionable elements and their ancestors, live status/alert text, and short item/table context needed to interpret controls. Long descriptions and unrelated prose stay out; use a scoped full snapshot when the representation is still unknown. |
diff |
false |
Return only the +/- lines changed since the previous same-shaped snapshot of this page, or (no changes since previous snapshot). |
ref |
— | Scope the snapshot to one element's subtree by its ref from the previous snapshot, e.g. {ref: 'e31'} — no CSS selector needed. |
selector |
— | Scope the snapshot to a CSS selector, e.g. {selector: '#main'}. |
depth |
— | Limit tree depth. |
urls |
false |
Keep - /url: property lines on links. |
maxChars |
10000 |
Size limit, capped at 20000. An over-limit snapshot returns a diagnostic string with the actual size and scoping hints instead of a cut-off tree. It does not throw or make the run envelope fail. |
timeout |
5000 |
Milliseconds. |
Interactive snapshots retain short text beside list/table controls, table column labels, and status/alert contents. This keeps prices, quantities, and outcomes grounded without including unrelated article text. Item-context lines over 300 characters are omitted rather than silently clipped; use a scoped full snapshot before verifying a representation you have not observed.
The automatic UI directory also carries visible item context, cart/output summaries, and target handles for empty live-status regions. These are read targets, not a prediction of the eventual result text.
On sites without WebAgents, the automatic fallback is offered once per origin
and pathname. Structured extractions, short acknowledgements, and summarized
Playwright handles still receive discovery context; an extracted object does
not establish that the caller has enough actionable state. When the snippet
returns a betterwright-ui/1 directory itself, the automatic duplicate is
omitted. First-party WebAgents discovery is unchanged.
The automatic directory is limited to 2,400 JSON characters before envelope
redaction. It includes up to two evidence entries, omits select-option lists,
and drops whole controls to fit; exact target strings are never shortened.
truncated: true signals omitted data. Call controls.directory() for the
full discovery limits and option lists, or use a scoped snapshot. Returning
that directory does not append another automatic copy. Failure evidence uses
the separate limits below.
Agents returning their own scoped observations can use
bw.run(code, {automaticUI: false}) or betterwright run --no-auto-ui to omit
the automatic UI catalog on that successful call. This does not consume its
one-time announcement: a later default call can still receive it. On-demand
controls.directory()/snapshot(), WebAgents discovery, challenge reporting,
failure evidence, and redaction continue to operate normally.
On an ordinary snippet failure, a live page may return a partial ui.evidence
directory: at most four 300-character excerpts, collected within a 200ms budget.
Read those observations before spending another call rediscovering the current
state. They do not turn a failed call into success, prove that a stale message
belongs to the latest action, or authorize replaying a submission. Evidence
uses the normal redaction path and is omitted when unavailable, when a challenge
takes priority, or when the worker must restart. Successful subsequent calls
do not acquire a new automatic observation payload.
The on-demand checkout-verification skill covers repeated-name carts,
quantity labels, tables, and fresh versus stale checkout outcomes. It loads for
matching checkout tasks, not ordinary reading. It does not relax assertions or
make the runtime interpret a failed operation as a successful transaction.
Escalate reading only as far as the task needs: snapshot({interactive: true})
to decide what to click, a full snapshot() to read content wholesale,
snapshot({diff: true}) to check what an action changed, and
screenshot({annotate: true}) when only the visual layout can answer the
question. For anything Playwright can read — text, attributes, computed
state — use the normal page.locator(...) API.
The frozen site global exposes application assets and first-party requests
without raw HTML dumps or repeated browser restarts:
| Method | Description |
|---|---|
site.assets() |
Scripts, stylesheets, fetches, and XHRs discovered in the DOM or network log. |
site.requests(options?) |
Recent request metadata, filterable by urlIncludes and resourceType. |
site.read(url, options?) |
Read a same-origin text asset; find, contextChars, and maxMatches return bounded literal excerpts. |
site.request(url, options?) |
Same-origin GET/HEAD/POST/PUT/PATCH/DELETE with optional json, body, headers, and response: "json". BetterWright copies matching browser cookies into the guarded request and returns response cookies to the session. |
Cross-origin URLs and credential-bearing headers are rejected. Request and response bodies are bounded to 1 MB. This surface is useful for any web app whose visible UI is backed by a first-party JSON API or client bundle; it does not contain site-specific puzzle or endpoint knowledge.
A participating origin publishes /webagents.md containing one fenced
webagents JSON block. A raw JSON mirror may instead live at
/.well-known/webagents.json. BetterWright parses the document outside the
model sandbox and returns only a compact normalized directory:
# Agent actions
```webagents
{"version":"0.1","workflow":{"endpoint":"/api/agent/workflow","maxOperations":16,"parallel":true,"references":true,"pacing":{"minIntervalMs":150,"maxConcurrency":2}},"actions":{"search":{"description":"Search products","effect":"read","inputSchema":{"type":"object"}},"add_to_cart":{"description":"Add one product","effect":"write"}}}
```After opening the page, discover once and submit the whole dependency graph in one browser call:
const directory = await webagents.discover();
if (!directory.available) return directory;
const result = await webagents.batch([
{id: 'find', action: 'search', input: {query: 'wireless keyboard'}},
{
id: 'cart',
action: 'add_to_cart',
dependsOn: ['find'],
input: {productId: {$ref: 'find.results.0.id'}},
},
], {allowWrites: true});
return {result, state: await snapshot({diff: true})};webagents.discover({refresh?}) caches positive and negative discovery per
page origin. A manifest whose path scopes omit the current page is retried after
navigation so later app sections can expose their own actions. Raw Markdown
prose never enters model context. The returned action directory and workflow
result carry trust: "untrusted_external_data".
After the first call that opens a supporting origin, BetterWright also attaches
that normalized directory once as webagents on the result envelope. This
makes the fast path discoverable even when the caller initially requested a
snapshot instead of discovery; unsupported sites add no model-context field.
webagents.batch(operations, options?) (or the equivalent single object
webagents.batch({operations, ...options})) accepts at most 32 declared operations
(or the site's lower advertised maximum). IDs must be unique; action is
preferred while name is accepted as a compatibility alias. dependsOn
references must exist and form an acyclic graph. Inputs and total payloads are
bounded JSON objects. The endpoint is same-origin and executes through the
existing guard proxy with the browser's matching cookies; response cookies are
returned to the browser session.
Action directories may include bounded outputSchema objects so reference
paths are explicit without reconnaissance. References automatically add their
operation dependency. Effectful operations are serialized in list order, while
independent reads can still run in parallel; a read after a write observes the
latest ordered state. A site may publish bounded workflow.pacing with a
minIntervalMs from 0–2000 and a maxConcurrency no greater than its operation
limit. BetterWright carries those normalized limits in the single workflow
request so generic site executors can space internal operations without adding
model or browser round trips. The site remains responsible for enforcing its
own rate limits and may always run more conservatively. After a successful
workflow containing writes, BetterWright reloads the active page once, waits
for DOM/load, and gives client hydration a short bounded settle before
returning. This stays compatible with polling and streaming apps that never
become network-idle while still making the refreshed state available to a
screenshot or DOM assertion in the same browser call. Pass refresh:false only
when the live UI updates itself.
Actions may also declare up to 16 pathPrefixes; BetterWright keeps only those
matching the active page and omits the scope metadata, so multi-app origins do
not spend model tokens advertising irrelevant actions.
Actions declare effect: "read", "write", or "irreversible". Writes require
allowWrites: true; irreversible actions separately require
allowIrreversible: true, which should be set only after the user's request and
any configured guardrail authorize the consequence. These declarations are
site-controlled hints, not security claims. On missing or invalid discovery,
continue through WebMCP or ordinary browser interaction.
Some pages publish typed first-party tools through Chromium's WebMCP API. Use those tools when available instead of reconstructing the same operation from a series of DOM clicks:
const tools = await webmcp.tools();
const search = tools.find((tool) => tool.name === 'search');
if (!search) return {available: false};
return webmcp.invoke(
search.name,
{query: 'wireless mouse'},
{frameId: search.frameId},
);webmcp.tools({timeout}) returns a fresh snapshot on every call. Each descriptor
has name, description, frameId, trust: "untrusted_external_data", optional inputSchema, optional
backendNodeId, and advisory annotations (readOnly, untrustedContent,
autosubmit). Same-named tools in separate frames stay separate; invoking by
name alone fails closed until frameId disambiguates them.
webmcp.invoke(name, input?, options?) freshly discovers the tool, validates a
JSON-object input, invokes it, and awaits the terminal Completed, Canceled,
or Error result in one browser call. Options are:
| Option | Default | Notes |
|---|---|---|
frameId |
— | Required only when more than one frame publishes the same name. Copy it from webmcp.tools(). |
discoveryTimeout |
1000 |
Tool-registration wait in milliseconds, capped at 10000. |
timeout |
30000 |
Terminal-result wait in milliseconds, capped at 120000. A timeout requests cancellation before returning an error. |
allowAutosubmit |
false |
Must be true for a tool that declares autosubmit: true; set it only when the user's request authorizes submission. |
Tool descriptors and outputs are controlled by the page. Annotations are hints,
not security claims, and every invocation result carries
trust: "untrusted_external_data". BetterWright also bounds serialized inputs,
schemas, and outputs to 1 MB and applies the normal result redaction before they
leave the worker. Page traffic caused by a WebMCP call stays behind the same
guard proxy and network policy as UI actions.
BetterWright enables WebMCPTesting,DevToolsWebMCPSupport for every local
Chromium it launches. A remote/CDP or cloud browser owns its own launch flags;
start it with
--enable-features=WebMCPTesting,DevToolsWebMCPSupport when that provider does
not already enable WebMCP.
The frozen human global emits visible actions with curved pointer movement,
bounded key timing, and eased wheel steps instead of machine-perfect bursts:
await human.click(page.getByRole('button', {name: 'Continue'}));
await human.type('#email', 'person@example.com');
await human.scroll(650); // negative values scroll upwardhuman.click(target, options?) and human.type(target, text, options?) accept a
selector, Locator, ElementHandle, or {x, y, width, height} bounds. Typing clears
the field by default; pass {clear: false} to append, or set minDelay and
maxDelay. For readable element targets, human.type reads the field back.
Success requires the requested text to be inserted in full, so a prefix, an
unchanged field, or an overlapping partial append is not a hit.
If that check fails — typical of Draft.js and other rich-text editors that
swallow synthetic key events — it restores the original value when appending,
retries with insertText, and throws if the field still did not accept the
text. Bounds-only targets cannot be read back, so they do not get this
verification or retry. Prefer a selector, Locator, or ElementHandle; verify
coordinate-based typing independently.
human.scroll(deltaY, options?) accepts steps, while the object
form also accepts deltaX.
These helpers and the managed engines reduce behavioral and browser-fingerprint false positives; they do not guarantee undetectability. Keep one stable persistent profile and respect a site's rate limits. For broad discovery, use the host's web-search tool and open returned results here rather than automating Google or Bing's public search UI.
screenshot(options) captures the current page and records it as an artifact.
| Option | Default | Notes |
|---|---|---|
kind |
"debug" |
"proof", "question", or "debug" — how the host UI should treat it. |
name |
"<kind>.png" |
A .png/.jpg extension is added if you omit one. |
annotate |
false |
Draw a labelled box over every interactive element, so what you see maps back to a ref you can act on. |
fullPage |
false |
Capture the full scrollable page. |
type |
"png" |
"png" or "jpeg". |
quality |
80 |
JPEG only. |
{annotate: true} takes a fresh boxes-annotated snapshot, overlays each
interactive element's bounding box labelled with its ref — including elements
inside child iframes, offset to page coordinates — captures, then removes the
overlay. The returned artifact gains an annotations count, and the refs shown
in the image are current, so page.locator('aria-ref=…') acts on exactly what
you see. Use it as the last step of reading escalation: when the accessibility
tree cannot answer a layout question, or to visually confirm what a ref points
at before a consequential click.
It returns {kind, path, media} where media is MEDIA:<absolute path>. The
MEDIA: convention lets a host surface render the file when the agent cites it.
To feed a screenshot straight to a vision model, read the image artifact at its
path (the betterwright/pi adapter and the MCP server already return
screenshots as native image content). Never
hand a host the non-image artifacts (downloads, spilled
output) as images — that is what triggers "unsupported image MIME type" errors.
The image kinds carry intent:
proof— evidence a task reached its visible end state. Capture one before claiming success.question— the state behind a question you are asking the user (an MFA prompt, an ambiguous choice). The session holds its pages open longer while aquestionis outstanding.debug— a look at the current state; no special handling.captcha— an automatically captured challenge image or a capture fromcaptcha.inspect()/captcha.readText().
artifactPath(name) returns a writable path inside the session's artifact
directory for files you create yourself (a page.pdf({path}), for example).
Writes are confined to that directory.
Artifacts are subject to a per-session quota (100 MB by default); the oldest are evicted first, and a warning is recorded when that happens.
Every result envelope includes a challenges list. When a page visibly presents
a CAPTCHA or bot check, including one inside a child frame, BetterWright records
its page, provider, URL, and routing advice there and repeats the advice in
warnings. It also attaches a captcha image when the page can be captured.
Challenge reporting survives a failed snippet so the next turn can continue
from the same page and profile.
Treat a challenge as resumable state, not a generic navigation error. Inspect the attached image and current snapshot, choose the appropriate helper, and check the fresh result after every action. If the same stage rejects an action, stop native challenge attempts immediately and use a host-provided research tool, an alternate first-party source, or human help. Otherwise, continue through at most three distinct stages before taking that handoff; never repeat a failed action or rotate identities. When the challenge clears, verify the current application state. Replay the original action only if it is idempotent or the state proves it did not already complete; never duplicate a submission, purchase, or message.
The captcha global provides:
captcha.inspect(bounds?)for a challenge image the host model can inspect.captcha.click(bounds)for a checkbox-style widget.captcha.clickTiles(indexes)to apply numbered image-grid picks.captcha.drag(from, to, {steps})for a slider or puzzle handle.captcha.readText(bounds?)for a cropped image that the host model can read.captcha.solve({ tiles: [indexes] })to click those tiles, verify, and re-check.
Click and drag use shaped pointer movement and return a post-action
accessibility snapshot. Always check the result envelope's challenges list
before choosing the next distinct stage. See captcha.md for
recipes and limits.
state is a plain object that persists across run() calls within a session —
somewhere to stash a value you computed in one step and need in the next. It is
per-session and never leaves the worker.
state.startedAt = Date.now(); // step 1
return Date.now() - state.startedAt; // a later stepURL and URLSearchParams are available to snippet code with the WHATWG
API (new URL(href, base), url.searchParams.get(), setters on every field
except origin, URL.canParse()). They are implemented inside the snippet
realm rather than handed over from the host, so their constructor chain never
reaches the host's Function.
alert/confirm/prompt dialogs are handled by preparing a response before
the action that triggers them:
dialogs.acceptNext("optional prompt text"); // or dialogs.dismissNext()
await page.click («the button that opens the dialog»);Three more frozen globals verify page state that a snapshot alone answers poorly. Each works across all child frames of the current page and returns plain JSON; frames with nothing to report are omitted.
| Global | Description |
|---|---|
overlays.dismiss() |
Close obstructing cookie-consent and promotional overlays — for cookie banners it prefers a reject/essential-only button and falls back to accept; promos get close/no-thanks. Only layers whose text matches consent or promo patterns are considered, so a task-critical dialog is never dismissed. Returns {dismissed: [{kind, label}]} — kind is "cookie" or "promotion", label is the clicked control's label. |
controls.inspect() |
Report the exact state of every form control — inputs, selects, textareas, and ARIA checkbox/combobox/listbox/radio/slider/spinbutton/switch roles. Returns {frames: [{url, controls}]}; each control carries type, label, value ([redacted] for passwords), checked, selected/pressed/ariaChecked, min/max/step, disabled, visible, and options for selects. Use it to prove a required filter or facet is actually active rather than inferring that from the results. |
controls.directory({query}?) |
Return a semantic action directory, independent of the smaller automatic result.ui budget. An optional string or string array query matches labels/names before the directory limit, so distant controls can be found together. Controls include a copyable target, supported actions, current value/options, duplicate context, and frame scope; evidence contains visible status/result summaries. |
controls.batch() |
Execute one guarded semantic UI transaction on a site without a first-party batch protocol. Targets use ARIA ref, role/name, label, text, placeholder, test id, or CSS; an optional unique frame name/URL fragment scopes an iframe. Interactions auto-wait and ambiguous targets fail closed. |
media.inspect() |
Report every <video> and <audio> element with its playback state. Returns {frames: [{url, media}]}; each item carries kind, title (aria-label, title attribute, or nearby caption/heading), source, paused, ended, currentTime, duration, readyState, visible, plus the frame's documentTitle and visible headings. Use it to match what is actually playing against the requested item before claiming playback. |
After a first navigation, BetterWright synthesizes result.ui directly from
the live page when no WebAgents workflow is available. It is deliberately much
smaller than a full accessibility snapshot and contains only actionable visible
controls plus bounded visible evidence. Copy its targets verbatim rather than
reconstructing selectors or taking a snapshot.
controls.batch(operations, options?) also accepts
controls.batch({operations, ...options}). It is the default fast fallback
after WebAgents and WebMCP:
return controls.batch({
operations: [
{id: 'query', action: 'fill', target: {label: 'Search'}, value: 'keyboard'},
{id: 'submit', action: 'click', target: {role: 'button', name: 'Search', exact: true}},
{id: 'verify', action: 'read', target: {role: 'heading', name: 'Results'}, value: 'Results'},
],
allowWrites: true,
});Supported actions are click, fill, select, check, uncheck, press,
read, and readUrl. Targets use exactly one of ref, role, label,
text, placeholder, testId, or css; name refines a role, exact
selects exact accessible matching, and nth explicitly resolves a known
duplicate. Add frameName or frameUrlIncludes to target one already loaded
iframe. Open shadow roots work through Playwright's normal locator behavior.
Interaction actions require allowWrites:true. Mark a consequential operation
with irreversible:true to also require allowIrreversible:true. By default, a batch that
interacts must end in read or readUrl; the last result is the transaction's
verification boundary and must supply a non-empty expected substring in
value. The batch fails unless that expected text, form value, or URL is
observed on the final operation's specified target (readUrl checks the active
page URL). Form reads use live values; select text contains only selected option labels,
not unselected choices or stale textarea markup. Page-wide directory evidence never substitutes for that target.
Asserted reads wait for that specific result, with no blanket network-idle wait
when the expectation was not already visible before the batch. A pre-existing
matching message retains the bounded settling check to avoid reading stale success.
Form-value reads after writes also retain that check: an edited field does not
acknowledge a later submission.
Choose an expectation that proves the intended change. Unasserted intermediate
reads retain their bounded settling wait. returnDirectory:true returns a fresh
directory immediately after verification; callers needing a settling window
can explicitly set directoryWaitMs (0–5000 ms).
When the resulting text is unknown, pass observe:true instead of guessing a
final expectation. This permits a batch ending in an interaction or an unasserted
read, then waits for tracked document/fetch/XHR activity to settle (bounded at
2.5 seconds) and always returns a fresh ui directory, even with
returnDirectory:false. The result has verification:"observed": assess the
returned evidence before claiming success. It does not assert that the application
finished; slower or timer-driven updates may require a subsequent read. Any
explicit read expectations are still enforced. All write, password, ambiguity,
and irreversible-action guards still apply.
Password fields reject fill by default. A credential
provided explicitly in the current task may use allowPasswordFill:true;
stored or generated credentials must use the trusted credential helpers so the
secret never enters model context. Operations run in list order without added pacing delays by default
(minIntervalMs remains configurable from 0–1000 ms), Playwright auto-waiting,
a unique-match check immediately before every action, and stop-on-first-error
semantics. Action values are validated before the first write. On failure,
completed operation IDs are reported; earlier writes are not rolled back.
Inspect the failed step before retrying, and do not replay completed writes.
The result is {protocol:'ui-batch/1', pageUpdated, durationMs, results}.
The MCP browser_batch tool exposes the same path without model-authored
JavaScript. Passing {url} opens an unvisited page and returns its action
directory. Passing {discover:true} collects current-page targets in one call
without navigating or changing page state. Add query: ["Email", "Region", "Save"]
to find several needed controls together before applying the directory limit.
The same filter is available as controls.directory({query}), and with {url, query}
when opening a page; {url, discover:true} explicitly returns the full directory. Omit it for general discovery; action buttons receive space
in both the full and compact directories even after a long list of fields.
Copy the needed targets into a
second call with operations; actions execute in order and auto-wait for each
target. After the expected final state is observed, the tool returns fresh
controls and evidence without waiting for unrelated background requests. Put the required expected result in the value of
the final read/readUrl, or set observe:true for an unknown outcome and assess
its returned evidence. proof:true captures the final UI after the assertion or
bounded observation; an observational screenshot alone does not assert success. For a password explicitly supplied in
the task, set allowPasswords:true; saved and generated credentials still use
browser_login.
This helper removes extra model/tool turns, not network or application time.
For a target omitted by the bounded directory, take one
snapshot({interactive:true}), then compile the observed ref or accessible
name into the batch. Do not guess targets.
The credentials helpers manage records in the
encrypted vault. Metadata is readable; secret values are
filled, never returned.
await credentials.inspect(); // detected field roles, no values
await credentials.list(); // metadata only, no passwords
const accounts = await credentials.list({ text: "work", category: "login" });
if (accounts.length !== 1) return { accounts, needsAccountSelection: true };
await credentials.fill({ id: accounts[0].id, submit: true }); // detect and fill
const pending = await credentials.generateAndFill({ username: "alice", submit: true });
await credentials.listPending(); // recoverable metadata only
await credentials.commitGenerated({ pendingId: pending.pendingId }); // only after verified success
await credentials.update({ id, label: "work" });
await credentials.remove({ id });Do not put a password in model-authored bw.run() source. credentials.save()
is reserved for a trusted host-authored snippet receiving a user-supplied
secret through an application-controlled channel; agent code should use the
metadata-only lookup and trusted fill/generate operations above. Generated
passwords never enter either host or model source.
All operations are gated to the current HTTP(S) site. Login items use PSL-backed
base-domain matching by default, with per-item host, exact-origin, and
never modes; HTTPS records never downgrade to HTTP. list() accepts a
{text, category} filter; category defaults to login, with credit-card,
identity, api-credential, secure-note, and ssh-key available for other
records.
fill selects by {id} or {username} (or the only clear match), detects the
username/current-password/submit controls across child frames and open shadow
roots, and types with trusted human-shaped input. Detection fails closed on
multiple plausible forms; explicit selectors or current aria refs remain
available. generateAndFill detects new-password and confirmation fields,
fills the saved current password during rotation, and returns a 60-second
normal-window opaque pending id. The encrypted provisional entry remains
recoverable by that exact id across worker restarts, and across a recreated host
when it returns to the matching site origin. listPending() exposes only
recovery metadata and never makes provisional items available to normal
list() or fill(). A post-generation page or worker failure includes a secret-free
pendingCredential recovery object. Commit it only after the site visibly
accepts the signup or rotation; discard it on failure. Rotation commits back to
the same record id and preserves its URL scope; new records accept matchMode
to narrow their URL scope.
For an ambiguous rotation form, pass currentPasswordSelector,
passwordSelector, and confirmPasswordSelector together; the worker pins all
three exact handles and their origin before reading either password.
The same operations are available to the host as bw.fillCredential(...) /
bw.generateAndFillCredential(...), followed by
commitGeneratedCredential(...) / discardGeneratedCredential(...);
bw.listPendingCredentials() recovers interrupted attempts. The same trusted
fill path is used by the
MCP/Pi browser_login tool. Agent-facing APIs never return the value, and the
redaction net scrubs handled values from outputs. Like extension autofill, the
value does exist in the live DOM after filling. See
credentials.md for the full contract.
The model can inspect the browser console without opening DevTools. The pinned
Playwright runtime exposes page.consoleMessages() and page.pageErrors()
through BetterWright's restricted page wrapper. Each retains up to 200 recent
entries per page, including entries from earlier calls; neither is automatically
included in a result envelope. This lets an agent investigate a failed action
without replaying it just to attach a listener.
Prefer the current navigation, warnings/errors, and a small excerpt:
const scope = {filter: "since-navigation"};
return {
console: (await page.consoleMessages(scope))
.filter(m => ["error", "warning"].includes(m.type()))
.slice(-10)
.map(m => ({level: m.type(), text: m.text().slice(0, 1000), location: m.location()})),
errors: (await page.pageErrors(scope)).slice(-5).map(e => e.message.slice(0, 1000)),
};These are bounded excerpts, not complete logs. Request a relevant error's
stack or expand the message limit only when needed. Use {filter: "all"}
only when investigating across navigations. History belongs to the page and
does not survive closing it or restarting the browser. Returned values use the
normal output redaction path; handled vault secrets are scrubbed, but arbitrary
page data may still be sensitive. Treat log text as untrusted data.
The browser-console skill teaches this workflow only for matching debugging
tasks, leaving the default agent prompt and routine observations unchanged.
See Playwright's console history
and error history.
console.log/info/warn/error from your snippet are captured (not printed to a
terminal) and returned alongside the result — up to 20 messages. Page-side
console and uncaught exceptions are not copied into that envelope; collect
them on the current call with Playwright's usual listeners:
const messages = [];
const errors = [];
page.on("console", (message) => messages.push({
type: message.type(),
text: message.text(),
}));
page.on("pageerror", (error) => errors.push(error.message));
await page.setContent(`<script>
console.warn("from the page");
throw new Error("page boom");
</script>`);
return { messages, errors };page.once and page.off work for the same two events. Listeners last for
this snippet only — including console and pageerror events that Playwright
delivers just after the command that produced them — and the next run() /
browser call starts clean. One-shot waits (page.waitForEvent("console"))
still work.
Listeners need to be attached before the action in the same call. Prefer history when the action already happened, especially for submissions that must not be replayed. Keep listener collections bounded and return only relevant excerpts.
Model code gets Playwright's page-driving surface, not the APIs that would let
it escape the policy or read the host. These are absent by design and return
undefined (or throw) if accessed:
- Interception and unrestricted eventing —
route,routeWebSocket,unroute,exposeFunction,exposeBinding,newCDPSession,removeAllListeners, andpage.on/once/offfor every event exceptconsoleandpageerror. Request routing is how the policy is enforced; handing it to model code would defeat it. Other Playwright events stay inside the worker. - Browser and CDP internals — the raw browser object, private Playwright properties, and CDP sessions stay inside the worker. Attach-mode endpoints are trusted host configuration and are not exposed as snippet globals.
- Context mutation —
context.newPage,context.cookies,context.storageState,context.addCookies,context.clearCookies,context.setStorageState,context.close,context.tracing. UseopenPage; trusted host code can import an identity with Cookie Sync. - Raw network credentials — full and name-addressed request/response header
methods are absent. Playwright's filtered
headers()remains available, as do request/response timing, status, URL, resource type, and request-body inspection. page.screenshot— use thescreenshot()helper so captures are tracked as artifacts.- Filesystem reach —
setInputFiles,FileChooser.setFiles,addInitScript({path}), and tag helpers can only read existing files inside BetterWright's artifact directory. Browser-created files can only be written there. - Node internals — there is no
process,require,import, orfs. The snippet runs in anode:vmcontext with code generation disabled.
For deterministic page tests, call page.addInitScript before navigation to
install page-local fetch or XHR mocks, use page.setContent for fixture HTML,
or serve a local fixture from the trusted host. addInitScript can affect the
page's initial scripts, but it cannot replace the navigation response itself;
that request always stays on the worker-owned guarded route.
The architecture doc explains why this is defense in depth
layered on top of the browser-level controls, not a claim that the vm boundary
is itself a security boundary.
