Tool reference: sessions
Sessions
Section titled “Sessions”Every browser-touching tool accepts an optional session arg (default "default"). Each session id is a fully isolated browser context: its own cookie jar / storage (for persistent sessions that means its own profile: with none, sessions share the workspace default profile and only one can be live), its own ref registry, its own console/network buffers, its own recorder + find-feedback memory. This is the concurrency model:
- Multiple agents, one server: give each agent its own
sessionid; they can’t stomp each other (no server-global “active session”). - One agent, many sessions: drive several windows/flows in parallel by id.
- Multi-user / multiplayer: two sessions logged in as different users on the same app don’t bleed, because they’re different browser contexts (different cookie jars).
Omitting session resolves to the lazily-created "default" session, byte-identical to pre-2.5 single-session behaviour, so existing callers need no changes.
open_session({ session, mode?, engine?, profile?, channel?, backgroundThrottling?, device?, viewport?, har?, hars? }): eagerly create an id (else it’s lazily created on first use, inheriting the server launch mode). Re-opening a live id errors.engine(chromium|firefox|webkit|android|safari|ios-app|android-app|electron) picks the engine for THIS session, overriding the server default.ios-appandandroid-appare NOT browsers. They drive a native app on an iOS Simulator and on an Android emulator, and both need the off-by-defaultnative-devicecapability (see “Native app control”).electronis a desktop Electron application attached over its debugging port; you rarely need to name it, because the desktop CDP-attach lane detects one from the protocol and reports it either way. One server can drive sessions on several engines at once (see “Session engine” below). A name that heldask-humankeeps it. If the session last closed withpermissionPolicy,fsPickerPolicyornotificationPolicyonask-human(top-level or per key), reopening that name with a policy that moves a held key offask-humanis refused withrequiredCapability: "human-gate-override"and areasonnaming the policies, before anything launches, unless the operator enabled that capability. A policy you leave out is inherited from the hold (a reopen with none, or a lazily re-createddefaultsession, stays onask-human). A name that never heldask-humanopens with any policy, and moving ontoask-humanis always allowed. Another name launched on the held session’s persistentprofileis refused the same way (unless its policies keep every held key onask-human), because the profile carries that session’s cookies and login state; attached sessions are not pinned. The record lives for the server process.harwires a HAR recorder at context creation (native PlaywrightrecordHar, finalized on session close).harsis the symmetric REPLAY axis: a workspace-rooted list of .har files served viarouteFromHAR(notFound:"fallback"). See the HAR record/replay section under “Advanced tools” for the full lifecycle.close_session({ session }): tear down (attached detaches only, never closes the user’s Chrome; incognito discards its ephemeral context + browser)."default"may be closed; it re-creates lazily.close_sessions({ prefix?, all?, idleMs? }): bulk teardown for multi-agent cleanup.prefix(id starts-with, e.g. one agent’sagentA-*),all:true, and/oridleMs(no activity in the last N ms). Selectors AND together; at least one required (won’t implicitly close nothing/everything). Returns{ closed:[ids], count }. The team-lead reap primitive when a sub-agent wedged/was-killed and stranded sessions. Activity is touched on every tool call against a session.list_sessions():{ sessions: [{ id, mode, engine, url, pages, openedAt }] }.profile_status({ profile? }): the on-disk counterpart, listing every managed profile directory with its size, last-modified time, and whether a session is currently open on it. See “Managed profile inventory” under “Advanced tools”.
Example.
open_session({ session: "agentA-checkout", mode: "incognito", device: "iPhone 14" })// …drive the flow by session id…close_sessions({ prefix: "agentA-" })// → { "closed": ["agentA-checkout"], "count": 1 }Session modes (open_session({ mode })):
| mode | isolation | persistence | when |
|---|---|---|---|
persistent (default off-attach) | own profile dir <workspace>/profiles/<profile>, or the workspace default profile when profile is omitted | cookies/storage survive across runs | logged-in flows you want to resume |
incognito | own ephemeral context + browser | nothing persisted; all state discarded on close | one-off agentic driving with no profile trace |
attached (default when BROWX_ATTACH_CDP set) | the externally-launched Chrome (not-owned) | the user’s real profile, one cookie jar shared by every attached session | BYOB; needs the server started with BROWX_ATTACH_CDP and the byob-attach capability. Each session leases its own tab (see below) |
In incognito, different ids are isolated browser contexts, so multi-user / multiplayer scenarios don’t bleed; in persistent, ids are isolated only when they name different profiles (see below). profile (persistent only) names the profile dir under <workspace>/profiles/. A profile name and a session id are each one plain name: letters, digits, ., _ and -, starting with a letter or digit, at most 128 characters, with no path separators and no ... Any other value is refused with invalid-name before anything is created, on open_session and on any tool that creates a session on first use. With no profile, every persistent session of a workspace uses the workspace default profile (<workspace>/profile, or BROWX_DEFAULT_PROFILE), whatever its id, so in persistent mode two ids with no profile are one cookie jar, not two. A profile is open in one browser at a time: while one live session holds it, open_session for another is refused with profile-in-use, naming the profile and the holder; the same refusal covers a profile held by a Chrome outside the server (browxai chrome start / chrome login). Pass a distinct profile name, or use incognito, when two sessions need two identities. (Before this default, an id other than default got its own profiles/<id>.)
attached is the exception, and the distinction matters. Sessions attached to one external Chrome each lease their own page target, so a fill in one session cannot land on another session’s form. But they share the browser, and therefore one cookie jar: every attached session is the same logged-in user. For scenarios that need two identities, use two persistent profiles, not two attached sessions. Dialogs, downloads, permission grants and file pickers are context-level and shared too.
The pool holds at most BROWX_ATTACH_POOL_MAX sessions per endpoint (default 8); past that, open_session refuses with attach-pool-exhausted naming the live leases. A session that goes BROWX_ATTACH_LEASE_TTL_MS (default 5 min) without a call may have its tab reclaimed when another session needs one. Idling alone is safe, and reclamation only happens under contention. A session whose lease was reclaimed is refused on its next call (attach-lease-expired) and cannot write to a tab another session now owns. Closing the leased tab in the browser surfaces attach-target-gone on the next call. A session that had to create its own tab closes it on close_session; a tab that was already open is left exactly as it was found.
Session engine (open_session({ engine })): pick the browser engine per session. Omit it to inherit the server default (--engine / BROWX_ENGINE / createServer({ browserType }), else chromium), byte-identical to before. A single server can hold sessions on different engines at the same time (list_sessions reports each session’s engine), and the capability gate is per session: the CDP-deep tools run on a chromium session and structured-refuse on a firefox/webkit one in the same server. Need a Chromium-only tool while on Firefox? Open a second engine:"chromium" session instead of restarting the server. An unimplemented engine is refused with a structured { ok:false, code:"unknown-engine", implementedEngines }, never a silent fallback. Engine × mode:
| engine | persistent / incognito | attached | omitted-mode default |
|---|---|---|---|
chromium | Yes | Yes, with BROWX_ATTACH_CDP and byob-attach (else byob-attach-endpoint-required, or the standard refusal with requiredCapability: "byob-attach") | attached if BROWX_ATTACH_CDP, else persistent |
firefox / webkit | Yes | refuses (firefox/webkit-attach-not-supported; no CDP/BiDi attach client yet) | same as chromium |
android | refuses (android-launch-not-supported, attach-only) | Yes, over adb discovery (no BROWX_ATTACH_CDP), with byob-attach | attached (android is attach-only) |
safari | persistent works, incognito refuses | refuses (safari-attach-not-supported) | same as chromium |
ios-app | persistent works, incognito refuses | refuses (ios-app-attach-not-supported) | same as chromium |
android-app | persistent works (leases a live device), incognito refuses (native-incognito-not-supported, use app_reset) | refuses; the device lease IS the attach | persistent |
electron | refuses (electron-launch-not-supported, attach-only) | Yes, with BROWX_ATTACH_CDP pointed at the app’s --remote-debugging-port, with byob-attach | attached (electron is attach-only) |
The two native engines are documented together under Native app control below.
Driving a desktop Electron app (engine: electron). An Electron application is Chromium, so one launched with --remote-debugging-port exposes the same CDP surface browxai already attaches to. Point BROWX_ATTACH_CDP at it, grant byob-attach, and snapshot / find / click / press / fill work against the app’s UI with no new tooling. Measured against VS Code 1.122.1 / Electron 39.8.8: snapshot returned a 132-line tree in 36 ms, find ranked three real candidates with bounding boxes, and a click by ref landed.
You do not declare the engine. browxai reads Browser.getVersion’s user agent at attach, an Electron build carries an Electron/<version> token there, and product does not, and reports engine: "electron" on the session. An app that has replaced its user agent reads as chromium, which leaves the attach exactly as it was before; name engine: "electron" explicitly if you know better.
Three things behave differently from an ordinary Chrome attach, all of them measured:
| what | behaviour |
|---|---|
navigate | Refused ({ok:false, engine:"electron", hint}). It would run a web page inside the app’s own renderer, privileged on many Electron apps through a preload IPC bridge, and discard everything that renderer held. go_back / go_forward work normally; they stay on the app’s own document. |
| a second session on the same app | Refused with attach-target-creation-unavailable, naming the session holding the target. Electron answers Target.createTarget with "Not supported", so browxai claims pre-existing renderer targets and cannot mint one. One window means one session; open another window in the app to get a second. |
click on animated app chrome | Works, and costs more. Playwright’s actionability check never settles against a perpetually-animating desktop UI, so the shipped auto-recovery fires: measured 4967 ms (with the force: true recovery warning) versus 528 ms for the same click passed force: true up front. Pass force: true on targets you have already confirmed via snapshot / find. |
browxai does not default Electron clicks to force: true. force skips visibility, stability and hit-target checks, and inside a logged-in desktop app that is the difference between clicking the control you named and clicking whatever happens to be under its coordinates. The cost is one warned-about recovery per click; the choice to skip it is yours to make per call.
Attaching to an app you are signed into is a security decision with real consequences, including an EDR detection your security team will see. Read the byob-attach section of Threat model before pointing this at Slack.
MCP-server restart vs Chrome lifecycle (gotcha). In persistent and incognito modes browxai spawns Chromium as a child process of the MCP server. When the MCP client (e.g. Claude Code) restarts the MCP server (for a config edit, a code reload, or simply because the user re-invoked the server), that Chrome child process dies with it, and any active page state is gone. The next browxai instance starts fresh; if a stored ref points at a now-dead page you’ll see about:blank or a fresh document instead of the page you were on. Recovery posture: for adopters who need page state to survive MCP-server restarts, run Chrome separately (browxai chrome start, which opens the installed Chrome on the workspace default profile with --remote-debugging-port=9222) and connect browxai via BROWX_ATTACH_CDP=http://127.0.0.1:9222. The attached Chrome is not-owned and survives browxai restarts cleanly.
Browser channel (open_session({ channel })): a chromium session with no channel launches the operator’s installed Google Chrome, and falls back to Playwright’s bundled Chromium, with a logged warning, when Chrome is not installed (browxai starts one browser: Playwright refuses the missing channel before spawning anything). "chromium" forces the bundled build; "msedge", "chrome-beta", "chrome-dev", "msedge-beta", … launch that installed browser and fail if it is missing. Server-wide default via config channel / BROWX_CHANNEL. Chrome runs without Playwright’s --enable-automation switch (set BROWX_AUTOMATION_SWITCH=1 to restore it), so it is not in Chrome’s automation-controlled mode. That is the whole change: browxai patches no fingerprints (no --disable-blink-features, no navigator patching, no injected scripts, no user-agent rewriting; the stealth capability is separate, off by default, and untouched). It does not hide that a debugger is attached, and it cannot guarantee access to any site; see Sites behind bot protection. Costs: the browser version becomes the operator’s, not the one browxai’s keystone lane tested, and extensions_* relaunches a persistent session on the bundled build (branded Chrome ignores --load-extension) on the same profile. Chromium only. Firefox has BROWX_FIREFOX_CHANNEL; WebKit / Safari / Android ignore it. Ignored on attached (already launched).
Background-tab lifecycle (open_session({ backgroundThrottling })): Chrome pauses requestAnimationFrame in a background tab, throttles timers to once a minute after five minutes, and can freeze a tab that is both hidden and occluded. An agent waiting on a poll in a backgrounded tab therefore sits there to its deadline, and the stall reads as a page bug.
| value | effect |
|---|---|
"default" (default) | Chrome’s own behaviour. Keeps background throttling reproducible: the lane you need when the bug you are chasing only happens in a throttled tab. |
"disabled" | launches with --disable-background-timer-throttling --disable-backgrounding-occluded-windows --disable-renderer-backgrounding; a backgrounded tab runs at full rate. |
"disabled" is the recommendation for pooled multi-agent attached work, where several sessions share one browser and most of them are backgrounded most of the time. It is not the default precisely because the opposite need is real: reproducing a background-lifecycle bug requires genuine throttling, and flipping every existing launch would remove that lane. Chromium persistent / incognito only. On attached the flags belong to whoever launched the browser, so use browxai chrome start --disable-background-throttling (a warning is logged if you pass it to an attached session). Pairs with tab_visibility, which drives document.visibilityState without touching the process-level throttling.
Device / viewport:
open_session({ device }): a Playwright device-preset name ("iPhone 14","Pixel 7","Desktop Chrome", …; any name in Playwright’sdevicesregistry) Returns viewport +deviceScaleFactor+isMobile+hasTouch+userAgent.open_session({ viewport: { width, height } }): explicit size; overrides a preset’s viewport while keeping its mobile/touch/UA.- Config defaults
defaultDevice/defaultViewport(viaset_config) apply whenopen_sessiondoesn’t specify. Pin “always test mobile” once at the user/project layer. set_viewport({ session, width, height }): mid-session resize for responsive-breakpoint testing. Returns anActionResult(re-layout commonly triggers responsive re-render / lazy-load, whichstructure/snapshotDelta/networkshow). Only the size changes live; full device emulation (isMobile/hasTouch/UA/DPR) is creation-time (Playwright context constraint) and best-effort onattached(not-owned Chrome: viewport via CDPEmulation, no isMobile/touch retro-fit). Unknown preset names return a clear error listing examples.
Dialog policy (alert / confirm / prompt / beforeunload):
- An
alert/confirm/promptdialog blocks every subsequent browser event until handled. Without a server-side handler the session deadlocks. browxai installspage.on('dialog')on every page in every session mode (persistent / incognito / attached) and routes each fired dialog through the per-session policy. open_session({ session, dialogPolicy: "<mode>" }): set the initial policy. Modes:"accept": accept every dialog (confirm/prompt: OK; prompt answer = empty string)."dismiss": dismiss every dialog (confirm/prompt: Cancel)."accept-prompt-with:<text>": accept; prompts get<text>as their answer. Alert/confirm just accept."raise": DEFAULT. Dialog is dismissed server-side so the page never deadlocks, but the next action returnsok:false+failure:{source:"app", hint:"unhandled dialog — set dialogPolicy …"}. Prevents a dialog from silently changing app state under a caller that didn’t opt in.
set_dialog_policy({ session, mode, text? }): mutate the policy at runtime.mode:"accept-prompt-with"requirestext. Persists across navigation: the handler is re-installed on every new page within the session. Returns the resolved policy. Capability:action.- Fired dialogs surface on
ActionResult.dialogs[] = [{ kind: "alert"|"confirm"|"prompt"|"beforeunload", message, defaultValue?, handledAs: "accepted"|"dismissed"|"raised" }], independent ofok(a successful action that happened to fire a dialog under anaccept/dismiss/accept-prompt-withpolicy reports the dialog in this array;raisemode additionally flipsokto false). - Attached (BYOB) sessions: policy applies to all pages in the contexts browxai is attached to. If the human navigates the external Chrome to a brand-new tab outside browxai’s awareness, that tab’s dialogs are not routed through this policy. They’re handled by whatever the underlying Chrome instance does (typically auto-dismissal).
Permission policy (camera / microphone / geolocation / notifications / clipboard / sensors):
- Page-side permission requests fired by
getUserMedia(camera/microphone),navigator.geolocation.getCurrentPosition/watchPosition,Notification.requestPermission,navigator.clipboard.read/write, and the long-tail sensor permissions are routed through a per-session permission policy, the same posture class as the dialog policy. Without a server-side interceptor, either the request silently sits forever (Chromium’s default in headless) or, if a priorgrant_permissionspre-granted, the app’s behavior changes silently under an unaware caller. open_session({ session, permissionPolicy: "<mode>" }): set the initial policy. String form sets the top-level mode; object form ({ mode, perPermission?: { <name>: <mode> } }) takes per-permission overrides. Modes:"allow": pre-grant via Playwrightcontext.grantPermissions; in-page wrappers call through. The app sees a granted permission."deny": in-page wrappers reject withNotAllowedError. The app sees a denied permission."raise": DEFAULT. In-page wrappers reject AND RECORD; the next action returnsok:false+failure:{source:"app", hint:"unhandled permission request — set permissionPolicy …"}. The page never deadlocks (the request is rejected), but a permission request can’t silently change app state under a caller that didn’t opt in."ask-human": server blocks on__browx.confirm(true|false)from the DevToolsbrowxaiconsole context (theawait_human({kind:"confirm"})mechanism; page scripts cannot answer it), then resolves to allow/deny per the human’s answer.
set_permission_policy({ session, mode, perPermission? }): mutate the policy at runtime. Per-permission overrides win over top-levelmode. Persists across navigation: the init-script is re-injected on every new document within the session. Returns the resolved policy. Capability:action. Leavingask-humanneedshuman-gate-override. A change that moves the top-level mode or anyperPermissionentry offask-humanreturns the standard gate refusal (requiredCapability: "human-gate-override", plus areasonnaming the keys) and leaves the policy and the browser’s permission state as they were, unless the operator enabled that off-by-default capability inBROWX_CAPABILITIES. Changes that keepask-humanin place, and any change on a policy with noask-humankey, work as before.- Supported permission names (v1, 13 total):
camera,microphone,geolocation,notifications,clipboard-read,clipboard-write,midi,midi-sysex,payment-handler,background-sync,accelerometer,gyroscope,magnetometer. USB / Bluetooth / HID are out of scope for v1 (slated for a futuredevice-emulationcapability). - Fired requests surface on
ActionResult.permissionRequests[] = [{ permission, origin?, handledAs: "allowed"|"denied"|"raised"|"asked-human" }], independent ofok(a successful action that happened to trigger a request under anallow/deny/ask-humanpolicy reports the request in this array;raisemode additionally flipsokto false). permission_state({ session, permissions[], origin? }): read-side companion. Returns{ [permission]: "granted"|"denied"|"prompt"|"unknown" }per requested name (W3C Permissions API, reflecting the CDP baseline). Defaultsoriginto the current page’s origin. Capability:read.- Sibling of
grant_permissions. That tool remains as the bulk-grant shortcut for themode:"allow"case;set_permission_policyis the full policy surface (allow/deny/raise/ask-human + per-permission overrides + the request capture). - Attached (BYOB) sessions: the CDP-level grant baseline mutates the not-owned Chrome and persists after browxai detaches;
set_permission_policysurfaces aBYOB caveatwarning to that effect on attached sessions. The in-page wrappers themselves install per-context and are torn down with the context.
Notification policy (new Notification(title, opts) constructor):
- The page constructing a
new Notification(...)is a user-facing event distinct from the permission check above. Earlier, browxai had no visibility into these calls; an action that fired three notifications was indistinguishable from one that fired zero. The per-session notification policy intercepts the constructor surface, captures every call, and routes the construction through one of four modes. open_session({ session, notificationPolicy: "<mode>" }): set the initial policy. String form sets the mode; object form is{mode}. Modes:"allow": DEFAULT (browser default). Constructor proceeds; the OS displays per its own settings. Every call is still captured onActionResult.notifications[]for observability."deny": Constructor throwsNotAllowedError(the same exception the browser raises when permission is denied). Use to suppress OS notifications while still observing what the page would have shown."raise": Constructor throws AND records; the nextActionResultflipsok:falsewithfailure:{source:"app", hint:"unhandled notification — set notificationPolicy …"}. Useful when notifications should be a hard signal that the action triggered an unexpected user-facing event."ask-human": server blocks on__browx.confirm(true|false)from the DevToolsbrowxaiconsole context (theawait_human({kind:"confirm"})mechanism; page scripts cannot answer it), then resolves to allow/deny per the human’s answer. The constructor returns a stub synchronously (the spec requires it); the real OS notification fires once the human-decision resolves. Apps that immediately readnotification.close()will operate on the stub until the real one attaches.
set_notification_policy({ session, mode }): mutate the policy at runtime. Persists across navigation. Returns the resolved policy. Capability:action. Leavingask-humanneedshuman-gate-override, same refusal asset_permission_policy.- Fired calls surface on
ActionResult.notifications[] = [{ title, body?, icon?, tag?, timestamp, origin?, handledAs: "allowed"|"denied"|"raised"|"asked-human" }], independent ofok(a successful action that happened to construct a Notification underallow/deny/ask-humanreports it in this array;raisemode additionally flipsokto false). Only the documentedNotificationOptionssubset (body/icon/tag) is captured;actions/data/badge/etc. are dropped to bound the result envelope. - Coordination with
permissionPolicycovers disjoint surfaces:permissionPolicy.notificationsgoverns the W3C permission check (Notification.requestPermission()and theNotification.permissionstate-getter). It controls whether the page is permitted to show notifications at all.notificationPolicygoverns the constructor invocation (new Notification(...)). It controls what happens when the page actually attempts to display one.- The two policies compose. Typical recipe:
permissionPolicy: {perPermission: {notifications: "allow"}}(so the app gets a granted permission and constructs freely) +notificationPolicy: "allow"(so the constructor proceeds and every call is captured). To suppress OS notifications while still observing:notificationPolicy: "deny"(constructor throwsNotAllowedError) with permission left allowed.
instanceof Notificationcaveat: the constructor wrapper uses a fresh prototype so platform accessor-only properties onNotification.prototype(title,body, …) don’t shadow our writes (aTypeError: Cannot set property … which has only a getterwould otherwise fire in headless Chromium). The trade-off:n instanceof Notificationreturnsfalsefor the wrapped stub. The native Notification, when the policy allows construction, is attached internally son.close()/ event listeners still route to the real OS notification.
File System Access policy (showOpenFilePicker / showSaveFilePicker / showDirectoryPicker):
- Modern web editors (VSCode for the web, Figma, anything with a “save to disk” button) call
showSaveFilePicker/showOpenFilePicker/showDirectoryPicker. Headless Chromium can’t drive the OS file chooser; without a server-side interceptor the picker call sits forever and the session deadlocks. browxai replaces the three entry points with init-script stubs (re-injected on every new document) that route through the per-session fs-picker policy, the same posture class as the dialog and permission policies. open_session({ session, fsPickerPolicy: "<mode>" }): set the initial policy. String form sets the top-level mode; object form ({ mode, perAPI?: { <api>: <mode> } }) takes per-API overrides. Modes:"allow": page-side stubs return syntheticFileSystemFileHandle/FileSystemDirectoryHandleobjects built from agent-supplied files. Callfs_picker_respondbefore (or in parallel with) the action that triggers the picker to stage the response."deny": stubs throwNotAllowedError. The page sees the user-dismissed-picker branch."raise": DEFAULT. Stubs throwNotAllowedErrorAND RECORD; the next action returnsok:false+failure:{source:"app", hint:"unhandled File System Access picker — set fsPickerPolicy …"}. The page never deadlocks (the picker rejects immediately), but a picker call can’t silently change app state under a caller that didn’t opt in."ask-human": server blocks on__browx.respond({kind:"fs_picker_respond", value:{files:[…]}})from the DevToolsbrowxaiconsole context (theawait_humanmechanism; page scripts cannot answer it), then resolves with the human-approved file list or denies. This lets a person pick files.
set_fs_picker_policy({ session, mode, perAPI? }): mutate the policy at runtime. Per-API overrides win over top-levelmode. Persists across navigation: the init-script is re-injected on every new document within the session. Returns the resolved policy. Capability:action. Leavingask-humanneedshuman-gate-override. A change that moves the top-level mode or anyperAPIentry offask-humanreturns the standard gate refusal (requiredCapability: "human-gate-override", plus areasonnaming the keys) and leaves the policy as it was, unless the operator enabled that off-by-default capability inBROWX_CAPABILITIES. Without it the agent can’t switch anask-humansession to"allow"and answer withfs_picker_responditself. Changes that keepask-humanin place, and any change on a policy with noask-humankey, work as before.fs_picker_respond({ session, api, files: [{ path | contents, name?, mimeType? }] }): stage agent-supplied files for the next picker call on this session. The queue is per-API: a response staged forshowSaveFilePickerwon’t satisfy ashowOpenFilePickercall.- Each file is either inline
{contents, name?, mimeType?}(base64 bytes, no filesystem read) OR workspace-rooted{path}(resolved inside$BROWX_WORKSPACEonly; path-escape rejected at the tool layer). - For
showSaveFilePicker: the suppliedpathbecomes the destination forcreateWritable()-driven writes from the page. Page-sidewrite()/truncate()/close()are routed through a server binding that persists bytes at the workspace path (first chunk truncates; subsequent chunks append). The page-side promise resolves only after the write hits disk (back-pressure preserved). - For
showOpenFilePicker: the server readspathonce at respond-time and inlines the bytes into the synthetic handle; the page reads them viagetFile(). - For
showDirectoryPicker: the basename ofpathbecomes the handle’s.name; the handle’sentries()/values()/keys()iterate empty. MVP scope: most editors will fall back to per-file pickers when iteration yields nothing. - Capability:
file-io(same posture asupload_file: workspace-rooted egress on writes; workspace-rooted ingress on reads).
- Each file is either inline
- Supported APIs (v1):
showOpenFilePicker,showSaveFilePicker,showDirectoryPicker. - Fired pickers surface on
ActionResult.fsPickerRequests[] = [{ api, suggestedName?, handledAs: "allowed"|"denied"|"raised"|"asked-human" }], independent ofok(a successful action that happened to trigger a picker underallow/deny/ask-humanreports the request in this array;raisemode additionally flipsokto false). - Persists across navigation: the init-script is re-injected by Playwright on every new document; the binding install and write-target handle map are per-context, so a rebuild of the browser context (BYOB reconnect, profile-restore) re-attaches and the previous handles GC with the previous context.
Per-primitive runtime device emulation: 7 sibling tools, each setting ONE knob on the live session. State persists on the session and is re-applied to new tabs in the same context. Deliberately NOT a bundled emulate({...}). Playwright + chrome-devtools-mcp keep these as siblings for a reason (forcing an over-spec on every call wastes tokens and locks the agent into setting fields it didn’t mean to change). All 7 sit under capability action.
| Tool | Mechanism | Mid-session mutable? | Reset |
|---|---|---|---|
set_locale({locale}) | CDP Emulation.setLocaleOverride (Playwright context.locale is creation-time-only) | yes (CDP) | locale: null |
set_timezone({timezoneId}) | CDP Emulation.setTimezoneOverride (Playwright timezoneId is creation-time-only) | yes (CDP) | timezoneId: null |
set_geolocation({latitude, longitude, accuracy?}) | Playwright context.setGeolocation() | yes (Playwright) | latitude: null |
set_color_scheme({scheme}) | Playwright page.emulateMedia({colorScheme}); light / dark / no-preference | yes (Playwright) | scheme: "no-preference" |
set_reduced_motion({on}) | Playwright page.emulateMedia({reducedMotion}); maps on:true → "reduce", on:false → "no-preference" | yes (Playwright) | on: false |
set_user_agent({userAgent}) | CDP Network.setUserAgentOverride (Playwright context.userAgent is creation-time-only) | yes (CDP) | userAgent: null |
grant_permissions({permissions, origin?}) | Playwright context.grantPermissions() | yes (Playwright) | permissions: [] (context-wide; per-origin revocation isn’t supported by the platform) |
Persistence model: each call records the resolved value on the session’s deviceEmulation bag; a BrowserContext.on("page") listener re-applies every set knob to new tabs in the same context, so an OAuth pop-up or target=_blank link inherits the overrides. The four CDP-routed primitives (locale, timezone, UA) are exactly the ones with no Playwright mid-session mutator. The CDP equivalents DO take effect on existing pages, so the runtime distinction is invisible to the agent.
set_geolocation paired with grant_permissions({permissions:["geolocation"]}) is the typical combination: geolocation is browser-gated on the permission, so a set-without-grant silently delivers nothing to the page (the tool surfaces a warning when this is detected).
BYOB caveat. Emulation overrides on mode:"attached" sessions are applied via CDP into a Chrome browxai does NOT own; they PERSIST on the human’s browser until it navigates / restarts after detach. Every emulation tool surfaces a warning to this effect when run against an attached session.