Skip to content

Tool reference: secrets, credentials, extensions, stealth and captcha

register_secret({ name, value, scope?, session? })

Section titled “register_secret({ name, value, scope?, session? })”

Register a sensitive value the agent will use without ever seeing the real string in any tool result. Gated behind the off-by-default secrets capability, the same posture class as eval / network-body / disableWebSecurity.

Shape:

  • name: agent-facing alias, must match /^[A-Z][A-Z0-9_]*$/ (uppercase identifier, e.g. PASSWORD, OTP, SESSION_TOKEN). The <NAME> mask is the stable contract.
  • value: the real secret. Stored per-session in memory only; never persisted, never logged. The registry never echoes it back, even on registration confirmation.
  • scope?: optional origin, or a list of origins. Each entry is an origin (https://app.example.com, http://localhost:3000, app://com.acme.app for a native app) or a bare host (app.example.com, app.example.com:8443) meaning https:// on that host. A path, query, fragment, credentials or a * wildcard is refused at registration. When set, dispatch-side substitution refuses unless the origin of the frame that receives the value matches an entry exactly: same scheme, same host, same port. There are no subdomain rules: app.example.com does not match login.app.example.com. The receiving frame is the one that owns the fill / press target element (so a field inside an iframe is checked against the iframe’s origin), or the focused frame for a targetless press. The value is then typed into that same element, and its frame’s URL is read again right before typing: if the frame moved to another origin in between, the action returns ok:false and nothing is typed. A targetless press re-checks the focused frame the same way. Frames with no origin to name (about:blank, about:srcdoc, data:) never match. Egress masking is global regardless of scope.

Returns: { ok, registered, scope, origins, names, tokensEstimate }. origins lists the normalised origins the scope matches (null when unscoped). names echoes the live alias list (NOT values).

Example.

register_secret({ name: "PASSWORD", value: "s3cr3t-hunter2", scope: "app.example.com" })
// → { "ok": true, "registered": "PASSWORD", "scope": "app.example.com",
// "origins": ["https://app.example.com"], "names": ["PASSWORD"], "tokensEstimate": 52 }
fill({ selector: "input[type=password]", value: "<PASSWORD>" })
// every subsequent tool result shows <PASSWORD>, never the real value

Dispatch-side pairing. Once registered, the agent calls:

  • fill({value: "<NAME>"}): runtime substitutes the real value AT Playwright dispatch; the action descriptor on ActionResult.action.value records the alias <NAME>, never the real value.
  • press({key: "<NAME>"}): same substitution path for keypress flows (one-shot OTP into a focused field). Modifier+key shapes like Shift+A pass through unchanged; the <NAME> shape doesn’t collide.
  • Plain string values pass through unchanged. The substitution is structural (/^<[A-Z][A-Z0-9_]*>$/), not value-based, so a literal angle-bracketed text in the page stays a literal.

Egress-side masking. Every sink that could carry the real value is scanned on the way out:

SinkStatus
ActionResult.network.requests[].url (URLs in action-window tap)masked
ActionResult.network.mutations[].urlPattern + responseShapemasked
ActionResult.network.wsFrames[].payload + urlmasked
network_read.requests[].url (session ring)masked
network_body.body (response body)masked: JSON / text; base64 bodies only where the value’s own base64 appears (see below)
ws_read.frames[].payload + .urlmasked
console_read.recent[].text + errors + pageErrorsmasked
snapshot() tree (a11y node names)masked
find() candidates (name, testId, selectorHint, context.rowText)masked (deep-walk)
text_search() matches (visible text)masked (deep-walk)
plan().evidence (selectorHint / role / name on the planned descriptor)masked (deep-walk)
inspect().styles (computed content / background-image: url(...))masked (deep-walk)
point_probe() (textContent of element-under-point + ancestor text)masked (deep-walk)
verify_text / verify_value / verify_attribute, failure.actual on missmasked (deep-walk); without this, a wrong-expected verify would echo the real value back
verify_count / verify_visible / verify_predicate, failure.actualmasked (deep-walk)
act_and_diff().diff (classDelta / styleDelta / attrDelta values)masked (deep-walk); covers aria-* / data-* attribute values + inline-style values
watch() regions / network / WS over the watch windowmasked (NetworkTap takes the secrets registry; result deep-walked)
extract, shadow_trees, cookies_get, idb_get, caches_get page datamasked (every tool result, see below)
any other tool’s text resultmasked (every tool result, see below)
screenshot() (image bytes)partial, warning only, see below

Every tool result. On top of the per-sink masking above, every registered tool’s result goes through one more pass on the way out: each text item has registered values replaced with <NAME>. A JSON result is parsed and its string values, object keys and any number whose digits hold a value are masked, then it is written back in the same layout, so a value that JSON encoding changed is caught and the document stays valid. Such a number is replaced as a whole by the string "<NAME>", so its type changes from number to string; other numbers stay numbers. A result with no registered value in it is returned untouched without being parsed, so already-masked sinks cost one substring scan. Image items are not touched. This pass covers every session’s registered values, and it runs only when the secrets capability is on.

Masking guarantees. The egress layer composes with the existing URL sanitiser at the same boundary: URL sanitiser runs first (regex on URL structure: query/fragment/userinfo/token-paths), then the secrets layer (literal real-value substring scan). They don’t fight: the sanitiser may already have stripped a credentialled query, but the literal-value scan catches a real value that landed in a path / payload / header value.

Idempotent: re-masking a previously-masked string is a no-op (the <NAME> mask never contains a registered value, by construction).

Longest-value-first: when two registered values overlap (one is a substring of another), the longer one is masked first, so a partial leak of the shorter alias is impossible.

Encoded forms: a value of 6 characters or more is also masked where it appears percent-encoded (encodeURIComponent, and the form-body encoding that writes a space as +) or as its own base64 or base64url encoding, padded or not. A shorter value is masked only as typed, since its encodings are short enough to turn up by chance.

Limitations (enumerated for the threat model):

  1. screenshot() is a partial sink. PNG/JPEG bytes are not OCR’d server-side. Instead, the page’s text content is swept for any registered real-value, and when one is detected the result prepends a warning naming the affected aliases. Pixel-level redaction (region-blur of the bounding boxes that contain a matched value) is a typed seam for v0.2.x. For verified-clean evidence, prefer snapshot() / find() / text_search() (all fully masked) over a screenshot.
  2. Base64 response bodies pass through network_body mostly unchanged. The body is masked only where the value’s own base64 appears at an encoding boundary. A value inside a larger encoded string is usually not found: base64 of user:password (Authorization: Basic …), a binary body, or a value at an offset that shifts its encoding. A prefix whose length is a multiple of 3 bytes (alice:) keeps the boundary, so that tail is masked. Decode + re-mask on the agent side if you fetch base64 bodies that may carry a secret. The common case (JSON / text) is fully masked.
  3. Cap is 32 secrets per session. Bounded so the per-sink scan stays O(secrets × text-len) reasonable; realistic auth flows fit well under.
  4. scope narrows dispatch, not egress. Scoped secrets won’t be substituted into a fill in a frame on another origin (refused with a clear error), but if a registered value reaches a sink for any reason, it’s masked regardless of scope.

Capability gate. Off by default. Add secrets to BROWX_CAPABILITIES to enable. A one-time loud warning fires at server boot (when the capability is on) and at the first register_secret call (naming the egress sinks now engaged). Mirrors the eval / network-body / disableWebSecurity posture documented in Threat model.

Pluggable hook into an operator-configured credentials / TOTP vault. Without this, agents driving real auth flows block on 2FA, and the only escapes (“bake the seed into the prompt”) defeat secrets-masking by leaking the seed into transcripts. Gated behind the off-by-default credentials capability, the same posture class as eval / network-body / secrets.

CRITICAL: provider is selected per-deployment, never bundled. The browxai server NEVER auto-installs a CLI binary, NEVER auto-purchases a vault, NEVER prompts the operator interactively. If the configured backend is missing, every lookup returns a structured {ok:false, error, hint} with the install instruction; the agent’s flow either retries with a different account, calls await_human, or fails cleanly.

Provider matrix (selected via BROWX_CREDENTIALS_PROVIDER):

ProviderTOTPCredentialDependency
oathtool (default)yesno (TOTP-only)system oathtool (macOS: brew install oath-toolkit; Debian/Ubuntu: apt install oathtool); seeds via env
1passwordyesyes1Password CLI op on PATH; op signin performed out-of-band
bitwardenyesyesBitwarden CLI bw on PATH; $BW_SESSION from bw unlock in server env
lastpassyesyeslpass CLI on PATH; lpass login performed out-of-band
nonenonoexplicit no-op; useful for testing the surface without a real vault

Configuration env:

BROWX_CREDENTIALS_PROVIDER=oathtool
BROWX_OATHTOOL_SEEDS="acme=JBSWY3DPEHPK3PXP,other=NBSWY3DPEHPK3PXP"
# or one of:
# BROWX_CREDENTIALS_PROVIDER=1password
# BROWX_CREDENTIALS_PROVIDER=bitwarden
# BROWX_CREDENTIALS_PROVIDER=lastpass

Optional CLI-path overrides (when the binary lives outside PATH): BROWX_OATHTOOL_BIN, BROWX_1PASSWORD_BIN, BROWX_BITWARDEN_BIN, BROWX_LASTPASS_BIN.

Per-account origins for get_credential, in the same account=value,… form as the seeds, with | between origins. get_credential needs the account listed here; an unlisted account is refused before the vault is asked:

BROWX_CREDENTIAL_ORIGINS="acme=https://app.acme.com|https://sso.acme.com,other=other.example"

Each origin takes the register_secret scope forms. An account whose list has an entry that does not parse is ignored with a startup warning, so it is refused like an unlisted one. The variable is read at start: restart browxai after changing it.

Account names. account must start with a letter or digit and use only letters, digits, space and . _ @ + : / -, at most 128 characters. Anything else is refused before the vault is asked; use the vault item’s id for a name with other characters.

Look up a one-time TOTP code. Returns {ok, code, provider} on success; {ok:false, error, hint, provider} on failure (missing seed / CLI not on PATH / CLI not logged in; actionable hint included).

  • account: provider-specific identifier. For oathtool, a key from BROWX_OATHTOOL_SEEDS. For 1password / lastpass, an item name. For bitwarden, an item id.

TOTP codes are NOT masked through the secrets registry: a TOTP is single-use and short-lived, so masking buys little while complicating the verify-step flow. The agent passes the code directly to fill({value: code}) or compares against on-page text.

Look up a {username, password} pair. Returns {ok, username, aliasName, origins, provider} on success, never the cleartext password. The password is auto-registered into the per-session secrets registry under <PASSWORD_<account>> (account sanitised to /^[A-Z][A-Z0-9_]*$/), scoped to origins: the account’s BROWX_CREDENTIAL_ORIGINS list. fill then materialises the password only into a frame on one of those origins. An account with no BROWX_CREDENTIAL_ORIGINS entry is refused before the vault is asked; the error names the variable and the account and gives an example value. With acme-corp=https://app.acme.com set, the agent drives:

navigate({url:"https://app.acme.com/login"})
get_credential({account:"acme-corp"}) → {username:"alice@…", aliasName:"PASSWORD_ACME_CORP", origins:["https://app.acme.com"]}
fill({selector:"input[name=username]", value:"alice@…"})
fill({selector:"input[name=password]", value:"<PASSWORD_ACME_CORP>"})

Dispatch-side substitution materialises the real value at Playwright dispatch; egress-side masking strips occurrences across every sink (see the register_secret matrix above).

Pairing rule. get_credential ADDITIONALLY requires the secrets capability to be enabled. Without it, the lookup refuses with a clear error (returning a password in cleartext would leak it into the transcript on first reference). Enable both: BROWX_CAPABILITIES=read,navigation,action,human,credentials,secrets.

Per-provider notes:

  • oathtool does NOT support get_credential (TOTP-only). Pair with a credential-bearing provider, OR await_human for the username/password half and get_totp for the TOTP half.
  • 1password reads the username + password labelled fields via op item get --fields label=username,label=password --format json -- <account>.
  • bitwarden reads login.username + login.password via bw get item -- <account>.
  • lastpass reads via lpass show --username --password -- <account>.

Posture. Off by default; loud one-time warning at server boot when the capability is on. Provider is per-deployment, never bundled, never auto-installed. All shell invocations use fixed argv (no shell interpolation, account name passed as a discrete argv element after --, and an account name can never start with -). 5-second wall-clock timeout per call so a hung CLI can’t block tool dispatch.

Extensions registry (capability extensions)

Section titled “Extensions registry (capability extensions)”

Per-session unpacked-Chromium-extension management. Gated behind the off-by-default extensions capability, the same posture class as eval / network-body / secrets.

Trust posture. A loaded extension can read every page the session visits and make arbitrary network requests. The extension code is trust-equivalent to the agent’s own action surface: treat the extension’s filesystem path as in-scope trust, just like you would the agent’s tool calls. Mitigations: workspace-rooted paths (no escape), the capability is off by default with a loud boot warning, and extensions cannot be loaded on incognito or attached sessions.

Session-mode constraints.

  • Headed + persistent sessions only. Chromium’s --load-extension flag is reliable only in headed mode; headless:true sessions refuse. The attached/BYOB session refuses because the human’s Chrome is not-owned (it already has its own extension set). The incognito session refuses because Chromium does not load unpacked extensions in incognito (the per-extension “allowed in incognito” flag is not togglable via the Playwright launch API).
  • install / reload / uninstall rebuild the underlying browser context. Chromium does not support adding or removing extensions on a live context, so the tools tear down the current BrowserSession, relaunch openManagedSession with the updated --load-extension flag set, and splice the new pieces (page, console, network, ws, bridge, refs) onto the existing SessionEntry. Consequences: open refs invalidate, the page navigates to about:blank, console/network/ws buffers reset. Profile state on disk (cookies, localStorage, IndexedDB) survives; it lives in the profile dir. Treat install/reload/uninstall as “session-restart with new extension set”, not as hot reload.

Load an unpacked extension (MV3 or MV2 directory containing manifest.json) into the session’s managed-profile launch.

  • path: workspace-rooted directory. Traversal (..), absolute paths outside $BROWX_WORKSPACE, files (vs directories), and directories missing manifest.json all reject with a structured error. Packed .crx archives must be unpacked first.

Returns: {ok, session, installed: {id, name, version, path}, loaded: [{id, name, version, path, enabled}], note, tokensEstimate}. The id is a stable hash of the resolved path; pass it back to extensions_reload / extensions_trigger / extensions_uninstall.

Example.

extensions_install({ path: "extensions/my-helper" })
// → { "ok": true, "installed": { "id": "ext-9f2c…", "name": "My Helper",
// "version": "0.3.0", "path": "/…/.browxai/extensions/my-helper" },
// "loaded": [{ "id": "ext-9f2c…", "enabled": true, … }],
// "note": "context rebuilt — open refs invalidated, page is about:blank" }

Return the session’s currently-loaded extensions: [{id, name, version, path, enabled}]. Empty list when none are loaded (the default).

Re-parse the manifest at the extension’s loaded path AND rebuild the browser context. Chromium re-injects content scripts and restarts the MV3 service worker on context start. Use after editing the extension’s source.

extensions_trigger({ id, command?, session? })

Section titled “extensions_trigger({ id, command?, session? })”

Best-effort invocation surface.

  • Without command, navigates the session’s active page to the extension’s chrome-extension://<runtime-id>/ URL; the page renders the extension’s default_popup (when one is declared) and is driveable like any other page.
  • With command, attempts to fire the named keyboard-command binding from the manifest’s commands map. Chromium does not expose extension keyboard-command dispatch via CDP / Playwright, so this branch returns a structured ok:false with a workaround hint. Use the popup branch (no command) or drive the extension’s underlying content-script API directly.

The id mapping caveat. browxai’s id is a hash of the unpacked path. The Chrome runtime id (the <id> in chrome-extension://<id>/… URLs) is derived from the extension’s manifest.key field when present; otherwise it’s hash-derived but using Chrome’s own algorithm, not ours. extensions_trigger discovers the runtime id by inspecting the context’s service-worker / background-page URLs (both start with chrome-extension://<runtime-id>/); when there’s exactly one loaded extension and one detected runtime id we assume the mapping. Otherwise the result returns the detected runtime-id set so the caller can decide.

Remove the extension from the session’s registry and rebuild the browser context without it.

Capability gate. Off by default. Add extensions to BROWX_CAPABILITIES to enable. A one-time loud warning fires at server boot (when the capability is on) describing the trust posture and the rebuild semantics. Mirrors the eval / network-body / secrets posture documented in Threat model.

Stealth fingerprint patches (capability stealth)

Section titled “Stealth fingerprint patches (capability stealth)”

stealth is a behaviour gate: it registers no new MCP tool. When the capability is on, every browser context created by the server (managed / incognito / and on the rebuild path used by extensions_*) loads a per-context init-script that overrides the well-known Playwright fingerprint surface BEFORE any page script runs:

  • navigator.webdriver: false
  • navigator.plugins: non-empty PluginArray-like (Chrome PDF Viewer)
  • navigator.languages: ["en-US", "en"] when the headless default emitted []
  • window.chrome: defined with runtime: {} when the UA tells

Patches use Object.defineProperty({configurable: true}), so legitimate page code can still inspect or replace them. We’re spoofing detection, not lying to legitimate code. The script is wrapped in an IIFE so no helpers leak into page globals, and guarded by a sentinel (window.__browx_stealth) so it is idempotent against re-application.

Capability gate. Off by default. Add stealth to BROWX_CAPABILITIES to enable. A one-time loud warning fires at server boot (when the capability is on) naming the legal/ToS exposure explicitly: circumventing automation detection may violate a site’s terms of service. browxai does NOT bundle a full anti-fingerprinting library; only the four well-known patches above. Mirrors the eval / network-body / secrets / extensions posture documented in Threat model.

Captcha solver delegation (capability captcha)

Section titled “Captcha solver delegation (capability captcha)”

solve_captcha({ type, selector?, siteKey?, imageBase64?, session? })

Section titled “solve_captcha({ type, selector?, siteKey?, imageBase64?, session? })”

Delegate a captcha challenge to a configured external provider and return the provider’s solution token / text. browxai is a delegation seam: the tool POSTs the challenge to the provider’s HTTP API and polls for the answer, and the solver runs entirely on the provider’s infrastructure. Off by default (capability captcha).

What leaves the machine. On each call: BROWX_CAPTCHA_API_KEY (in the form body of the submit and of every poll request, never in a URL), the URL of the current page (pageurl), and the widget site-key, or the base64 image bytes for type:"image". They go to the provider’s API, https://2captcha.com or https://api.capmonster.cloud unless BROWX_CAPTCHA_API_BASE overrides it. Cookies, DOM, snapshots and screenshots are not sent. With no provider configured nothing is sent.

Provider config (per-deployment, env-driven). browxai does NOT bundle a solver and does NOT auto-purchase credits. Operator chooses a provider, funds the account, and sets the env vars:

  • BROWX_CAPTCHA_PROVIDER (required): 2captcha or capmonster (case-insensitive).
  • BROWX_CAPTCHA_API_KEY (required): the provider account API key.
  • BROWX_CAPTCHA_API_BASE (optional): override the canonical base URL (useful for self-hosted CapMonster-compatible proxies / testing). It must be an https URL, because the API key and the challenge go to it. Plain http is accepted only for 127.0.0.1, ::1 and localhost; any other base makes the tool return an error that names the variable. A redirect on the submit or poll call ends the solve with an error naming BROWX_CAPTCHA_API_BASE, because the request carries the API key.
  • BROWX_CAPTCHA_TIMEOUT_MS (optional, default 120000): per-attempt deadline.
  • BROWX_CAPTCHA_POLL_MS (optional, default 5000): poll interval.

When the capability is on but no provider is configured, the tool returns a structured {ok:false, error:"no captcha provider configured, …", hint:…} and sends nothing. Setting only one of BROWX_CAPTCHA_PROVIDER / BROWX_CAPTCHA_API_KEY, or an unknown provider name, returns an ok:false that names the missing piece.

Protocol target. v0.2.0 targets the 2Captcha-compatible REST API (POST /in.php submit + POST /res.php poll). CapMonster Cloud documents itself as drop-in compatible with this shape, so the same code talks to either provider. Other providers (AntiCaptcha’s /createTask + /getTaskResult, etc.) are extensible: add a branch in src/page/solve-captcha.ts and append the provider name to KNOWN_PROVIDERS.

Inputs.

  • type: one of recaptcha2, recaptcha3, hcaptcha, turnstile, image.
  • selector (widget captchas): CSS selector for the widget element on the current page. When given, the server reads data-sitekey (or data-site-key / sitekey) to populate siteKey.
  • siteKey (widget captchas): explicit site-key (alternative to selector).
  • imageBase64 (image type): raw base64 image bytes (no data:image/...;base64, prefix).

Returns. {ok, provider, solution, taskId, elapsedMs} on success; {ok:false, provider, error, hint, providerCode?} on failure. The agent is responsible for wiring the solution back into the page (different sites call recaptcha callbacks differently, fill a hidden form field, or invoke grecaptcha.getResponse). We do NOT auto-submit. The solution string passes through the per-session secrets registry mask on egress (same posture as other egress sinks).

Example.

solve_captcha({ type: "recaptcha2", selector: ".g-recaptcha" })
// → { "ok": true, "provider": "2captcha", "solution": "03AGdBq2…",
// "taskId": "7211…", "elapsedMs": 34000 }
// then wire the token into the page yourself (site-specific).

Capability gate. Off by default. Add captcha to BROWX_CAPABILITIES to enable. A one-time loud warning fires at server boot (when the capability is on) naming the legal/ToS exposure explicitly: solving captchas may violate the target site’s terms of service and, depending on jurisdiction, computer-misuse / unauthorised-access law; the operator carries that exposure. Mirrors the eval / network-body / secrets / extensions / stealth posture documented in Threat model.

Device emulation: emulate_bluetooth / emulate_usb / emulate_hid / device_requests

Section titled “Device emulation: emulate_bluetooth / emulate_usb / emulate_hid / device_requests”

Per-session synthetic-device catalogs for the three Web platform device-picker APIs. The page-side init-script wrappers around navigator.bluetooth.requestDevice / navigator.usb.requestDevice / navigator.hid.requestDevice resolve with synthetic objects matching W3C shapes, so an agent can drive a page that gates a flow behind a device picker without owning the hardware.

Capability gate. Off by default. Add device-emulation to BROWX_CAPABILITIES to enable. A one-time loud warning fires at server boot. The capability is posture-broadening (the wrappers tell the page it found physical devices that don’t exist), so it sits as its own slot, separate from action. Same posture class as eval / network-body / secrets / extensions / stealth / captcha. See Threat model.

The wrappers install eagerly at session creation so a page calling requestDevice on initial document parse never hangs. When the capability is OFF, the wrappers still install (the page sees the user-dismissed shape and no deadlocked promise), but the check binding short-circuits to refused, and device_requests surfaces the attempt with handledAs:"refused" so an operator without the capability can still see that the page asked.

emulate_bluetooth({devices?, session?}): stage a Bluetooth catalog. {devices:[…]} installs; omit or pass {devices:[]} to clear (next requestDevice rejects with NotFoundError, the user-dismissed shape). The synthetic BluetoothDevice carries {id, name, uuids, gatt, addEventListener, watchAdvertisements, forget}. gatt.connect() resolves with a stub server whose getPrimaryService / getPrimaryServices reject; v1 covers picker-clear flows, not full GATT exchange. Returns {ok, session, api:"bluetooth", catalog:{devices}, warnings?, tokensEstimate}.

emulate_usb({devices?, session?}): stage a USB catalog. The synthetic USBDevice carries {vendorId, productId, productName, manufacturerName, serialNumber, deviceClass, deviceSubclass, deviceProtocol, usbVersionMajor/Minor/Subminor, deviceVersionMajor/Minor/Subminor, configuration, configurations} plus the full method surface (open, close, selectConfiguration, claimInterface, releaseInterface, selectAlternateInterface, controlTransferIn/Out, clearHalt, transferIn/Out, isochronousTransferIn/Out, reset, forget). All resolve; transfer endpoints resolve with zero-byte payloads (no synthetic data flow).

emulate_hid({devices?, session?}): stage a HID catalog. The HID API is multi-result by construction: requestDevice resolves with an Array<HIDDevice>; an EMPTY catalog resolves with [] (the HID user-dismissed shape), NOT a rejection. The synthetic HIDDevice carries {opened, vendorId, productId, productName, collections, oninputreport} plus open / close / forget / sendReport / sendFeatureReport / receiveFeatureReport (resolves with an empty DataView). oninputreport is never fired; no synthetic input stream.

device_requests({since?, session?}): read-side companion. Returns {ok, session, supportedApis:["bluetooth","usb","hid"], requests:[{api, handledAs, returned, filters?, ts}], tokensEstimate}. handledAs:

  • "resolved": catalog non-empty; picker resolved with synthetic device (Bluetooth/USB) or list (HID).
  • "rejected": Bluetooth/USB + catalog empty; picker rejected with NotFoundError (user-dismissed shape).
  • "empty": HID + catalog empty; picker resolved with [] (the HID user-dismissed shape).
  • "refused": capability was OFF at call time; the wrapper short-circuited but the buffer recorded the attempt.

since slices the buffer to ts >= since; omit to return everything (buffer is capped at 200 records).

Example.

emulate_bluetooth({ devices: [{ name: "HR Monitor", id: "hr-1", services: ["heart_rate"] }] })
// …drive the page's "pair device" button…
device_requests({})
// → { "ok": true, "supportedApis": ["bluetooth", "usb", "hid"],
// "requests": [{ "api": "bluetooth", "handledAs": "resolved",
// "returned": "HR Monitor", "filters": […], "ts": 1765540264420 }] }

Synthetic device fields (W3C compatibility). The devices[] entries accept the W3C-relevant union of fields across the three APIs, and each wrapper picks the ones its spec exposes:

FieldBluetoothUSBHIDDefault
namedevice.namedevice.productNamedevice.productName"browxai-virtual"
iddevice.idn/an/a"browxai-<api>-<index>"
vendorIdn/adevice.vendorIddevice.vendorId0x0000
productIdn/adevice.productIddevice.productId0x0000
manufacturerNamen/adevice.manufacturerNamen/a"browxai virtual"
serialNumbern/adevice.serialNumbern/a"BROWX-VIRTUAL"
deviceClassn/adevice.deviceClassn/a0xFF
deviceSubclassn/adevice.deviceSubclassn/a0x00
deviceProtocoln/adevice.deviceProtocoln/a0x00
servicesdevice.uuidsn/an/a[]
collectionsn/an/adevice.collections[]

Missing fields default to deterministic placeholders so the page sees a complete shape regardless of how sparsely the catalog was populated. The fields the wrapper doesn’t surface for an API are still accepted on the agent side (a single catalog entry can carry every field, useful for a multi-API page that probes the same device via different APIs).

Deferred follow-ups (v2+). GATT service emulation for Bluetooth (synthetic characteristics + read/write/notify so a page can exchange data over the synthetic device); transferIn / transferOut synthetic data streams for WebUSB; oninputreport synthetic input streams for WebHID; getDevices() cross-permission-grant persistence so an already-paired device survives a navigation.