Skip to content

Kitsune API Reference

Base URL: http://127.0.0.1:47651

MCP tool surface

KITSUNE_CAPS=core is the default; KITSUNE_CAPS=all registers 97 tools. The groups are core, browse, read, inspect, tabs, automation, network, admin, storage, and history. A comma list selects those groups only. all enables every group except storage and history, which read personal data and must be named (KITSUNE_CAPS=all,storage,history); see History and bookmarks. See Tools and capabilities for the complete tool list per group.

The core group is Playwright MCP's 25 core tools and Kitsune's browser_resume and browser_ask_user (#376). The 25 are published verbatim from src/kitsune/playwright_tools.json: browser_close, browser_resize, browser_console_messages, browser_handle_dialog, browser_emulate_media, browser_evaluate, browser_file_upload, browser_drop, browser_find, browser_fill_form, browser_press_key, browser_type, browser_navigate, browser_navigate_back, browser_network_requests, browser_network_request, browser_run_code_unsafe, browser_take_screenshot, browser_snapshot, browser_click, browser_drag, browser_hover, browser_select_option, browser_tabs, browser_wait_for. Their arguments are Playwright's (target is a snapshot ref such as e12, a CSS selector or one of the Playwright selector forms Kitsune resolves), they act on the current tab (writes without one fail, see No current tab), and browser_emulate_media and browser_run_code_unsafe answer not_supported; see Core tools for how each maps onto the extension. The tool sections below describe the Kitsune-owned tools and the bridge commands the core tools use; Kitsune's former core tools are in read (browser_page_info, browser_page_text, browser_forms, browser_dom, browser_markdown), browse (browser_scroll, browser_check, browser_submit_form, browser_forward), admin (browser_status) and automation (browser_workflows, browser_workflow). The ones Playwright's replace (browser_keypress, browser_js, browser_screenshot, browser_back, browser_form_fill, browser_upload_file, browser_watch_console, browser_console_logs, browser_double_click, and Kitsune's own signatures of the shared names) are no longer MCP tools; their sections document the bridge commands.

A call to a known tool that this configuration does not publish returns an MCP error (isError) instead of FastMCP's Unknown tool. Its hint names the settings that publish it; restart the MCP client after changing them:

{ "error": "browser_scroll is not published by this MCP server", "code": "tool_not_published",
  "retryable": false,
  "hint": "Set KITSUNE_CAPS=browse,core in the MCP server environment, then restart the MCP client.",
  "state": { "group": "browse", "caps": ["core"] } }

browser_status adds mcp_tools to the bridge status: this MCP server's enabled caps, the count of known tools it leaves unpublished, its audit client id and its tab owner id (Tab owners), for example {"caps": ["core"], "unpublished": 79, "client": "48213-9f2c1a", "owner": "claude-48190"}. With the tool gateway it also has the publish groups and how many enabled tools are unlisted when a session starts. The bridge is shared by every MCP server, so GET /api/status does not have it. MCP tool names and parameters are separate from HTTP endpoint names and JSON command actions. Task-mode requests restrict the bridge routes too (below).

Capability enforcement

The MCP server sends its enabled groups (all expanded) in X-Kitsune-Caps on every bridge request. The bridge maps every route and every container action to the groups whose tools use it (src/kitsune/capabilities.py) and refuses the others. A route is refused with HTTP 403; a container step (batch, parallel, retry, broadcast, if/while/try/sequence blocks, workflow and resumed steps) fails with the same error as its result, and its progress status is failed:

{ "error": "cookies needs the storage capability", "code": "capability_restricted",
  "retryable": false,
  "hint": "Enable that group in this MCP server's KITSUNE_CAPS, then restart the MCP client.",
  "state": { "groups": ["storage"], "caps": ["automation", "core"] } }

Container routes need automation, and each step needs its own action's group; actions the core tools also use (navigate, click, type, check, waits, screenshots, js, ...) are core. /api/scroll is browse (browser_scroll), except a request whose body has loadMore: true, which core may also post: the scrolls of the core browser_find's load_more (#447). The bridge reads the body to tell them apart, so a core client's /api/scroll without loadMore: true is still capability_restricted, and container scroll steps need browse with or without it. /, /api/status, /api/version and /view are open to every client. An unknown group in the header returns 400 invalid_caps. Requests without the header, such as the kitsune-bridge command's, are not restricted.

Tool gateway

With KITSUNE_PUBLISH set to fewer groups than KITSUNE_CAPS enables, tools/list starts with the published groups plus three gateway tools, and the server advertises tools.listChanged. They reach only tools KITSUNE_CAPS and KITSUNE_MODE permit. See Finding tools without a restart.

browser_tools_search(query, limit=5) ranks the enabled tools that tools/list did not show at session start against the query words (name matches count most, then group names, then descriptions; words many tools share count less). limit is 1 to 10.

{ "tools": [ { "name": "browser_scroll", "group": "browse", "listed": false,
               "description": "…", "inputSchema": { … }, "annotations": { … } } ],
  "matches": 3, "hint": "Run a tool with browser_tools_call(name, arguments)." }

listed tells whether browser_tools_publish added it to this session's list. With no match, or query: "", tools is empty and groups maps each group to its tool names.

browser_tools_call(name, arguments={}) runs the tool through the same path as a direct call and returns that call's result: its content (text, images) and isError. A name the server does not permit returns the direct call's tool_not_published; an unknown name returns unknown_tool; a gateway tool returns invalid_argument. Its bridge requests carry X-Kitsune-Gateway-Tool, which audit entries record.

browser_tools_publish(names) takes tool and group names and adds the permitted tools among them to this session's tools/list, then sends notifications/tools/list_changed. It returns {"published": [...], "notified": bool}; names already listed are a no-op. Any name it cannot add, unknown or not permitted alike, refuses the whole call with tool_not_published and state.names.

Task modes

KITSUNE_MODE=read|draft|act selects both the MCP surface and per-request bridge limits. Default: act (unchanged behavior). Case and surrounding whitespace are ignored; invalid modes fail at MCP startup. Modes intersect with the capability groups, including the named-only storage and history groups.

  • read: read tools plus browser_navigate and browser_scroll (scroll needs browse). browser_tab_list(new_tab=None) replaces browser_tabs: it returns the same compact tab inventory as browser_tabs(action="list"). With new_tab, an HTTP(S) URL (or about:blank), it first opens that URL in a background tab, waits for the load and returns its tabId, final url and status with the inventory; later calls without tab_id act on that tab, as after browser_tabs new (#270). It cannot close or select tabs, and it never switches the user's view. Screenshots are excluded because they can write files.
  • draft: read plus type/select-option/fill-form/check (check needs browse). Plain-text editor writes only. Forces ask-before-act checks; a sensitive field, payment-like page or uninspectable target returns mode_restricted before dispatch. Form-fill preflights the entire target set, sending only field keys for inspection.
  • Neither restricted mode permits click, submit, keypress, dialog confirmation, agent JavaScript, policy changes, container calls or resume. No confirmation tokens are issued or accepted. Navigate and new tabs permit HTTP(S) or about:blank, not script, data or local-file URLs. User tabs are read-only in every mode by default: writes return user_tab_read_only without a token. Open an owned background tab with browser_tab_list(new_tab=...) before writing. Existing global URL/readonly/ask policy can tighten this.

Restricted tools/list descriptions use their first paragraph, and schemas omit confirm_token, the approval fallback for clients without elicitation: these modes cannot approve. Calling an excluded name returns the existing tool_not_published MCP error, with KITSUNE_MODE=draft or KITSUNE_MODE=act in its hint, together with any required caps changes. The error's state and browser_status.mcp_tools include mode when restricted. act definitions and status fields are unchanged.

The MCP server sends X-Kitsune-Mode: read|draft on every bridge request. No header means act. The bridge validates the header, canonicalizes route aliases before checking access, and carries the mode through async dispatch without changing the shared policy. A denied route/action answers HTTP 403 with {"error":"…", "code":"mode_restricted", "retryable":false, "hint":"…"}; an invalid header answers HTTP 400 with code: "invalid_mode". GET/POST /api/get-policy reports the shared policy plus the effective mode, ask and confirmAllowed:false for restricted requests. Another authenticated client can still use act; this is not a separate authentication boundary. Keep the bridge token private.

See Task modes for configuration, the eight-tool read surface and site-event/autosave limitations. Exact token and task-success measurements are external kitsune-benchmark work, not inferred from compact JSON character counts.

Element targets

For single-element tools, prefer ref from browser_snapshot over a CSS selector. When both are supplied, ref takes precedence, as for browser_click. element is an audit-only human description, never a selector or fallback target. A stale/disconnected ref returns a stale_ref error (see Action errors); take another snapshot to refresh it. See Ref lifetime for which events invalidate refs. Existing positional selector arguments remain supported.

Target selectors

Actions, scoped snapshots (browser_snapshot, browser_dom) and element screenshots resolve a target with one shared resolver. It accepts a ref, CSS (including shadow trees), and this subset of Playwright's selectors; it is not Playwright's engine:

Form Matches
e12 The snapshot element
text=Save, "Save" Lax text=Save: the smallest element whose whitespace-collapsed text contains it, ignoring case. Quoted text="Save" (also "Save"s, 'Save') is the whole text, case-sensitive; "Save"i is lax again
text=/Sav(e\|ed)/i The smallest element whose whitespace-collapsed text the regular expression matches. Only flags may follow the last /; otherwise the value is lax text (text=/api/users)
internal:text="Save"i, internal:text=/…/ getByText: as text=, but the value is quoted or a regex
role=button[name="Save"] Elements with that role (explicit or implicit) whose accessible name is the whole name, ignoring case; [name="Save"s] is case-sensitive, [name*=…]/[name^=…]/[name$=…] match part of it, [name=/…/flags] tests it with a regex. Also [level=N] and [include-hidden]; hidden and aria-hidden elements are skipped otherwise
internal:role=button[name="Save"i] getByRole: as role=, but "…"i is a substring
internal:label="Email"i getByLabel: elements whose aria-labelledby text, else aria-label, else <label> (for a form control) contains the text, ignoring case. "Email" and "Email"s are the whole label, case-sensitive; /…/flags is a regex
:has-text("…"), :text-is("…"), :text("…"), :visible CSS pseudo-classes: contains the text (lax), whose whole text it is, the smallest element containing it, rendered
a, b A CSS list, with or without the pseudo-classes above: the union of its alternatives
a >> b Chains: b searched inside each match of a. A chain may start with a ref (e12 >> xpath=.., e12 >> text=Save)
nth=N The Nth match so far (0-based; -1 is the last); not first in a chain
xpath=.., .. The parent element; at the top of a shadow root, the shadow host. Only a bare .. step leaves a shadow root: write .. >> .., not xpath=../..
xpath=//h2[text()='Stock']/.., //…, ..… General XPath (document.evaluate), elements only; after >>, a leading / is relative to the earlier match, which may be inside a shadow root or same-origin frame. It does not cross into shadow roots or frames. Because XPath reads text and the value attribute unredacted, it is refused (unsupported_selector) when a document it would run in, including that document's shadow roots, holds a sensitive field with a value attribute or masked text

Images, <area> and <svg> graphics are named as snapshots name images: by aria-labelledby, aria-label, alt (an <svg>'s <title>), then title unless alt="". <input type=image> is a button named by its alt. Text and names are read redacted, so masked text and sensitive values never match. Write actions stay strict for every form. Anything else (other engines such as id= or data-testid=, :near() and other layout pseudo-classes, Playwright pseudo-classes inside :has(), a ref after the start of a chain, invalid CSS, XPath or regex) fails with unsupported_selector (not retryable) before anything is dispatched. A valid selector that matches nothing is not_found, for scoped snapshots too.

Regular expressions and XPath run on the page's main thread, as in Playwright, and are not capped: a pathological pattern (such as text=/(a+)+$/ on long text) can block the page until the bridge command times out.

Strict selectors

A write action's CSS selector must name one element (#134). When it matches more than one actionable element, the action fails with ambiguous_target (retryable: false) and nothing is dispatched; state.candidates lists up to 5 of them (ref, role, name) and state.matches counts them. Pass a candidate's ref, a more specific selector, or first: true ("first": true on the bridge endpoint or batch command) to act on the first match as before. This is an intended behavior change: these actions used to act on the first match silently.

  • Write actions: click, doubleClick, hover, drag (both ends), type, fill, setEditableContent, checkBox, selectOption, submitForm, keypress, focusElement, blurElement and scroll with a selector, the uploadFile target, and each formFill key (a per-key error). Labels and placeholders count every matching field, including duplicates in the same tree and across shadow roots. Batch, parallel and workflow steps follow the same rule; clickAndWaitNavigation also accepts the opt-in. MCP: first=True on browser_click, browser_double_click, browser_hover, browser_drag, browser_type, browser_check, browser_select_option, browser_submit_form, browser_keypress, browser_focus, browser_blur, browser_scroll, browser_trusted_click, browser_click_and_wait_navigation, browser_upload_file and browser_form_fill. For an individual match, use a candidate ref or a CSS :nth-of-type(...) selector; there is no separate nth parameter.
  • Actionable: enabled, and not <input type=hidden>; for pointer actions (click, doubleClick, hover, drag, trusted click) also rendered, as in the actionability wait. Value actions count hidden native inputs, which they can act on. A selector with one actionable match still acts on its first match, as before.
  • Upload without a selector checks all input[type=file] controls in the document (including hidden ones); several actionable inputs fail unless first=True. This preserves upload's document-only lookup. Explicit mode="drop" without a selector still drops on the body; it does not select a file input.
  • Refs and coordinates name one element and are unaffected. Read tools (browser_html, browser_bounds, browser_wait_for, screenshots, …) keep first-match semantics. Blur without a target still blurs the active element; scroll without a target still scrolls the window with a viewport-center fallback.
  • With wait, an ambiguous selector fails at once instead of waiting.
  • Under ask-before-act, clicks (including trusted and navigation clicks) and Enter keypresses check ambiguity before offering confirmation. first=True is honored both during inspection and dispatch.

These tools also accept refs (all new parameters are optional):

MCP tool Parameters
browser_keypress key, selector=None, modifiers=None, text=None, tab_id=None, ref=None, element=None, wait=False, timeout=10000, repeat=1
browser_submit_form selector=None, tab_id=None, ref=None, element=None
browser_scroll direction="down", amount=500, tab_id=None, selector=None, ref=None, element=None, first=False
browser_blur selector=None, tab_id=None, ref=None, element=None, first=False
browser_html, browser_bounds selector="", tab_id=None, ref=None, element=None
browser_computed_style selector="", properties=None, tab_id=None, ref=None, element=None
browser_click_and_wait_navigation selector="", timeout=15000, tab_id=None, ref=None, element=None, first=False
browser_markdown selector=None, tab_id=None, ref=None, element=None

browser_keypress accepts characters, Space/Spacebar, Enter/Return, Escape/Esc, ArrowUp/Down/Left/Right (or Up/Down/Left/Right), Tab and Backspace; text explicitly inserts text.

repeat (1–100, also a keypress step's repeat) presses the key that many times in one request, a task apart so the page renders between presses as it does between a user's; each press resolves its target again (#311). The result is the last press's with repeat; a press that fails returns its error with pressed, the presses before it. The core browser_press_key accepts repeat too, not listed in its schema (Playwright MCP's). Anything else returns repeat must be an integer from 1 to 100 and presses nothing. An extension older than repeat presses once and answers without it, which the MCP tool reports as extension_version_mismatch (state.reason: "repeat_unsupported").

In the same way, the core browser_click accepts hold_ms and the core browser_hover accepts duration_ms and steps, for long-press and hover-intent controls (#351). They are not listed in the schemas either, and Kitsune's Python browser_click and browser_hover take them too; see POST /api/click and POST /api/hover.

Synthetic key events have no browser default, so in an input, textarea or editor keypress applies the key's default edit itself (#267), unless the page cancels keydown, keypress (fired for a typed character and Enter) or beforeinput: a character or Space types at the caret, replacing a selection; Backspace/Delete remove the selection or the character before/after the caret; arrows, Home and End move the caret, and with Shift extend the selection; Control/Meta+A selects all. Words are Alt+arrow/Backspace/Delete on macOS and Control+ them elsewhere; on macOS Meta+Left/Right/Backspace/Delete reach the line boundary and Meta+Up/Down the whole text, elsewhere Control+Home/End. Up/Down move between a textarea's own lines (not its wrapped ones). Enter adds a line to a textarea, a paragraph to an editor (Shift+Enter a line break), and in a text input submits its form as a browser's implicit submission does: it clicks the form's default button, or submits a form that has none and no other text input (#257). Both go by form ownership, not containment: a control's form attribute associates it with that form, wherever it is in the document, and the default button is the first submit button or image input the form owns in document order (#288). A disabled default button submits nothing. The result reports:

  • key: the key with its modifiers, e.g. Shift+ArrowLeft.
  • effect: what changed: an input type such as insertText, insertParagraph, deleteContentBackward or deleteWordBackward; moveCaret, extendSelection, selectAll or implicitSubmit; null when nothing changed (End at the end, Enter in an input without a form, a key outside a field).
  • prevented: true: the page cancelled the key, or in an editor the edit's beforeinput, so its default was not applied (#289). effect is then null.
  • note: a default Kitsune does not apply: Tab does not move focus, Enter and Space do not activate buttons or links (click them), keys do not scroll the page, and clipboard and undo shortcuts do nothing.

Without selector/ref, keypress targets the active element (or body), blur targets the active element, submit-form submits the form of the field last filled or focused, else the only form (see browser_submit_form), and Markdown reads the whole page. Submit-form can target a form or an element inside it. Scroll accepts an explicit container selector/ref; otherwise it scrolls the window and falls back to a scrollable container at viewport center. HTML, bounds, computed-style, and click-and-wait-navigation require a selector or ref for a successful action. See browser_wait_for below for its element condition.

Ref lifetime

A short ref such as e12 names one attached node in one document of one tab. Observations reuse the same identity while the node stays attached, even if it is renamed or reparented. Tab/document identity is tracked privately, not encoded in the public ref. Indexes increase across observations and tabs and never alias another node. The extension persists reserved indexes across reloads, but not ref mappings: after an extension reload, take a new snapshot (unknown old aliases report not_in_snapshot). Legacy s<generation>e<index> inputs are still accepted; a generation now identifies one document/content injection, not a snapshot.

Call/event Effect on refs in that tab
browser_snapshot (also scoped), browser_dom, browser_find, browser_forms, browser_wait_for(selector/ref) Reuse attached-node refs and add identities for newly observed nodes; earlier attached refs stay valid
Full or delta post-action snapshots Reuse attached-node refs; a full observation does not invalidate earlier refs. An expect failure can still carry the action's snapshot and target_ref
A click, double-click or hover that returns opened_popup Reuses or adds refs for the popup, its items and the acted-on element; earlier refs stay valid
Node detachment/replacement or a frame's document navigation/removal The issued node ref fails as stale_ref/element_removed, including after detached entries have been pruned. A replacement node gets a different ref
Navigation, reload, back/forward to another document or content reinjection Old refs fail as stale_ref/other_document; aliases unknown after an extension restart report not_in_snapshot
Other reads/actions or failed observations No identity change unless the page itself detaches nodes or changes document

Closing a tab or starting a document load prunes its short-ref mappings without resetting the index counter. Same-document URL changes alone do not prune refs.

Snapshots in one tab do not affect refs in another tab. Pass tab_id when more than one tab is in use: a ref sent to the wrong tab fails as other_document and never acts on another element.

A ref can fail as other_document because it came from another tab or because its tab has since started loading another document. If a snapshot line listed that ref and the targeted tab's page (the whole page, as browser_find reads it) lists exactly one element with that line's role and name, state.same_element is {ref, role, name} for that element and the hint names it: The same textbox "Reply" here is e14; retry with it. Nothing is sent to that element. The page there can differ from the one the ref came from, so retry with its ref only if it is the element you meant. With no or several such elements, a page listing a readable cross-origin frame, or no answer within 2 s, the error is unchanged.

After an action, use the refs in its snapshot. target_ref is the ref of the acted-on element (absent for browser_navigate, or when that element is gone), so a follow-up such as browser_keypress(key="Enter", ref=target_ref) needs no extra snapshot. Only emitted lines carry new refs: when snapshot_truncated is set and the element you need is missing, snapshot_hint provides scope guidance and explains if the target and its ancestor context could not fit. When the target is omitted, target_ref is omitted too. If snapshot_error replaces the snapshot, call browser_snapshot before using a ref the result did not show. With the post-action snapshot disabled (KITSUNE_POST_ACTION_SNAPSHOT=0, or includeSnapshot: false in a batch), actions leave refs unchanged.

Recovering without repeating a consequential action:

  • stale_ref and not_found fail before anything is dispatched to the page. Take a new browser_snapshot of the same tab_id, find the element again, and retry with its new ref. Do not fall back to another element by its element description.
  • For stale_ref, state.reason distinguishes element_removed, not_in_snapshot (unknown/unissued alias) and other_document. Observe the intended tab/document again and select its current node; do not infer that a replacement is the old node. An other_document error's state.same_element, when present, already names the one element there with the same role and name. Newer observations alone do not cause stale refs; no latestObservation or currentRef substitution is needed or returned.
  • To get refs after a successful write, use its snapshot/target_ref (with snapshot_delta, earlier refs also stay valid) or call browser_snapshot. Never repeat the write.
  • After a failure with retryable: false (a write whose outcome is unknown), observe first (browser_snapshot, browser_page_info, browser_wait_for) and act again only if the page shows the effect did not happen.
  • In a batch, earlier commands may already have run. Resume from the failed command instead of re-running the whole batch.

Refs remain stable across snapshots while the same node stays attached to its live document. Detached-node entries are pruned; ID-only removal history lasts for that content injection. Unobserved detach-and-reinsert of the same node is not tracked.

Post-action snapshots

A successful browser_click, browser_type (type/fill), browser_select_option, browser_check, browser_keypress or browser_hover returns a delta: snapshot_delta: true and a snapshot with only the lines the action changed, in document order under their ancestors. Right before acting, the content script records the page's snapshot lines; afterwards the delta lists the acted-on target and every line that record lacks: new or newly shown elements (an opened menu's options, a toast, autocomplete suggestions) and elements whose name, value or state changed. A popup the action opened is listed even when it is not rendered, marked [not_rendered] (see Opened popups). Unchanged parts of the page, such as a header above the target, are left out. An element the record lists that is now detached or hidden (a closed dialog, a hidden listbox) is named in one line after the rest, with the ref the agent already has for it, if any: - removed: dialog "알림 설정" [ref=e4]. What was under it is not listed (#457). More than three removals under one parent line, such as a re-rendered table page or calendar month, fold into one line that names the first and counts the rest with the parent's ref: - removed: button "2027년 6월 1일" [ref=e9] … and 29 more under main [ref=e2] (#471). A delta is capped at 4,000 characters like a full snapshot (snapshot_truncated); when truncated, snapshot_hint gives scope guidance, including when the target/context did not fit and target_ref was omitted. A same-document URL or title change (history.pushState) adds url or title:

{ "ok": true, "tag": "button", "text": "Review type", "snapshot_delta": true,
  "url": "https://shop.example/app/reviews?filter=open", "target_ref": "e41",
  "snapshot": "- main \"\" [ref=e40]\n  - button \"Review type\" [ref=e41] [expanded=true]\n  - listbox \"Review type\" [ref=e90]\n    - option \"Negative\" [ref=e91]" }

An empty delta means nothing visible changed. Use expect to verify an outcome, or browser_snapshot for the whole page.

Waiting for the page (#348). A delta is taken once the page has reacted to the action, not right after dispatch. The bridge waits:

  • at least 400 ms after the action (1.1 s in a hidden tab, where Firefox delays page timers to at least 1 s). In a visible tab a plain field write has no minimum (#460): a fill (browser_type without slowly, browser_fill_form), select or check of a native field without role=combobox, aria-autocomplete or a list attribute. Typing slowly, keys, clicks and fields with that markup keep it, and so does a browser_fill_form call when any of its fields does. A browser_press_key the page handled (prevented: true) drops it once the page has changed (#475); a key that changed nothing yet keeps it;
  • until the page has made no DOM change for 150 ms, counting changes in same-origin frames and shadow roots;
  • until the XHR/fetch, script and frame requests the tab started after the action are done.

It waits at most 1.5 s, or 4 s while such requests are pending. This covers confirmations, status lines and autocomplete lists that render a moment later, including debounced ones (a debounced reaction to a plain field write, such as a filter box, may come after the delta). A delta also lists text the action made appear anywhere on the page as text lines (for example - text "Saved."), also when the snapshot is otherwise compact. In a run of similar items, up to 20 items that are new since the delta's baseline are listed even past the first five that a snapshot shows, so the next batch of an infinite list is named, not only counted. When the delta found no new, changed or removed line and the URL and title are the same, the result adds no_visible_change: true, unless during the wait the page added an element (still attached when the delta is read) or an editor's content changed, which a delta may not show, or a request the wait watches is still pending (#385). A change that comes after the wait is not seen: use expect or browser_wait_for. Actions without a post-action snapshot (includeSnapshot: false, KITSUNE_POST_ACTION_SNAPSHOT=0) and full post-action snapshots do not wait.

An action that loads another page (#349). A link, a form, or a script that sets location may start loading another document in the tab during the wait above. The action then waits for that load, like browser_navigate, for up to 10 s. It returns navigated: {"url": …, "title": …} with a full snapshot of the new page instead of a delta of the old one, and adds navigation_pending: true when the page has not finished loading by then. A main-frame request that keeps the document (a 204, a download) is not a navigation: the action returns its delta, after up to 1 s more.

Text of a page a navigation loaded (#401). The snapshot of a browser_navigate result, or of a navigated one, is readable, with the page's - text "…" lines, when the whole page fits 2,000 characters; a larger page gets the compact snapshot (no text lines, up to 4,000 characters) as before. A short notice or confirmation page then needs no browser_snapshot.

{ "ok": true, "tag": "a", "navigated": { "url": "https://store.example/app/1030300/", "title": "Hollow Knight Silksong" },
  "snapshot": "- heading \"Hollow Knight Silksong\" [ref=e91] [level=1]\n…" }

The core browser_tabs action=new url=… and browser_navigate_back results carry the same readable snapshot when the page fits 2,000 characters (#448); a larger page adds nothing, so their result is as before. Back waits for the page it loads, as an action that navigates does: up to 1 s for the load to start and 10 s for it to finish. A back still loading by then carries no snapshot, and one that starts no load (no history, a same-document entry) reads the page as it is. browser_tabs action=new without a url opens about:blank and carries nothing.

A snapshot that repeats a full action result (#384). Agents often send browser_snapshot in the same turn as an action, before reading its result, so a page that result already carried would be sent twice. The core browser_snapshot without target, depth, boxes or filename (it sends "skipUnchanged": true to HTTP /api/snapshot) is still read in full, but when it is exactly the snapshot text of the tab's last action result, in the same document at the same URL, it returns one line instead of the page, with unchanged_since naming that action's tool and the refs' range:

{ "snapshot": "unchanged since the browser_click result (refs e91–e130)", "unchanged_since": "browser_click",
  "url": "https://store.example/app/1030300/", "title": "Hollow Knight Silksong", "readyState": "complete" }

That result's snapshot and refs describe the page. This applies only:

  • after a full snapshot (navigated, browser_navigate, browser_submit_form, snapshot: "full", or the page text of browser_tabs action=new url=… and browser_navigate_back, #448), not a delta, a truncated snapshot or navigation_pending;
  • to the first such snapshot of the same tab after it; any other page action on the tab ends it;
  • to the caller that got that result: the same client (its owner id) and handoff session. Any other caller never saw it and gets the page (#442). Calls through the same MCP server share both ids, so a harness or proxy that navigates a tab before handing it to the model through that server should take one whole-page browser_snapshot itself: that drops the record, and the model's first snapshot gets the page;
  • when the text is identical. The core snapshot is readable and an action's is compact, so a page with text lines the action result left out (- text "…") is returned in full. A browser_navigate or navigated result is readable too when the page fits 2,000 characters (#401), so a small page it loaded is not sent twice (#400).

A scoped, saved or boxes snapshot, browser_find and other reads return the page as before.

browser_fill_form returns one delta for all its fields: the first field records the page's lines, and the last waits and lists every field's change under the refs the agent already has.

Cross-origin frames (#354). A delta also lists what changed inside cross-origin frames (Frames), under the frame's line, which reads [frame=cross_origin …]. The document the action ran in (the page, or the frame holding its target) is compared with its record from right before the action, as above. Every other document (the page, or another frame) is compared with the last whole-page snapshot or delta that read it, so what the last result already listed is not repeated. A frame with nothing to compare with is listed in full, with its text lines, when:

  • the action ran in it, for example a Pay or Sign in button that loaded the frame's next page;
  • it now holds another page than when Kitsune last read it;
  • its line is in the delta, for example a frame the action added.

A frame that only browser_find, browser_forms or browser_dom has read is not listed for that alone; its first delta read becomes what later deltas compare with. An action inside a frame names its target there as target_ref. no_visible_change covers the frames that answered, and is left out when the frame the action ran in did not answer in time. Frames the browser no longer finds (removed from the page) are not read again.

Limits: - The wait above watches only the document the action ran in, so a frame that changes after it goes quiet is not seen. - A frame's changes count as seen once its delta is read, even when the result does not show them, for example when the delta's 4,000-character cap cut them or the frame was hidden. - browser_fill_form with fields in both the page and a frame compares the last field's frame with what was last read of it, not with its state before the first field.

browser_wait_for(time) lists frame changes the same way.

The snapshot is full (no snapshot_delta; existing attached refs stay valid) for browser_submit_form and browser_navigate, which usually load another document, when the action loaded another document before the snapshot was taken (it has no record to compare with), and on request: "snapshot": "full" on an HTTP or batch command, or KITSUNE_POST_ACTION_SNAPSHOT=full in the bridge environment for every action. A full post-action snapshot prioritizes the acted-on target and its ancestor context when that block fits, then fills the rest in document order.

Action errors

Failures keep the human-readable error string (its text is unchanged) and, when classified, add machine-readable fields. Errors that are not yet classified have only error. MCP tools return these failures with isError: true (see MCP error results).

{ "error": "Element not found", "code": "navigating", "retryable": true,
  "hint": "The tab is loading a new document; wait for it with browser_wait_for, then take a new browser_snapshot.",
  "state": { "cause": "not_found", "tabId": 7, "url": "https://example.com/next", "status": "loading" } }
  • code: stable identifier; branch on it, not on error text.
  • retryable: true means repeating the same command cannot duplicate a side effect, because it never reached the page or is a read. It does not promise that an identical retry succeeds; follow hint first (for example, take a new snapshot). false means a write may already have taken effect (a submit, message, payment or deletion can be behind any click or keypress); inspect the page before acting again. The exceptions are error_page, ambiguous_target, needs_user, permission_required, unsupported_platform, capability_restricted, mode_restricted and unknown_action: nothing reached the page, and repeating cannot succeed until the tab navigates, the target is named uniquely, the user approves the close, navigation or action or grants the permission, the bridge runs where the tool is implemented, the client's KITSUNE_CAPS or KITSUNE_MODE permits the action, or the step names a batch action.
  • hint: a short recovery step. state: the current state that explains the failure, where available (for page targets: tabId, url, status).
code Meaning retryable
not_found No element matches the selector (also No target, from/to element not found; a scoped browser_snapshot/browser_dom reports Selector not found: … with state.selector); after an actionability wait, state has timeoutMs and elapsed true
not_fillable browser_type (type/fill modes) or a browser_form_fill key found no field to write. The target is not a field (fill and browser_form_fill: input, textarea or select; type: input, textarea or contenteditable region) and has none in its open or closed shadow roots, searched through nested hosts up to 8 roots deep (fill and browser_form_fill also look in its light DOM). Nothing was written. state.tag is the target's tag; state.reason is no_field, or shadow_depth when its shadow roots nest deeper than 8. On an ARIA slider or spinbutton (state.role) that fill cannot step (no numeric aria-valuenow), that type mode targets, or that a browser_form_fill key names, the hint names the keyboard alternative (click it to focus it, then arrow keys with browser_press_key repeat), and for type and browser_form_fill on a slider with aria-valuenow also fill true
value_not_reached fill on an ARIA slider or spinbutton did not make aria-valuenow the value (see /api/fill). state has role, value (where it stopped), target, min, max, keys and reason: out_of_range (the value is outside aria-valuemin/aria-valuemax; nothing was pressed), stepped_past (its steps skip the value), stuck (it stopped moving: at an end, disabled, ignoring synthetic keys, or rendering only on an animation frame), moved_away (its arrow keys moved it away from the value and the other arrow keys did not move it), key_limit (1,000 keys) or time_limit (20 s) true for out_of_range, else false
ambiguous_target The command names no single target, so nothing was dispatched: a write action's selector matches several actionable elements (state.selector, state.matches; see Strict selectors), a browser_form_fill label or placeholder key matches several actionable fields (state.field, state.matches), or browser_submit_form without a target found several forms and no filled field naming one (state.forms). state.candidates lists up to 5 of them with a ref to target one false
target_not_at_point browser_trusted_click found another element (state.hit, null for none) at its target's center (state.viewportX, state.viewportY), such as an overlay; no OS click was made. browser_trusted_drag checks both ends the same way, and a destination out of view has nothing at its point; nothing was dragged true
browser_not_at_point On macOS, a viewport browser_trusted_click would land outside the browser: another app's window (state.app, null for the desktop) is at the desktop point (state.screenX, state.screenY; state.reason: "other_window"), another window or panel of the browser is there instead of the tab's window ("other_browser_window", state.window, state.targetWindow), the macOS window list could not be read ("unknown", state.detail), or, without Quartz, focus=True could not bring the browser to the front ("not_frontmost"). Typically the browser is in full screen on another Space; no OS click was made. browser_trusted_drag checks both of its points the same way and drags nothing true
keyboard_focus_lost browser_trusted_type or browser_trusted_press_key stopped before a key: another app came to the front (state.reason: "not_frontmost") or the target lost the page's focus ("target_not_focused"); state.keysSent keys were sent. No key reached anything but the target true
tab_not_shown A trusted click, type, key press or drag found its tab with no size on screen, even after showing it and waiting up to 1 s: the window shows no page, as during Zen's first-run welcome screen (#360). state adds width and height. No OS input was sent and the user's tab is shown again; ask the user to finish or close what hides the page true
drag_interrupted browser_trusted_drag failed after it began posting OS events; the error names the cause. The button was released where the drag stopped and the pointer moved back, but the page may have seen part of the drag or a drop there false
unsupported_platform browser_trusted_type, browser_trusted_press_key or browser_trusted_drag on a platform other than macOS, or without pyobjc Quartz; nothing was sent false
key_denied browser_trusted_press_key was asked for a chord that closes, quits or leaves the browser (Meta+Q, Meta+W, Meta+Tab, ...); nothing was pressed false
not_actionable An actionability wait timed out on a target that exists but never rendered (state.reason: "not_rendered", timeoutMs, elapsed); nothing was dispatched true
unsupported_selector The shared target resolver rejected unsupported/invalid syntax (see Target selectors) before dispatch; state.selector echoes the selector and hint lists accepted forms. Applies to actions, scoped snapshots and element screenshots false
stale_ref The ref is no longer valid; state.reason is element_removed, not_in_snapshot (unknown/unissued ref), or other_document (the tab navigated, or the ref came from another tab; state.same_element then names the one element there with the ref's role and name, if exactly one) true
navigating The tab is loading: a not_found/stale_ref while loading (state.cause), or the page unloaded while handling the command. Also a page that refused the content script within 5 s of a wake or reload without an error status, or whose 2xx response arrived only while the command waited for it: it may be an old document about to be replaced true before dispatch; false for writes after
tab_closed The target tab no longer exists true
tab_unloaded The tab is discarded and its page cannot be reached; use browser_wake_tab. Reads wake it themselves (#449), so writes report it true
restricted_page Browser-internal page (about:, moz-extension:, …) true
content_unavailable The page's content script could not be injected or did not answer. state.reason: "no_receiver": the injection succeeded but no ping from the current content script was answered within 0.5 s, so nothing was sent true before dispatch; false for writes after
error_page A loaded http(s) page refused the content script: the browser shows its own document, such as an error response with an empty body. state adds title and, when the extension saw the response, httpStatus. Nothing was dispatched, but reads fail the same way until the tab navigates; for a 4xx/5xx status, browser_page_text instead returns an empty result (text: "", empty: true, httpStatus, tabId, url, title) false
extension_disconnected The extension is not connected, reconnected before sending, or disconnected while waiting (state.connected) true if not sent; false for writes after
timeout The bridge sent the command but no reply arrived in time (state.timeoutSeconds); from the MCP server, the bridge did not answer. An element or text wait whose page reads all went unanswered within its timeout (see browser_wait_for) false for writes (MCP: POST)
bridge_unavailable The MCP server cannot reach or start the bridge (state.exitCode, state.logExcerpt when it exited at startup; state.reason, state.pid when it runs but does not answer) true
bridge_disconnected From the MCP server: the bridge closed the connection before its reply. state.requestSent is false when the request could not be sent in full, so the command did not run true if not sent; false for writes (MCP: POST) after
token_mismatch The bridge rejected the MCP server's token (state.port, state.tokenSource) true
port_conflict A port is held by another program, answers but not as a Kitsune bridge, or differs from the extension's (state.port, state.reason) true
extension_version_mismatch The extension does not know the command, and its version differs from the bridge's (state.extensionVersion, state.bridgeVersion) or it is a developer build edited but not reloaded (state.reason: "stale_developer_build"). Also browser_press_key with repeat on an extension older than repeat: it pressed the key once and ignored the count (state.reason: "repeat_unsupported", state.pressed: 1, retryable: false). Likewise browser_click with hold_ms clicked without holding ("hold_unsupported", retryable: false), and browser_hover with duration_ms or steps moved once ("sustained_hover_unsupported", state.moves: 1, retryable: false) true, except repeat_unsupported, hold_unsupported and sustained_hover_unsupported
browser_window_unavailable No active tab: the extension sees no normal window it can use true
host_permission_denied Firefox does not let Kitsune script the tab's site (state.reason: not_granted, restricted_domain) true
permission_required A history or bookmark lookup needs an optional browser permission the user has not granted (state.permission: history or bookmarks). Only the user can grant it, on Kitsune's options page; the agent cannot. Also returned, with a reload hint, when the permission is granted but the running extension has not picked it up. browser_trusted_type, browser_trusted_press_key and browser_trusted_drag return it when the bridge lacks macOS Accessibility permission; nothing was sent false
needs_user Nothing ran: only the user can let the call go on (#376). state.reason: "approval": it needs the user's approval, and state.detail is the code it replaces (below). state.message is a short request to show the user. On an MCP client with elicitation the MCP server asks the user itself and the result has no token (see Approval prompts); elsewhere, the fallback for clients without elicitation, a direct call's state.confirmToken runs it once the user approves. state.approvable is false in read and draft modes, which cannot approve, and state.approval says how it can be approved (token, elicitation). state.reason: "agent_request": browser_ask_user asks the user, and state.detail is what it asks for. A resumable direct call's result also has call, the audit call id of the hold, and, from the MCP server when the user did not resolve it in the call, resume_token for browser_resume. Explicit handoff requests use state.detail: "tab_handoff" and have no resume checkpoint. What Kitsune sees only the user can do on a page (sign_in, human_check, trusted_input) is never this error, but a needs_user hint on the result. See the end-to-end example false
user_active The user is using the tab (state.reason: input, typing, navigating); the write or navigation did not run (see User activity) true
outcome_not_observed The action was dispatched, but the outcome given in expect did not happen in time (see Action outcomes) false for writes
user_tab_read_only A user or other client owns the target; nothing ran and no prompt is offered. state.tabId, state.index, state.action; open an owned tab before writing (see Tab permissions) false
unsupported_target Absolute screen-coordinate input is refused in every permission mode; native input is also refused on handed-off tabs. Use DOM actions, or a ref or viewport coordinates on an agent-created tab for native input false
no_current_tab From the MCP server: a write or navigation names no tab and the agent has no current tab (state.tool, state.action for a container step; state.ownTabs: the open tabs this MCP client opened, when there are any); nothing was sent (see No current tab) false
tab_required A parallel step that acts on a tab has no tabId (state.sequence, state.index); nothing was dispatched (see Container progress) true
tab_owned Two parallel sequences write the same tab (state.tabId, state.owner); nothing was dispatched true
invalid_tab_id A batch, workflow or parallel step names a tabId that is not a tab id (0, "", an unresolved ${$N.tabId}); it was not sent to the active tab instead true
unknown_action A container step's action is not a batch action, so nothing was dispatched (state.action). When a batch action is close to it, ignoring case, state.suggestion and the hint name it (html → getHTML) false
container_timeout A batch or workflow reached its deadline; steps in progress may have taken effect and later steps did not start false
tab_claimed A resumed parallel sequence writes a tab that another call's parallel in progress writes (state.tabId, state.call); nothing in that sequence was sent (see Checkpoint and resume) true
checkpoint_not_found, resume_token_invalid, workflow_changed, observed_invalid, observed_required, resume_failed, current_tab_changed browser_resume refused or stopped; see Checkpoint and resume and, for current_tab_changed, Direct calls observed_*, current_tab_changed: true; others: false

needs_user replaces two codes, which stay as state.detail. The current extension refuses user-tab writes with user_tab_read_only instead, without approval; user_tab below comes only from a v0.7.1 extension. A reason the old state carried moves to state.holdReason; every other field keeps its name:

state.detail Was the code When state.holdReason
confirmation_required confirmation_required (retryable: true) Ask-before-act held a sensitive action (state.description, state.params) the hold reason (submit_control, payment_page, …)
user_tab user_tab A v0.7.1 extension refused browser_tabs close on a tab the agent did not open, or browser_close_window on a window with one; it stays open (see Agent-owned tabs) none
{ "error": "Confirmation required: clicks a submit control (Pay)", "code": "needs_user", "retryable": false,
  "hint": "Nothing ran. Ask the user to approve this exact action (state.message), then repeat the identical call ...",
  "state": { "reason": "approval", "detail": "confirmation_required", "holdReason": "submit_control",
             "action": "click", "description": "clicks a submit control (Pay)", "tabId": 12,
             "url": "https://shop.example/cart", "params": { "selector": "#pay", "tabId": 12 },
             "message": "Allow the agent's click? It clicks a submit control (Pay).", "approvable": true,
             "confirmToken": "…", "expiresInSeconds": 120, "approval": "token" } }

Below, needs_user (user_tab) is a needs_user result with that state.detail. The Python API raises ConfirmationRequired for an approval with a token, and for the old codes too.

needs_user hints

When Kitsune sees that only the user can do something on a page, the result carries a needs_user object (#376). It is a hint, not a hold: nothing was refused or held for it, an ok result stays ok and a failure keeps its own code. message is a short request to show the user; hint tells the agent to ask the user (browser_ask_user) if it cannot go on.

{ "ok": true, "snapshot": "…",
  "needs_user": { "reason": "sign_in", "message": "Sign in on this page.", "evidence": { "field": "password" },
                  "hint": "Kitsune noticed this; nothing was held for it. If you cannot go on without the user, ask them: browser_ask_user(reason, message)." } }
reason When evidence
human_check A shown frame of a known CAPTCHA or verification widget, by origin and path: reCAPTCHA (www.google.com or www.recaptcha.net, /recaptcha/api2/ or /recaptcha/enterprise/ anchor or bframe), hCaptcha (*.hcaptcha.com/captcha/…) or Cloudflare Turnstile (challenges.cloudflare.com/cdn-cgi/challenge-platform/…), also in a shadow root. Not an invisible widget (size=invisible), a challenge frame still hidden, or http. Gone once every response field of these widgets on the page (g-recaptcha-response, h-captcha-response, cf-turnstile-response) holds a token, the widget solved widget (recaptcha, hcaptcha, turnstile), frame (its origin and path)
sign_in A shown password field, or a shown field whose autocomplete is current-password, username or webauthn, unless a shown field sets a new password (autocomplete="new-password": a sign-up or a password change). A shown CAPTCHA comes first field (password, username, webauthn)
sign_in A click whose OAuth or OpenID Connect request (client_id with response_type or redirect_uri), SAML request (SAMLRequest) or Firebase sign-in handler (/__/auth/handler) popup the popup blocker refused (see Script-opened popups) popup (its origin and path)
trusted_input A click on a link whose tab the popup blocker dropped and Kitsune could not open (see Popup-blocked links), or a click whose expect was not met while its target is still shown (see Action outcomes) popup_blocked: "link", or target_visible: true
  • Page text never makes a hint, and neither does a hidden or off-page element, so a page cannot word itself into one. A page can still show a field or frame that makes one: it only ever adds a hint.
  • sign_in and human_check come from the page as it is: on browser_page_info (and /api/page-info), browser_snapshot, and each action whose result has a post-action snapshot, from the tab's top document and its shadow roots (not its frames' documents). A click's own hint (a refused sign-in popup, trusted_input) replaces the page's.
  • The extension checks one query of the document's frames and fields per read, so the hints cost no extra request.

The connection codes (bridge_unavailable, token_mismatch, port_conflict, extension_disconnected before sending, extension_version_mismatch, browser_window_unavailable, host_permission_denied) are explained under Connection diagnosis.

Writes are the bridge's read-only-mode write actions plus file-upload commits, focus, and blur. For a read, the bridge reports retryable: true, except for error_page and permission_required, where repeating cannot succeed.

Identity is checked before mutation. A missing or browser-internal target tab fails before any message reaches a page. Refs encode their document: a ref used after navigation, or on a different tab after a focus change, fails as stale_ref/other_document instead of acting on another element. Without tab_id, a command and its post-action snapshot target the tab that was active when the command started. CSS selectors carry no identity, so pass tab_id when focus can change.

If a content receiver disappears between the readiness ping and command delivery (for example, just after a wake), the extension rechecks the same tab and its content script, reinjecting if needed, and retries delivery once. This applies only to Gecko's exact missing-receiver error, which proves the command was not dispatched. Closed ports, missing responses and page errors are never automatically replayed this way.

The batch/browser_retry retry action stops after the first attempt that returns retryable: false, with reason: "not retryable", even if onErrors matches. It also stops on an ask-before-act hold (needs_user, confirmation_required), with reason: "confirmation required". Other errors are retried as before.

Pointer actions (browser_click by selector/ref, browser_double_click, browser_hover) still dispatch to a target that is not rendered (hidden, display: none, visibility: hidden, or inside a display: none ancestor), but the success result adds a warning. With stopOnWarning, a batch stops there.

{ "ok": true, "tag": "input", "warning": { "code": "hidden_or_covered",
  "state": { "reason": "not_rendered" }, "hint": "... prefer its label or visible control ..." } }

The hint depends on why the target may be unrendered (#328):

  • A target in a hidden tab (document.visibilityState is hidden: a background tab or a minimized window) adds state.hiddenTab: true. Tabs the agent opens are in the background by default, so their warnings usually carry it.
  • A hidden form control (input, select or textarea), such as the native checkbox behind a custom widget, keeps the hint to prefer its label or visible control from a new snapshot, inside a popup or in a hidden tab too: CSS hides it whether or not the page paints.
  • Any other target inside an open popup adds state.inPopup: true. A popup is an element with role menu, listbox or dialog that is rendered, a <dialog open>, or an open popover (:popover-open).
  • For any other target in a hidden tab or an open popup, the hint says the tab is in the background and the page may not paint the element until the tab is shown, or that the popup is open but not painted. Events dispatched to the element work normally, so if it belongs to the control the agent meant, the agent can act on it and check the effect instead of looking for another element. Other targets keep the label hint, and state has only reason.
{ "ok": true, "tag": "div", "visibilityState": "hidden", "warning": { "code": "hidden_or_covered",
  "state": { "reason": "not_rendered", "hiddenTab": true, "inPopup": true },
  "hint": "The tab is in the background, and the page may not paint this element until the tab is shown. Events dispatched to it work normally: ..." } }

Zero-size and transparent targets count as rendered. Covering overlays are not detected. Items of a popup that a click or hover opened but the page did not paint get this warning too; opened_popup says which control opened that popup.

Actionability wait

Action tools act immediately by default. With wait=True ("wait": true on the bridge endpoint or batch command), the page first waits up to timeout ms (default 10000) for the target, then dispatches exactly as in immediate mode. Nothing is dispatched while waiting or on timeout.

Action MCP tools Waits until the target
click, doubleClick, hover, drag (both ends) browser_click, browser_double_click, browser_hover, browser_drag exists, is connected, and is rendered
type, fill, setEditableContent, selectOption, checkBox, keypress browser_type, browser_select_option, browser_check, browser_keypress exists and is connected

Rendered means the same as for the hidden_or_covered warning above. Value actions don't need a rendered target, so a hidden native checkbox or select behind a custom widget still works in wait mode. A target that never resolves returns not_found; a pointer target that exists but never renders returns not_actionable. Both carry the last observed state and are retryable, because nothing reached the page. timeout is not used here; that code means a command was sent and its outcome is unknown. A ref that goes stale while waiting fails at once with stale_ref. Coordinate clicks and keypresses without a selector/ref have no target and run immediately. Other actions ignore wait. The bridge and MCP transport timeouts grow by the wait bound. Batch commands run in sequence, so a batch's timeout covers the sum of its waits (actionability waits, element/URL/title/result waits, newTab waits and sleeps); for parallel, the slowest sequence counts. Control flow counts its bodies: retry counts every attempt plus its backoff sleeps, if its longer branch, while maxIterations passes of its body, and try its do and catch. browser_retry and /api/retry grow the same way; without waits they keep their 60 s and 35 s bounds.

These are reliability checks borrowed from Playwright. They don't make the events trusted input: dispatch is still synthetic DOM events. A rendered target can still be covered by an overlay, which isn't detected.

hidden_or_covered stays a success warning in immediate mode, not a failure. Synthetic events reach a hidden element, and pages rely on that: a label-driven custom widget keeps its native checkbox or select display: none, and refusing by CSS heuristics would break actions that work today. Callers that want refusal pass wait=True, which fails a pointer target that never renders with not_actionable before dispatching anything. Overlay detection with an elementFromPoint hit test is deliberately not done for synthetic actions, as a failure or as a wait condition (browser_trusted_click does hit-test, because its OS click lands on whatever is at the point). Cookie banners and sticky headers often cover a target's center after it scrolls into view while the synthetic event still reaches the target, so refusing there would fail working actions. Hit tests also stop at closed shadow roots and cross-origin frames, and a single sample is unreliable under pointer-events: none, transforms, and animations. A covered target could later be reported as an additive warning, after benchmark validation. If the page unloads during the wait, the command fails like any other unload (navigating or content_unavailable, retryable: false for writes), because the background can't tell whether the action ran.

MCP error results

An MCP tool call returns isError: true when the tool's own top-level result reports a failure: a non-empty error, or ok: false. The content is the same JSON text as before, so error, code, retryable, hint, state, and any other fields (timedOut, tabId, …) are kept. This covers bridge and extension errors (stale_ref, not_found, extension_disconnected, bridge_unavailable, …), argument errors reported by the MCP server, ok: false results such as browser_navigate or browser_tabs(action="new", wait=True) load timeouts (the tab stays open), and a browser_retry that gave up. Errors raised by FastMCP itself, such as invalid arguments, were already isError.

These results are not errors:

  • A success with a warning (for example hidden_or_covered): the action was dispatched.
  • Results nested inside a successful result, such as browser_click_and_wait_navigation's navigation: {ok: false, timedOut: true} after clicked: true.
  • Container results: browser_batch, browser_parallel, browser_broadcast, browser_workflow and browser_resume report each command's outcome in results. A failed command there does not make the call an error, because commands before it may already have taken effect and must not be re-sent blindly. Inspect each entry, or its progress status. The container call itself is an error only when it fails as a whole (for example bridge_unavailable, or container_timeout).
  • Reads that describe state, such as browser_status with extension_connected: false.
  • A browser_form_fill that filled at least one key; keys that failed carry their own error in results.

A needs_user approval hold is an error result. On a client that declared elicitation, the MCP server asks the user before it returns one, and the result never carries a confirm token, also inside container results (see Approval prompts).

An outcome_not_observed error from an expect check still carries the action's post-action snapshot and target_ref: the action ran (see Ref lifetime). browser_save_session fails without writing the session file when any cookie or storage read fails, and browser_load_session reports an error, with cookies_set, origins_restored and per-item failures, when any write failed. Read-only mode refuses browser_load_session, a cookie and storage write, and allows browser_save_session like cookie reads. The URL allow/deny policy applies to every URL a session saves or restores; a refused URL fails the call before anything runs. A non-2xx bridge response without a JSON body becomes an error with state.httpStatus; a 2xx one cannot come from a Kitsune bridge and is a port_conflict (not_kitsune).

isError applies only to MCP tool results; HTTP bridge endpoints return the same JSON.

Connection diagnosis

browser_status always has a diagnosis: a code, plus a hint and usually a state unless the code is connected. When the bridge answers, it computes the diagnosis from its own state and what the extension reports. When the bridge cannot be reached, browser_status fails (isError) and diagnosis repeats the error's code, hint and state. A tool that fails for the same reason returns the same code and hint as an action error, and keeps its error text.

{ "status": "running", "extension_connected": false, "...": "...",
  "diagnosis": { "code": "extension_disconnected",
    "hint": "Open the browser profile where Kitsune is installed and check that Kitsune is enabled in about:addons ..." } }
code Condition Reported by Hint
connected No problem found browser_status —
bridge_unavailable The bridge is not running and could not be started, or is not reachable. A bridge that exits at startup is reported at once, not after the 15 s start timeout, with state.exitCode and state.logExcerpt (its last bridge.log line, at most 300 characters, bearer tokens and token values replaced by [redacted]). state.reason: not_responding, the bridge state.pid accepts connections on state.port but does not answer, reported about 4 s after a start, or after the 2 s status timeout for a bridge that already ran; not_listening, the bridge started but never listened, reported at the 15 s start timeout MCP server Run kitsune-bridge start in a terminal to see why; see bridge.log. not_responding: kitsune-bridge stop, then retry
port_conflict state.reason: in_use, nothing answers after a start and another program holds state.port (with exitCode and logExcerpt as above when the bridge exited), or the HTTP port accepts connections but does not answer and no Kitsune bridge runs; not_kitsune, the HTTP port answers but not as a Kitsune bridge (a 2xx reply without a JSON body); extension_ws_port, no extension has connected and the bridge listens on a KITSUNE_WS_PORT other than the extension's (state.extensionPort, see below) MCP server, bridge Find the program with lsof -nP -iTCP:<port> -sTCP:LISTEN, or use another port in every client
token_mismatch The bridge answered HTTP 401 to this MCP server's token. state.tokenSource is KITSUNE_TOKEN or the token file path MCP server Use the same KITSUNE_TOKEN and KITSUNE_HOME for every client, or kitsune-bridge stop
extension_disconnected The bridge runs, but no extension is connected. After the MCP server starts or replaces the bridge, its first tool call other than browser_status waits up to 16 s for the extension before it is sent, and fails with this code only if none connects; nothing is sent twice bridge Open the profile where Kitsune is installed and enable it; load a developer build again after a browser restart; it reconnects within 15 s of a bridge start
extension_version_mismatch The connected extension's version (state.extensionVersion, null when it is too old to report one) is not the bridge's (state.bridgeVersion). Or the versions match, but a developer build was edited and not reloaded (state.reason: "stale_developer_build", see below). Tools fail with it only for a command the extension does not know (Unknown action: …); others still run bridge Install the matching extension, or update the server; reload a developer build in about:debugging; reload open tabs
browser_window_unavailable The extension has no normal window it can use: all are closed, or only private windows where Kitsune is not allowed are open (state.windows: 0). A tool fails with it when it targets the active tab (No active tab found) bridge, extension Open a normal window in that profile, allow Kitsune in private windows, or pass tab_id
host_permission_denied Kitsune may not script a site. In browser_status: access to all sites is withheld (state.reason: "not_granted"). From a tool: the tab's site is withheld (not_granted), or it is one of Mozilla's restricted domains such as addons.mozilla.org (restricted_domain); state adds the tab state bridge, extension Allow site access in about:addons → Kitsune → Permissions; use another tab for a restricted domain

The bridge reports the first that applies: port_conflict or extension_disconnected, then extension_version_mismatch, browser_window_unavailable, host_permission_denied. All are retryable: true, because nothing reached the browser or a page, but repeating helps only after the hint is followed. Diagnoses hold ports, versions, the token's source, a bridge PID, a window count and, for tab errors, the usual tab state; never the token or page content.

Stale developer builds: on connect the extension sends sourceHash, a fingerprint of the scripts its manifest loads (background, then content scripts), and manifestHash, a fingerprint of its manifest.json bytes. For a developer build the bridge compares each, at each diagnosis, with the same fingerprint of the unpacked extension on disk: KITSUNE_EXTENSION_DIR, by default extension/ of the checkout the bridge runs from. A difference is extension_version_mismatch with state.reason: "stale_developer_build" and, for each that differs, state.extensionSourceHash and state.expectedSourceHash or state.extensionManifestHash and state.expectedManifestHash (see Updating Kitsune). A fingerprint unknown on either side (an extension from before that check, the signed build, or a bridge installed from a package) is not compared.

Extension port: until an extension connects, the bridge takes the extension's port from WS_URL in background/background.js of KITSUNE_EXTENSION_DIR, read at each diagnosis, and otherwise (a bridge installed from a package) assumes the default 47652 that the signed build uses. It cannot see which copy the browser loaded: a developer build loaded from another directory with another WS_URL is not recognized unless KITSUNE_EXTENSION_DIR names that directory.

Not diagnosed: sites Mozilla quarantines refuse Kitsune like a page's own error document and stay error_page. Firefox tells extensions neither which sites are quarantined nor that a refusal was one, and site access still reads as granted. If a normal site is error_page, check about:addons → Kitsune → "Run on sites with restrictions". A bridge that hangs after it answered is reported as timeout once the request times out. A bridge of another build is replaced automatically rather than reported.

Action outcomes

A successful action result (ok: true, no error) only means the command was dispatched: the click, keypress or submit reached the page. It does not prove the task succeeded, because a submit can be blocked by validation or a click can do nothing. To check, pass an optional expect object. After dispatch, the bridge waits up to expect.timeout ms (default 5000) for that outcome in the same tab. An MCP tool called without tab_id sends the current tab (the one browser_tabs last opened or selected), and without one it is refused (No current tab); an HTTP request with no tab at all is resolved to the active tab first and sent to that tab, so the action and the check cannot target different tabs after a focus change. Page conditions (selector, value, text) and new_tab are single reads that the bridge repeats until the deadline. They do not depend on the page's timers, which browsers throttle in background tabs. A url or url_changed check waits in the extension, on tab events, until the last check. Either way, each check must answer before expect.timeout, including its wait behind other commands on the tab, and no check starts after it. The last check starts 100 ms before the deadline (at once for a shorter expect.timeout), so that is how long it has to answer. The check therefore ends near expect.timeout (elapsed is measured, not clamped).

expect.timeout bounds only that observation, which starts when the action's reply arrives. Before it come two steps with their own limits: resolving a missing tab, plus the one read that url_changed or value_changed takes before dispatch, and then the dispatch, which the action's own timeout bounds. After an unmet observation, the reads for the failure's state, opened_tabs and click hint share a 2 s allowance, and none starts once it is used up. An action with expect therefore takes its dispatch time plus at most expect.timeout, and 2 s more when the outcome is not met.

expect takes exactly one condition:

expect Observed when
{"url": "<regex>"} the tab URL matches the JavaScript regex (tab metadata, survives navigation)
{"url_changed": true} the tab URL differs from its URL before dispatch (navigation, including a hash change)
{"new_tab": true} the action opened a tab (target="_blank" link, window.open); see Opened tabs
{"selector": "<css>"} a matching element is visible
{"selector": "<css>", "gone": true} no matching element is visible (removed or hidden)
{"selector": "<css>", "value": "<str>"} the first matching element's value equals the string
{"selector": "<css>", "value_changed": true} the first matching element's value differs from its value before dispatch (an element absent before dispatch counts as changed once present)
{"text": "<str>"} / {"text_gone": "<str>"} the visible page text contains / no longer contains the literal substring
{ "selector": ".toast", "timeout": 3000 }

Supported on browser_click, browser_double_click, browser_hover, browser_scroll, browser_drag, browser_type, browser_form_fill, browser_keypress, browser_submit_form, browser_select_option, browser_check, browser_navigate, browser_back, browser_forward, and browser_reload_tab. Over HTTP and in batch commands (including retry), expect is also accepted by focus, blur, and click-and-wait-navigation. Element conditions take a CSS selector, not a ref, because the page may navigate or replace nodes before an outcome is observed. An invalid expect is rejected before anything is dispatched.

When the condition is met, the result is the usual action result plus two fields:

{ "ok": true, "snapshot": "...", "dispatched": true,
  "outcome": { "expected": { "text": "Saved", "timeout": 5000 }, "met": true,
               "observed": { "source": "snapshot", "textVisible": true }, "elapsed": 0 } }

observed.source names where the outcome was seen: snapshot, tab (tab metadata), or page (a read of the current document). For text, the post-action snapshot already in the result is searched first, so no further read is needed when it contains the text. Snapshot text includes accessible names and non-sensitive field values, so it can match text that the page read would not.

When the condition is not met in time, the result is an action error that keeps the dispatch result (dispatched: true, the post-action snapshot, any warning) and adds outcome.met: false, ok: false, code: "outcome_not_observed", and the current tab state:

{ "ok": false, "dispatched": true, "error": "Expected outcome not observed within 5000ms",
  "code": "outcome_not_observed", "retryable": false, "hint": "...",
  "state": { "tabId": 7, "url": "https://shop.example/checkout", "title": "Checkout" },
  "outcome": { "expected": { "url_changed": true, "timeout": 5000 }, "met": false,
               "observed": { "source": "tab", "url": "https://shop.example/checkout" },
               "elapsed": 5003, "state": { "...": "same as state" } } }

retryable is false for writes, because the action already ran. The batch retry action does not repeat it either. Inspect the page before acting again. If the check itself could not read the page, observed has its error and code. A tab that is navigating is checked again until the deadline. If the tab closed, state.closed is true.

A check that does not answer before expect.timeout is not an observation; its late reply, met or an error, is ignored. If the check in progress at the deadline does not answer, outcome.observationIncomplete is true, observed is the last completed check, and the error adds that the check in progress did not finish. If no check answered at all, observed has code: "timeout" and the error says whether the outcome happened is unknown. Neither case means the action failed or is safe to repeat: it was dispatched, and retryable stays false. A state with only tabId means the tab could not be read within the allowance. A hint that needs a read the allowance had no time for (an opened tab, a click target still visible) is left out. For browser_click, when the clicked element (the result's target_ref, or the request's ref or selector if no post-action snapshot was taken) is still visible after the wait, the hint adds that the page may accept only trusted input and that browser_trusted_click (in the browse capability) is the alternative, and the result has a needs_user hint (trusted_input, evidence.target_visible) for asking the user to click it; the code stays outcome_not_observed. That is a possibility, not a diagnosis: the page may also be slow, or expect something else. Kitsune never retries the click or switches to an OS click on its own. A coordinate click with no target_ref, or a target that is gone, gets no such hint. Sensitive fields are never read back, so a value outcome on one is reported as not met with observed.redacted: true, and the expected value is not echoed. value_changed reads the value once before dispatch (observed.before reports it). If that read fails, or the field is sensitive, the action is not dispatched and the result is an error. The post-action snapshot is taken once the page has settled (see Post-action snapshots); after an outcome that comes later, take a new browser_snapshot to see the updated page. Without expect, results are unchanged.

wait and expect can be combined: wait runs before dispatch and expect after it. A batch's timeout also covers each command's expect.timeout.

Opened tabs

A page action can open a tab instead of changing its own, for example a click on a target="_blank" link. The result then lists the tabs whose opener is the action's tab and that were created after the action started, with or without expect:

{ "ok": true, "snapshot": "...", "opened_tabs": [{ "tabId": 4, "url": "https://shop.example/addresses" }] }

These tabs are agent-owned (see Shared browser): Firefox shows such a tab, and the extension shows the user's tab again at once. The list is read after the post-action snapshot. A new tab starts on about:blank, so the extension waits up to 1 s for its URL; a tab still loading after that is reported as about:blank. Results without an opened tab have no opened_tabs field. Act on an opened tab by passing its tabId as tab_id.

expect: {"new_tab": true} waits until such a tab exists and reports it in observed.opened_tabs (observed.source is tabs). The outcome of any other expect is still checked only in the action's tab. When it is not met but the action opened a tab, the outcome_not_observed result adds opened_tabs (read again after the wait) and its hint names the tab, so that case is distinguishable from a no-op:

{ "ok": false, "dispatched": true, "code": "outcome_not_observed",
  "opened_tabs": [{ "tabId": 4, "url": "https://shop.example/addresses" }],
  "hint": "The command was dispatched and opened tab 4, where the outcome may have happened; inspect it with browser_snapshot tab_id=4 instead of repeating the action." }

Clicks are synthetic DOM events without user activation. With the default popup blocker (dom.disable_open_during_load=true), the browser therefore drops the tab of a clicked target="_blank" link, although a user's click on the same link opens it. So when a browser_click follows a link into a new tab and no tab opens within 500 ms, the extension opens the link's own URL with tabs.create in the background, in the clicked tab's window and container, with the clicked tab as opener, and marks it in opened_tabs:

{ "ok": true, "opened_tabs": [{ "tabId": 5, "url": "https://shop.example/addresses", "via": "link_fallback" }] }

This applies only when all of these hold:

  • the click target is an <a> or <area> with an http(s) href, or is inside one;
  • its target is _blank (case-insensitive), or it has no target and the page's <base target> is _blank;
  • it has no download attribute and the page did not cancel the click (preventDefault);
  • no tab opened by the clicked tab appeared within 500 ms.

A tab opened this way gets no Referer and no window.opener, and does not copy sessionStorage. A link with rel="opener" needs window.opener, so it is not opened. If such a link, or a failed tabs.create, leaves no tab, the click result has a popup_blocked warning instead of a silent success:

{ "ok": true, "warning": { "code": "popup_blocked", "state": { "url": "https://shop.example/addresses" },
  "hint": "The link opens in a new tab, but no tab opened; the popup blocker likely dropped this synthetic click. ..." } }

Only a trusted click opens it, so the result also has a needs_user hint (trusted_input, evidence.popup_blocked: "link") for asking the user to click it (#376).

The warning's state.url uses History URL redaction, so tokens and OAuth secrets read [redacted]. The fallback itself still opens the original URL. Do not navigate to a redacted placeholder URL; use the original page control instead.

With expect, an unmet outcome then uses that hint. A browser_trusted_click is a real OS click with user activation, so the popup blocker lets its tab open. It needs a visible, focused browser window and OS permission (macOS: Accessibility), and its result does not list opened_tabs; the tab it opens is not agent-owned.

Script-opened popups

Popups that page scripts open with window.open, such as OAuth/SSO sign-in popups (see Sign-in popups), are never reopened, because the script keeps using the window it opened. The popup blocker refuses them after a synthetic click as it refuses a link's tab, and window.open then returns null. So the content script wraps the page's window.open once per document, and a browser_click (Kitsune or core; the click action) during whose dispatch a page script called window.open and got null returns a popup_blocked warning with state.reason: "script_popup", when no tab opened for the click:

{ "ok": true, "warning": { "code": "popup_blocked",
  "state": { "url": "https://accounts.example/o/oauth2/auth?client_id=1", "urls": ["https://accounts.example/o/oauth2/auth?client_id=1"], "reason": "script_popup" },
  "hint": "A page script tried to open a popup (window.open), which Firefox blocks without user activation, so none opened. Check state.urls for all refused URLs. If this click should open that popup (a sign-in at state.url, for example), ask the user to click it, then continue in the new tab by tab_id (find it with browser_tabs), or use browser_trusted_click; if not (an ad, for example), ignore this warning." } }
  • state.url is the URL the script passed, resolved against the page's base URL like a link's href (a URL that does not parse stays as given), cut at 2,048 characters, and redacted like snapshot URLs (see History URL redaction), so a nonce or access_token value reads [redacted]. It is left out when the script passed no URL or a non-string, such as a URL object or an empty string for a window it navigates later.
  • The dispatch is the click's pointer, mouse and click events. A window.open the page calls later, after a fetch or a timer, is not counted.
  • Any refused window.open during the dispatch counts, wherever its listener is: an ad script that listens for clicks on the whole page and opens a popunder also triggers the warning. state.urls lists every refused non-empty string URL in call order, resolved, clipped and redacted the same way as state.url. The record keeps at most the latest 20 calls; state.url is the first URL in that retained list for compatibility. state.urls is empty if no refused call supplied a URL. Check the whole list: an ad listener can run before the button's own handler. Involve the user only for the popup the click should open, and ignore unrelated popups.
  • A call that returned a window, or whose features include noopener or noreferrer (which make window.open return null even when the popup opened), gives no warning. Neither does a click that reports a tab in opened_tabs.
  • Only one warning is returned: an earlier one, such as hidden_or_covered or a link's popup_blocked, is kept.

This is a likely cause, not a diagnosis, and Kitsune does not retry the popup or switch to an OS click on its own. With expect, an unmet outcome uses the warning's hint.

When a refused URL is a sign-in request (an OAuth or OpenID Connect authorization request: client_id with response_type or redirect_uri; a SAML SAMLRequest; Firebase's /__/auth/handler), the result also has a needs_user hint (sign_in, evidence.popup: its origin and path) for asking the user to click the sign-in button (#376). Only those protocol parameters count, never the button's label.

The wrapper is page code that the content script injects with a <script> element before page scripts run. In a page that was already open when Kitsune first reached it, the wrapper installs later, so a script that saved the original window.open bypasses it. It calls the original window.open with the same this and arguments, and returns its result, or throws its exception, unchanged. It is not an exportFunction export, which would throw "can't access dead object" in open tabs once the extension updates or reloads. A page whose Content Security Policy blocks inline scripts therefore gets no wrapper and no warning. The record is page data, so a page can alter it; it keeps the URL of a refused call only, and Kitsune reads at most 20 calls and uses them only for this warning. Not covered: browser_press_key, browser_double_click, browser_click_and_wait_navigation (it builds its own result, so an OAuth button waits for the navigation timeout and reports navigation.timedOut without this warning), and clicks on an element inside a same-origin frame, whose window.open is the frame's own and is not wrapped.

Opened popups

A click, double-click or hover can open a menu, listbox or dialog instead of a tab, often in a shared container at the end of <body> that says nothing about which control opened it. The result then has opened_popup (#329):

{ "ok": true, "snapshot_delta": true, "target_ref": "e41",
  "opened_popup": { "ref": "e90", "role": "menu", "name": "Video actions", "opened_by": "e41",
    "rendered": false, "items": [
      { "ref": "e91", "role": "menuitem", "name": "Add to queue" },
      { "ref": "e92", "role": "menuitem", "name": "Save to Watch later" } ] } }
  • opened_by is the ref of the acted-on element, so an item can be matched to the row whose control opened the popup.
  • The popup is found in this order: when the aria-expanded of the acted-on element, or of its nearest ancestor that has one, turned "true", the element its aria-controls or aria-owns names, unless it or an ancestor below <body> has hidden or aria-hidden="true"; else the last role=menu, listbox or dialog element, open <dialog> or open [popover] that opened or was added during the action; else, when aria-expanded turned "true" or the acted-on element (or its nearest ancestor with the attribute) has aria-haspopup (other than "false"), the open menu, listbox or dialog that items were added to, such as one shared menu that each row refills. A menu, listbox or dialog counts as open unless it is hidden itself or it or an ancestor below <body> has hidden or aria-hidden="true"; a display: none ancestor alone does not close it, because a background tab may not have shown it yet. A popup that contains the acted-on element is not reported. The action's own document and shadow root are searched, not other frames. Results without a popup have no opened_popup field.
  • A menu, listbox or dialog that opened or was added but is not rendered is reported only when aria-expanded turned "true", the acted-on element (or its nearest ancestor with the attribute) has aria-haspopup, or the popup's nearest display: none ancestor was already in the page before the action (a shared layer). A closed dropdown that the action mounted in its own display: none host, such as one inside a "Show more" section, is not reported. Rendered popups, open <dialog>s and open [popover]s are reported without these conditions.
  • role is the popup's role (dialog for <dialog>, generic when it has none) and name its aria-labelledby, aria-label or title.
  • rendered is false when the popup is hidden or has no layout box, for example when a background tab never painted it.
  • items lists up to 20 descendants with role menuitem, menuitemcheckbox, menuitemradio, option, button or link (explicit or native), not those inside another item. Hidden items of a rendered popup are left out. In an unpainted popup an item is left out when it is hidden itself (hidden, its own display: none, aria-hidden="true", or visibility: hidden set on it while its parent's visibility is not hidden); visibility: hidden inherited from the unpainted container does not count. Each ref acts like a snapshot ref. A pointer action on an item of an unpainted popup dispatches normally and returns the hidden_or_covered warning.

The popup is looked up when the action returns, before its post-action snapshot. With a delta snapshot, that is after the page has settled (see Post-action snapshots). A popup that opens later is not reported.

While a reported popup stays open, every snapshot of its document lists it even when it is not rendered, with [not_rendered] on its line:

- menu "Video actions" [ref=e90] [not_rendered]
  - menuitem "Add to queue" [ref=e91]
  - menuitem "Save to Watch later" [ref=e92]

A popup found through aria-expanded stays listed while it is attached, that element still has aria-expanded="true", and neither the popup nor an ancestor below <body> has hidden or aria-hidden="true". Any other popup stays listed while it is attached and open (an open <dialog> or [popover]; a menu, listbox or dialog that is open as described above) and the hidden, class, aria-hidden and open attributes and the inline display and visibility of it and its ancestors below <body> are unchanged; other inline style, such as its position, may change. Once it closes it is not listed again until an action reports it again. Below a hidden ancestor, only the path to the popup is walked, so other hidden content in a shared container, such as a menu opened earlier, stays out. Inside the popup, elements hidden themselves, such as <input type="hidden">, are left out as in items. Strict role and name selectors still skip unrendered elements (see Strict selectors); act on these items by ref.

Shared browser

The user and the agent use one browser at the same time. Agent work never takes the user's focus, never switches their view as a side effect, and does not act over what the user is doing (#139). Three rules follow:

  1. Never take focus. Tabs the agent opens open in the background, and no capture or action shows a tab or focuses a window unless the call exists to do that (browser_tabs select, browser_focus_window).
  2. Agent-owned tabs. Every tab is readable, whoever owns it; user and other clients' tabs are read-only, and only this client's owned tabs accept writes by default (see Tab permissions).
  3. Don't fight the user. On an owned tab the user is using, agent writes and navigations return the existing retryable user_active instead of acting.

Never taking focus

Every place that could show a tab or focus a window:

Site Before Now
browser_tabs new, batch/workflow newTab, session save/load and tab-pool tabs (tabs.create) opened in the foreground active: false: opens in the background
Link fallback for popup-blocked links (below, tabs.create) foreground active: false
duplicateTab (tabs.duplicate) showed the duplicate active: false
createWindow, detachTab (windows.create) focused the new window focused: false, and the focus goes back to the user's window right away in case the browser focused the new window anyway (see Agent windows)
Tabs a page opens on an agent action (target="_blank", window.open) Firefox shows them the extension shows the tab the user saw again at once (tabs.onActivated's previousTabId) and gives a popup window's focus back to the window the user had focused; the new tab flickers for a moment. Firefox decides to show them, so this is an undo, not a prevention
browser_screenshot viewport with tab_id selected the tab and focused its window, and never restored them tabs.captureTab captures the tab in place
Element and full-page screenshots showed the tab during capture, then restored it tabs.captureTab; nothing is shown, and the page's scroll position is restored afterwards (#449)
browser_trusted_click on a tab that is not shown clicked whatever tab was shown shows the tab for the click, then shows the user's tab again 300 ms after it (unless the user moved elsewhere meanwhile)
browser_trusted_click(focus=True) foregrounds the browser (OS) unchanged: an explicit opt-in
browser_tabs select, batch switchTab (tabs.update({active: true})) switched the view unchanged, but only for an explicit request; refused with user_active while the user is using the tab they would leave
browser_focus_window, batch focusWindow (windows.update({focused: true})) focused the window unchanged, but only for an explicit request; refused with user_active while the user is using the focused window's tab
wakeTab, navigate, reload, back/forward never showed a tab; unchanged. A read of a discarded tab wakes it the same way (#449)

The OS click of browser_trusted_click still moves the real cursor for the click (it goes back afterwards unless restore_pointer=False) and focuses the window it lands in; the bridge cannot give OS focus back to another application.

Changed behavior: browser_tabs new (POST /api/new-tab, batch newTab) no longer shows the new tab. Pass its tabId as tab_id to act on it. A caller that relied on the new tab becoming the active tab must select it explicitly, and should do so only when the user asked to see it. Commands without tab_id still target the tab the user sees, which is not the new tab.

Agent-owned tabs

browser_tabs list (GET /api/tabs) marks every tab with owned: true for this MCP client's tabs, false for user tabs and other clients' tabs (#394). Explicit creations (browser_tabs new, duplicateTab, createWindow, session/pool tabs and link fallback) belong to the calling client's owner id. A page-created window.open or target=_blank tab inherits its openerTabId's owner, even outside an agent action. If the opener received trusted user input in the 1 s before creation (inclusive), the new tab belongs to the user instead. Synthetic events and Kitsune's recognized OS input do not count. Input does not change the opener's ownership. The agent window's synced copies inherit its owner as well.

Tab permissions

Ownership now enforces permissions per client (#395). User tabs and other clients' tabs refuse all writes immediately with user_tab_read_only, retryable: false, and no prompt or confirmation token. state contains tabId, index (the zero-based browser_tabs list position, across windows) and action.

{"error":"Tab 12 is read-only for this client; nothing was done","code":"user_tab_read_only",
 "retryable":false,"state":{"tabId":12,"index":0,"action":"click"},
 "hint":"Open a tab of your own with browser_tabs action=new and this URL, or request this tab with browser_tabs action=request. Reads work; those that scroll it (element or full-page screenshots, find load_more) only while it is not shown."}

Every tab is readable, whoever owns it; only writes are gated (#449):

On a tab this client cannot write Shown tab Tab not shown
Snapshots, ordinary find, DOM/console/network reads, viewport screenshots, core browser_tabs select allowed allowed
Element and full-page screenshots, find load_more refused allowed; the scroll position is restored afterwards
Writes, hover, scroll, focus/blur, browser_evaluate and other JavaScript, navigation refused refused
  • Allowed: snapshots, ordinary find, DOM/console/network reads and viewport screenshots. These do not scroll, focus or switch the shown tab. Reading a discarded tab wakes (reloads) it in the background without showing it, as wakeTab does; waking for a read is not a write, while an explicit wakeTab or a write on a discarded tab still is.
  • Allowed only while the tab is not shown (not active in its window): element and full-page screenshots, which scroll the page to capture it and restore its scroll position afterwards, and find load_more, whose scrolls the core browser_find sends with loadMore: true and scrolls back by what they scrolled. On the shown tab they are refused, since they would move the user's view. The extension decides by the tab's active state when the command arrives; a tab the user shows during a capture is not stopped mid-capture.
  • Refused: page writes, hover, scroll, focus/blur, navigation/history/reload/close, low-level switchTab, wakeTab, uploads, arbitrary JavaScript, dialog/storage writes, and equivalent batch, workflow and trusted-input commands.
  • Window close/focus/resize cannot mutate a window containing a user or other client's tab. A new tab opens in the same window and would not help, so for these actions the hint is instead: "This window holds a tab this client cannot write, so nothing was done. Window actions (close, focus, resize) work only in a window that holds only this client's tabs; a new tab opens in this same window, so it does not help." windowId must be a positive safe integer (decimal strings are canonicalized); aliases such as Firefox's WINDOW_ID_CURRENT (-2) and malformed IDs fail with invalid_window_id, even in writable compatibility. Close/focus require an explicit ID from browser_windows; nonexistent windows or windows with no available tabs fail with window_unavailable before mutation. Resize remains tab-scoped: it binds the target tab's concrete window during admission, rather than resolving it again at dispatch.
  • Moving/detaching an inactive owned tab remains allowed in shared windows; shifting tab-strip indices does not confer ownership of other tabs. A missing/null move destination binds the tab's current window; an explicit destination must be concrete and have available tabs. Default permissions refuse moving an active tab to another window or detaching it (unsupported_target, nonretryable), since that may select another tab or focus/close a window. No speculative move-and-restore is performed. Reordering in the same window stays allowed, as does the established unfocused-window path for detaching an inactive owned tab. Explicit writable compatibility retains prior active-tab movement behavior.
  • Own tabs continue to work with the existing user_active protections. Trusted OS input may show an owned tab temporarily, then restore the previous user tab; restoration is bound to that client's recorded input transition, not an arbitrary view-switch API.
  • Direct and container tabId values must be positive safe integers (decimal strings are canonicalized). Booleans, zero, empty strings, arrays and other malformed targets fail with invalid_tab_id before admission; they never fall back to the active tab. Missing/null targets retain implicit-target behavior. Nonexistent write targets fail before dispatch.
  • OS viewport coordinates must be finite, inside every affected page/frame viewport and hit page content. Bounds are checked again after frame translation and before desktop conversion; invalid points return invalid_coordinates, without OS input. Transformed iframe geometry that the translation-only mapper cannot prove is refused, not approximated.
  • Under default permissions, trusted keyboard shortcuts that can target browser tabs, windows, address/search bars or developer tools return key_denied before focus or keys. This includes ordinal/next/previous-tab variants, history/reload shortcuts and trusted Tab/Shift+Tab traversal. Use explicit focus or tab-scoped APIs instead: this is an OS-target-binding limitation, not a prohibition on navigation in owned tabs. A trusted text string containing Tab is refused in full, before earlier characters are typed. Chord sequences/unsupported modifier aliases are not accepted by the one-chord API. Ordinary editing and Meta/Control+F (tab-local find) remain subject to ownership, activity and per-key field-focus checks; subsequent calls must refocus/revalidate the owned field. Explicit writable compatibility retains the previous shortcut/traversal behavior, while existing always-denied application escapes and focus/activity guards still apply.
  • Raw coordinate_space="screen" clicks cannot prove which tab they hit. They are now refused even in writable compatibility mode and even with an owned tab_id (unsupported_target, nonretryable). This intentionally removes the former writable raw-screen behavior: coordinates could otherwise hit a handed-off tab. The parameter remains accepted so callers receive a structured refusal; the HTTP space alias is also refused, and malformed coordinate-space values never fall back to raw-screen mode. Use a ref or viewport coordinates on an originally agent-created tab instead.

current always identifies the agent's actual current tab, whoever owns it (#399). Core browser_tabs select changes the agent's current selection, not the shown tab, and takes any tab, user and other clients' tabs too (#449): reads on it work. When this client cannot write the tab, the result adds read_only: true and a hint (open your own tab with browser_tabs action=new, or browser_tabs action=request this one), and writes on it return user_tab_read_only. Low-level switchTab changes the shown tab and refuses user-tab targets. Neither grants ownership. browser_tabs action=request asks for an explicit handoff (#396), described below. Tokens for other actions do not grant ownership or override this refusal.

Requesting and returning a tab

Use browser_tabs(action="request", index=0, reason="Explain why this tab is needed"), or supply tab_id instead of index. The native Python tool uses tab_id. This is an existing tool action; it adds no tool. A request does not select, activate, navigate or focus the tab. After approval, core select can make it current without changing the user's view; Python/HTTP DOM operations may target its tab_id directly. A handoff does not grant trusted/native OS-input capability.

The Kitsune toolbar badge marks pending requests. Its popup shows the client name, tab title, URL, and the agent's reason, with Approve handoff and Decline. These labels are untrusted text, not page HTML or executable links. Only the extension popup can answer: a page/content script cannot spoof it. A browser answer completes the call without a client prompt. No answer for 30 seconds retires that browser prompt and falls back to MCP elicitation. Without elicitation the result contains a 120-second confirm token: ask the user and repeat the identical request with confirm_token. Tokens are one-use and bound to the session, tab, document and reason. Reordering an index must never authorize a different tab; if the repeat no longer targets the original tab, request it again. Navigation before approval invalidates the prompt/token. Navigation after approval preserves ownership. A decline returns needs_user with state.decision="declined" and recommends an agent-created tab.

A successful handoff is owned for this requesting session, including DOM/browser writes without further handoff prompts (ordinary policy checks and user_active still apply). Approval releases text the user left in a focused field of the tab or its frames, so a half-filled form they hand over does not refuse the agent's writes with user_active typing; the 3 s input window still applies, and the user's later typing in a field counts again (#456). The popup lists handed-off tabs by client and provides Take back. Taking back immediately revokes permission without changing the user's view. It does not undo an action already sent to the page. Other clients' tabs, including handoffs to another simultaneous session of the same stable owner, cannot be requested/transferred: they remain read-only.

Browser/content mutations retain their original admission and recheck it at dispatch, including readiness retries and every admitted tab of a window operation. Taking back and granting again does not revive an older command. Browser approval claims the request and retires the 30-second timer before storage work, so it cannot also trigger elicitation. Handoffs are DOM/browser-only, not full native-input capability. Trusted OS click (including double-click, held click and leaving the pointer for hover menus), drag, type and press-key are refused on handed-off tabs before focus, page preparation or OS scheduling. The low-level screenPoint, agentInput and focusState preparation routes are refused too. There is no separate native-hover tool; ordinary DOM hover remains supported, as do DOM click, fill/type, press-key and drag. An authorized handed-off target returns nonretryable unsupported_target with state.reason="handoff_native_input", recommending DOM operations or a new own tab (browser_tabs action=new) for native input. Unowned tabs and another session's grant retain user_tab_read_only under the default policy.

The extension decides from its internal grant provenance, never caller JSON. Writable opt-outs cannot authorize native input on a current handoff grant, and ownership bookkeeping cannot promote that grant into persistent ownership. Regranting does not change the native refusal. Because no native preparation or OS work is admitted for a handed-off target, executor queue delays and cross-command regrant cannot revive those denied operations. Native input on originally agent-created tabs remains supported, with existing activity, focus and late bound preflight checks. This is not a claim of atomic revocation for arbitrary native actions: extension-to-OS scheduling and already-started OS effects are not made atomic or cancellable. Already-dispatched page changes, including temporary screenshot styling/scroll, are not undone after revocation.

Handoffs are memory-only, separate from persistent owner_id ownership. A dedicated authenticated bridge connection represents the MCP session (one stdio session per server process); normal shutdown closes it, and EOF after abrupt process exit returns that session's handed-off tabs. Stateless HTTP/native calls cannot acquire a handoff without a live BridgeClient.open_handoff_session channel; native callers must close it with close_handoff_session when their session ends. Detection of network/process failure is not instantaneous: it depends on socket closure/detection. Bridge/extension disconnection or extension reload also drops grants and pending approvals; reconnection requires fresh approval. At most 64 channels and 64 pending approvals are kept. Session secrets are never tool arguments, page messages, popup labels or results.

Only explicitly handed-off tabs return. Tabs the agent originally opened, and children it opens from a handed-off tab, remain persistent agent tabs. The trusted-input exception for user-opened children still applies. Closing a tab clears its pending approvals/grant.

Handoff holds intentionally do not offer browser_resume checkpoints in this version. Use the exact token repeat while valid; after decline, expiry, take-back or disconnect, make a fresh browser_tabs action=request. Existing tools' resume behavior is unchanged.

Pools are partitioned by stable client owner: create/acquire/release/shrink affect only that owner's pool. Releasing another owner's tab is refused, with no transfer of ownership. Pool tabs remain excluded from mine results. The popup's user-operated global cleanup remains separate and may close pool tabs.

KITSUNE_USER_TABS=writable allows writes without changing ownership; neither caller JSON nor approval tokens enable it. The bridge uses one authoritative X-Kitsune-User-Tabs header. See User-tab setting for values, defaults and invalid-value errors. Tab permissions do not sandbox browser/account-wide side effects: cookie changes, clipboard writes, downloads/cancellation, clearing browsing data and the global network interception rules can still affect shared state. They have no concrete tab target and remain subject to their existing capabilities, task-mode and optional ask-before-act policy. Interception is a global blocking webRequest listener, not a safe read or a tab-local rule. In contrast, dialog policies and page storage have concrete tab targets and are guarded. A v0.7.1 extension's user_tab refusal to close a user tab is still turned into a needs_user approval by the bridge; the current extension emits no approval holds for user tabs.

Ownership lives in the extension's background page. A bridge restart or reconnect keeps it. The extension also stores it on each owned tab as a session tab value (browser.sessions, the sessions permission, #376), so an extension reload or update, and a browser restart that restores the tabs, keep it too: when the extension starts, it reads the values back before it runs a command. Pages cannot read or write these values. A tab that goes back to the user loses its value. Limits:

  • A tab the user duplicates from an agent tab, and an agent tab the user closed and reopens (Undo Close Tab, a reopened window), are the user's: Firefox copies or restores the value with the tab, and the extension removes it when the tab opens or starts loading. The agent's own duplicateTab copy stays the agent's.
  • A browser restart keeps only the tabs the session restore brings back. If the extension starts before the browser restored a tab and reads no value for it, that tab counts as a user tab, as it did before #376.
  • A restart that does not restore the tabs, a new profile, or a browser without browser.sessions loses ownership: the tabs count as read-only user tabs.
  • Transient input/action timestamps and agent window records do not persist. Ownership does, including the client id in version-2 session values. Older version-1 values have no owner: no named client sees them as owned or may write them by default.
  • A lightweight observer watches eligible child frames independently of frame commands, including same-origin/cross-origin frames and about:blank/srcdoc frames where Firefox permits injection. It is also installed into existing frames on extension reconnect. Only trusted event timestamps/types and modifier names are reported, never typed text.
  • Ownership observation excludes announced agent OS input across the tab, including new frames: pointer/touch events and the announced modifiers for at most 2 s after click preparation, and keyboard/paste events for the announced keyboard window (at most 60 s). Unrelated event types/modifiers still count. Child-frame activity reuses these exclusions; top-frame activity retains its existing local OS-input windows. Concurrent genuine user input matching an announced OS event cannot be distinguished from the agent's input during that finite window.
  • Content scripts cannot observe input on browser-internal pages or while the extension is unloaded. The 1 s threshold has deterministic unit coverage. The trusted-mousedown-to-tabs.onCreated delay was measured in headless Zen with privileged synthesized trusted DOM input and no button hold time: 6–103 ms over 60 middle and Cmd clicks, and 8–45 ms from a trusted Enter keydown over 16 keyboard opens (Cmd+Enter, Enter on a target=_blank link) (tests/test_real_browser_trusted_ownership.py). With real OS input (Quartz CGEvents) in headed Zen 1.24b on macOS, holding the button 50–150 ms, 60 middle and Cmd clicks took 68–183 ms (median 116.5 ms) from posting the OS mousedown, and 22–177 ms (median 110 ms) from the page's mousedown, the time the extension compares (tests/test_real_browser_os_trusted_ownership.py). The link opens on release, so the delay grows with the hold. Real users' timing in a loaded browser has not been measured.

Agent tab cleanup

The toolbar popup follows the browser's popup colors (theme permission), with an automatic light/dark fallback. It also shows the extension's connection to the local bridge, completed MCP calls (including errors), and time since the last completed call. Usage is summed across MCP clients for the current extension–bridge connection and resets on reconnect. One outer MCP call counts once, including gateway calls, batches, and approval retries; internal browser commands and Python API calls do not count. Reporting is best effort, in memory only, and contains no tool names, URLs, arguments or results. Older bridges show usage as unavailable, not zero. “Connected” means the bridge is reachable, not that an MCP client is currently running.

Agents often leave the tabs they opened behind, so a long unattended session can pile up dozens. Kitsune's toolbar button opens a popup with two ways to clean them up. Both close only agent-owned tabs and never close pinned tabs, so pinning an agent tab keeps it. A tab the user handed off stays the user's: cleanup neither counts nor closes it, while children the agent opened from it are agent tabs. Closed tabs can be reopened from the browser's recently closed tabs, and a reopened tab belongs to the user.

  • Close agent tabs closes every unpinned agent tab at once, including the one shown. The popup shows how many there are.
  • Auto-close after (Never, 5 minutes, 15 minutes, 30 minutes, 1, 2 or 4 hours; default 5 minutes) checks once a minute (alarms permission) and closes agent tabs that have been idle that long. A tab counts as used when the agent opens it or runs a command on it, and when any window shows it. A tab that is shown, playing sound, or has an agent command running on it is never closed by this check. Automatic cleanup also skips user_active tabs, including recent input in a just-backgrounded tab; the explicit user-requested popup cleanup does not wait. Use times are kept in memory only, so after an extension reload or browser restart the timer for restored agent tabs starts again from then. The setting is stored in the extension's storage.local (agentTabIdleMinutes). Existing saved settings, including Never, are preserved; the 5-minute default applies only when no setting has been saved.

Only the popup can trigger these actions; pages and agents cannot. An agent that later uses an id of a closed tab gets tab_closed. Tab-pool tabs are agent tabs, so the cleanup can close idle pool tabs too.

Protected user tabs

Navigation, back, forward and reload follow Tab permissions, just like clicks and writes: a user tab is refused without a prompt. This replaces the old navigation-only approval hold. Read-only clients can open their own background tab with browser_tab_list(new_tab=...).

Agent windows

Agent work stays in background tabs of the user's window. Only an explicit browser_create_window or browser_detach_tab (batch createWindow, detachTab) opens a window; these tools are in the tabs capability, which the default tools leave out. Nothing else calls windows.create. A popup window that a page opens on an agent action is the page's own (see Never taking focus).

Focus. windows.create passes focused: false, but some browsers ignore it: Zen (on Firefox 158) focuses the new window even with its window sync off. So right after it creates a window, the extension gives the focus back to the browser window the user had focused, and does so again if the browser focuses the new window within 1.5 s. The new window can flicker for a moment. If no browser window had focus (the user was in another application), nothing is focused. If the user clicks into the new window within those 1.5 s, the focus goes back to their previous window. browser_focus_window on the new window is never undone.

Zen's window sync (zen.window-sync.enabled, on by default) mirrors tabs between windows. A new window gets a lazy copy of each tab of the other windows, and each other window gets a lazy copy of the new window's tab. Zen fills that copy with the agent's page when the agent window closes. Closing a copy closes its original. Kitsune handles this as follows:

  • In a createWindow window, only the tabs windows.create made are the agent's (tabIds in the result). The browser's copies there are discarded when the window opens. Kitsune records them once, at that moment, and never makes them owned. Under default permissions, browser_tabs close and browser_close_window both refuse them (user_tab_read_only). The writable opt-out allows closing the window with those copies: removing a window closes no originals.
  • A tab that opens in another window as a copy of the agent window's tab is adopted: owned: true, and browser_close_window closes it after the window. Adoption is conservative, and every condition must hold. The tab opens within 1.5 s of windows.create, in a window that existed before it, in the background, without an opener, discarded (a lazy tab that loads nothing), and at about:blank (where Zen's copies start) or one of the agent window's URLs. A tab the user opens is shown, has an opener, or loads a page, so it is never adopted.
  • An adopted copy goes back to the user (owned: false, left open) when the user shows it, when it starts a load the agent did not start, or when it reports user activity as browser_close_window would close it.

Limits: this is a heuristic. A lazy background tab that something other than the sync opens in those 1.5 s at about:blank counts as the agent's, for example one opened by another extension. If the user closes the agent window, its adopted copies stay open, agent-owned, and show the agent's page; browser_tabs close closes them. In a browser_detach_tab window, the browser's copies count as user tabs, so browser_close_window on it is refused by default. The extension keeps its records of an agent window in memory only: after an extension reload or a browser restart, adopted copies stay owned but no longer go back to the user when shown, browser_close_window no longer closes them after the window, and the browser's copies in a createWindow window count as user tabs, so closing that window is refused by default.

User activity

An agent write, navigation or close on a tab the user is using returns the error user_active (retryable: true) instead of acting, with state.tabId, state.url and state.reason:

state.reason The user is Window
input giving the page trusted input: keydown, mousedown, pointerdown, touchstart, wheel or paste (state.input, state.sinceMs, state.windowMs) 3 s after the last such event
typing typing in a field: the input, textarea, editor or custom element (a shadow-root field shows as its host) that last got a trusted keydown or paste still has focus, holds content, and its document has focus (state.field is its tag; the value is never read out; state.sinceMs since the document's last trusted input, state.windowMs) while all of that holds, at most 10 s after the last such input
navigating loading a new document in the tab they see (address bar, back button, a link), and no agent command on the tab started it (state.sinceMs) while it loads, at most 10 s

On your own tab, wait a few seconds and retry. Input does not transfer ownership. Typing releases when the field loses focus or is emptied, when the user gives its document no trusted input for 10 s (#456), or when the user approves a handoff of the tab, which releases the drafts of the tab's top document and child frames. The 10 s limit is more than three input windows, long enough that a pause to think mid-sentence keeps the draft, and matches the bound on a user navigation. For typing the hint names these releases instead of only asking for a retry. Once a check has seen the release, the field counts again only after the user's next trusted keydown or paste in it, so the agent's own focus or text there does not hold the tab. Checked actions: click, double-click, type/fill/editable, select, check, keypress, submit, form fill, hover, scroll, drag, focus, blur, JavaScript, upload, navigate, back, forward, reload, click-and-wait-navigation, close, element and full-page screenshots (they scroll the page), trusted input preparation and each native keyboard focus check on their own tab. Window close/resize checks every affected tab; pin/mute/zoom/move/detach also respect activity on their target. Select (switchTab) and browser_focus_window check the tab the user would leave. Reads never are. Ownership is checked before activity: user tabs return user_tab_read_only regardless of whether they are viewed.

How it tells the user from the agent: Kitsune's own DOM events are synthetic, so their isTrusted is false, and the content script only counts trusted events. Its listeners are on the window in the capture phase, so a page that stops propagation does not hide input from them. The OS click of browser_trusted_click is trusted, so pointer events within 2 s of its screenPoint are not counted, nor are keydown events of the modifiers it holds when cliclick, xdotool or Windows press real keys for them (Quartz sets event flags instead, so the user's keys still count). mousemove, scrolling by itself and input events are not counted either, which keeps autofill, the agent's own editing and a mouse resting over the page from counting as use. A navigation counts as the agent's when an agent command on the tab was running, or ended at most 2 s before, when the load started, so a click's or a navigate's own load never blocks the agent; a load in a background tab never counts.

Eligible same-origin, cross-origin and blank child frames use the lightweight input observer even before any frame command. Typing is queried in the originating document, including its document focus; field values never leave it. Focus that moves from the frame to elsewhere in its page releases the draft, as a blur does in the top document. Temporary loss of the whole page's focus (another window or app, the address bar) releases activity, but the same focused draft is protected again when focus returns, without another input event. Replaced/removed frames cannot leave a stale typing lock. A missing or unresponsive child query releases typing after the 3 s input pause (queries wait at most 250 ms), like an unavailable top document.

Limits: input outside the page is not observed (address bar typing counts once the load starts). Announced native keyboard/paste events are excluded for the bounded keyboard window (at most 60 s); simultaneous human input of the same class cannot be distinguished. Unrelated genuine input still blocks, and the next native keyboard focus check stops remaining keys with user_active and state.keysSent if keys were already delivered. Native-input cleanup leaves a busy temporarily shown tab displayed rather than switching away. That restoration attempt is consumed; a stale retry cannot later change the user's view after activity releases. Idle, still-authenticated restoration continues normally. Screen-coordinate trusted clicks are prohibited in every permission mode, including writable compatibility, because coordinates cannot establish the destination tab. Use DOM actions, or tab-bound native input on an agent-created tab. Activity checks are admission checks, not an atomic lock against input arriving between a check and an action.

Viewed tab

The old viewed-tab approval hold is replaced by Tab permissions. Viewing does not change ownership: user tabs are read-only (yet readable) even in the background, and owned tabs remain writable while viewed unless the existing user_active guard reports trusted user activity. No approval can lift a write refused as user_tab_read_only.

No current tab

The MCP server's current tab is the one browser_tabs new or select (or browser_tab_list new_tab) last made current, or a tab it adopted (below). It lives in the MCP server process, so a restarted or reconnected server starts without one (#303). Until it has one, a write or navigation that names no tab is refused before anything is sent, instead of going to the tab the user is viewing:

{ "error": "browser_scroll writes a tab but names none, and there is no current tab, so it was not sent to the tab the user is viewing",
  "code": "no_current_tab", "retryable": false,
  "hint": "Nothing was dispatched. Pass tab_id, or open a tab of your own with browser_tabs action=new and work there. browser_tabs action=select makes any tab current for reads; user and other clients' tabs stay read-only.",
  "state": { "tool": "browser_scroll" } }

The hint names only tools the MCP server publishes (#448). In the read and draft modes, which publish browser_tab_list instead of browser_tabs, it says browser_tab_list new_tab. The core tools' schemas, Playwright MCP's, list no tab_id, so a core tool's hint does not offer one: it says to open a tab with browser_tabs action=new url=<url> or select one of the agent's own tabs. When exactly one tab is active (one browser window), that hint also names it by its browser_tabs index and URL, since the refs of the write likely came from a snapshot of it: on a tab this client cannot write, how to open a copy or ask for it; on one it can, how to select it. It comes from the same quick tab lookup as state.ownTabs (below):

{ "error": "browser_type writes a tab but names none, and there is no current tab, so it was not sent to the tab the user is viewing",
  "code": "no_current_tab", "retryable": false,
  "hint": "Nothing was dispatched. browser_type acts on the current tab: open a tab of your own with browser_tabs action=new url=<url> and work there, or make one of your tabs current with browser_tabs action=select; user and other clients' tabs stay read-only. The user is viewing tab 2 (https://example.com/form), which this client cannot write: to write it, open your own copy with browser_tabs action=new url=https://example.com/form, or ask the user for it with browser_tabs action=request index=2.",
  "state": { "tool": "browser_type" } }

Writes are the bridge's write actions that act on a tab, plus focus and blur (see Action errors): the core browser_click, browser_type, browser_press_key, browser_select_option, browser_fill_form, browser_hover, browser_drag, browser_drop, browser_file_upload, browser_evaluate, browser_handle_dialog, browser_resize, browser_navigate and browser_navigate_back, and Kitsune's browser_scroll, browser_check, browser_focus, browser_blur, browser_submit_form, browser_click_and_wait_navigation, browser_trusted_click, browser_forward, browser_reload_tab, browser_highlight, browser_pin_tab, browser_mute_tab, browser_duplicate_tab, browser_set_zoom and browser_storage except get, list and snapshot. browser_batch and browser_retry are refused when one of their commands, or a command nested in sequence, if, while, try or retry, writes a tab without a tabId (state.action; an if or while condition is JavaScript on its tab). A browser_workflow without tab_id is always refused: its steps live in a file on the bridge and cannot be inspected first, so even a workflow that only reads needs a tab. Reads (snapshots, screenshots, page text, tab lists, console and network reads, waits) still act on the active tab, and browser_tabs close keeps its own rule (above).

When this MCP client has open tabs of its own (Tab owners), for example the tab it worked in before its MCP server restarted, the refusal adds state.ownTabs: up to 5 {tabId, url, title}, the most recently opened first, and the hint names the first one (#325):

{ "error": "browser_scroll writes a tab but names none, and there is no current tab, so it was not sent to the tab the user is viewing",
  "code": "no_current_tab", "retryable": false,
  "hint": "Nothing was dispatched. ... Tab 812 is the tab this MCP client opened most recently (state.ownTabs), maybe in an earlier conversation: to keep working in it, pass tab_id=812.",
  "state": { "tool": "browser_scroll",
             "ownTabs": [{ "tabId": 812, "url": "https://example.com/report", "title": "Report" }] } }

A core tool's hint names that tab by its index instead (Tab 3 is ...: to keep working in it, browser_tabs action=select index=3.), and the tab the user is viewing only when it is another tab. In the read and draft modes a core tool's refusal offers no tabs: nothing they publish makes another tab current.

None of them becomes current: the MCP client may have opened it for an earlier conversation, so the agent decides. The MCP server asks only a bridge that already runs, with a 3 s timeout: it does not start a bridge or wait for the extension for this list. When it cannot get the list (no bridge runs, the extension is unavailable, or a bridge of an older version answers), or there is no such tab, the refusal is the one above.

The core browser_navigate and browser_navigate_back take an optional tab_id, accepted like confirm_token (the approval fallback for clients without elicitation) but not listed in their schemas, which are Playwright MCP's; omitted, it is the current tab. browser_press_key takes an unlisted repeat the same way (see Element targets).

Tabs the agent gets in other ways become current too, and the result then adds current_tab with the tab's id:

  • The last tab that newTab steps of a browser_batch or browser_workflow opened and did not close (the bridge's newTabs, see POST /api/batch) always does, like browser_tabs new: the agent opened it on purpose.
  • The last tab an action opened (opened_tabs: a target=_blank link, window.open, or a link_fallback tab) does only when the MCP server had no current tab or the action ran on the current tab. An action on another tab the agent names leaves the current tab where it is.

Tabs that commands inside a batch or workflow open by clicking, and tabs opened by browser_parallel, browser_broadcast, browser_retry or browser_resume, do not change the current tab.

Only the MCP server refuses these calls. HTTP requests to the bridge, the Python API (kitsune.browser, whose tabs are always named) and kitsune.tools called directly keep the active-tab default.

Tab owners

Every bridge request from an MCP server or a Python API connect() carries X-Kitsune-Owner, an id of the client it works for that survives an MCP server restart (#325). It is KITSUNE_OWNER_ID from the process environment when that is 1 to 64 letters, digits, ., _, : or - (the characters X-Kitsune-Client allows; another value is ignored). Otherwise it is <name>-<pid> of the nearest process above the MCP server that is not uv or uvx (at most 4 levels, read with ps), such as claude-48190: an MCP client that reconnects starts a new server process but keeps running itself. Any other process is the MCP client, a Python one too, such as a client built on the MCP Python SDK. When ps cannot tell, and on Windows, it is ppid-<parent pid>. A server whose parent is pid 1 (started by launchd or systemd, or orphaned) or pid 0 gets pid-<its own pid>, which no other server shares, so it is offered no tabs after a restart. Set KITSUNE_OWNER_ID when the MCP client runs its server through another launcher, such as a shell script or a Python wrapper, or to keep the id across MCP client restarts. Give each MCP client its own value: clients with the same value share their tabs. browser_status shows it as mcp_tools.owner, and a Python Browser has it as browser.client.owner_id. It is not the audit client id (X-Kitsune-Client), which changes with every MCP server process.

The bridge sends the validated owner header to the extension as command metadata; tool arguments cannot override it. The extension stores the owner in each tab's session value, so it survives extension reload, bridge restart and a browser restart that restores the tab. Restored tabs remain owned only for the same owner id, not for a new client process with a different derived id. For continuity across a full client restart, configure a distinct stable KITSUNE_OWNER_ID for each client. Clients deliberately configured with the same id share ownership; the default process-derived identity cannot distinguish clients using the same launcher process.

mine: true is a compatibility alias for owned: true on named clients' non-pool tabs. Shared pool tabs remain excluded from mine, even when owned by their creator; pool acquisition does not transfer ownership in this stage. POST /api/tabs with {"mine": true} lists only those tabs, which the MCP server uses for state.ownTabs. The bridge keeps a volatile creation-order cache for most-recent-first ordering; restored tabs absent from that cache follow browser list order. Against an older extension with only global ownership, only tabs recorded for the requesting owner in the bridge cache are owned/mine; reconnecting clears that cache rather than risking another client's tabs.

Ask-before-act

Ask-before-act is an opt-in bridge policy. It holds sensitive actions without running them and asks the caller for an explicit, action-bound second step. It is off by default; when it is off, behavior is unchanged. Turn it on with browser_set_policy(ask=True) (POST /api/set-policy {"ask": true}), or lock it on for a bridge (see below).

In ask mode the bridge holds:

state.holdReason Actions
submit browser_submit_form (submitForm)
submit_control Click, double-click, trusted click, or click-and-wait-navigation on a submit control (button whose type is submit, input type=submit/image, or an element inside one, with a form). Also Enter (browser_keypress) on a text-like input in a form or on a submit control
submit_label The same clicks, or Enter, on any other button whose label reads like a submit, order, or payment ("Place order", "Pay $12", "결제하기"), for example a JS-driven type=button or role=button. See Submit labels
consequential_label The same clicks, or Enter, on a button or link whose label reads like a send, transfer, delete, or account change ("Send", "Delete account", "Unsubscribe", "송금하기"), including icon-only controls named by aria-label, alt or title. See Submit labels
javascript Agent-supplied JavaScript: browser_js (including full=True), browser_wait_for(js=...), browser_sync_barrier, batch js, and batch if/while conditions
cookies browser_cookies (every op, including get), browser_save_session, browser_load_session
storage browser_storage (every op, including get, list and snapshot), on any page
clipboard browser_clipboard (read and write)
downloads browser_downloads (every op: download, list, cancel)
payment_url Navigation, new tabs, or new windows whose URL matches an askUrls pattern
payment_page Page actions (click, type/fill, form fill, select, check, keypress, drag, submit, upload, dialog policy, JavaScript) on a tab whose current URL matches askUrls
cross_origin_frame Page actions (as for payment_page) whose target ref is inside a cross-origin frame (Frames): another site's page, such as an embedded payment form, sign-in widget or chat. The description names the frame's origin and path, or origin unknown when the frame does not answer. It takes precedence over the other target reasons. In draft mode, typing keeps the draft rules (sensitive_field)
payment_frame The same, when the frame's origin and path match an askUrls pattern
unknown_outcome Repeating a write whose identical previous attempt ended retryable: false (a timeout or disconnect after sending, see Action errors)
uninspectable_target A click or Enter whose target the bridge could not inspect (for example, an extension older than this bridge, or a screen-coordinate trusted click). Inspection fails closed

Storage, clipboard, and downloads are held for every op, reads included, and on every page, not only payment-like ones. Page storage often holds session tokens, as cookies do. The clipboard reaches outside the browser: a read can return a password copied from a password manager, and a write replaces what the user will paste. A download writes a file from any URL to disk, and list returns local file paths and source URLs.

askUrls are case-insensitive regular expressions. The default list is checkout, payment, billing, /pay\b, purchase, and /order. Replace it with browser_set_policy(ask_urls=[...]); [] disables URL matching. ask must be a boolean and askUrls a list of non-empty strings that compile as regular expressions. Any other value (for example a bare string "checkout") returns invalid_policy (HTTP 400), and none of the request is applied, in locked mode too.

A held call returns needs_user (confirmation_required; HTTP 403 from the bridge) and does not run. Until #376 the code was confirmation_required with retryable: true and the hold reason in state.reason; now retryable is false like every needs_user, and the hold reason is state.holdReason (see Action errors):

{ "error": "Confirmation required: clicks a submit control (Place order)",
  "code": "needs_user", "retryable": false, "hint": "Nothing ran. Ask the user ...",
  "state": { "reason": "approval", "detail": "confirmation_required", "confirmToken": "…",
             "expiresInSeconds": 120, "approval": "token", "action": "click",
             "holdReason": "submit_control", "description": "clicks a submit control (Place order)",
             "tabId": 7, "url": "https://shop.example/cart", "params": { "ref": "e12" },
             "message": "Allow the agent's click? It clicks a submit control (Place order).", "approvable": true } }

On an MCP client with elicitation, the MCP server asks the user and repeats the call once they approve (see Approval prompts). Elsewhere (the fallback for clients without elicitation, and the Python API and HTTP callers) the client asks the user, then repeats the identical call with the token: MCP tools take confirm_token, and HTTP and batch bodies take confirmToken. The token:

  • is bound to the exact action, its parameters, and the resolved target tab. Without tab_id, that is the tab that was active when the call was held. A different action, any changed parameter, or another active tab is rejected;
  • is single-use. Any presentation spends it, including a mismatched one;
  • expires after 120 seconds;
  • is honored only on a direct tool call. Inside browser_batch, browser_parallel, browser_retry, browser_broadcast, browser_resume, workflows, and batch sequence/if/while, a token is spent and the step stays held. Batch if/while can therefore not run in ask mode.

An ask-before-act token does not override tab permissions or grant ownership. Open an agent tab or explicitly request a handoff before writing a read-only user tab.

A rejected token returns a fresh needs_user (confirmation_required) error with state.tokenRejected set to unknown, used, expired, mismatch, or not_direct. A batch, workflow, parallel sequence, or batch sequence stops at the first held step, so later steps that may depend on it do not run. A held batch result has _stopped: true.

The audit log (browser_audit) records each held call (params.confirmation: "required", error: "confirmation_required") and each accepted token (params.confirmation: "accepted"), followed by the action's normal entry. Both add approval: how the user approved (Audit log). The token itself is never logged.

What the token proves. Without elicitation the token goes back to the agent that made the call, so it enforces an explicit second step bound to one exact action, a guard against accidents. It does not prove that a human approved: the agent can repeat the call with it without asking (#374). Such approvals are logged with approval: "token". On an MCP client with elicitation the agent never receives the token (see Approval prompts). An agent that can call browser_set_policy can also turn agent-settable ask mode off. That mode guards against accidents only.

Locked mode. Start the bridge with KITSUNE_ASK_BEFORE_ACT=1 in its environment (the MCP server passes its environment to a bridge it starts) or with kitsune-bridge start --ask-before-act. Ask mode then starts on and is locked. browser_set_policy can still add askUrls patterns. Setting ask=False or dropping a pattern returns policy_locked (HTTP 403), and none of the request is applied. allow, deny, and readonly stay settable. browser_get_policy reports ask, askUrls, and askLocked. The bridge is shared: if one is already running without the lock, stop it (kitsune-bridge stop) so that the next start applies it.

A locked start also writes ask-before-act-locked to the Kitsune home ($KITSUNE_HOME, default ~/.kitsune), user-only (0600) like the token file. While that file exists, every bridge using that home starts locked, whatever its environment. So a restart cannot come up unlocked: kitsune-bridge stop followed by the next tool call, an idle shutdown, or an MCP server that replaces an outdated bridge. KITSUNE_ASK_BEFORE_ACT=0 does not unlock either. Any entry at that path locks, including an unreadable file or a symlink, and the bridge never follows a symlink there. If the file cannot be written, the bridge still starts locked, prints a warning to its log, and the lock does not survive a restart.

To unlock deliberately:

  1. Remove KITSUNE_ASK_BEFORE_ACT=1 (or --ask-before-act) from every MCP server configuration and script that starts the bridge. Otherwise the next start writes the file again.
  2. Delete the file: rm ~/.kitsune/ask-before-act-locked.
  3. Run kitsune-bridge stop. The running bridge stays locked until it stops.

Agent-settable ask mode (browser_set_policy(ask=True)) is not persisted.

Submit labels

submit_label covers buttons that submit or pay through JavaScript rather than through a form. consequential_label covers buttons and links that send, transfer, delete, or change an account. The two reasons are separate so a client can word its approval prompt; a label that matches both is reported as submit_label.

The content script reads the label of the clicked control. The control is a button, an input type=button/submit/image, or an element with role="button", including an element inside one. For consequential_label it can also be a link: an <a href> or an element with role="link" (an <a> without href is not a link). The label is the control's accessible name, in this order: the text of the elements named by aria-labelledby; aria-label; its text; the value (else alt) of a button input; for an icon-only control without text, the aria-label, alt, or SVG <title> of the first icon inside it; then title.

The label is normalized: case, Unicode width, and whitespace are folded; a trailing parenthetical, an amount ("$12.00", "12,000원"), and leading or trailing punctuation are dropped; labels over 60 characters never match. The whole remaining label must then be one of a fixed set of phrases.

Submit, order, and payment phrases (submit_label, buttons only):

  • English: place/submit/confirm/complete/finish (your/my/the) order/purchase/payment/ booking/reservation; pay, pay now, pay securely, pay with …; buy, buy (it) now, purchase, order now; (proceed/continue/go to) checkout; continue to payment; submit; confirm, confirm and pay;
  • Korean: 결제, 주문, 구매, 제출 with an optional 지금/바로 prefix and 진행/완료/확정 and 하기 suffixes (결제하기, 바로구매, 주문 완료);
  • Japanese: 注文する, 注文を確定する, 購入する, 今すぐ購入, 支払う, お支払いへ進む, レジに進む;
  • Chinese: 支付, 付款, 立即购买, 确认支付, 下单, 提交, 提交订单, 确认订单, 去结算, 結帳;
  • German, French, Spanish: (jetzt) kaufen/bezahlen/bestellen, zahlungspflichtig bestellen, zur Kasse; payer, acheter, commander, passer la commande; pagar, comprar, realizar pedido, finalizar compra.

Send, transfer, delete, and account-change phrases (consequential_label, buttons and links):

  • English: send/transfer/wire (it/money/funds/payment) (now); confirm/complete/make (the/a) transfer; confirm and send; delete/remove/erase (permanently/forever/all/ everything); (permanently) delete/remove/erase (my/your/the/this/all) account/ profile/data/repository/project/workspace; close/deactivate/terminate/cancel (my/…) account; cancel/end (my/…) order/booking/reservation/subscription/membership/plan; unsubscribe (from all/from everything/me/all);
  • Korean: 송금, 이체, 전송 (하기) with an optional 지금/바로 prefix; 보내기; (계정/회원/영구) 삭제/제거 (하기); (회원) 탈퇴 (하기); 구독/멤버십/정기결제/주문 해지/취소 (하기); 해지 (하기); 수신거부 (하기);
  • Japanese: 送金/振込/振り込み/送信 (する), 振り込む; (アカウントを) 削除 (する); 退会, 解約, 配信停止, 購読解除 (する); サブスクリプション/定期購入/プラン/注文を解約/キャンセル (する);
  • Chinese: (立即/确认) 发送, 转账, 汇款 (and traditional forms); (永久) 删除/移除 (账号/账户); 注销 (账号); 退订; 取消订阅, 取消订单;
  • German, French, Spanish: (jetzt) senden/absenden/überweisen/löschen/entfernen/ kündigen/abbestellen; Konto/Account/Profil löschen/schließen/kündigen; Abo/ Abonnement/Vertrag/Mitgliedschaft/Newsletter kündigen/abbestellen; Überweisung absenden/ausführen/bestätigen; envoyer, transférer, supprimer, résilier (maintenant); supprimer/fermer/clôturer/désactiver (mon/le/votre) compte; effectuer/confirmer/ valider le virement; se désabonner, se désinscrire; résilier/annuler (mon/votre/l') abonnement; enviar, transferir, eliminar, borrar (ahora); enviar dinero; realizar/ confirmar/hacer transferencia; eliminar/borrar/cerrar/desactivar (mi) cuenta; dar/darse/darme de baja; cancelar/anular (mi) suscripción/membresía.

The match is deliberately conservative: "Submit search", "Search", "Add to cart", "Order history", "Payment methods", "Continue", "Subscribe", "Send feedback", "Send code", "Transfer history", "Remove filter", "Manage subscription", a bare "Cancel", "OK", "확인", "취소", "確認", "确认" or "取消", and an <a> labeled "Place order" are not held. A commit control with other wording, a control without an accessible name (for example, an unlabeled icon or a label drawn on a canvas), or a clickable element that is neither a button nor a link is not caught. On a payment-like page every click is held anyway (payment_page).

An extension older than this bridge reports fewer labels, and the bridge treats a missing flag as no match. With an extension from before submit_label, only structural submit controls are held; with one from before consequential_label, send, transfer, delete, and account-change labels are not held. A tab that still runs an older content script of a current extension gets the current one injected before the next command.

Check-then-act window. The bridge inspects the target (tab, URL, and whether a click or Enter submits a form or hits a submit- or consequential-labeled control) with describeTarget before it dispatches the action. The page can change between the two: it can navigate to a payment-like URL, or a script can turn the target into a submit control. Such a change is not caught. Passing tab_id removes the active-tab part of this window, but not changes inside the page.

Annotations are not policy. MCP ToolAnnotations (readOnlyHint, destructiveHint, idempotentHint, openWorldHint) are static, per-tool metadata. Clients may use them for their own permission prompts. The bridge never reads them, and they do not change when ask mode is on. Enforcement comes from the bridge policy alone: readonly, allow/deny, and ask.

Approval prompts

A direct call held by ask-before-act (needs_user with state.reason: "approval") is approved through MCP elicitation when the client supports it (#376). The same fallback handles a v0.7.1 extension's user_tab close holds; current user-tab writes are refused without prompting. Explicit handoff requests ask in the browser first, then use client approval after 30 seconds, and do not offer resume checkpoints. The client declares the elicitation capability (form mode, protocol 2025-06-18 or later) at initialize; Claude Code (2.1.76 and later) and Codex do.

  1. The MCP server asks the client to ask the user, in form mode with one choice, Approve or Decline. The prompt names the request (state.message), the tool, the target the call acts on (its ref or selector), the call's element labeled as the agent's description of it, the call's other arguments (text, URL, function, field values, submit; cut at 500 characters), the tab's title and URL where the result has them, and why it was held (state.detail, and the ask-before-act description).
  2. On Approve, the MCP server repeats the identical call once with the token. The model never receives the token, so it cannot approve itself. The repeat's result is the call's result. The repeat starts at the held request: requests of the call that ran before it are not sent again (the text of browser_type(submit=true) when its Enter was held, the fields browser_fill_form filled before the held one). If a later request of the repeat is held (the next field on a payment-like page), the user is asked about that one too, once per held request.
  3. On Decline, or when the prompt is dismissed (cancel), fails or gets no answer in time, the result is the held needs_user without confirmToken and expiresInSeconds, with state.approval: "elicitation" and state.decision: declined or cancelled (also for no answer). Nothing ran. The result carries call and resume_token: browser_resume asks the user again (Direct calls).
  4. If the repeat holds the request the user approved again (it no longer matches: the target resolved in another tab), it is not repeated again: the result has state.tokenRejected and no token, and what the call did before it (filled) stays done. Calling the tool again asks again.

The prompt waits until 5 s before the token expires (expiresInSeconds, 120 s), then the MCP server cancels it (notifications/cancelled), so an answer after that approves nothing. It cancels it too when the client cancels the tool call. While it waits, a call that sent a progressToken gets a progress notification every 10 s ("Waiting for the user's approval"), so a client that resets its request timeout on progress keeps waiting; no bridge request is open meanwhile.

An approval covers one held call, bound like the token to its action, parameters and tab. Claude Code Elicitation hooks can answer the prompt; that is the user's own configuration. A harness that drives Kitsune answers elicitations as the user.

Containers cannot confirm a step: the bridge honors tokens on direct calls only. A held step's token would still approve the same step as a direct call, so on a client with elicitation the MCP server removes every confirmToken from the result, in results, nested sequences and parallel branches, and replaces the held step's hint. Repeat the held step as a direct call: the user is asked there. browser_resume does not ask either.

Clients without elicitation, the Python API and raw HTTP callers keep the token in the result (state.approval: "token"), as before. On an MCP client the result also carries call and resume_token; browser_resume then checks the hold once and returns at once, so it runs the call only when a new call would run it too. That is a guard against accidents, not a security boundary: such an agent can repeat the call with the token without asking (#374). The audit log records each approval's path (approval: "token" or "elicitation", see Audit log). The MCP server sends elicitation only with an MCP SDK that has form elicitation (ServerSession.elicit_form); with an older one, it keeps the token too.

Sensitive-field redaction

Structural read tools never return the value (or its length) of a sensitive field. A field is sensitive if it is:

  • an <input type="password">;
  • an input whose autocomplete contains current-password, new-password, one-time-code, cc-number or cc-csc;
  • a text, search, tel or number input whose name, id, placeholder or aria-label contains a secret word such as password, passcode, pin, otp, cvv/cvc/csc, security code, verification code or card number (so a password field whose show/hide toggle switched it to type=text stays redacted), or an inputmode="numeric" input named like a code or digit box (zip/postal codes excepted);
  • an input, <textarea> or contenteditable region masked with -webkit-text-security;
  • an input the browser or password manager has autofilled (:autofill), until the autofill state is cleared;
  • a custom element (e.g. a web-component text field) or other element with a value property that either has type="password" or a sensitive autocomplete, or whose shadow root contains a sensitive field. Closed shadow roots are checked too, through Firefox's openOrClosedShadowRoot.

browser_snapshot omits the value= state; browser_forms, browser_dom, and browser_query's value field replace value with "redacted": true and "hasValue": <bool>. Tag, type, name, id, label, selector, and ref are kept, so the field remains interactable. browser_find does not match queries against sensitive values.

A masked textarea or editing region keeps its secret as text content, so that content is also left out of snapshot names and text lines, browser_find matching, scoring and result text, element text fields (browser_dom, browser_query, browser_explain_selector, browser_wait_for element results, click results), field labels, browser_markdown and browser_readability; browser_html and browser_query html serialize the element empty. Unmasked textareas and editing regions are unchanged. Results and errors of write actions on sensitive fields (browser_type, browser_form_fill, …) do not echo the typed value.

Masked static text is left out of the same reads. Text in any element whose computed -webkit-text-security is disc, circle or square (so an inherited mask counts) is sensitive, editable or not, and so is all text inside a masked editing region, including its contenteditable="false" parts, whatever their own style. That text is left out of snapshot names and text lines, browser_find, element text fields, labels (including a button's or button input's label), browser_markdown and browser_readability; browser_html and browser_query html keep the element and drop its text. Element text fields and markdown pre blocks and table cells leave masked text out rather than showing it as mask characters. A child that sets -webkit-text-security: none inside a masked element keeps its text, as do unmasked elements and ordinary text.

Masks come from computed style, which is read per element. Within one synchronous read (browser_snapshot, browser_dom, browser_forms, browser_find, browser_query, browser_html, browser_markdown, browser_readability, browser_explain_selector), each element's mask and redacted text are computed once and reused, so nested names, labels and find scores do not walk the same subtree again. The cache is dropped when the read returns, so the next read sees page changes. Other results (click, browser_wait_for, ask-before-act labels) cache only within their one text lookup. Elements without child elements use the browser's textContent.

Known limitations:

  • Page text (browser_page_text) is rendered text, where the browser shows masked text as mask characters, so the length of masked content is visible there, as it is on screen.
  • A non-editable part inside a masked editing region that sets its own -webkit-text-security: none is visible on screen and therefore appears in browser_page_text; structured reads still omit it.
  • Masking done with custom fonts or canvas drawing is not detected. Neither leaves a reliable signal, so detecting it would cause false positives.

There is no opt-in to reveal redacted values through these tools, and none is planned (decided in #71). A reveal switch, whether per call or as a bridge policy, would give an agent or an injected page instruction one more way to read secrets through tools that are otherwise safe to call on any page. The only way to read a raw value is agent JavaScript (browser_evaluate), which stays the separate, explicit and unrestricted trust boundary. It needs the developer extension build, and read-only mode refuses it.

Frameworks such as React mirror a typed value into the value attribute, so markup and attribute paths drop that attribute from sensitive fields, whether it was server-rendered or synced: browser_html and browser_query html omit it from every sensitive field in the returned markup, and browser_query attrs or a field named value in any letter case never return it. Other elements keep their value attribute. The page DOM is not modified.

While any sensitive field on the page carries a value attribute, every tool that takes a selector (browser_query, browser_explain_selector, browser_html, browser_click, browser_wait_for, browser_form_fill keys, …) refuses selectors that test the value attribute, because input[type=password][value^="K"], or a combinator or :has() form of it, would reveal the value one character at a time through found/not-found results. These tools return the error value-attribute selectors are disabled while a sensitive field has a value attribute; browser_find instead skips its CSS branch and falls back to text search. A selector counts as testing value if it contains [value (any case, optionally with a namespace prefix such as [*|value), or if it contains [ together with a backslash escape or a /* comment.

If a cloned subtree does not mirror the page, sensitivity cannot be mapped onto it, so markup from that clone drops every value attribute and the content of every textarea and contenteditable element, and all of its text when any of the original's text is sensitive.

These rules are heuristics: a field that is masked some other way (canvas, custom fonts) or named without a recognizable word is not detected.

browser_js, the js condition of browser_wait_for, browser_sync_barrier, and format="html" need the developer extension build; the signed build returns an error with "devBuildRequired": true.

This is not a sandbox: browser_js runs arbitrary page JavaScript and can read any value the page can, including sensitive fields. Treat access to browser_js as a separate, unrestricted trust boundary.

Audit log

browser_audit (/api/audit) returns recent write-action entries: {ts, action, params, ok, error, ms[, client]}. params never contains JavaScript bodies (code, the condition of if/while, the predicate of syncBarrier), typed text or stored secrets. These values are replaced with "[redacted]":

  • text of type and keypress;
  • key of keypress when it types itself into a field: one character without Control, Alt or Meta. Named keys such as Enter and shortcuts are kept;
  • value of fill and setEditableContent;
  • value of selectOption, whether or not the <select> is a sensitive field (the bridge sees only the selector or ref);
  • prompt_text of handleDialog (browser_handle_dialog prompt answer);
  • every value of formFill fields. The field keys (selectors, names or labels) are kept;
  • value of cookies (browser_cookies);
  • value of storageOp (browser_storage set and restore);
  • text of clipboard (browser_clipboard write).

The action, selector/ref/element, tabId, other options (op, name, url, domain, kind, other keys, modifiers, clear, …), duration and outcome are kept. intercept takes only URL patterns and an effect, never a response body, so its entries are logged as sent. Nested commands (batch commands, parallel sequences, retry/broadcast command, if/while/try blocks) follow the same rule. uploadFile entries keep their file paths; file contents are never logged. The extension still receives the unredacted value. Only the audit copy is scrubbed.

Entries of commands a browser_tools_call ran add via: {"gateway": "browser_tools_call", "tool": "<tool name>"}. Direct calls have no via; entries never name the MCP tool otherwise.

A direct request held for the user (needs_user) gets an audit call id: its entries add call, the call of its result (#376). Container steps keep theirs in parent.call and have no call of their own. A browser_ask_user request gets one too: an askUser entry with params.reason and params.tabId, without its message.

An approved write's entries (its params.confirmation: "accepted" entry and the write's own) add approval, how the user approved it (#376): "elicitation" when the MCP server asked the user in the client's prompt and repeated the call, "token" for every other repeat with a confirm token (a client without elicitation, the Python API, a raw HTTP call). The MCP server sends a random secret in X-Kitsune-Approver with each bridge request, and the bridge keeps only its hash with each token it issues. A repeat counts as "elicitation" only when it sends X-Kitsune-Approval: elicitation and the approver of the request the token was issued to, so another client sharing the bridge, or a caller that only claims it, logs "token". Held calls and writes nothing approved have no approval, and the secret and its hash are never logged or returned.

The bridge is shared by every client, so entries add client: the id the client sent in X-Kitsune-Client. Each MCP server and each Python API connect() sends its own, its process id and a random suffix ("48213-9f2c1a"), which matches it with that process's logs. browser_status shows the MCP server's own as mcp_tools.client, and a Python Browser has it as browser.client.client_id. Requests without the header, such as other HTTP callers, have no client. Entries also add owner, the X-Kitsune-Owner id (Tab owners), which a restarted MCP server keeps.

Each command run inside browser_batch, browser_parallel, browser_broadcast, browser_retry, a workflow, or a batch sequence/if/while/try gets its own entry, including held ask-before-act steps. These entries add parent: {call, path}. call is shared by every entry from one container call. path lists the enclosing steps from the outermost, for example [{"action": "batch", "index": 1}, {"action": "parallel", "sequence": 0, "index": 2}]. The frames are batch/sequence index, workflow name and index, parallel sequence and index, broadcast tabId, retry attempt (from 1), if branch and index, while iteration and index, and try block (do or catch) and index. A nested container (for example a parallel inside a batch) is logged after its steps. Direct calls have no parent. Steps run by browser_resume carry the original call and add resumed: true to parent; the resume token is never logged (Checkpoint and resume).

Every history and bookmark lookup is recorded, refused ones included, with its params (query, limit, since; url for visits), the number of results as count, and the scope it ran in as filters (allowDomains, denyDomains, maxDays, startTime, urlPolicy). Result URLs are never logged; see History and bookmarks.

History and bookmarks

Opt-in, read-only lookup of the user's browsing history and bookmarks (#140), so an agent can find "the page I looked at yesterday" and follow the user's trail. Two switches must both be on, and the browser's is the user's alone:

  1. The user's browser permission. history and bookmarks are optional_permissions in the extension manifest, not granted at install. The user grants them on Kitsune's options page (about:addons → Kitsune → Options, or Preferences): the Allow history and bookmarks button calls permissions.request from its click, which shows the browser's own permission prompt. Revoke on the same page, or the add-on's Permissions tab in about:addons, takes them back. No bridge command requests a permission, so the agent can never grant it. Without the grant, every lookup returns permission_required (retryable: false) with a hint naming the options page.
  2. The history capability group. Off by default, and KITSUNE_CAPS=all leaves it out: name it, as in KITSUNE_CAPS=core,history.
Tool Returns
browser_history_search(query, since=None, limit=20) results: [{url, title, last_visit, visit_count}], newest first. query matches titles and URLs ("" for all); since is an ISO 8601 date or datetime (local time without an offset); limit is 1–100
browser_history_visits(url) visits: [{visit_time, transition, visit_id, referring_visit_id?}] for one URL, newest first, at most 100. transition is Firefox's history.getVisits transition (link, typed, auto_bookmark, reload, …); referring_visit_id appears only where Firefox reports a referrer, which current Firefox releases usually do not
browser_bookmarks_search(query, limit=20) results: [{url, title, folder}]; folder is the path of folder titles, such as Bookmarks Toolbar/Work. "" lists the most recently added bookmarks

Results also carry count and, when more matched than limit, truncated: true. History results carry since, the start of the window the lookup used (UTC). last_visit and visit_time are ISO 8601 UTC. visit_count is Firefox's count for the URL, not only visits inside the window. Only URLs, titles, visit times, transitions and bookmark folders are returned, never page contents, form data, cookies or passwords. Only http(s) URLs are returned: file:, data:, about: and other schemes can hold local paths or page contents.

Scope. The bridge sends each lookup's scope to the extension, which filters before anything leaves the browser:

Setting Environment (bridge) browser_set_policy Default
Allowed domains KITSUNE_HISTORY_ALLOW_DOMAINS (comma list) history_allow_domains Empty: every domain
Denied domains KITSUNE_HISTORY_DENY_DOMAINS (comma list) history_deny_domains None
Time window (whole days back from now) KITSUNE_HISTORY_MAX_DAYS history_max_days 30

A domain also covers its subdomains (example.com covers docs.example.com; a leading *. is accepted and dropped), and a denied domain wins over an allowed one. since earlier than the window is moved to the window's start. The window applies to history; bookmarks have none. browser_get_policy and browser_health show the current settings as historyAllowDomains, historyDenyDomains and historyMaxDays. An invalid environment value stops the bridge at startup with the variable's name.

browser_set_policy can only tighten the scope, so an agent never widens what it may read: it can add denied domains, narrow the allowed list (each entry within a current one, never empty once set) or lower the days. A call that would loosen any of them fails with policy_locked (HTTP 403) and changes nothing; the user loosens the scope through the environment and a bridge restart. The URL allow/deny regex policy also applies: history results, and browser_history_visits URLs, stay filtered by every URL deny pattern and allow list set since the bridge started, even after browser_set_policy removes them for page actions. When such a pattern is set, the bridge reads up to 100 results from the extension and applies limit after filtering.

The lookups are not batch actions: browser_batch, browser_parallel, browser_broadcast, browser_retry and workflows reject them as an unknown action (unknown_action), so none can run them outside this scope. Read-only mode allows them; ask-before-act does not hold them.

History URL redaction

Every URL the extension returns is rewritten first:

  • user:password@ is removed.
  • A query or fragment parameter's value becomes [redacted] when its name is sensitive: a word of it (split at punctuation and camelCase) is one of token, secret, password, passwd, pwd, pass, auth, authorization, apikey, key, sig, signature, hmac, session, sid, jwt, otp, code, ticket, nonce, credential(s), saml, samlrequest, samlresponse or assertion, or the name contains token, secret, passw, sessid, sessionid, apikey or credential (access_token, apiKey, X-Amz-Signature, PHPSESSID).
  • Any parameter's value becomes [redacted] when it looks like a credential: a Bearer value, a JWT, a known token prefix (ghp_, github_pat_, glpat-, xoxb-, sk-, sk_live_, AKIA, ya29., AIza, …), 32 or more hex digits, or 32 or more token characters that mix upper case, lower case and digits.
  • Fragments are handled as parameters (#access_token=…&state=…), as a route with a query (#/inbox?token=…), or, when the whole fragment looks like a credential, replaced as a whole.

Ordinary values, such as q=firefox+history or a lowercase slug, are kept. The rules are heuristics like Sensitive-field redaction: a secret under an innocuous name and in a short or lowercase value is not recognized, and path segments and titles are not rewritten. History entries whose URLs differ only in redacted values are listed once, with the newest title and visit and the summed visit_count. A redacted URL is still a valid browser_history_visits argument: the extension resolves it to the visited URLs it stands for.

Following the trail

A history or bookmark result is enough to continue from it: open url in a tab (browser_tabs(action="new", url=url, wait=True), or the agent-owned background tab that #139 adds) and read it with the usual tools. browser_history_visits shows when and how the user reached the page. A redacted URL opens without its secret, so a page that needed the token in its URL may show a login or an error instead; Kitsune never reveals the original value.

Container progress

browser_batch, browser_parallel and browser_workflow (POST /api/batch and /api/workflow) add progress, status and summary next to results, which is unchanged. progress has one record per command, in order, including commands that never ran:

{ "results": [{ "ok": true }, { "error": "Command timed out after 30s", "code": "timeout", "retryable": false }],
  "progress": [
    { "index": 0, "action": "click", "tabId": 3, "status": "ok" },
    { "index": 1, "action": "submitForm", "tabId": 3, "status": "unknown" },
    { "index": 2, "action": "pageText", "tabId": 3, "status": "skipped" }],
  "status": "unknown",
  "summary": "3 commands: 1 ok, 1 unknown, 1 skipped. Unknown, may have taken effect (check the page before repeating): step 1 (submitForm on tab 3). Skipped, never started: step 2 (pageText on tab 3)." }
status Meaning
ok The command ran without an error. Like any result, this means dispatched, not that the outcome happened (see Action outcomes)
failed It returned an error that is safe to repeat (retryable: true, unclassified errors, error_page, ambiguous_target, needs_user other than an ask-before-act hold, permission_required, the pre-dispatch refusals capability_restricted, mode_restricted and unknown_action, or ok: false)
held Ask-before-act held it (needs_user, confirmation_required); nothing ran
unknown It may have taken effect: an error with retryable: false, or it was in progress at the container's deadline. Observe the page before acting again
skipped It never started: an earlier step stopped the container (a held step, stopOnWarning, a failed workflow step) or the deadline passed

tabId is the command's tabId when it has one. A container command (sequence, retry, if, while, try, parallel, broadcast) takes the first of unknown, held, failed, skipped that it or a nested command has, else ok; a retry whose last attempt succeeded is ok. A parallel record adds sequences: one list per sequence of {sequence, index, action, tabId, status}; commands that never started are skipped. The top-level status combines the records the same way.

The condition of an if or while is JavaScript run on its tabId. A condition that cannot run (for example tab_closed, navigating, a stale document, a script error, or the signed build) is not false. The step stops with the condition's error (error, code, retryable, hint, state), so its record is failed, or unknown for retryable: false. An if then runs neither branch. A while keeps the passes that already ran: it adds iterations, results and stoppedBy: "conditionError" to the error. Conditions that ran return the same results as before. Read-only mode (browser_set_policy(readonly=True)) refuses if and while like js, with Read-only mode is active; refusing action: if, before the condition or any branch runs. It refuses the other agent JavaScript the same way: browser_js (including full=True), browser_wait_for(js=...) and browser_sync_barrier.

summary states progress in one paragraph a user can read without the records: the count of each status, then each command that is unknown, held (with how to confirm it), failed or skipped, by its index and action (and tabId), at most five per status. A parallel that started is counted by the commands of its sequences (sequence 1 step 0). It starts with Stopped at its 300s deadline. after a container_timeout. It is text for people; read progress in code.

progress is also what to resume from: the commands before the first record that is not ok have run, unknown ones must be observed before they are repeated, and skipped ones are safe to send. A call whose status is not ok adds call and resume_token; pass them to browser_resume to continue it without sending its ok writes again (Checkpoint and resume).

A batch stops at its deadline (the sum of its waits plus 15 s, at least 300 s; a workflow at 300 s): the command in progress is cancelled and later commands do not start. The response then adds a container_timeout error (retryable: false, state.deadlineSeconds) to the partial results and progress, and the audit log records the cancelled command with error: "cancelled". The MCP tools wait 30 s past that deadline (15 s for the cancelled command to settle, 15 s for the reply), so the client receives this report instead of its own request timeout.

Tab ownership in parallel: sequences run concurrently, so a focus change in one sequence must not move another sequence's command to a different tab. Before any sequence starts, the bridge refuses the whole parallel (results is one error):

  • with tab_required when a command that acts on a tab has no tabId, also inside a nested sequence, retry, if, while or try. Commands that act on no page tab (sleep, newTab, tabs, getWindows, createWindow, closeWindow, focusWindow, cookies, clipboard, downloads, clearBrowsingData, intercept, reloadExtension, screenshot, broadcast, syncBarrier) need none; screenshot captures the visible tab, or the tab its tabId names.
  • with tab_owned when two sequences write the same tab (the writes that make an unknown outcome unsafe to repeat, including switchTab and closeTab). The condition of an if or while counts as a write of its tabId: it is JavaScript run on that tab, like js, and the bridge cannot tell whether it changes the page. Several sequences may read one tab, for example with waitForElement.

A tabId substituted at run time (${$N.tabId} in a nested sequence) is checked when its command runs: the first sequence that writes a tab owns it, and a later write from another sequence fails with tab_owned without being dispatched. In any batch, workflow or parallel step, a tabId that is not a tab id (0, "", null from a failed newTab, an unresolved template) fails with invalid_tab_id instead of falling back to the active tab, and a numeric string such as "12" is sent as 12. A step that acts on a tab and names no tabId runs on the call's default tab: the top-level tabId of /api/batch, /api/retry or /api/workflow, which browser_batch, browser_retry and browser_workflow set to their tab_id, by default the current tab. Without one it goes to the active tab, which the MCP tools refuse for writes (No current tab). Parallel sequences still name their tabs, tabless actions (newTab, tabs, cookies, ...) get none, and a resumed call keeps its original default tab. A default tabId that is not a tab id is refused with invalid_tab_id before anything runs. A closed tab is never replaced: commands on it fail with tab_closed.

browser_tabs(action="list", tab_id=None, url=None, force=False, raw=False, wait=False, timeout=15000, window_fields=False, include_active=False)

Replaces browser_new_tab, browser_close_tab, and browser_switch_tab. action is list, new, close, or select. List uses GET /api/tabs; new uses POST /api/new-tab (default URL about:blank), close uses /api/close-tab, and select uses /api/switch-tab. Close and select require tab_id. force disarms beforeunload for close; raw bypasses the image URL viewer for new tabs. List is never cached and marks agent-owned tabs owned: true, and the tabs this MCP client opened mine: true (see Tab owners); it drops each tab's windowId and index unless the tabs span more than one window or window_fields=True, and keeps every other field. With include_active=True, list also returns active_page: the active tab's browser_snapshot(text=True) (text lines, refs, url, title and tabId, capped and redacted the same way), so orienting and the first read take one call. A failed page read is returned as active_page beside the inventory. New opens the tab in the background and returns immediately unless wait=True, which waits up to timeout ms for the page to load and returns the actual URL and load status. Close follows Tab permissions: a tab this client may not write is refused with user_tab_read_only. A v0.7.1 extension instead refuses a tab the agent did not open with needs_user (user_tab) until the user approves it (in the client's prompt, or with confirm_token on a client without elicitation). Select switches the user's view: use it only when the user asked. Options for other actions are ignored.

browser_page_text(tab_id=None)

Replaces the duplicate browser_page_text_by_tab_id. Uses GET /api/page-text for the active tab or POST to the same endpoint with tabId for a specific tab.

browser_dom(tab_id=None, selector=None, ref=None, max_depth=None)

Returns a JSON tree of page elements with fresh refs, bounds and states. With no arguments, reads body to depth 6 via GET /api/dom; optional arguments use POST (tabId, selector, ref, maxDepth). selector, ref and max_depth scope the tree as in browser_snapshot below, except that max_depth defaults to 6.

The serialized nodes are capped at 20,000 characters. The tree keeps nodes in document order up to the cap and stops there; a cut tree has truncated: true and a hint to scope the read or use browser_snapshot. Only returned nodes receive usable refs.

browser_snapshot(tab_id=None, selector=None, ref=None, max_depth=None, text=False)

This full-toolset function returns a compact accessibility-style tree with stable refs and explicitly forwards text: false by default via POST /api/snapshot. The core Playwright-schema tool instead omits text: it includes readable static text and HTTP(S) link URLs. Direct HTTP GET or POST with omitted text has the same readable default and no character cap. Explicit text: true selects the bounded readable mode; text: false omits static prose. Optional scope arguments are tabId, selector, ref and maxDepth in the JSON body.

The core browser_snapshot returned inline is capped at 12,000 final public characters (it sends "maxChars": 12000; #262); with filename it sends no budget and saves the whole snapshot. A cut core snapshot has truncated: true, ends with - … truncated; browser_find searches the rest and has a hint that names only core tools and arguments: browser_find (which reads the whole page), a ref from it or from the snapshot as target ("e12 >> .." reads that element's container, such as the card around a product name), or filename. On the deterministic fixtures of tests/test_real_browser_bounded_snapshot.py, the inline core snapshot of 80 heterogeneous product cards went from about 24,200 to 9,200 characters (the first 30 cards, each whole) and that of a 24-section article from about 44,600 to 10,800 characters (the first 29 paragraphs, each with its links and URLs). The cap counts lines before refs are shortened to their public e12 form, so a snapshot can stop well below it. Cards and paragraphs past the cut stay reachable through browser_find and a scoped target. (These figures predate the link URL and landmark rules below, #310.)

A link to an HTTP(S) URL has a - /url: "<url>" child line (#218). Since #310 a URL on the page's own origin is shown as its path, query and fragment (/t/notes?tab=2#top); a URL on another origin stays absolute. A link has no /url line when the sibling line right before it (under the same parent line) is a link with the same URL, or, when no sibling line comes before it, when its URL is the page's own. So a link without a /url line has the URL of the link right before it, or of the page if it comes first under its parent, unless it links to another scheme (javascript:, mailto:, data:, …): such links have no /url line either, as before, and nothing marks them. Every snapshot, bounded or a delta, lists a link with its own /url line or, for a link without one, with the link and /url line it repeats; a bounded snapshot that cannot fit a URL shows - /url: "…" instead of leaving the line out. The acted-on target of a post-action snapshot always has its own /url line. Path URLs are redacted like absolute ones. The core browser_find searches the same lines, but its inline result matches /url lines only for a URL-like query: text, or a regex pattern, that starts with /, http(s):, www. or a domain such as github.com, or that contains ://. For a regex, a leading ^ or inline flags such as (?i) and escapes such as \/ and \. are ignored, so https?:// and \/wiki\/ are URL-like. It then matches a same-origin link by its path, not its absolute URL. Pass browser_navigate an absolute URL, resolving a path against the snapshot's url.

The core browser_find returns {"count": N, "blocks": [...]} (#323). Each block holds the matches under one path (the ancestor lines): the union of a ±2-line window around each match, in document order, with each matched line prefixed > and … where lines are left out (between windows that do not touch, or a hidden /url line). A window stays inside the match's container, but can show a matched child under another path; a line already shown in an earlier block, and context only for matches shown there, is left out. In a window, a /url line is shown only as the URL of a matched link (or when it matches itself). Blocks are ordered by their first match, so a cut can list a block's later matches and leave out a later block's earlier ones. Inline results are cut line by line at about 8,000 characters (FIND_LIMIT). A cut result still lists at least one match, ends after the last listed match's own context, and carries truncated: true, shown (matches listed) and a hint to narrow the query, scope a snapshot, or set filename. count is always the total. With filename every match, including a matched /url line for any query, is saved with its own window and path, as before, so its count can be higher than the inline one.

An inline result without a match has a hint that names load_more (#404). The core browser_find accepts load_more: true (and a confirm_token for it), not listed in its schema (Playwright MCP's): for an infinite-scroll list that loads its next batch when scrolled to its end, it scrolls the current tab to the bottom (POST /api/scroll with direction: bottom and loadMore: true, which falls back to the scrollable container at the viewport's center) until the query matches, at most 10 times (FIND_LOAD_MORE_SCROLLS). After each scroll it reads the snapshot every 250 ms for up to 3 s until it changes; a scroll that changed nothing within that time ends the search. The result adds scrolls and, without a match, a hint that says whether the page stopped changing or may load more on another call. A list that loads through an IntersectionObserver may not load in a background tab, which Firefox may not deliver its callbacks to (nor those of a hover). Scrolling writes the tab, so without a current tab load_more returns no_current_tab (state.action: "scroll"), and a held or failed scroll is returned as it is. Its scrolls carry loadMore: true, so they run under the default KITSUNE_CAPS=core, which does not enable browser_scroll's /api/scroll (#447; see Capability enforcement). On a current tab this client cannot write (#449), it runs only while the tab is not shown: the extension admits loadMore: true scrolls there, and find scrolls each scrolled target back by what it scrolled before returning; on the shown tab it returns user_tab_read_only.

Without an acted-on target, a cut snapshot of a page with a main landmark defers each navigation, banner or contentinfo landmark outside main whose lines exceed a small block (2,000 characters or a quarter of the budget). The landmark keeps its line, in document order, followed by a marker such as - … 71 lines (69 links) not shown; snapshot e2 to list all; the rest of the page, main included, fills the budget first. Budget left after that lists deferred landmarks whole, or the first that does not fit as far as it does, with the marker counting the lines still left out. A snapshot scoped to the marker's ref lists the landmark. On the crowded gallery fixture of tests/bounded_snapshot.cjs (a banner whose navigation has 68 links before 60 product cards) the core snapshot keeps about 11,800 characters of main instead of 1,430.

  • selector scopes the snapshot to the subtree of its first match, including its root; it takes the target selector forms.
  • Open and closed shadow roots are read as rendered: a web component's shadow tree appears under its host, with refs, and slotted children appear where their slot is. browser_dom, browser_find, browser_forms and CSS selectors in action tools see shadow roots too; a selector matches within one tree (the page or a shadow root), so a descendant combinator does not cross a host. Coordinate clicks and a browser_keypress without a target resolve into shadow roots as well.
  • ref scopes to an existing snapshot element and takes precedence over selector. It resolves to the same attached node across observations. Missing selectors and stale refs return errors rather than silently falling back to the full page.
  • max_depth is a non-negative integer DOM depth relative to that root (body without a scope). 0 considers only the root, 1 also considers its immediate element children, and None traverses all depths. Non-semantic containers still count toward DOM depth, even when they produce no snapshot line.

Hidden elements (hidden, display: none, visibility: hidden, aria-hidden="true") and their subtrees are left out, except a popup that an action reported and that is still open: it is listed with [not_rendered] on its line (see Opened popups).

Successful snapshots, including scoped reads, preserve earlier attached-node refs. Only emitted elements receive new usable refs; repeated observations reuse them. Generic clickable wrappers with any native interactive descendant or at least two interactive descendants are not reported as buttons; their children remain discoverable. Explicit roles and native controls are preserved. Custom controls use explicit labels/title/alt, then direct text; subtree text is used only when at most 80 characters and five elements long.

Consecutive composite siblings (children containing elements, or list items) with identical tag/role/input-type structure show the first five items, followed by - … N more similar items; snapshot e14 to list all. A marker always hides at least three items: a run of up to seven is listed in full, and a run of eight shows five items and - … 3 more similar items. Names, IDs and classes do not affect the signature. Leaf controls such as toolbar buttons are not collapsed. Hidden items are excluded from grouping; collapsed items get no new refs from this snapshot. The marker's ref (e14) names the items' container (a tbody, list or grid, which may have no line of its own): a snapshot scoped to that container, by this ref or a selector, lists up to 100 consecutive similar items of its own, or all of up to 102. Past that it shows - … N more similar items; use browser_find to locate a specific one. Runs nested deeper inside it still collapse after five items, each with its own marker. The core browser_find searches every item, collapsed or not (it reads HTTP /api/snapshot with "collapse": false), and its matches carry refs that actions accept. Post-action snapshots additionally keep the acted-on target's item, even beyond the first five, and return its target_ref (a marker between listed items also hides at least three); a delta lists a run's marker after any of the run's lines it lists, or when the number of collapsed items changed. Character-limit truncation prioritizes the acted-on target and its ancestor context when that complete block fits. If it cannot fit, target_ref is omitted and truncation guidance says so.

text=True (opt-in; the default is unchanged) adds readable text in the same call: each block's visible inline text becomes a - text "…" line in document order, and the result adds url, title and tabId. Text lines have no refs; refs keep the lifetime above. Text that only repeats a control, label or heading name is omitted, as are hidden, aria-hidden, form-field and sensitive-field content. Lines are clipped at 1,000 characters with …; the snapshot is capped at 12,000 final public characters after URL redaction/alias serialization. Explicit HTTP maxChars (also with omitted/false text) and post-action limits are enforced there as well, on complete lines with ancestor context. Without an acted-on target, a cut snapshot is a document-order prefix that never ends inside a block: the lines of an element below the snapshot root (a product card's name, URL, variant, price and discount; a paragraph with its links) that total at most 2,000 characters or a quarter of the budget are kept or left out whole, so a card is never split or joined to its neighbor. The block at the cut is still kept with its /url lines shortened to - /url: "…" when that fits (#310); a scoped snapshot shows the URLs, and browser_find shows a matched link's URL. When URL redaction or ref aliases lengthen the snapshot past its budget, the bridge cuts again only at those boundaries; it can leave out a whole block that no longer fits, or keep it with shortened /url lines, and keep a later one. Truncation sets truncated: true, includes a scope hint, and normally ends the snapshot with - … truncated; scope with selector, ref or max_depth. For budgets too small for the marker, the snapshot may be empty but the flag and hint remain. Repeated composite items still collapse, but siblings with only inline content (prose paragraphs, text list items) do not. selector, ref and max_depth scope text the same way. HTTP maxChars accepts a finite non-negative number (zero is valid); invalid or nonfinite budgets return an error before reading. With KITSUNE_POST_ACTION_SNAPSHOT=text in the bridge environment, post-action snapshots use the same text lines within their 4,000-character cap (a delta lists the text lines the action changed), so an expect={"text": ...} already present there needs no further read.

Snapshot readiness

A readable snapshot (the core tool's, or HTTP /api/snapshot without text: false) adds readyState, the document's document.readyState (loading, interactive or complete). A tab that finished loading is not a page that finished rendering: an application can still insert its content, and a complete page can be empty. When the snapshot has no lines and is not truncated, the result also has empty: true and a hint to wait for text the task expects (browser_wait_for with text) before snapshotting again. It is an observation, not an error, and Kitsune adds no wait of its own:

{ "snapshot": "", "url": "https://example.test/hotels", "title": "Hotels",
  "readyState": "complete", "empty": true,
  "hint": "The page shows no content yet. If it should, wait for text you expect with browser_wait_for (page text, not a field's placeholder or label), then take a new snapshot; a loaded page can also be empty." }

Saving is separate from readiness: the core browser_snapshot(filename=...) returns ok: true and saved_to once the file is written, with the snapshot's readyState, empty and hint alongside. empty: true means the saved file is empty. Compact (text: false) and post-action snapshots have none of these fields, since they leave prose out. A tab showing an HTTP error response without a document fails with error_page instead (see Action errors).

When the document's document.visibilityState is hidden (the tab is in the background, or its window is minimized), every snapshot (readable, compact and post-action) and the result of every page action adds visibilityState: "hidden" (#328). Visible tabs omit the field. Tabs the agent opens are in the background by default, so their results usually carry it. The page actions are click, double click, hover, drag, type, fill, set editable content, select, check, keypress, submit, form fill, scroll, focus and blur, including the failures the page reports (such as not_found). A result with a post-action snapshot, including browser_navigate's, takes the field from that snapshot; a warning's state.hiddenTab describes the moment of the action. Some pages render lazily, or do not paint menus and popups, while their tab is hidden (#259, #260), so an empty snapshot or an unrendered target there may only mean the page has not painted yet. Kitsune does not show the tab on its own. Kitsune's browser_tabs select with tab_id (/api/switch-tab) shows it and switches the user's view, so ask the user first; it is refused with user_active while they use the tab they would leave (see Never taking focus). The core browser_tabs select only makes a tab current.

Frames

The content script runs in the top document and reads into same-origin frames (iframe/frame whose contentDocument it can access), at most three frames deep. browser_snapshot, browser_dom, browser_find and browser_forms include their elements with refs in the same document/injection scope as the page, and refs, CSS selectors (the top document first, then each frame), form_fill labels and coordinate clicks reach into them; sensitive fields inside frames are redacted the same way. A frame is a snapshot line such as - iframe "배송지 편집" [ref=…] with its content below it. A frame whose content is unavailable is still listed, with [content=cross_origin src="https://pay.example/widget"] (or content=too_deep), its src without query or fragment; browser_dom adds the same content and src, and browser_forms lists such frames in unavailableFrames. Fields inside a frame carry frame (its title, name or src) in browser_forms, and bounds are in the top viewport.

Cross-origin frames in browser_snapshot. A cross-origin frame's content is read by a content script Kitsune injects into that frame when a read first reaches it, never on page load. Its content follows the frame's line one level deeper, and the line reads [frame=cross_origin src="…"] instead:

- iframe "Payment form" [ref=e12] [frame=cross_origin src="https://pay.example/widget"]
  - textbox "Card number" [ref=e13]
  - button "Pay" [ref=e14]

Its refs work in every ref-taking action, including browser_trusted_click (the frame's point is converted to the screen by the page that shows it) and browser_snapshot(ref=…). Sensitive fields inside are redacted as on the page, and its link URLs are absolute. Frames inside it are read the same way, up to three cross-origin frames deep (content=too_deep below). One snapshot reads at most six cross-origin frames, in document order, all within 1.5 seconds. Frames smaller than 24×24 CSS pixels are not read. Frames past the limit, and frames that do not answer in time, keep content=cross_origin. When the limit leaves frames out, the result's hint says how many; a snapshot scoped to a frame's ref reads that frame. In ask mode writes inside them are held (cross_origin_frame, payment_frame).

Cross-origin frames in other reads (#354). browser_find, browser_forms, browser_dom and post-action deltas read the same frames, with the same limits per call, and the refs they return act inside the frame:

  • browser_find searches each frame with the same query. Its matches join the page's, best score first (the shorter text first among equal scores), at most 20 in all. When the query matches as a CSS selector in any document, only such matches are returned, as on a page without frames.
  • browser_forms lists a frame's fields after the page's, with frame set to the frame's title or src. A frame that was read is no longer in unavailableFrames.
  • browser_dom puts a frame's tree under the frame's node, which then has frame: "cross_origin" instead of content, within max_depth and the 20,000-character cap. A frame's tree that no longer fits is left out, and the tree is truncated. Link href values from a frame are redacted like snapshot URLs.
  • Bounds from a frame are in the top viewport, as for same-origin frames. Results from a frame have no CSS selector, because a selector would match in the page; use the ref.
  • A delta lists a frame's changes under the frame's line, as described in Post-action snapshots.

Images

A rendered <img> with a name appears as an img line with a ref, e.g. - img "Distorted characters" [ref=…], with no state. Its name is aria-labelledby, aria-label, alt, or title when there is no alt, clipped at 160 characters like other names. Decorative images (alt="", role="presentation"/"none"), aria-hidden images and images without a name are left out, and src is never listed (browser_images, in the read group, lists sources). The line names the image but does not describe its pixels: pass its ref to browser_screenshot to see it. An image that is a control (onclick, tabindex, role="button") is listed as a control instead, and an image inside a link is listed below the link.

Named elements with explicit role="img", graphics-document or graphics-symbol also receive refs, including canvas, span and SVG graphics. An unroled <canvas> at least 24×24 CSS pixels receives a canvas line and ref even without a name; use boxes: true for its bounds or screenshot its ref to inspect the drawn controls.

Named containers

Named elements with explicit roles group, article, figure, listitem, row, cell, tabpanel, dialog or application receive refs for hover, drag and other targeted actions. An unnamed generic container remains omitted. A container whose name already equals its text does not duplicate that text as a separate text line.

Media

Rendered <video> and <audio> elements appear as video/audio lines with a ref and their state: src (the current source, clipped at 160 characters), duration and currentTime in seconds (null while unknown), paused, muted and controls. Their children (sources, tracks, fallback content) are not listed.

The browser's native media controls answer only trusted input, so a synthetic click cannot press their buttons or scrub their timeline. Instead, browser_click on a media element whose click the page neither cancelled nor handled itself toggles playback with play()/pause() and adds media (the state above) to its result. The batch media command drives one element directly: {"action": "media", "ref": "…", "op": "play"} with op one of play, pause, seek (with time in seconds), mute or unmute; it returns {ok, tag, media}. play waits up to 2 s for playback to start, and seek up to 2 s for the frame at time, so a screenshot taken next shows it. media is a write: read-only mode refuses it.

When playback does not start, media fails with error, reason and media, and browser_click stays a success whose media adds error and reason:

  • autoplay_blocked: the browser rejected play() with NotAllowedError: synthetic clicks do not grant user activation, even in the active tab. Use the batch media command with op: "mute" then op: "play", use browser_trusted_click on the control where available, or ask the user. Kitsune does not mute or retry automatically.
  • refused: the browser rejected play() for another reason, such as an unsupported source.
  • background_tab: Firefox delays playback in a tab until it has been shown once (media.block-autoplay-until-in-foreground). Agent tabs open in the background (Never take focus), so the agent's own tabs cannot play media until shown. Kitsune never shows a tab for playback: it cancels the pending play(). Select the tab explicitly with browser_tabs select (refused with user_active while the user is using their tab), then play again.

For example, after an autoplay block, play muted with:

{"commands": [{"action": "media", "ref": "e2", "op": "mute"},
              {"action": "media", "ref": "e2", "op": "play"}]}

The model never receives audio content, even when playback succeeds. Muted playback can expose video frames in screenshots, but cannot make an audio puzzle audible to the model. Use the page's hand-off path when available, or ask the user to listen.

browser_type(selector="", text="", mode="type", clear=False, tab_id=None, ref=None, element=None, wait=False, timeout=10000)

Replaces browser_fill and browser_set_editable_content.

Mode Behavior Bridge endpoint
type Type using events; clear=True clears first /api/type
fill Always replace the field value with native setter and input/change events; ignores clear /api/fill
editable Insert plain text into contenteditable/input/textarea; append by default, replace with clear=True /api/set-editable-content

In a contenteditable editor, type and editable insert at the caret when it is inside the target and otherwise at the end of the target, firing beforeinput and input; clear=True replaces the target's content. When the editor's page cancels that beforeinput, type and fill leave the content as the page left it and return prevented: true (#289). browser_click moves focus as a real click does, unless the page cancels mousedown: it focuses the clicked control, or the editor with the caret at the click point (x/y) or at the end of the clicked element (selector/ref). So clicking an editor's last paragraph and then typing appends to it. Snapshots list an editor once, as a textbox whose value= state is its text with whitespace collapsed, cut at 160 characters like a field's and then ending in … (#385); a masked editor has none (see Sensitive-field redaction).

All modes accept the actionability wait. Editable mode sends format="text". Callers migrating from the old editor tool should pass clear=True to retain its replace-by-default behavior. For trusted HTML insertion use browser_js; the underlying endpoint still accepts format="html" (both developer build only).

browser_form_fill(fields, tab_id=None)

Fills several fields in one call via POST /api/form-fill. Each key is matched as a CSS selector, then name attribute, label text, and placeholder; results are returned per key, and one failing key does not stop the others. A matched web-component host is filled through the same shadow-root lookup as /api/fill; a key with no reachable field gets a not_fillable action error. Selectors, names, labels and placeholders also match inside shadow roots; a key matching several actionable fields, in one tree or across components, writes nothing and gets ambiguous_target with state.candidates. first=True explicitly fills the first match. Labels take precedence over placeholders within each searched tree; multiple labels for the same control count once. When every key fails, nothing was filled and the result adds "error": "No field was filled" (an MCP error); a partial fill is not an error, so check each key.

For a <select>, the value must equal an option's value, the same rule as browser_select_option. A match sets it and dispatches change and input. An unmatched value returns {"error": "No matching option for value: X"} for that key, leaves the previous selection untouched, and dispatches no events.

For a checkbox or radio, the value must be true/false, 1/0, or one of the case-insensitive strings "true"/"false", "on"/"off", "yes"/"no", "1"/"0". Any other value (for example "maybe", "" or null) returns {"error": "Invalid checkbox value: ..."} for that key and leaves it unchanged. A selected radio cannot be set to false, since that would leave its group with no selection; set another radio in the group to true instead. False on an unselected radio is a no-op that reports "checked": false.

{ "results": { "Email": { "ok": true, "tag": "input" }, "Color": { "error": "No matching option for value: purple" } } }

browser_submit_form(selector=None, tab_id=None, ref=None, element=None)

Submits the target form, or the form enclosing the target element, via POST /api/submit-form. Without selector/ref it submits the form that owns the field Kitsune last filled or focused in that document (browser_type, browser_form_fill, browser_select_option, browser_check, browser_focus), else the page's only form. When that field is gone or outside every form and the page has several forms, nothing is submitted: the result is an ambiguous_target action error listing up to 5 forms.

It clicks the form's first submit control by form ownership (#288; a <button> without a type counts, as in HTML), so the page's submit handlers and validation run: "submitted": "button". A form without one is submitted with requestSubmit(), which runs them too ("requestSubmit"); only where that is missing does it fall back to form.submit(), which skips them ("method").

{ "error": "3 forms on the page and no filled field names one; nothing was submitted",
  "code": "ambiguous_target", "retryable": false,
  "hint": "Pass the ref of the form to submit from state.candidates, or fill one of its fields first.",
  "state": { "forms": 3, "candidates": [
    { "ref": "e0", "action": "/wishlist/delete", "method": "post", "button": "Delete lantern" },
    { "ref": "e1", "action": "/wishlist/delete", "method": "post", "button": "Delete mug" },
    { "ref": "e2", "action": "/wishlist/add", "method": "post", "button": "Add" }] } }

A successful submit names the form it submitted in form, with the same fields (action and method as written in the form's attributes, button the submit control's label). Its ref remains valid across the full post-action snapshot while that node stays attached in the same document; navigation or replacement invalidates it.

{ "ok": true, "submitted": "button",
  "form": { "ref": "e3", "action": "/wishlist/add", "method": "post", "button": "Add" } }

browser_wait_for(selector=None, text=None, text_gone=None, js=None, url=None, title=None, network_idle=False, time=None, timeout=10000, tab_id=None, ref=None, element=None, new_only=False)

Replaces browser_wait_for_element, browser_wait_for_result, browser_wait_for_url, browser_wait_for_title, and browser_wait_for_network_idle. Exactly one condition must be supplied, including time=0 or an empty string. network_idle=False does not count as a condition. selector and/or ref count as one element condition (ref takes precedence); combining it with text, URL, sleep, or any other condition is invalid. element alone is not a condition. Invalid combinations return an error before contacting the bridge. timeout must be positive.

Condition Meaning Bridge endpoint
selector / ref Wait for a visible element, poll every 200 ms /api/wait-for-element
text, text_gone Literal substring of document.body.innerText appears/disappears, poll every 100 ms; placeholders and aria-labels are not page text; once met, returns what changed like time (see below) /api/wait-for-text, then /api/snapshot (sinceObservation)
js Expression returns a non-empty result, poll every 500 ms /api/wait-for-result
url, title JavaScript regex pattern matches /api/wait-for-url, /api/wait-for-title
network_idle=True No new network resources for 500 ms /api/wait-for-network-idle
time Sleep a finite, non-negative number of seconds in the MCP server, then return what changed since the last snapshot or action result of the page (see below) /api/snapshot (sinceObservation)

After a time sleep (#350), the result adds the changes since the page last returned a whole-page snapshot or an action's result, as a post-action delta does: snapshot, snapshot_delta: true, and no_visible_change: true when nothing changed. Changes that land between that result and the sleep count too, such as an infinite list's batch arriving while the agent decides what to do. Without an earlier observation of the document, the snapshot is full. KITSUNE_POST_ACTION_SNAPSHOT=0 turns it off, and a page that cannot be read leaves the result as {"ok": true, "slept": …}.

A met text or text_gone wait (#403) adds the same changes to its {"ok": true, "text": …, "text_gone": …, "elapsed": …} result, so a toast that appears as a status line goes away is in the result rather than lost before the next snapshot. A timed-out or failed text wait returns its result unchanged.

timeout is in milliseconds and does not limit an explicit sleep. JS retains bridge non-empty-result semantics: false and 0 count as results; return null or an empty string while waiting for a predicate.

Element and text waits are single page reads that the bridge repeats until timeout. They do not depend on the page's timers, which browsers throttle in background tabs. Each read, including its wait behind other commands on the tab, must answer before timeout, and no read starts after it, so a wait ends near timeout (elapsed is measured, not clamped). Reads are the poll interval apart, except the last, which starts 100 ms before timeout (the whole of a shorter timeout) whatever the poll interval, so that is how long it has to answer; a text wait's last read also looks for the text among element names. If the read in progress at timeout does not answer, an unmet wait returns its last completed read with observationIncomplete: true, and its error says so: the condition was not observed within timeout, but the final read did not finish. A late reply, met or an error, is ignored. If no read answered at all, the wait returns code timeout without ok: whether the condition holds is unknown, which is the usual result of a positive timeout shorter than one read takes. A zero timeout (HTTP and batch only) is instead one read that may take up to 5 s. js waits still poll inside the page.

browser_screenshot(selector=None, full_page=False, tab_id=None, save_to=None, data_url=False, ref=None, element=None, format="png")

Replaces browser_element_screenshot and browser_full_page_screenshot. Returns [Image, metadata] (MCP image content) by default; save_to writes the full PNG (or JPEG with format="jpeg") and returns metadata with saved_to instead, and data_url=True adds the raw image data URL. selector/ref and full_page=True are mutually exclusive.

  • Default: viewport capture via POST /api/screenshot. A supplied tab_id is captured where it is (tabs.captureTab); no tab is shown and no window focused.
  • selector: crop one element via POST /api/element-screenshot. Nonempty selectors use the shared resolver (CSS, including shadow trees, and the supported Playwright subset) and an exact-element marker that is cleaned up even after capture failure. Unsupported syntax returns persistent unsupported_selector before capturing.
  • full_page=True: scroll and stitch up to 30 viewports via POST /api/full-page-screenshot.

No capture activates a tab. save_to overrides the file destination; data_url=True additionally returns dataUrl. The former element tool's scale option remains available through the bridge endpoint; the merged MCP tool uses its default scale of 1.

browser_trusted_click(selector="", x=0, y=0, coordinate_space="viewport", focus=False, tab_id=None, first=False, restore_pointer=True, button="left", click_count=1, modifiers=None, hold_ms=0)

Clicks with the real OS mouse pointer via POST /api/trusted-click, for pages that ignore synthetic DOM clicks. It moves the user's cursor for the click, so prefer browser_click. Afterwards the cursor goes back to where it was; restore_pointer=False (request field restorePointer: false) leaves it on the target, for hover menus that close when the pointer leaves. A selector is scrolled into view and clicked at its center; otherwise x/y are viewport coordinates. Raw desktop coordinates (coordinate_space="screen") return nonretryable unsupported_target, including in writable mode: no tab_id can bind where they land. Handed-off tabs also return unsupported_target; use DOM operations there or create a new own tab for native input. The OS click lands on whatever the screen shows, so a tab_id that is not the shown tab of its window is shown for the click, and the user's tab is shown again 300 ms after it unless the temporarily shown tab is now busy or the user changed the view (see Shared browser). A viewport click returns user_active without clicking while the user is using the target tab or the tab it would hide. A user-tab target instead returns user_tab_read_only before any OS input; approval tokens cannot bypass ownership. Ambiguous selectors return ambiguous_target before scrolling the element or invoking the OS; first=True opts into the first match.

A viewport point is converted to desktop coordinates with the tab's page zoom (browser.tabs.getZoom): Firefox measures the element and the viewport's screen origin in the page's CSS pixels, which zoom scales, so their sum is multiplied by the zoom. Viewport-click results report it as zoom (1 at 100%). Before a selector click, the element at the point (elementFromPoint, through same-origin frames and shadow roots) must be the target, inside it, or inside one of its <label>s (a custom checkbox whose input sits under its label); otherwise nothing is clicked and the call returns target_not_at_point with state.viewportX, state.viewportY and state.hit (the covering element's ref, role and name, or null when nothing is there). A tab that still has no size on screen 1 s after it is shown returns tab_not_shown without clicking: its window shows no page, as during Zen's first-run welcome screen (#360). The "Zoom text only" setting and OS display scaling are not compensated.

button (left, right or middle), click_count (2 double-clicks) and modifiers (any of Shift, Control, Alt, Meta, held during the click) choose the click (request fields button, clickCount, modifiers). Any other value returns {error} before anything is clicked, and so does a variant the backend cannot send: it never falls back to a left click. cliclick's refusals come before the tab is shown or the browser focused. The desktop may still give some combinations its own meaning, for example Control+click as a right click on macOS, Alt+click as a window drag in some Linux window managers, or the Start menu opening when Windows sees Meta (the Windows key) released.

hold_ms (request field holdMs, 0-10000) keeps the button pressed that long between its down and up events, for long-press controls (#351); otherwise the press lasts 30 ms. Quartz and Windows sleep between the two events and send the up event even if the sleep is interrupted. xdotool runs mousedown, sleep and mouseup in its one command, which may take 10 s plus the hold; when that run fails or times out, the bridge sends mouseup (and the modifiers' key-up) once more. It needs click_count=1, and cliclick, which has no hold, refuses it before the tab is shown. In ask mode a held click reads "clicks and holds".

Platform Button Double click Modifiers
Windows mouse_event LEFTDOWN/LEFTUP, RIGHTDOWN/RIGHTUP, MIDDLEDOWN/MIDDLEUP two down/up pairs 50 ms apart keybd_event key down before the click and up after it (VK_SHIFT, VK_CONTROL, VK_MENU, VK_LWIN)
macOS Quartz kCGEventLeftMouseDown/Up, kCGEventRightMouseDown/Up, kCGEventOtherMouseDown/Up with kCGMouseButtonCenter two down/up pairs 50 ms apart, kCGMouseEventClickState 1 then 2 event flags on the button events (kCGEventFlagMaskShift, Control, Alternate, Command); no key events
macOS cliclick c: or rc:; middle and hold_ms return {error} dc:; with the right button returns {error} kd: before the click and ku: after it (shift, ctrl, alt, cmd)
Linux (X11) xdotool click 1, 3 or 2 click --repeat 2 keydown before the click and keyup after it, in the same command (shift, ctrl, alt, super)

When a cliclick or xdotool run that held modifiers fails, the bridge sends their key-up once more so no key stays pressed.

Platform Click backend Pointer restore
Windows user32 SetCursorPos + mouse_event GetCursorPos before the click, SetCursorPos 100 ms after it
macOS Quartz CGEventCreateMouseEvent when pyobjc is importable (uvx --from 'kitsune-browser-mcp[macos]' kitsune), else cliclick on PATH Quartz: CGEventGetLocation before the click, CGWarpMouseCursorPosition 100 ms after it (no mouse event is posted), then CGAssociateMouseAndMouseCursorPosition so the user's mouse does not freeze for the 0.25 s macOS applies after a warp. cliclick: cliclick p c:x,y prints the position in the same run as the click, then cliclick m:x,y 100 ms later
Linux (X11) xdotool on PATH (sudo apt install xdotool). Wayland sessions (XDG_SESSION_TYPE=wayland, or no DISPLAY) are not supported and return {error}. Coordinates are not scaled for HiDPI X11 (GDK_SCALE, layout.css.devPixelsPerPx), so clicks there may land off by the scale factor mousemove restore at the end of the same xdotool command, after the click

On macOS, the app running the bridge (your terminal or MCP client) needs Accessibility permission (System Settings > Privacy & Security > Accessibility); without it, or without either backend, the call returns {error} explaining what is missing instead of clicking. The click runs in the bridge, which keeps the Python and PATH of the MCP server that started it, so after adding the [macos] extra or cliclick, run kitsune-bridge stop and let a client that has it start the bridge again. On Linux, a missing xdotool, a failed xdotool run or a Wayland session also returns {error} without clicking. Results include clickBackend (win32, quartz, cliclick, or xdotool) and pointerRestored: true once the cursor is back, false with restore_pointer=False or when it could not be read or moved back. A failed restore adds pointerRestoreError and still returns ok: true, since the click already happened. On Linux the move back is part of the click command, so a failure there fails the whole call.

focus=True tries to foreground the browser first and reports focused, which is true only when the browser then is in the foreground:

  • Windows matches the window title: the page title for viewport clicks, otherwise Zen Browser, Mozilla Firefox, then LibreWolf. Set KITSUNE_BROWSER_WINDOW_TITLE (comma list) to change the defaults.
  • macOS activates the first running process named zen, firefox, or librewolf through System Events (osascript, which may ask for Automation permission) and waits up to 1 s for it to become the frontmost process. A browser in full screen on another Space may never do so. Set KITSUNE_BROWSER_PROCESS (comma list) to change the defaults.
  • Linux does not focus the browser; focused is false.

The bridge endpoint also accepts title and process to override these per call.

An OS click goes to whatever window macOS shows at the point, whether or not it is the browser's. So before a viewport click on macOS, with Quartz, the bridge checks the front on-screen window at the desktop point (CGWindowListCopyWindowInfo; windows with alpha 0 and the pointer's own window are skipped, and no Screen Recording permission is needed). Unless that window belongs to the browser process (KITSUNE_BROWSER_PROCESS, or process), it returns browser_not_at_point with state.app (the owning app's name, null for the desktop) and makes no click; the tab shown for the click is shown again as usual. With focus=True it rechecks for up to 1 s while a Space switch finishes. If the window list cannot be read, it refuses the same way with state.reason: "unknown". This is how a browser in full screen on another Space, or behind another app, is caught: the click would otherwise land in the app in front, such as the terminal running the agent. Ask the user to show the browser there (switch to its Space, or leave full screen), then retry. With only cliclick the point cannot be checked, so a viewport click refuses only when focus=True was asked and the browser did not come to the front (state.reason: "not_frontmost"). Raw coordinate_space="screen" clicks are refused altogether.

The browser's window at the point must also be the tab's own window (#336). The extension reports that window's bounds (browser.windows.get), and the bridge compares them with the window at the point, within 4 points. Another window of the same browser (for example the user's second Zen window, or a Firefox window in front of the Zen window Kitsune drives) or a browser panel or menu covering the page refuses with state.reason: "other_browser_window", state.window (the window at the point, [left, top, width, height]) and state.targetWindow. An extension older than this bridge sends no bounds, so any browser window passes, as before.

Full screen, verified on macOS 26.5.1 with Zen 1.24b (#336): in native full screen the hidden menu bar is not in the on-screen window list, so it does not refuse clicks. Only a menu bar the user shows (the pointer resting at the top edge of the screen) is listed: a Window Server window at layer 24 over the top 30 points, with Control Center's menu extras at layer 25. A click there refuses with state.reason: "other_window" and state.app: "Window Server" (or Control Center, named in the system's language), so it never lands on the menu bar. What remains is Zen's own full-screen strip: a second Zen window, 68 points tall, over the top of the page (the page starts at 8 points). It moves down below a shown menu bar (30 to 98 points). A click on it refuses with state.reason: "other_browser_window", so the top 60 or so points of a full-screen Zen page take no trusted click (#431). Scroll the target further down the page, or ask the user to leave full screen, then retry. To see which windows macOS reports there on another setup, run the opt-in check while nobody uses the machine:

KITSUNE_E2E_BROWSER=/Applications/Zen.app/Contents/MacOS/zen KITSUNE_E2E_TRUSTED_INPUT=1 \
    uv run --extra macos python -m unittest discover -s tests -p test_real_browser_fullscreen_menubar.py -v

Without --extra macos Quartz is missing and the check skips. It puts its own browser window in full screen, makes no click and prints the windows at a few points near the top of the page. With KITSUNE_E2E_REVEAL_MENU_BAR=1 as well, it then moves the pointer (no click) to the top edge to show the menu bar, prints them again, and moves the pointer back.

Focusing the browser, the window check and the click run off the bridge's event loop, so other clients' requests are served while a trusted click waits for focus.

browser_trusted_type(selector, text, tab_id=None, clear=False, submit=False, delay_ms=30, first=False) and browser_trusted_press_key(keys, selector, tab_id=None, first=False)

browse group. These type with the real OS keyboard (#346, #347), via POST /api/trusted-type and /api/trusted-press-key. Use them for fields that reject synthetic input, such as one-time-code sign-in fields, and for browser commands that only a real keyboard runs: paste (Meta+V), copy, cut, find and undo. They are implemented on macOS only, with pyobjc Quartz and Accessibility permission for the bridge. Elsewhere, or without them, they return unsupported_platform or permission_required and type nothing.

Each call: 1. Clicks the target (selector or a ref, required) with the OS pointer, as browser_trusted_click does. A tab_id that is not shown is shown for the call (tab_not_shown when the window shows no page). A user or other client's tab returns user_tab_read_only before any click or view switch. 2. Requires the window at the click point to be the tab's own window (browser_not_at_point, #336). 3. Sends each key only while the browser that owns that window is the frontmost app and the target has the page's focus. 4. Shows the user's tab again after the last key, not after the click, so the keys land in the target tab.

If a check fails before a key, the call stops with keyboard_focus_lost, before that key could land elsewhere. state.reason is not_frontmost (another app came to the front) or target_not_focused (the user or the page moved the focus), and state.keysSent says how many keys were sent. During the call, the page counts the trusted keys and pastes as the agent's, not as the user's input (see user_active).

browser_trusted_type types text, at most 1,000 characters (\n presses Enter, \t Tab), each as a key press carrying the character as Unicode text, so the keyboard layout does not matter. clear selects all and deletes first, submit presses Enter after the text, and delay_ms (0-1000) pauses between keys. The result reports keysSent and typed but never the text, and the audit log redacts it.

browser_trusted_press_key presses one chord in Playwright form (Meta+V, Control+Shift+T, Enter). Chords that close, quit or leave the browser are refused with key_denied and press nothing: Meta+Q, Meta+W, Meta+Shift+W, Meta+H, Meta+M, Meta+Tab, Meta+Space, Control+W, Alt+F4 and similar. In ask mode, save, open and print chords (Meta+S, Meta+O, Meta+P, and the Control forms) are held as outside_page_keys, and browser_trusted_type(submit=True) is held as submit. Both tools also follow the page rules, such as payment_page.

They take the user's keyboard and pointer for their duration (about delay_ms plus 50 ms per key), so prefer browser_type and browser_press_key unless the page ignores them.

browser_trusted_drag(selector, to_selector, tab_id=None, steps=10, first=False)

browse group. Drags with the real OS mouse pointer (#351), via POST /api/trusted-drag, for pointer-capture handles such as custom sliders, resizers and canvases. Such a handle calls setPointerCapture on pointerdown, which accepts only a pointer the browser tracks: for browser_drag's synthetic pointer, Firefox throws NotFoundError ("Invalid pointer id"), and the handle never moves. It is implemented on macOS only, with pyobjc Quartz and Accessibility permission for the bridge. Elsewhere, or without them, it returns unsupported_platform or permission_required and drags nothing.

Each call: 1. Measures the source (selector or a ref) as browser_trusted_click does: scrolled into view and hit-tested (target_not_at_point). A tab_id that is not shown is shown for the call. User and other clients' tabs return user_tab_read_only before anything is shown or measured; approval tokens do not override ownership. 2. Measures the destination (to_selector) in the same tab without scrolling, so the source point stays valid. It is hit-tested too, so a destination out of view returns target_not_at_point. 3. Brings the browser to the front, as browser_trusted_type does, and requires the tab's own window at both points (browser_not_at_point). 4. Presses the left button at the source's center (kCGEventLeftMouseDown), waits 50 ms so the page's pointerdown handler captures the pointer, moves to the destination's center in steps (1-50) kCGEventLeftMouseDragged events 16 ms apart, and releases there. If posting fails midway, the button is released at the last point it reached, not at the destination, and the call returns drag_interrupted (action error). 5. Moves the pointer back as a trusted click does (pointerRestored, pointerRestoreError), also after drag_interrupted, and shows the user's tab again 300 ms later. If the user switched tabs between the two measurements, the tab they switched to is shown again first.

The result reports from and to (screenX, screenY, viewportX, viewportY), steps, zoom, focused and clickBackend: "quartz". In ask mode it follows the page rules, like browser_drag. It takes the user's pointer for its duration (about 0.2 s plus 16 ms per step), so prefer browser_drag unless the page ignores it.

browser_resume(call, resume_token, observed=None, wait=120)

core group (#376; it was in automation). Continues a call whose response had call and resume_token, including browser_ask_user's.

A direct call that stopped on needs_user runs again from its held request, from the checkpoint the MCP server keeps: nothing that ran ok is sent again, and the held request goes through the current policy, so a resume runs nothing a new call would not. An approval hold is asked about again in the client's approval prompt, for up to wait seconds (0 to 300, default 120; larger values are capped); without elicitation it is checked once and the resume returns at once. See Direct calls.

A browser_batch, browser_parallel or browser_workflow call that did not finish ok continues from the checkpoint the bridge keeps in memory for 30 minutes (at most 20); wait is not used. Steps that ran ok are not sent again. Each unknown write needs an observed entry by its progress path ("1", "0.1.2" for a parallel sequence step): "not_applied" sends it again, "applied" marks it ok without sending it. Read-only mode, the URL policy and ask-before-act apply to every resumed step. The response has the original call's shape (results, progress, status, summary; a workflow adds workflow and steps). While the call is still not ok, the response also has call and a new resume_token. See Checkpoint and resume for each decision and error.

browser_ask_user(reason, message, tab_id=None)

core group, and published in every task mode (#376). Asks the user for what the agent cannot do or Kitsune cannot see: reason is sign_in, human_check (a CAPTCHA or verification), trusted_input (a control that ignores synthetic clicks) or other (a choice, an audio puzzle). message is addressed to the user (at most 1,000 characters are kept); tab_id defaults to the current tab. Nothing reaches the page, and the audit log records the request (askUser, without the message).

  • On a client with elicitation the MCP server asks the user in the client's prompt, labeled as the agent's request and naming the tab, with Done and Can't, for up to 120 s:
{ "ok": true, "answer": "done", "via": "elicitation", "reason": "sign_in", "tabId": 12,
  "hint": "Check the page (browser_snapshot) before going on." }

Can't, or a declined prompt, returns needs_user with state.decision: "declined"; a dismissed prompt, or none answered in time, "cancelled" with call and resume_token: browser_resume asks again. - Without elicitation it returns needs_user at once, for the agent to ask in its reply:

{ "ok": false, "error": "The agent needs the user", "code": "needs_user", "retryable": false,
  "state": { "reason": "agent_request", "detail": "sign_in", "message": "Sign in to your shop account, please.",
             "tabId": 12, "url": "https://shop.example/login", "title": "Sign in", "evidence": { "field": "password" } },
  "call": 61, "resume_token": "…",
  "hint": "Nothing was done. Ask the user in your reply (state.message) and wait for their answer. Tell the user what to do …" }

When reason is sign_in or human_check and the tab shows that hint (state.evidence), the result has call and resume_token: browser_resume waits up to wait seconds for the hint to be gone from the tab and then returns { "ok": true, "answer": "done", "via": "page", … }. Otherwise there is nothing to wait for, and no resume_token. See Direct calls.

Example: needs_user end to end

An agent needs the user to sign in to a shop (#376).

  1. The agent opens its own background tab (browser_tabs action=new), tab 12, navigates to the shop, reads it, then calls browser_click(target="e12", element="Sign in"). A user tab would instead refuse the click with user_tab_read_only; merely viewing an owned tab does not hold the click. A handoff is an alternative when the existing user tab is needed.
  2. The click's result shows a sign-in form, with a hint: "needs_user": { "reason": "sign_in", "message": "Sign in on this page.", … }. Nothing was held for it, and the agent cannot sign in, so it asks: browser_ask_user(reason="sign_in", message="Sign in to your shop account, please.", tab_id=12).
  3. On a client with elicitation the user signs in and answers Done in the client's prompt: { "ok": true, "answer": "done", "via": "elicitation", "reason": "sign_in", "tabId": 12, … }. Without elicitation the result is needs_user (state.reason: "agent_request"), with call and resume_token since the tab shows the sign-in hint (state.evidence). The agent tells the user state.message, then calls browser_resume(call, resume_token), which waits up to 120 s for the sign-in form to be gone from the tab: { "ok": true, "answer": "done", "via": "page", "reason": "sign_in", "tabId": 12, … }.
  4. The agent checks the page with browser_snapshot and goes on.

WordPress workflow

browser_wp_html is removed. Run browser_workflow("wp-html", {"site_url": "https://example.com", "page_id": 5719}) from a browser logged into WordPress. The workflow navigates the current tab, waits for Elementor preview widgets, then extracts their HTML. Its final step returns the JSON string containing widget data inside the workflow's results.

HTTP bridge endpoints

Requests require a bearer token from ~/.kitsune/token (or KITSUNE_TOKEN).

Status & Info

GET /api/status

Connection status and config; diagnosis is described under Connection diagnosis.

{ "status": "running", "extension_connected": true, "port": 47651, "ws_port": 47652, "screenshot_dir": "...",
  "diagnosis": { "code": "connected" } }

Browser build diagnostics

browser_status(diagnostics=true), Python browser.status(diagnostics=True), or authenticated GET /api/status?diagnostics=true adds browser_diagnostics. The default status stays lightweight: no process inspection, app metadata reads, or wait for extension reconnection. The existing connection diagnosis is unchanged; a connected extension can coexist with a browser build mismatch.

{
  "browser_diagnostics": {
    "status": "mismatch",
    "comparison": "gecko_build_id",
    "runtime": {
      "name": "Zen", "vendor": "Mozilla", "version": "1.23.4b", "buildID": "20261020090730",
      "source": "browser.runtime.getBrowserInfo"
    },
    "installed": {
      "name": "Zen", "vendor": "Mozilla", "version": "1.24b", "buildID": "20261029123729",
      "source": "application.ini [App]"
    },
    "process": { "source": "websocket_peer", "mapped_bundle": "backup" },
    "restart_recommended": true,
    "hint": "The connected browser's runtime and installed build differ. Ask the user to save work and restart the browser ..."
  }
}
  • status: "match": runtime and installed Gecko app build IDs agree; restart_recommended: false. It does not prove that page loading is healthy. Product/engine version strings are displayed, never compared against each other; missing or invalid build IDs produce unknown instead.
  • status: "mismatch": those build IDs differ; restart_recommended: true and hint suggest asking the user to save work and restart, then check status and list tabs again. This is evidence of a pending browser restart, not proof of the cause of a page failure.
  • status: "unknown": restart_recommended: null and a reason; not a healthy/matching result.

Runtime metadata is sent by the extension on each connection, not read from page content or a user agent string. Installed-build inspection is currently macOS only. The bridge matches the exact reverse TCP connection of its active extension WebSocket to one PID using lsof, verifies the main executable against its bundle's Info.plist, and reads the current bundle's application.ini. mapped_bundle says current (Contents) or backup (Contents.bak); a backup path alone is not a build mismatch. It never picks a browser by name from the process list or reads a user profile. Each of at most three lsof calls has a one-second timeout, and metadata files are capped at 64 KiB. The peer is rechecked after inspection; a replaced/disconnected extension invalidates the result. The diagnostic returns no paths, PIDs, socket addresses, tokens, or raw subprocess errors.

Unknown reasons include extension_disconnected, runtime_info_unavailable (older extension or runtime metadata not yet received), unsupported_platform (installed-build inspection unavailable), connection_address_unavailable, browser_process_unavailable, ambiguous_browser_process, process_lookup_unavailable (missing helper or access denied), process_lookup_failed, process_lookup_timeout, browser_bundle_unavailable, installed_metadata_unavailable, build_id_unavailable, and connection_changed. A remote/forwarded WebSocket that cannot be tied to a local browser is also unknown, not guessed.

This is read-only: no tabs are opened, navigated, activated or closed; no settings change, extension reload, or browser restart is performed. A bridge predating this option may ignore the query: absence of browser_diagnostics is not a successful build comparison.

GET /api/version

Bridge and extension version info.

{ "bridge": "0.7.3", "api": "1", "extension_connected": true, "extension_version": "0.7.3", "extension_build": "dev", "extension": 25 }

Page Data (GET, cached 5s except page-info)

GET /api/page-info

Page metadata plus current dialog policy and recent dialog history. Never cached. Also accepts POST with { "tabId": 123 }. MCP tool: browser_page_info(tab_id=None).

{ "url": "...", "title": "...", "readyState": "complete", "lang": "en", "width": 890, "height": 992, "forms": 0, "links": 1, "images": 0 }
readyState is the document's document.readyState; see Snapshot readiness. A page that shows a sign-in field or a CAPTCHA adds a needs_user hint.

GET /api/page-text

All visible text on the page.

{ "text": "..." }

GET /api/tabs

All open browser tabs.

{ "tabs": [{ "id": 1, "url": "...", "title": "...", "active": true, "windowId": 1, "index": 0, "owned": true, "mine": true }] }
mine: true marks the tabs the request's X-Kitsune-Owner opened. POST with { "mine": true } returns only those, the most recently opened first (see Tab owners).

GET /api/dom

Accessibility tree (depth=6). POST accepts tabId, selector, ref and maxDepth, scoped as in browser_dom.

{ "tree": { "ref": "s1e0", "tag": "body", "children": [...] }, "refs": 42 }
Sensitive fields report redacted/hasValue instead of value. A tree past the 20,000-char cap also has "truncated": true and a hint.

GET /api/forms

All form fields on the page. Sensitive fields have redacted/hasValue instead of value (see Sensitive-field redaction).

{ "fields": [{ "ref": "e0", "tag": "input", "type": "password", "name": "password", "id": "password", "placeholder": "", "redacted": true, "hasValue": true, "label": "Password", "required": false, "selector": "#password", "bounds": [...] }] }

GET /api/screenshot

Captures the active page as PNG and saves to the bridge screenshot directory ($KITSUNE_HOME/screenshots, default ~/.kitsune/screenshots).

{ "url": "...", "title": "...", "saved_to": "...", "filename": "kitsune_20260313_120000.png" }

POST /api/screenshot, /api/element-screenshot, /api/full-page-screenshot

Return dataUrl and capture metadata. Set imagePreview: true for an inline preview under roughly 1 MB (large images are JPEG-encoded and downscaled in the extension). Without that flag, return full PNG data by default, or JPEG with format: "jpeg". Viewport requests accept tabId. Element requests require selector and accept tabId and scale (1–4); full-page requests accept tabId. A capture never shows the tab (tabs.captureTab). Captures retain existing canvas and scroll/segment limits and can truncate very large documents.

MCP tool browser_screenshot(selector=None, full_page=False, tab_id=None, save_to=None, data_url=False, ref=None, element=None, format="png") returns [Image, metadata] by default, giving the model actual MCP image content. save_to writes the full requested PNG/JPEG and returns metadata with saved_to instead. data_url=True returns the raw image data in metadata. Inline preview dimensions can be smaller than the original capture metadata.

POST /api/handle-dialog

{ "accept": true, "prompt_text": "answer", "tabId": 123 }

MCP tool: browser_handle_dialog(accept=True, prompt_text=None, tab_id=None). Sets the tab's policy for subsequent dialogs and returns {ok, policy, dialogs}. By default, with no policy, original native alert/confirm/prompt functions run and the user's actual response is recorded. beforeunload also keeps native behavior. Only an explicitly configured policy auto-answers dialogs. Clear a policy with browser_handle_dialog(accept=None, tab_id=123) or HTTP { "accept": null, "tabId": 123 } to restore native pass-through. Accepted prompts return prompt_text if supplied, otherwise the prompt's default; dismissed prompts return null and dismissed confirms return false. With a policy set, alerts return immediately; in native mode they wait for the user. Recent history is also included in browser_page_info:

{ "policy": { "accept": true, "prompt_text": "answer" }, "dialogs": [
  { "type": "prompt", "message": "Name?", "default_value": "Guest",
    "timestamp": 1792200000000, "response": "answer", "pending": false }
] }

The page-world override records every intercepted invocation (last 100 per document). Answers are synchronous; pending: false means the user's native response or configured automatic answer was already returned, and a later tool call cannot change it. Alert responses are null (no return value). Policies persist across navigation until tab closure or extension restart, with native behavior applying until asynchronous restoration completes. Clearing a policy also removes it from the per-tab policy store. Capture starts at document_start for new documents and on connection for existing tabs. Scripts holding original references before injection, iframe dialogs (including cross-origin frames), browser-internal pages, and already-open native dialogs are outside this mechanism. Page scripts can also replace or bypass the overrides.

accept: true also disarms beforeunload using the same returnValue/preventDefault suppression as browser_close_tab(force=True), allowing navigation away. Unload events are recorded with type beforeunload. accept: false leaves native unload behavior; the extension cannot answer a native “Leave page?” prompt. Accepting temporarily clears onbeforeunload; clearing or dismissing the policy restores the saved handler if the page has not supplied a replacement. Force-closing remains available regardless of dialog policy.

POST /api/wait-for-text

{ "text": "Ready", "text_gone": "Loading", "timeout": 10000, "tabId": 123 }

MCP tool: browser_wait_for(text=...) or browser_wait_for(text_gone=...). Provide at least one non-empty condition (the HTTP endpoint accepts both at once). Polls rendered top-level document text about every 100 ms, excluding hidden text; if both conditions are provided they must both hold. Matching uses case-sensitive substrings. Frames and shadow roots are not traversed. Field names from placeholder, aria-label or aria-labelledby, which snapshots show as names, are not page text and never match. Returns {ok, text, text_gone, elapsed}; timeout returns ok: false with an error. When the text of a timed-out text wait is the name of a visible element, the result also has matched_as: "accessible_name", name_matches (up to 3 {ref, role, name, source}, where source is the attribute) and a hint: act on the ref, or wait for text the page shows. A text wait met by its first read, because the text was already on the page (in a field's label, say), adds already_present: true and a hint (#402). With "new_only": true (browser_wait_for(new_only=True); the core tool's newOnly is accepted but not published), the wait counts the text's occurrences at its start and is met only once more occur. The bridge polls single reads (see browser_wait_for); a zero timeout performs one immediate check.

Navigation (POST)

POST /api/navigate

{ "url": "https://example.com", "tabId": 1, "expectTitle": "Example" } - tabId optional (defaults to active tab) - expectTitle optional — warns if title doesn't match (catches silent redirects) - User and other clients' tabs return user_tab_read_only without a prompt; the same applies to /api/back, /api/forward and reload. Explicit compatibility opt-outs are described in Protecting your tabs. - A load that does not finish in 15 s returns { "ok": false, "timedOut": true, "url": "..." }. If the tab is still about:blank / complete and the page cannot confirm a loaded destination, it also carries code: "navigation_stalled", retryable: false, and the recovery hint of new_tab_stalled (#366). A requested about: URL never gets this code.

POST /api/back

{ "tabId": 1 } goes back in the tab's history and returns { "ok": true, "tabId": 1 } once dispatched. "pageText": true, an internal flag the core browser_navigate_back sends, waits for the page it loads and adds the page's readable snapshot when it fits 2,000 characters (see Post-action snapshots, #448). With KITSUNE_POST_ACTION_SNAPSHOT=0 the flag does nothing.

POST /api/new-tab

{ "url": "https://example.com" } — url optional, defaults to about:blank. Opens an agent-owned tab in the background (it is not shown; see Shared browser). Returns { "ok": true, "tabId": 123 } immediately.

{ "url": "https://example.com", "wait": true, "timeout": 15000 } waits up to timeout ms (default 15000) for the load and returns { "ok": true, "tabId": 123, "url": "https://example.com/after-redirect", "status": "complete" }. On timeout the tab stays open and the result is { "ok": false, "timedOut": true, "tabId": 123, "url": "...", "status": "loading" }. While Zen's first-run welcome screen is open, the browser keeps reporting a loaded tab's URL as about:blank (#360); the wait then asks the page, and url is the page's own URL. With wait, "pageText": true, an internal flag the core browser_tabs action=new url=… sends, adds the loaded page's readable snapshot when it fits 2,000 characters, as a browser_navigate result has it (#448); a larger page, a timeout or KITSUNE_POST_ACTION_SNAPSHOT=0 adds nothing.

If the wait times out with the tab still about:blank / complete, and the page cannot confirm a loaded destination, the result also carries code: "new_tab_stalled", retryable: false, and a hint saying that retrying will not fix it (#366). The tab already exists: tabId is retained (the core browser_tabs returns its index and tab list instead). Do not repeat creation or navigation: the browser needs an update, a restart or another fix first. A browser left running after an on-disk update is one possible cause; check browser_status(diagnostics=true) for build mismatch evidence. Tell the user, ask them to save work and restart or update the browser (or fix it another way), and retry only after they say it is fixed. The code describes the observed blank-tab timeout, not a confirmed update mismatch. Kitsune does not restart the browser, activate the tab, or create a replacement automatically. A blank tab requested intentionally still succeeds; a tab still loading retains the ordinary timeout result.

POST /api/switch-tab

{ "tabId": 123 } shows the tab. It switches the user's view, so send it only on the user's request; while the user is using the tab it would hide, it returns user_active.

POST /api/close-tab

{ "tabId": 123, "force": true, "confirmToken": "…" } closes a tab this client may write; any other tab returns user_tab_read_only (see Tab permissions). With a v0.7.1 extension, a tab the agent did not open returns needs_user (user_tab) with a confirm token instead; confirmToken is that token after the user approved. force disarms beforeunload first.

POST /api/wake-tab

{ "tabId": 123 } reloads a discarded tab this client may write; reads of a discarded tab of any owner wake it themselves (#449). A tab that is not discarded returns { "ok": true, "woken": false, "discarded": false } at once. Otherwise the call waits up to 15 s for the reloaded page to finish loading and answer the content script, and then returns { "ok": true, "woken": true, "ready": true }; the next command can reach the page. Two results report ready: false: - { "ok": true, "woken": true, "ready": false, "unscriptable": true, "state": { "tabId", "url", "status", "title", "httpStatus" } }: the reloaded page loaded but refuses the content script, such as a JSON or PDF viewer or an error response with an empty body. Commands fail with error_page. - { "ok": true, "woken": true, "ready": false, "timedOut": true, "state": { "tabId", "url", "status" } }: the page did not become ready within 15 s; state is the last observed state. state.reason: "no_receiver" means the last check found the reloaded page loaded and accepting the content script, but nothing answered its ping. While the page stays silent, commands fail with content_unavailable (state.reason: "no_receiver") before anything is sent.

Ready means the content script answered a ping after the load, not that an injection succeeded. Until the deadline, each check injects and pings again, so a page that starts answering late still ends the wait as ready. Each ping waits at most 1 s; the injection is one browser call whose own duration the deadline cannot cut short.

Interaction (POST)

POST /api/click

{ "selector": "button.submit" } — also accepts ref, position.

The click moves to the target before pressing: pointerover/mouseover, non-bubbling pointerenter/mouseenter on newly entered ancestors and the target, then pointermove/mousemove, sharing browser_hover's pointer tracking. A drag also moves to its source before pressing. The click then dispatches pointerdown, mousedown, pointerup, mouseup and click at the target's center or the given point. For a primary-button press, button: 0; buttons is 0 during approach, 1 at the press and 0 from pointerup on. click is a PointerEvent with detail: 1 and pointerType: "mouse", and it runs the element's native activation once, as el.click() does; a disabled form control gets no click. Limits: every event has isTrusted: false and grants no user activation, and no pointerout or pointerleave is sent. For pages that need trusted input, use browser_trusted_click; an unmet expect whose target is still visible says so in its hint (see Action outcomes).

"holdMs": 600 (0-10000; MCP hold_ms) holds the press for long-press controls (#351): pointerup, mouseup and click (or contextmenu/auxclick) follow that long after pointerdown and mousedown, and the result adds held_ms, the measured hold. The background times the hold, because a hidden tab's page timers fire after 1 s at the earliest; measured in a background tab, a 700 ms hold lasted 729 ms. Any other value returns holdMs must be an integer from 0 to 10000 and dispatches nothing. Add "wait": true, "timeout": 10000 to wait for the target first (see Actionability wait); the other action endpoints accept the same keys.

POST /api/trusted-click

{ "selector": "button.submit", "focus": true } or { "coords": { "x": 640, "y": 400 }, "coordinateSpace": "viewport" } — optional tabId, title, process, restorePointer, button (left/right/middle), clickCount (1 or 2), modifiers (["Shift", "Control", "Alt", "Meta"]). OS-level click on Windows, macOS and Linux (X11); see browser_trusted_click. holdMs (0-10000) holds a single click's press. A click that does not finish within 35 s returns a timeout action error with retryable: false.

POST /api/trusted-drag

{ "selector": "#knob", "to": "#mark", "steps": 10 } — refs work for both, plus optional tabId, first, process and focus (false leaves bringing the browser to the front to the caller). OS-level drag on macOS; see browser_trusted_drag. A drag that does not finish within 35 s returns a timeout action error.

POST /api/type

{ "text": "hello", "selector": "input" }

POST /api/fill

{ "selector": "input#email", "value": "user@example.com" } Handles shadow DOM and React/Vue state. A web-component host is filled through the first input, textarea or select in its open or closed shadow root, else in the shadow roots of hosts nested inside it, up to 8 roots deep (closed roots through Firefox's openOrClosedShadowRoot). /api/type, /api/set-editable-content and /api/form-fill use the same lookup. When /api/fill, /api/type or a /api/form-fill key cannot reach a field, the result is a not_fillable action error.

An ARIA slider or spinbutton (role=slider/spinbutton with a numeric aria-valuenow and no native field) has no value to assign, so /api/fill sets it with keys, in the page, in one request (#311): it focuses the target and presses Home or End when the value is aria-valuemin or aria-valuemax, then arrow keys toward the value (ArrowRight/ArrowLeft on a horizontal slider, ArrowUp/ArrowDown on a vertical one or a spinbutton; the other pair when the first does not move it toward the value). Keys are keydown/keyup on the target as /api/keypress dispatches them, a task apart so the page renders each step; a widget that renders only on an animation frame is not waited for, so it looks stuck. The value must be a number; values are compared up to float rounding. It stops at the value: { "ok": true, "tag": "div", "role": "slider", "value": 15, "target": 15, "min": 0, "max": 100, "keys": 35 }. A value outside aria-valuemin/aria-valuemax is a value_not_reached action error (reason: "out_of_range", keys: 0, retryable: true) before any key. When a key steps past the value, a key stops moving it, the arrow keys move it away, or 1,000 keys or 20 s run out, it is a value_not_reached with retryable: false and the same state. A native <input type=range>, also inside a role=slider wrapper, keeps the value setter.

POST /api/scroll

{ "direction": "down", "amount": 500 } — optional container selector or ref; element is an audit-only description. Needs browse; with loadMore: true (the core browser_find's load_more scrolls, #447) core also permits it.

POST /api/hover

{ "selector": "a.link" } Returns { "ok": true, "tag": "a" }, plus opened_popup when the hover opened a menu, listbox or dialog.

A selector is scrolled into view first. The hover dispatches, in this order, pointerover, pointerenter, mouseover, mouseenter, pointermove and mousemove at the target's center, as a pointer arriving there would. pointerover, mouseover and the move events go to the target and bubble, across shadow roots (composed: true). pointerenter and mouseenter do not bubble; each goes to every newly entered element from the outermost ancestor to the target, so a row that shows its controls on enter sees the hover of a child. The ancestor chain crosses shadow roots to their hosts and stays within the target's document (in a frame, the <iframe> element is not entered). An ancestor that also contains the element the previous hover pointed at counts as already entered and gets no enter events; the first hover enters every ancestor up to the root element, and so does hovering the same element again, so a retry can reveal what was hidden meanwhile. Kitsune remembers only its own hovers in this document: clicks, the real pointer and browser_trusted_click do not change it, and navigation or reinjection starts over. Pointer events carry pointerType: "mouse", pointerId: 1 and isPrimary, and every event carries view and the point.

Limits: every event has isTrusted: false, and no pointerout, pointerleave, mouseout or mouseleave is sent to the element pointed at before. CSS :hover rules follow only the real pointer, so a control that only a :hover style reveals stays hidden. For it, use browser_trusted_click with restore_pointer=False, which clicks the target with the real pointer and leaves the pointer there, on a target where a click is harmless; or ask the user to point at it.

"durationMs": 400 (0-10000; MCP duration_ms) and "steps": 5 (1-50) keep the pointer moving, for hover-intent controls that wait for several moves over some time (#351). After the hover above, which then arrives at a quarter of the target's width, steps more pointermove/mousemove pairs cross the middle half of the target from left to right at its vertical center, spread evenly over durationMs (5 steps when only durationMs is given), or a task apart without it. The result adds moves (steps + 1) and hovered_ms. As for a held click, the background times the pauses: in a background tab, a 500 ms hover with 5 steps took 579 ms. Values out of range return an error and move nothing.

POST /api/drag

{ "from": "#card", "to": "#done" } — or ref and toRef. Returns { "ok": true, "from": "#card", "to": "#done", "via": "html5" }. It presses the source (pointerdown, mousedown) and dispatches the HTML5 drag events (dragstart … drop, dragend). They count (via: "html5") when the source is draggable, the page let dragstart through and the destination accepted the drop by cancelling dragenter, dragover or drop, as a browser requires. Otherwise the drag is replayed as a pointer drag (via: "pointer") for libraries that build drag on pointer events: ten pointermove/mousemove steps from the source's center to the destination's, so a drag threshold is crossed, each in a new task so the page can render in between; then pointerup and mouseup. Pointer events (pointerId: 1, buttons: 1, isPrimary) go to the source, where setPointerCapture routes them; the page's capture itself is not visible to Kitsune, and Firefox can throw NotFoundError from setPointerCapture for a pointer it has not seen, such as this synthetic one. mouseup lands on the destination.

POST /api/find

{ "query": "search text" } or { "selector": "a.link" } Both query and selector are accepted (normalized to query internally). Returns { "results": [{ "ref": "r0", "tag": "a", "text": "...", "selector": "...", "bounds": [...] }] } A query that is not a matching CSS selector is split into words (filler words such as "the" or "in" are dropped). Elements are scored per word they match in their text, aria-label, placeholder, title, alt, id, name or value, or a role word ("button", "dropdown", "search bar"), with more for matching every word and for the whole query or a quoted part as a phrase. Only matching elements are returned, best first; when nothing matches, the reply is { "results": [], "hint": "..." }.

POST /api/js

{ "code": "document.title" } Returns { "result": "..." }. Results capped at 50KB with truncation flag. Page JavaScript is not subject to password redaction.

POST /api/highlight

{ "selector": "h1" } — draws red border overlay on element.

POST /api/clear-highlight

No body needed. Removes all highlights.

Waiting (POST)

POST /api/wait-for-element

{ "selector": ".loaded", "timeout": 10000, "pollInterval": 200 } Polls until element appears. Returns { "ok": true, "found": true, "ref": "r0", "elapsed": 1200 }; timeout returns ok: false, found: false with an error. The bridge polls single reads (see browser_wait_for); a zero timeout performs one immediate check.

POST /api/wait-for-result

{ "code": "document.querySelector('.data')?.textContent", "timeout": 15000, "pollInterval": 500 } Polls JS expression until non-empty. Returns { "ok": true, "result": "...", "elapsed": 2000, "polls": 4 }

POST /api/page-text-by-tab-id

{ "tabId": 123 } — get text from a specific tab (not just active).

Batch Execution (POST)

POST /api/batch

Execute multiple commands sequentially or in parallel.

{
  "commands": [
    { "action": "navigate", "url": "https://example.com" },
    { "action": "sleep", "ms": 2000 },
    { "action": "pageText" }
  ],
  "stopOnWarning": false,
  "tabId": 12
}
tabId (optional) is the tab for commands that name none; see Container progress for how steps pick their tab.

Parallel sequences (commands within each sequence run sequentially, sequences run concurrently):

{
  "commands": [{
    "action": "parallel",
    "sequences": [
      [{ "action": "navigate", "tabId": 1, "url": "https://a.com" }, { "action": "pageText", "tabId": 1 }],
      [{ "action": "navigate", "tabId": 2, "url": "https://b.com" }, { "action": "pageText", "tabId": 2 }]
    ]
  }]
}
Note: every command that acts on a tab inside parallel sequences must have an explicit tabId, and two sequences may not write the same tab (see Container progress).

Returns { "results": [...], "progress": [...], "status": "ok", "summary": "..." }; progress, status and summary are described in Container progress. When newTab commands, nested ones included, opened tabs that a later closeTab command did not close, newTabs lists their ids, oldest first; the MCP server makes the last one its current tab (No current tab).

A response whose status is not ok adds call and resume_token for POST /api/resume.

Batch actions: navigate, newTab, closeTab, switchTab, click, type, fill, scroll, hover, find, js, pageInfo, pageText, pageTextByTabId, tabs, forms, dom, highlight, screenshot, waitForElement, waitForResult, sleep, parallel, media (see Media).

POST /api/resume

Continue a batch, parallel or workflow call that did not finish ok (browser_resume). A direct call's checkpoint is in the MCP server, so this route answers checkpoint_not_found for its call.

{ "call": 42, "resume_token": "x3J...", "observed": { "1": "not_applied", "0.1.2": "applied" } }
Returns the original call's response shape; see Checkpoint and resume.

POST /api/ask-user

The needs_user stop of browser_ask_user (#376); nothing reaches the page. tabId is optional.

{ "reason": "sign_in", "message": "Sign in to your shop account, please.", "tabId": 12 }
Returns needs_user with state.reason: "agent_request", state.detail (the reason), state.message, the tab's tabId, url and title, state.evidence when the tab shows that hint, and call; see browser_ask_user. Asking the user is the MCP server's.

POST /api/needs-user

The needs_user hint a tab shows now, which browser_resume polls (#376).

{ "tabId": 12 }
Returns { "ok": true, "tabId": 12, "needs_user": { "reason": "sign_in", … } }, with needs_user: null when the tab shows none.

Workflows (NEW)

GET /api/workflows

List workflow files bundled in the kitsune/workflows package directory.

{ "workflows": ["gmail-template-draft", "test", "wp-html"], "directory": ".../site-packages/kitsune/workflows" }

POST /api/workflow

Execute a named workflow from the workflows directory.

{ "name": "test", "variables": { "url": "https://example.com" }, "tabId": 12 }
tabId (optional) is the tab for steps that name none. Workflows are .md files with JSON code blocks. Variables use {{name}} syntax. Returns { "workflow": "test", "steps": 4, "results": [...], "progress": [...], "status": "ok", "summary": "..." }, plus newTabs as for POST /api/batch. A workflow stops at its first failed step; progress marks the rest skipped (see Container progress). A workflow that did not finish ok adds call and resume_token for POST /api/resume; resuming is refused if the workflow file changed.

History and bookmarks (POST)

POST /api/history-search {query, limit?, since?} (since in epoch milliseconds), POST /api/history-visits {url}, and POST /api/bookmarks-search {query, limit?} back the history tools; see History and bookmarks. The bridge adds the policy scope.

Cache Control (NEW)

POST /api/cache

Control the response cache (pageText is cached for 5s by default; tab inventories are always read fresh).

Clear cache: { "action": "clear" } Check status: { "action": "status" } → { "entries": 3, "ttl": 5 } Set TTL: { "action": "ttl", "seconds": 10 } — seconds must be a non-negative number; anything else answers HTTP 400 and keeps the current TTL.

Error Handling

All endpoints return { "error": "message" } on failure; classified failures add code, retryable, hint, and state (see Action errors). The matching MCP tools mark these results isError: true (see MCP error results). Common errors: - "Kitsune extension not connected. Open Firefox or Zen and make sure the Kitsune extension is loaded." (extension_disconnected) — extension not loaded or browser closed - "Extension disconnected" (extension_disconnected) — extension dropped mid-command (fails fast, no timeout wait) - "No active tab found" (browser_window_unavailable) — no normal browser window the extension can use - "Command timed out after Ns" (timeout) — extension didn't respond in time - "Cannot access browser internal page: about:..." (restricted_page) — can't inject into restricted pages