Python API¶
The kitsune package drives the browser you already have open from Python, through
the same extension and bridge as the MCP server. The two have different jobs:
- The MCP server gives an agent a small, curated tool surface.
KITSUNE_CAPS,KITSUNE_PUBLISHandKITSUNE_MODElimit what it sees and what the bridge accepts from it. - The Python API reaches every capability of the extension and bridge, for
scripts and for your own agent frameworks. Its requests carry no capability
header, so every group is available,
storageandhistoryincluded.
Both are clients of one shared bridge, and the bridge's shared-browser rules hold for both: tabs open in the background, writes wait while you are using a tab, your own tabs close only with your approval, and sensitive fields are redacted. Ask-before-act, when it is on, holds the same actions.
pip install kitsune-browser # or: uv add kitsune-browser
kitsune-browser is the code; the MCP server's package, kitsune-browser-mcp, installs it
with the mcp extra. Keep a script on the version your MCP clients run, since they share
one bridge (Connecting). It needs the Kitsune extension installed in
Firefox, Zen or LibreWolf (Installation).
import kitsune
browser = kitsune.connect() # starts the bridge if none runs
tab = browser.new_tab("https://news.ycombinator.com") # a background tab you don't see
for link in tab.query(".titleline > a", fields=["text", "href"], limit=3):
print(link["text"], link["href"])
tab.close()
Connecting¶
kitsune.connect() starts the shared bridge if none runs, waits for the extension
to reconnect to a bridge it started, and raises ConnectionFailed if the extension
is not connected. It returns a Browser.
| Argument | Default | Effect |
|---|---|---|
mode |
"act" |
"read" or "draft" restricts requests as KITSUNE_MODE does for MCP |
user_tabs |
unset (read-only) |
writable allows this client to write user tabs. See User-tab setting |
confirm |
None |
A function that decides on actions ask-before-act holds; see Ask-before-act |
replace_bridge |
False |
True replaces a running bridge of another build |
KITSUNE_HTTP_PORT, KITSUNE_TOKEN and KITSUNE_HOME choose the bridge, as for the
MCP server (Configuration).
A script and your MCP clients share the bridge, so they should run the same Kitsune
version from the same source. A bridge of another build raises ConnectionFailed
with code bridge_version_mismatch and keeps running. The MCP server would replace
it, but replacing a bridge drops its clients' keep-alives, intercepts and resume
checkpoints, so the Python API does that only with replace_bridge=True (or after
kitsune-bridge stop).
Tabs¶
Browser method |
Returns |
|---|---|
tabs() |
Every open tab: id, url, title, active, windowId, index, owned (this client owns it) |
new_tab(url="about:blank", wait=True, timeout=15000) |
A Tab for a new background tab. A load past timeout ms raises TimedOut; the tab stays open, with its id in error.result["tabId"] |
tab(tab_id) |
A Tab for an existing tab |
active_tab() |
The tab you are viewing. With several windows, each has one: it raises ambiguous_target, and you pick one from tabs() |
A Tab sends its id with every request, so it acts on that tab whether or not you are
viewing it.
Tab method |
Does |
|---|---|
close(force=False) |
Close this client's tab; user and other clients' tabs are refused with user_tab_read_only by default. force disarms beforeunload |
show() |
Switch your view to the tab; call it only when you want that |
wake() |
Reload a tab the browser discarded and wait until the page answers |
duplicate() |
A Tab for a background copy |
pin(pinned=True), mute(muted=True), zoom(), set_zoom(factor) |
Tab state |
move(window_id=None, index=-1), detach(), resize_window(width, height) |
Place the tab, give it a window of its own, or resize its window |
tag(name) |
Name the tab; browser.tags.tab(name) finds it, .list() and .remove(name) manage names |
browser.windows has list(), create(url=None, incognito=False), close(window_id)
and focus(window_id), which takes your focus. Closing or focusing a window with user
or other clients' tabs is refused by default. browser.pool keeps reusable background tabs: create(size, url),
acquire() for a Tab, release(tab).
Reading¶
Tab method |
Returns |
|---|---|
text() |
The visible text, as str |
snapshot(target=None, max_depth=None, text=False) |
The accessibility-style tree with element refs (e12), as str (Snapshots) |
markdown(target=None) |
The page, or one subtree, as Markdown |
html(target) |
One element's outerHTML ("html" for the whole page) |
query(selector, fields=None, limit=50) |
Fields of every element matching a CSS selector, as a list |
find(query) |
Elements matching a description such as "login button", best first, each with a ref |
forms() |
Every form field with its label and value |
links(internal_only=False) |
The page's links |
info() |
URL, title, readyState, sizes, scroll position and recent dialogs |
screenshot(path=None, target=None, full_page=False, format="png") |
PNG or JPEG bytes, also written to path. No capture shows the tab |
evaluate(function, target=None) |
The JSON value a JavaScript function returns, for example evaluate("() => document.title") or evaluate("el => el.value", "e3"). Needs the developer extension build |
dom(target=None, max_depth=None) |
The accessibility tree of interactive elements, as nested dicts |
images(), iframes() |
The page's images and frames, as lists |
meta(), structured_data(), readability() |
Meta tags and <link>s; JSON-LD, Open Graph and Twitter cards; the main article |
Sensitive values read redacted, as for the MCP tools
(Sensitive-field redaction). When the
bridge cuts a read at its size cap (page text at 20,000 characters, HTML at 100 KB,
Markdown at 80 KB), the method warns with a UserWarning; read a part of the page
with a target instead.
evaluate runs in the extension's content-script world, not in the page's: it sees the
page's DOM, and reaches the page's own JavaScript globals only through
window.wrappedJSObject, so evaluate("() => window.wrappedJSObject.appState").
Inspecting¶
Tab method |
Returns |
|---|---|
bounds(target) |
An element's box, the viewport and page sizes, and inView |
computed_style(target, properties=None) |
Computed CSS, a common set of properties by default |
explain_selector(selector) |
How many elements a CSS selector matches, samples, and more specific suggestions |
full_page_metrics() |
Document size, viewport, scroll position and device pixel ratio |
highlight(target), clear_highlight() |
Outline an element on the page |
watch_console(enabled=True), console_logs(since=None, level=None) |
Record the page's console, then read the messages |
network.start(), .requests(since=0), .stop(), .clear(), .resources() |
The page's requests from its resource timing (no headers or bodies); resources() needs no start() |
browser.intercept blocks or redirects requests in every tab: add(patterns, effect="block")
with URL regexes, rules(), log() and clear().
Acting¶
Targets are a ref from snapshot() or find(), CSS, or one of the
Playwright-style selectors such as
text=Save or role=button[name="Save"]. A write's selector must match one
element: otherwise it raises ambiguous_target and nothing runs, and first=True
acts on the first match (Strict selectors).
Tab method |
Does |
|---|---|
navigate(url), back(), forward(), reload(bypass_cache=False) |
Navigate the tab |
click(target, button=None, modifiers=None, hold_ms=0), double_click(target), hover(target, duration_ms=0, steps=None) |
Pointer events (synthetic) on the target; hold_ms (0-10000) holds a click's press for long-press controls, and duration_ms (0-10000) or steps (1-50) keep a hover moving across the target for hover-intent controls (POST /api/click, POST /api/hover) |
fill(target, value) |
Replace a field's or editor's value |
type(target, text, clear=False) |
Type with key events |
fill_form(fields) |
Fill several fields; each key is a CSS selector, name, label or placeholder |
press(key, target=None, modifiers=None, text=None) |
Press "Enter", "ArrowDown", "a", ... with modifiers such as {"ctrl": True} |
select_option(target, value, by_text=False) |
Choose an <option> |
check(target, checked=True), uncheck(target) |
Set a checkbox or radio button |
scroll(direction="down", amount=500, target=None) |
Scroll the target or the window |
submit_form(target=None) |
Submit a form |
handle_dialogs(accept=True, prompt_text=None) |
Answer this tab's future dialogs; None leaves them to you again |
focus(target), blur(target=None) |
Move focus |
drag(source, destination) |
HTML5 drag events, else pointer events (via says which) |
insert(target, text, html=False, clear=False) |
Insert text into an editor, input or textarea, appending unless clear; html=True (developer build) inserts HTML into an editor |
upload_files(paths, target=None), drop(target, paths=None, data=None) |
Put local files into a file input, or drop files and data on an element |
trusted_click(target=None, x=0, y=0, coordinate_space="viewport", restore_pointer=True, button="left", click_count=1, modifiers=None, hold_ms=0) |
A click with the real OS pointer, for pages that ignore synthetic clicks; button="right"/"middle", click_count=2, modifiers=["Shift", ...] and hold_ms (a long press) choose the click. It moves your cursor for the click, then back unless restore_pointer=False, and may show the tab for the click (browser_trusted_click) |
Actions return the bridge's result. ok: True only means the action was dispatched.
To check what happened, pass expect, for example expect={"url_changed": True} or
expect={"text": "Saved"}; an unmet outcome raises KitsuneError with code
outcome_not_observed (Action outcomes). Pointer
and typing actions also take wait=True to wait up to timeout ms for their target
first (Actionability wait). Tabs an action opened
are in its opened_tabs; open them with browser.tab(id). A menu, listbox or dialog that
click, double_click or hover opened is in its opened_popup
(Opened popups).
Waiting¶
tab.wait_for(...) waits up to timeout ms (default 10000) for exactly one
condition and raises TimedOut if it does not hold:
| Condition | Holds when |
|---|---|
target= |
the element is visible |
text=, text_gone= |
the rendered page text contains, or no longer contains, the literal text. Names from placeholders and aria-labels are not page text |
url=, title= |
the tab's URL or title matches a JavaScript regex; survives navigation |
js= |
an expression returns a non-empty result (developer build only) |
network_idle=True |
no new resource loaded for 500 ms |
Batches, parallel tabs and workflows¶
browser.batch(commands, tab=None) runs batch commands
in one request, browser.parallel(sequences) runs one sequence per tab concurrently,
and browser.workflow(name, variables) runs a named workflow (browser.workflows()
lists them). A failed command does not raise: each one's outcome is in results and
progress (Container progress). A call that
did not finish ok returns call and resume_token for browser.resume(...)
(Checkpoint and resume). browser.retry(command, max_attempts=3)
repeats one command while it fails retryably.
For several tabs, browser.keep_alive(tabs) stops the browser's tab unloader from
discarding them until stop_keep_alive(), browser.broadcast(tabs, command) runs one
command in each, and browser.sync_barrier(tabs, predicate) waits until a JavaScript
predicate holds in all of them.
Browser data¶
| Method | Returns |
|---|---|
tab.local_storage, tab.session_storage: .get(key), .set(key, value), .remove(key), .clear(), .items() |
The page's storage |
browser.cookies.get(url=None, name=None, domain=None) |
Matching cookies |
browser.cookies.set(url, name, value, domain=None, path=None, secure=False), .remove(url, name), .clear(url=None, domain=None) |
The bridge's result |
browser.clipboard.read(), .write(text) |
The system clipboard |
browser.downloads.start(url, filename=None), .list(query=None), .cancel(download_id) |
A download id, the latest downloads |
browser.sessions.save(name, urls=None), .load(name), .list(), .delete(name) |
Cookies, and the localStorage of urls' origins, saved to disk by the bridge |
browser.clear_browsing_data(types, since=None) |
Remove the named kinds of data, such as ["cache"]. types is required: without it the extension would also remove cookies, history and saved passwords |
browser.history.search(query="", since=None, limit=20) |
Visited pages, newest first; since is a datetime (naive is local time) |
browser.history.visits(url) |
The visits to one URL from search |
browser.bookmarks.search(query="", limit=20) |
Matching bookmarks |
With ask-before-act on, page storage, cookies, the clipboard and downloads raise
ConfirmationRequired for every operation, reads included
(Ask-before-act).
History and bookmarks need a browser permission that only you can grant, once, with
Allow history and bookmarks on Kitsune's options page (about:addons → Kitsune →
Options); until then they raise permission_required. Results stay inside the
bridge's history scope (History and bookmarks).
Errors¶
A failed result raises a KitsuneError. Its code, retryable, hint and state
are the action error fields, and result is the
whole reply. Branch on code, not on the message. retryable=False means a write may
already have taken effect: check the page before repeating it. needs_user is the
exception: nothing ran, and only you can let it go on.
| Exception | Raised for |
|---|---|
ConfirmationRequired |
An action that needs your approval (needs_user with state.reason approval, and a token), with token, expires_in, message and detail: confirmation_required (ask-before-act, with hold_reason), or user_tab (closing a user tab, from a v0.7.1 extension) |
ConnectionFailed (also a ConnectionError) |
The bridge or extension was not reached or dropped the request: the connection codes, bridge_disconnected and bridge_version_mismatch |
TimedOut (also a TimeoutError) |
timeout, a page load that timed out, and a wait that ran out |
KitsuneError |
Every other failure, such as stale_ref, ambiguous_target or outcome_not_observed |
Warnings that accompany a success (warning) do not raise.
Until #376 these approvals had their own codes, confirmation_required and user_tab.
They are now code needs_user, with the old code in state.detail (error.detail), and
an ask-before-act hold's reason in state.holdReason (error.hold_reason, before:
state.reason). ConfirmationRequired is raised for both
shapes. A needs_user without a token, as in read and draft modes
(state.approvable: false), raises a plain KitsuneError: nothing can approve it there.
Ask-before-act¶
Some actions need your approval, and nothing runs until you give it: with
ask-before-act on, the bridge holds submits, payment
pages, JavaScript and cookie access (detail confirmation_required). User-tab writes
instead fail immediately with user_tab_read_only; approval tokens do not override tab
permissions. Actions needing approval raise
ConfirmationRequired; error.message is a short request to show you. Repeat the call
with confirm=error.token within
error.expires_in seconds, or let connect(confirm=...) decide: the function gets the
error, and when it returns True the action is sent once more with its token. In a
script, that function can ask you, so the approval comes from a person. The Python API has
no MCP elicitation, so it always gets the token, and the bridge's
audit log records these approvals with approval: "token":
def ask(held):
return input(f"{held} -- allow? [y/N] ").strip().lower() == "y"
browser = kitsune.connect(confirm=ask)
The bridge¶
Browser method |
Returns |
|---|---|
status(), version(), health() |
Connection state with its diagnosis, versions, and the bridge's own state |
logs(limit=200), audit(limit=100, since=None) |
The bridge's log lines; every client's commands from its audit log |
scheduler() |
Command scopes, pending commands and tab locks |
policy.get(), policy.set(allow=None, deny=None, readonly=None, ask=None, ask_urls=None, ...) |
The bridge's policy, for every client; a locked ask-before-act stays on |
cache(action="status", seconds=None), reload_extension() |
The read cache; reload the extension |
Any other route¶
browser.request(route, body=None, timeout=15) sends any
bridge route: GET without a body, POST with
one. It raises the same errors and applies confirm. Tab routes take tabId in the
body, for example browser.request("/api/full-page-metrics", {"tabId": tab.id}).