# The browser API

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.

```js
await bw.run(`
  await page.goto('https://news.ycombinator.com');
  const titles = await page.locator('.titleline > a').allInnerTexts();
  return titles.slice(0, 5);
`);
```

## Return values

- A single trailing **expression** is returned automatically:
  `bw.run("return page.title()")` and `bw.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 — a `Page` becomes
  `{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 a `truncated` marker. 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.

## Pages

| 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(idOrIndex)` | Make another page current; accepts a `pageId` or an index. |
| `closePage(idOrIndex?)` | Close a page (the current one if omitted). |
| `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](sdk.md#errors-timeouts-and-the-worker).

```js
// 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 the page

`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](recording.md) for options, limits, and CLI commands.

```js
return recording.start({ name: "demo.mp4", fps: 60 });
```

## Reading the page

`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:

```js
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` | `10000` | 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.

### Same-origin application data

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.

## Batch-native WebAgents workflows

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:

````md
# 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:

```js
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.

## Page-published WebMCP tools

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:

```js
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.

## Human-shaped interactions

The frozen `human` global emits visible actions with curved pointer movement,
bounded key timing, and eased wheel steps instead of machine-perfect bursts:

```js
await human.click(page.getByRole('button', {name: 'Continue'}));
await human.type('#email', 'person@example.com');
await human.scroll(650); // negative values scroll upward
```

`human.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.

## Screenshots and artifacts

![A completed task with a proof checkmark and captured screenshots](https://raw.githubusercontent.com/BetterWright/betterwright/main/docs/assets/proof.png)

`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 a
  `question` is outstanding.
- **`debug`** — a look at the current state; no special handling.
- **`captcha`** — an automatically captured challenge image or a capture from
  `captcha.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.

## Bot-challenge detection

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](captcha.md) for
recipes and limits.

## State

`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.

```js
state.startedAt = Date.now();          // step 1
return Date.now() - state.startedAt;   // a later step
```

## Dialogs

`alert`/`confirm`/`prompt` dialogs are handled by preparing a response before
the action that triggers them:

```js
dialogs.acceptNext("optional prompt text");   // or dialogs.dismissNext()
await page.click («the button that opens the dialog»);
```

## Overlays, controls, and media

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. |

### Semantic UI batches for ordinary sites

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:

```js
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.

## Credentials

The `credentials` helpers manage records in the
[encrypted vault](credentials.md). Metadata is readable; secret values are
filled, never returned.

```js
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](credentials.md) for the full contract.

## Console

### Read recent browser history on demand

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:

```js
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](https://playwright.dev/docs/api/class-page#page-console-messages)
and [error history](https://playwright.dev/docs/api/class-page#page-page-errors).

### Capture a fresh reproduction

`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:

```js
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.

## What is removed

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`, and `page.on` / `once` / `off` for every event except
  `console` and `pageerror`. 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`. Use `openPage`;
  trusted host code can import an identity with
  [Cookie Sync](cookie-sync.md).
- **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 the `screenshot()` 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`, or `fs`. The
  snippet runs in a `node:vm` context 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](architecture.md) 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.
