lyra-browser
OfficialThis MCP server lets an AI agent drive a shared, user-visible Chromium browser with gated actions, an audit trail, and human takeover/handoff.
Open/close the browser, navigate, go back, reload, manage tabs, and wait for page or URL conditions.
Interact with pages: click, type, press keys, hover, scroll, and answer native dialogs.
Fill real forms: inspect fields/options, select options, write rich-text editors, upload files, read drafts, save private drafts, and publish only through an explicit publish action.
Read and inspect pages: get URL/title, read rendered text or an actionable accessibility tree, take screenshots, capture element images, and list saved downloads.
Collaborate with the user: highlight elements, ask the user to perform sensitive steps (credentials, CAPTCHA, 2FA, payments), request takeover, and resume agent control afterward.
Enforce safety/permissions: risky navigations, form submissions, uploads, downloads, and publishing require approvals; actions are auditable and some tools fail safely when unattended/headless.
Provides browser automation for a visible Chrome/Chromium window, letting AI agents navigate pages, read rendered text and accessibility trees, click and type, fill forms, capture screenshots, manage tabs/downloads, and hand control to the user when needed.
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@lyra-browser open my Gmail and hand over when it asks for my password"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
lyra
A headful, collaborative browser MCP server for AI agents. The agent drives a single visible Chromium window that the user can watch and take over at any time — co-browsing, not headless scraping.
The same server runs headless when nobody sits at a window — for example under Hermes, where the user is reached through chat (see docs/HERMES_INTEGRATION.md), or as a remote server (see docs/CLOUDFLARE.md).
Names. The project is lyra; the repository is
lyra-browser. The command (lyra-browser), the Python package (lyra_browser) and the environment variables (LYRA_BROWSER_*) keep thelyra-browsernames for now. The UAT runner islyra-uat.
Built on Playwright (headful, persistent profile) and exposed over FastMCP, so any MCP client can use it with a single entry in its configuration.
Why
An agent that is local-first and model-agnostic needs a browser the user shares: the agent reads the page, clicks, and types, while the user can step in for the things an agent must not do alone (passwords, CAPTCHAs, 2FA, payments) and grab full control whenever they want. Every action is written to an append-only audit trail.
Related MCP server: duplex
Architecture
agent loop ──(MCP: stdio/http)──> lyra-browser server ──> Playwright ──> visible Chromium window
│ ▲
├─ audit.jsonl (every action) │ user watches / takes over
└─ approval + takeover gating ─────────┘Standalone MCP server — registered in the MCP client's configuration; the client needs no code change.
Python, no browser download — the only required pin is
fastmcp>=3.2. The default driver talks to the Chrome or Edge already installed; Playwright is an extra (lyra-browser[playwright]) for the drivers that use it.Dedicated headful window —
launch_persistent_context(headless=False)with a persistent profile, so logins survive across sessions. One server at a time holds that profile (anflockon<data_dir>/profile.lock, taken when the browser opens, not when the server starts). A second server on the same data dir opens a private, empty profile at<data_dir>-instances/<pid>/profileinstead of failing, andopen_browsersays so (profile: "instance", "no saved logins in this instance").No setup tax — reuses the user's installed Chrome/Edge by default (
channel="chrome"→"msedge"→ bundled Chromium), soplaywright installis not needed when either is present — and neither is Playwright itself. If no browser is found, tools return abrowser_unavailableenvelope that the client can turn into an install prompt.Full preset — human-in-the-loop (highlight, ask-the-user, approval gate), takeover/handoff, bot-check detection, audit trail, CI + lint + pre-commit.
Tools
Group | Tools |
Navigation |
|
Tabs |
|
Waiting |
|
Interaction |
|
Downloads |
|
Forms |
|
Reading |
|
DevTools |
|
Collaboration |
|
Filling a real form
click and type_text cover what every page has, but a form on a real site needs
more. Each of these was measured on KVR's developer forms before it was
written:
read_form— a form'sname,type,labeland<select>options.read_pagereturnsinnerText, so a 188-field product form arrives as a wall of labels with nothing to address. Hidden inputs are counted, not dumped. Every field carriesusable— false means it is in the DOM but hidden or disabled right now, which is how a form keeps a section it has not revealed (measured on KVR:event_countrysits in the Deal/Offer block). Checkusableinstead of finding out through a 30s locator timeout.select_option— a<select>is not a click target. Chooses byvalue,labelorindex, and fireschangethe way a page listens for it. Passsubmits=truewhen the choice posts. Answershiddenordisabledby name when the control cannot be used as it stands.set_editor— CKEditor/TinyMCE keep the editable body in aniframeand leave the<textarea>the form posts hidden and empty, sotype_texton that textarea does nothing.html=truewrites markup and syncs the editor's own data. Leaveselectorempty to use the first editor on the page.upload_file— attaches local files to aninput[type=file]. A styled picker usually keeps it hidden, which is fine. Missing paths are reported before anything is attached.save_draft— sends the form while keeping the item private. It names the control that sends (submit, fromread_form'ssubmitText), first confirms the publish control is still on its private setting and refuses otherwise, and asks forSUBMITbut neverPUBLISH— so it cannot release an item, and a page left armed by anything else will not slip out through it.read_draft— everything a human needs to approve an item, in one answer: the filled fields, each editor's real content (a separate documentread_pagenever shows), the attachment slots, the submit control, and whether the item is a draft or already public. A read, so it needs no approval.publish— the one action that sends an item to an audience. Kept apart from every other tool so that writing a draft can never release it: it asks forPUBLISH, which nothing implies, and it is the only tool that does. A site splits the act across two controls and means neither alone — measured on KVR, the publish radio only arms the form and the item travels on the form's own Submit — so it takes bothselector(the live control) andsubmit(the one that sends). Giving only the first is refused rather than left half-done: arming a form with no way to send it would leave the page primed to publish on someone else's next click.
Drafts and release
Writing an item and releasing it are different acts, and the tool surface keeps
them apart. Every writing tool can be driven to completion with the item still
private; publish is the only way out, and it is PUBLISH-gated and single-use.
The intended flow is: fill the form → read_draft → show the human → only then,
on an explicit yes, publish.
On KVR this matches the page: a new item opens on Draft and stays there until
the publish radio is switched, so a form that is merely filled is not visible to
anyone. A form sent without the publish control set saves as a draft — which also
means every writing tool must stay away from Submit, since KVR has no separate
"save draft" button. That is why publish sends the form itself rather than
leaving the send to click.
UPLOAD and PUBLISH are single-use: a file handed to a page, and content shown
to an audience, cannot be recalled.
Reading and acting on a page
Reading: read_page
read_page has two modes, both read-only (no approval, works during a takeover):
mode="text"(default) — the rendered text (innerText), not HTML.links=trueadds the visible links as{text, href}(absolute, de-duplicated, at most 100, withlinks_truncated).mode="tree"— one line per thing that can be acted on (links, buttons, inputs, selects, checkboxes, tabs, menu items), each with a ref:aria-ref=e12 link "Pricing" -> /pricing aria-ref=e15 button "Save" aria-ref=e16 checkbox "Remember me" [checked]Input values are never shown. The reply carries
elementsandrefs, plusin_viewport(on-screen elements come first) on Playwright 1.60+.
selector limits either mode to one region (first match; not_found at once
when nothing matches). offset and max_chars page through long output:
total_chars is the full length, truncated says more follows and next_offset
is where to continue. Tree pages break on whole lines, so a ref is never cut in
half. A reply that finds a bot check also carries challenge and a hint; see
Bot checks.
Addressing an element: aria-ref
read_page(mode="tree") → pass the token aria-ref=eN, exactly as written, as
the selector of click or type_text. A ref reaches what a CSS or text
selector cannot name: icon-only links, several links with the same text, elements
inside open shadow roots and iframes. A ref is a handle into the latest tree
read, not a query over the live page: a newer read (a region read included), a
navigation or a removed frame ends it, and so does the element being removed. A
stale ref answers not_found (or hidden for an element that is gone but still
counted) with a hint to read the tree again; refs get no waiting window, because
they cannot appear later.
The Playwright drivers need playwright>=1.59. Refs come from
aria_snapshot(mode="ai"), which 1.59 introduced; on 1.58 the tree still works but
answers refs: false and its lines carry no ref — address elements with
role=link[name="Pricing"] or text= selectors instead. The default native driver
builds the same tree from Chrome's accessibility tree and has no such floor.
When an action cannot be done: click / type_text
Both fail fast and explain, instead of holding the call for Playwright's 30s:
| Meaning |
| Nothing matches the selector, even after up to ~3s for a page that builds its controls late |
| The match is in the page but stayed invisible / disabled for that window (present-but-not-ready controls are re-checked until ready, so a button enabled 0.8s after load still works) |
| The action outran |
| Covered by another element (the hint names it), detached, read-only, not a text field, unstable or outside the viewport |
| The tab died under the call (a popup that closed itself, a closed window) |
Each carries selector, url and a hint with the next move. timeout_ms
(default 10000, at most 30000; 0 is raised to 1ms, never "no limit") bounds the
action itself. The usability check runs before permission is asked, so an
unusable control neither prompts nor leaves a single-use grant behind; the same
driver failures during the action are converted into these envelopes (and
audited) rather than raised. A successful click also reports matches (how
many elements the selector hit — the first is clicked) and clicked
(tag, role, name of what was hit), with a hint when matches > 1.
What an action set off: blocked_by_policy, new_tab
click, type_text, press_key and hover answer when the driver reports the action
done, and two things can still be true of that moment:
The guard refused a navigation the action caused (a link to a site no approval covers, a form sent without
submits=true, a page that redirected itself). The refusal leaves the tab where it was, so the reply used to readokwith the sameurland the agent had no idea why nothing happened — the audit trail was the only account. It is nowblocked_by_policywithurl(where the tab stands), ahintand, for a refused redirect,redirected_to; the fields the reply already had (matches,clicked,dialogs) are kept, and the audit row of the action carries the same status. The hint is the way out:navigateto the destination (that asks the user), or repeat the action withsubmits=true(submit=trueontype_text) when it was meant to send a form. A file the call saved staysokwith itsdownload, and a download the browser started and the call cancelled keepsdownload_blocked. Inobservemode nothing is refused, so the reply staysokand the audit sayswould_deny.The action opened a tab (
target=_blank,window.open). The session follows onto it whileurlis still the page the action was made on, so the reply carriesnew_tab: trueandtab_count, and a hint to usetabs.get_urlandopen_browsercarrytab_counttoo. A popup whose first navigation the guard refused opens no tab at all, and the reply isblocked_by_policyinstead.
Neither costs a click a wait of its own. The driver holds a click until the navigation it
started has been judged, so that verdict is in before the call returns (0.5 to 48 ms
before, measured over playwright and patchright, both guard backends, headless and
headful). What it does not hold for lands 3 to 22 ms after — a popup's tab, a form posted
from a frame, a page's own setTimeout(0) — inside the download_settle_s window every
click and key press already listens for. hover and type_text have no such window, so
they linger up to 40 ms after the driver returns, and only while nothing has shown (their
navigation reaches the guard 1 to 8 ms after they return); a page timer that fires after
that is only on the audit trail. Numbers in scripts/verify_click_refusal_e2e.py
(INFO lines).
Waiting: wait_for
wait_for holds until the page is ready instead of a sleep — or a click used to
pass time. Give exactly one condition: text (in the visible text read_page
shows; state="hidden" waits for it to vanish), selector (reaches state:
visible, hidden, attached, detached), url (a glob over the whole URL,
e.g. **/checkout**) or load_state (load, domcontentloaded,
networkidle). It waits up to timeout_ms (default 10000, capped at 30000) and
returns ok with waited_ms. Running out of time is an answer, not an error:
{"status": "timeout", "waited_ms", "last_seen"} with a short page excerpt (the
URL for a url wait). Bad arguments return error. It only observes: no
approval, and it keeps working while the user holds a takeover.
Tabs: tabs
The session follows onto any tab a page opens (target=_blank, window.open),
so the agent is never left reading the page that opened it. A click or
press_key that opened one says new_tab: true and tab_count, and get_url and
open_browser carry tab_count. tabs covers the rest:
action="list"(default) —tabs: [{index, url, title, active}]plustab_countandactive_index. A read; a tab stuck in a script loop is listed without a title rather than hanging the list.action="switch",index=N— make that tab the one to read and act on, and bring it to the front so the user sees what the agent sees.action="close",index=N— close it. Closing the active tab returns to the tab that opened it, else the newest one; closing the last tab leaves a blank one (closing headful Chrome's last window would quit the browser).
Indexes shift whenever a tab opens or closes, so list again before using one; a
bad index answers not_found with tab_count. Switching and closing are
mutations: they stand down for a takeover, are audited, and ask for INTERACT
on the tab's own site, not the active page's — being approved for one site is
no licence to read or close another's. A tab that closes itself (a sign-in popup)
sends the session back to its opener; if the active page is ever closed, the next
tool moves onto the opener, else the newest tab, else a fresh blank one, instead
of failing on a dead page.
What navigate reports
ok means the navigation happened, not that the page is good. navigate,
go_back and reload_page report http_status (404, 500 …) and content_type
(media type only, lower-cased, e.g. application/pdf) of the document that
answered; both are null when no HTTP response was involved (about:blank,
data:, a page the browser restored from history). wait_until chooses how much
of the load to wait for: domcontentloaded (default), load, commit or
networkidle (which never settles on a page that keeps polling); anything else is
an error before any prompt. A page that draws itself after loading needs
wait_for. A URL that turns out to be a file download does not move the tab: see
Downloads for download=true and download_blocked. Only when the
driver announced a download the browser never reported does it answer
{"status": "download_started", "url", "requested_url", "hint"}; this server then wrote
no file to the download dir. If the page that answered is a bot check, navigate
answers status: "challenge"; see Bot checks.
Bot checks: challenge
A page that asks "are you a robot" is not a failure of the tool, and it is not a page to get past. It is a page a person has to answer. lyra names the check and stops:
navigateanswersstatus: "challenge"when the page it landed on is a bot check, andread_pageadds achallengefield (the text is still returned, so the agent can see what it is).The reply carries
challenge: {vendor, kind, evidence}.kindisinteractive(a person has to solve it: a checkbox, a puzzle, press-and-hold) orblocked(refused outright, e.g. an HTTP 403 "Access Denied").vendoriscloudflare,datadome,recaptcha,hcaptcha,perimeterx, orunidentifiedwhen the text names a check without a known host.The
hintdepends on who is watching. With a person at the window it says to ask them (ask_user_to_do, thenread_pageto see whether it cleared). With nobody there it says not to wait and not to look for another way past, to report the task as blocked (finishwithoutcome=blocked) and carry on with what remains.
Nothing solves, waits out or retries a check. Detection reads the frame hosts, the page
title and the first 3000 characters of visible text; an article that quotes a check's
wording further down is not named. A detection in navigate is audited as
challenge_seen; read_page only reports it in its reply.
Hover and scroll
hover— move the pointer over an element, for menus and tooltips that open only while it is on their trigger:hover, thenclickthe item that appears. Nothing is pressed, so no form is sent. Fails fast likeclick(not_found,hidden,disabled,timeout,element_not_actionable,page_closed,timeout_ms) and reportsmatchesandhovered(tag,role,name). NeedsINTERACT.scroll— give exactly one ofto(top/bottom),by_y(pixels, negative up, at most 20000 per call, done with the mouse wheel) orselector(bring it into view). The reply isscroll_y,scroll_heightandat_bottomonce the position has settled. It moves the window only: a feed or panel that scrolls inside its own container needsscroll(selector=...)on an element inside it, and a scroll that moved nothing says so. Lazy lists grow after a scroll, so repeatscroll(to='bottom')untilat_bottomis true.
Downloads
A file a page hands to the browser is kept only when the call declared it. Chromium accepts a download the moment a page offers one, and the navigation guard judges requests, not responses, so this is a separate gate on the file as it arrives:
download=trueonclick,press_keyornavigatedeclares it and buys a single-useDOWNLOADgrant (asked like any other, never implied by a trusted origin) inside that call. The grant pays for exactly one file and is retired when the call stops waiting. The reply carriesdownload:{filename, path, bytes, url_origin}.An undeclared download is cancelled and deleted before anything reaches the download dir, and the call answers
download_blocked(blocked_download:filename,url_origin) — repeat it withdownload=true. A declared call that produced no file answersdownload_not_started; one whose file was not kept answersdownload_failedwith areason(too_large,timeout,error).go_backandreload_pagecannot declare one; their hint points atnavigate. Chrome reports a reload or history step that turns into a download asnet::ERR_ABORTED(not "Download is starting"); an abort that a download follows within 2 s is answered as that download, and any other abort is still an error.With
LYRA_BROWSER_ENFORCEMENT=observe("record, do not block") nothing is cancelled either: an undeclared download is saved like a declared one — same name rules, caps and ledger — and answered asdownloadplusobserved: true, audited aswould_block. Observe never spends a grant (like the navigation guard), so it under-reports a second file in one call that enforce would have cancelled. A download aDOWNLOADgrant covered is auditedsavedand is not flagged.The site picks the file name, so it is treated as hostile: only the last path component survives, control and right-to-left-override characters, characters Windows forbids, leading dots and reserved device names are removed, long names are cut keeping their extension, and a taken name becomes
name (1).ext. Nothing is ever written outsideLYRA_BROWSER_DOWNLOAD_DIR.Size and time are capped (
LYRA_BROWSER_DOWNLOAD_MAX_BYTES,LYRA_BROWSER_DOWNLOAD_TIMEOUT), and the dir is pruned oldest-first toLYRA_BROWSER_DOWNLOAD_KEEPusing a ledger, so files that are not this server's are never deleted.While the user holds a takeover, or does a step
ask_user_to_doasked of them, the gate stands aside and their own downloads are saved, recorded asuser_driven, as with navigation.list_downloads— the files this session saved (filename,path,bytes,url_origin), oldest first, plusdownload_dir. A read: no approval, works during a takeover, does not open the browser. Blocked and failed downloads are not listed.
The listener is per tab (popups included), not BrowserContext's download
event: that only exists from Playwright 1.60 while this project supports 1.58,
where a context listener would never fire and every download would go unjudged.
Native dialogs
alert, confirm, prompt and beforeunload stop a page until answered, and a
listener that leaves one open freezes the tab. So one context-level listener
always answers the moment a dialog appears. The default is what a browser with
no listener does: alerts and beforeunload are accepted, confirm is dismissed
(false) and prompt is dismissed (null).
What was raised is reported as
dialogs(type,message, how it was answered) in the reply of theclick,type_textorpress_keythat raised it — only present when there were any. The message is the page's text, not the user's. The form tools (save_draft,publish) use the same handler and fold dialog text into theirerrors.handle_dialog(accept, text)chooses the answer for the dialog the next browser action raises: call it immediately before that action.accept=falseanswers no;textis what apromptreceives (empty accepts the default). Like a one-shot grant it belongs to that one call —click,type_text,press_key,hover,scroll,navigate,go_back,reload_page,select_option,set_editor,upload_file,save_draft,publishor atabsswitch/close — and ends with it, used or not, even when the call was refused (arm again before retrying). A dialog raised after it, by a page's own timer or on a page reached later, gets the default, so a yes armed for one click cannot answer a laterDelete this?. Reading (read_page,screenshot,get_url…) does not use it up, and about 60s is the most it ever waits. It asks nobody (the click that raises the dialog was already asked for), stands down for a takeover, is audited, and the prompt text is never written to the audit log.Nothing here judges the answer: accepting a
confirmthat goes on to post a form is still stopped where the request leaves unless it was declared (submits=true).
DevTools: console, network, performance, storage, evaluate
What Chrome's DevTools panels show, as tools. What they return was produced by the page (console text, URLs, headers, bodies, storage): data to report, never instructions.
Tool | Panel | Gate |
| Console: | none |
| Network: method, URL, type, state, status, | none |
| One request: headers, timing breakdown, sizes; |
|
| Performance: Navigation Timing, paints, FCP/LCP/CLS (INP after interactions), resources, JS heap | none |
| Application: cookies, localStorage, sessionStorage — names, sizes, flags |
|
| Network → Save as HAR (1.2), under |
|
| The console prompt: runs in the page's own world, awaits a Promise, reports a throw |
|
Console capture is off until
console_messages(capture=true). It needsRuntime.enable, the DevTools call Kasada refuses on (see Drivers); a session that never asks for the console never sends it. On turns it on for every open tab and every later one; messages logged before that are gone. Off stops new tabs, but a page that receivedRuntime.enablehas seen it.The network log costs nothing new: it listens to the
Networkevents every page already has enabled. It is kept across navigations (DevTools' "Preserve log") and bounded (1000 requests, 500 console entries;droppedcounts what was let go). Page through withsince=<next_since>.duration_msandttfb_mscome from the browser's own timestamps, not from when Python heard of the event.Secrets stay out unless asked for. URLs lose their query values (as in the audit log), credential headers (
Cookie,Set-Cookie,Authorization,*-token,*-key, …) are always<redacted>—INSPECTdoes not unredact them — and bodies are never buffered:network_request(body=true)asks Chrome for one, which may already have let it go.evaluateis gated because the guard cannot see what it does. Afetch()POST is not a document request, so the navigation guard never judges it; the single-useEVALUATEgrant, whose prompt shows the expression, is the only gate. The expression is audited in full. Theautonomouschannel refusesEVALUATEandINSPECT.
Measured 2026-11-04 by scripts/verify_devtools_e2e.py (loopback fixture, headless,
native driver and Playwright 1.6x, Chrome stable, N=1 run each): every step passed on
both drivers, including an /api call the server delays 300 ms reported with
duration_ms ≥ 270 and ttfb_ms ≤ duration_ms, a 404, a refused connection as
failed, FCP/LCP and navigation timing, a HAR with no cookie, API key or query value in
it, evaluate reaching window.appState (the page's world) and an approved fetch() POST
reaching the server.
Seeing the page
screenshot (the viewport or the full page) and read_image (one element —
an img, a canvas, an svg, a chart) write a PNG to disk and return its
path:
{"status": "ok", "image_path": "…/captures/page-20260917T123557Z-fd1a0721.png",
"mime_type": "image/png", "width": 1280, "height": 800, "bytes": 115889}The file is the handoff. Measured on the same client: a tool result is shown to the
model as its text blocks, so a base64 PNG inside the JSON arrives as tens of
thousands of tokens of prose and no picture. A path costs nothing, and a client that
can read the server's files attaches the file to the model's next turn as an image.
Captures go to <data_dir>/captures unless LYRA_BROWSER_CAPTURE_DIR says otherwise.
inline=true adds the base64 for a client with no access to this filesystem.
Captures are written atomically and pruned oldest-first
(LYRA_BROWSER_CAPTURE_KEEP, default 200), so an agent that looks every turn
does not fill the disk. The audit records the path and size, never the pixels.
read_image fetches nothing — it captures what the element renders, so no
request leaves the browser and nothing is gated.
Permissions
The agent declares what it intends; the browser is judged on what it actually
does. Those are separate layers because a tool cannot know what a page will do
with a click — onclick="form.submit()" submits from a control that looks inert,
an input listener submits while the agent only typed, and an element's type can
change between being checked and being clicked.
Grants are (origin × capability) and short-lived:
Capability | Meaning |
| Load a document from this origin. Implies |
| Click and type while staying on this origin |
| Send a non-idempotent request. Single-use |
| Give a file to a page (a picker, a drop target). Single-use — requested by |
| Make content publicly visible — an item an audience can see. Single-use — requested by |
| Read what the person's page holds — response bodies, cookie and storage values, full URLs. Leasable per origin, implied by nothing — requested by |
| Run caller-written script in the page. Single-use, implied by nothing — requested by |
| Write a file to disk. Single-use — requested by |
Arriving on a site carries permission to use it, so ordinary clicking is not
re-asked. Sending a form and leaving for another site are separate decisions and
are asked — declare them with submits=true (submit=true on type_text).
Enforcement watches outgoing navigations at the request, so it catches a
submission or an escape whatever produced it. A refusal fails the request as
net::ERR_ABORTED, the one network error Chrome never turns into an error page, so the
page stays standing and the agent can recover. (It used to answer with HTTP 204, which
Chrome also ends as ERR_ABORTED; Playwright 1.64 reads a 204 document as a finished
navigation and then waits on it until every later action times out — measured
2026-11-01, playwright 1.64.1 vs 1.63.0, Chrome 154.)
URLs with no host — file:, data:, javascript: — are never "the same site"
and are asked every time. Where the guard stands — Playwright's route, or a second
DevTools connection that also sees redirect hops — is a choice, see
Guard backends.
Where the line falls, measured rather than asserted:
A same-origin GET navigation is interaction, not a submission. A search box puts what you typed in the query string; gating that would gate every search box and every link with parameters.
SUBMITmeans a body or a mutating method.A submission is authorised by the origin that sends it, not by wherever the form points. Cross-origin form actions are ordinary — payment handlers, SSO, third-party endpoints — and no tool can read a form's
actionbefore the click without the TOCTOU this layer exists to avoid. The destination is recorded in the audit under the labelcross_origin_send— an audit key, not a capability, and never purchased.A page that moves itself to another host is leaving, and is asked. A site bouncing to its own subdomain lands here too; the guard cannot tell that from a hostile page escaping. The refusal is recoverable — the original document is still there and the audit names the destination — so the agent can read where it was being sent and ask for it.
navigate,go_backandreload_pageanswer it asblocked_by_policy, with theurlthe tab still stands on, about a second after the refusal — also when the page did it before it finished loading. The refusal commits nothing, so the driver would otherwise wait out its own 30 s timeout for a navigation that is gone (scripts/verify_scriptredirect_e2e.py; neverssl.com does exactly this).click,type_text,press_keyandhoveranswer it too when the navigation was theirs (see What an action set off); they used to sayok(scripts/verify_click_refusal_e2e.py).A redirect hop is judged like the navigation it leads to. A 301/302/303/307/308 to another origin is classified exactly as a first request there would be: leaving the site is
navigateand is asked, a 307/308 that repeats a POST body issubmit, and a hop that stays on the origin that sent it (or upgrades it fromhttptohttps) is not judged at all. The audit row carriesredirect_from, and an approved site that answers 302 to an unapproved one is refused like the same destination asked for directly:navigate,go_backandreload_pageanswerblocked_by_policyand the tab stays where it was. The agent asked for one URL and was turned away from another, so that answer also carriesredirected_to, the origin it was sent to (never the path or the query), for the agent to ask for. Playwright never shows a hop to a route handler, so the guard hears of it from therequestevent, after the request was sent, and cancels the load there (scripts/verify_redirect_hop_e2e.py; what that leaves open is listed below).
Verified against the shipped defaults: four undeclared POSTs (space key, JS
form.submit() from a type="button", an oninput listener, and an attribute
flipped after the check) are all stopped, while the declared submit goes through.
What this does not cover, stated rather than discovered later:
With the default
routebackend, an HTTP redirect hop is judged after it has left, not before. The request to the first hop that no grant covers is sent — with that site's cookies, and the body of a 307/308 POST — and only the rest of the chain is cut. A server that answers before the cancellation lands commits the page first, and loopback or a LAN, where the targets most worth refusing live, always does: the guard then replaces the tab withabout:blankso the agent does not read it, the audit recordsnot_stopped, and the call still answersblocked_by_policy. Seeing a hop before it leaves is not possible fromcontext.route: Playwright continues every hop itself, also one that follows aroute.fulfill(302), and answering each document from Node (route.fetch) to see the redirect first puts a Node TLS and HTTP/1.1 fingerprint on every page while still leaving the later hops unseen (measured).LYRA_BROWSER_GUARD=cdpjudges every hop before it leaves, so a refused hop's destination sees nothing; see Guard backends.A page can ask the browser to prefetch or prerender another page (
<script type="speculationrules">). Chrome sends those requests, and serves the navigation that later activates one, without an interception point: neither backend is shown the request, nor the click that follows (measured, Chrome 154: a prefetched cross-site link was followed with the guard seeing nothing, and the cross-site prefetch itself reached its server). It is a GET, so it is the same channel as<img src="https://elsewhere/?data">; what it adds is a navigation nobody judged.With
route, a navigation that a service worker answers is never shown to the guard either. Playwright'sservice_workers="block"is an init script that a page defeats by calling the prototype'sregister, and a worker already in the profile is untouched by it (measured: an origin's pre-registered worker served that origin's page to a link click with no judgement). Thecdpbackend turns workers off for every page it guards.A single-page app does its damage over
fetch(), which is not a navigation and is not judged. Deleting mail in a webmail client is an XHR, not a form post.An approved page choosing where to send its form. Judging a submission on its sender is what makes payments and SSO work, and it is also an exfiltration channel: the audit records it (under the key
cross_origin_send, which is only a label on the audit row, not a capability), it does not stop it.A single-use scope is handed back about two seconds after the action that bought it, not instantly, because a click's navigation can leave just after the call returns. A page that submits inside that window rides an approval meant for the click. Two seconds rather than the ten minutes it used to be (
LYRA_BROWSER_RELEASE_GRACE).The approval for a
file:URL names that URL and covers only it, once — everyfile:URL is otherwise the same origin, which would make one yes a yes to the whole disk.The content of a download. It is gated, not inspected (see Downloads): an approved download is bytes from that site, written as-is under a sanitised name.
Enforcement narrows what a compromised turn can reach; it does not make an approved origin safe.
One browser, one session. Reading checks who is driving; only a mutating
tool becomes the driver, so a passive client cannot take the browser by asking
for a screenshot. Reading still counts as using it, so a holder part-way
through a read-only pass is not idle and does not lose the window. Grants are kept per session, but every session drives the
same window, and the route handler has no way back to a request
context — so whichever session called a tool last would decide how the next
request is judged, and one client's single-use approval would be spent by
another's traffic. Rather than offer an isolation it cannot deliver, the server
serves one session at a time and answers the rest with session_conflict.
Ownership lasts as long as the window: close_browser ends a turn, drops the
grants, and lets the next session claim it. A holder that goes quiet for
LYRA_BROWSER_OWNER_IDLE_TIMEOUT (default 900s) also hands it on — closing is
owner-only, so a client that drops with its window open would otherwise lock the
server for the life of the process. The handoff closes the window: passing on
a running browser would leave the previous holder's document loaded and still
able to start navigations, which the guard would then judge, and charge, against
whoever now holds the session.
Who is answering. auto asks the user through MCP elicitation, where the
model cannot forge the answer. Only a client that cannot be asked at all — no
elicitation handler, no live request — falls back to honouring confirm=true,
and the audit records consent_channel=legacy with the reason. A decline, a
cancel and a timeout are answers: the model asserting confirm=true never
overrides one. A client that does not pass an elicitation handler takes that
fallback; wiring one is what turns the gate from a convention into a boundary, and
needs no change here. Hermes does pass one: its approval prompt answers, and its
value-less accept is read as the yes it is.
Autonomous runs. When nobody is there to answer, LYRA_BROWSER_CONSENT_CHANNEL=autonomous
(or target.policy: autonomous in a UAT run) approves what a task on the open web
needs: navigation to any site, and a declared SUBMIT, UPLOAD or DOWNLOAD. Each
approval is audited as consent_channel=autonomous, so a run's side effects can be
listed afterwards. It still refuses PUBLISH, EVALUATE, INSPECT, file:/data:/javascript: origins,
and any site in LYRA_BROWSER_DENIED_ORIGINS (same entry forms as the trusted list,
and it wins over every other yes). The default stays auto/elicit, so nothing
changes unless the operator chooses it.
Trusted origins. Every new site costs one prompt, and a prompt nobody
answers holds its tool call for LYRA_BROWSER_CONSENT_TIMEOUT (default 300s)
before it becomes a denial. For sites the operator always uses, that answer can
be given in advance: LYRA_BROWSER_TRUSTED_ORIGINS is a comma- or
whitespace-separated list of sites that are pre-approved for NAVIGATE — and the
INTERACT it implies — so they are not asked about again. The operator writes it;
nothing the model or a page says can add to it.
Entry | Trusts |
| exactly that scheme, host and port |
|
|
|
|
Hosts are compared as parsed (scheme, host, port) fields, never as text, so
https://kvraudio.com.evil.io, https://kvraudio.com@evil.io and
https://kvraudio.com:8443 are not kvraudio.com. A site that redirects between
example.com and www.example.com needs both (example.com and
*.example.com). An entry that cannot be read is dropped and the rest still load:
empty and malformed entries, a lone *, single-label wildcards (*.com),
wildcards on an IP address, IPv6 literals, paths, userinfo, a port on a bare host
(write http://localhost:3000), and any scheme but http(s) — file:, data:,
about: and blob: name no site. The list does not know public suffixes:
*.co.uk is two labels and would be accepted, so do not write it.
What it covers is deliberately small. It never answers SUBMIT, UPLOAD,
PUBLISH or DOWNLOAD: those stay single-use and are still
asked on a listed site, every time. (To pre-approve sending from your own product for
end-to-end runs, LYRA_BROWSER_TRUSTED_SEND_ORIGINS is a separate list that answers
SUBMIT and UPLOAD and nothing else.) The list also holds where the browser arrives without
a navigate call -- a redirect hop or a followed link to a listed site -- because the guard
applies it to NAVIGATE too. It does not apply to an opaque origin, while
the user holds a takeover, or when approval is off (that stays
consent_channel=off). A listed site gets a real grant with the same lifetime
and initiator binding an approved one would, so enforcement judges it identically,
and the audit records consent_channel=trusted with the entry that matched.
trusted is a way a decision was reached, not a value of
LYRA_BROWSER_CONSENT_CHANNEL. It applies under auto, elicit and legacy.
Takeover. While the user holds the session, every mutating tool returns
takeover_active and the agent waits — including highlight_element and
ask_user_to_do, which write to the page. Reading tools keep working.
Enforcement steps aside too. Grants record what the agent was allowed to do;
holding a person to them would refuse them their own browser, and takeover exists
for the passwords, CAPTCHAs, 2FA and payments the agent must not do alone — every
one of which is a navigation. Those are recorded as user_driven, so the trail
distinguishes what a person did from what was approved for the agent.
A requested step. ask_user_to_do hands the window over the same way without
freezing the agent, which keeps reading to see when the step is done. Until the agent's
next acting call (anything that stands down for a takeover, except highlight_element)
the user's sign-in, CAPTCHA or payment navigates as theirs (user_driven, reason
ask_user_to_do; a takeover's rows say takeover). That call takes the window back, and
so does closing the browser or handing it to another session. Without this, a user asked
to sign in clicked "Log in" and nothing happened: the guard measured their form POST
against the agent's grants, which held no SUBMIT.
Guard backends
Enforcement has to see every document request before it leaves the browser. There
are two ways to stand there, chosen with LYRA_BROWSER_GUARD (route unless set);
both ask the same judgement (NavigationGuard.decide), so grants, single-use
scopes, takeover, observe mode and the audit trail behave identically. They differ in
what they can see and what they cost.
|
| |
How | Playwright's | A second DevTools connection: |
Redirect hops | Judged after they have left: a | Every hop judged before it leaves: the destination of a refused hop sees nothing |
HTTP cache | Off: Playwright sends | On |
etsy.com (DataDome), headful, fresh profile, N=5 | refused 5/5 | let in 5/5 |
Service workers |
| Bypassed on every guarded page ( |
Open port | none | a debugging port on |
Guard lost | cannot happen | the browser is killed and every call answers |
Launch | Playwright's defaults | adds |
Dependencies | none | none: a stdlib WebSocket client ( |
How cdp guards. Chrome is started with --remote-debugging-port=0 next to the
pipe Playwright uses, and cdp_guard.py opens a second connection to it. It
auto-attaches to every page and out-of-process iframe at browser level with
waitForDebuggerOnStart, so a popup or a cross-site iframe is held until
Fetch.enable is in place on it (a session made through Playwright after the tab
exists cannot promise that: popups' first requests and cross-site iframe requests
escaped in the earlier probe). Each paused request becomes the two objects
classify reads, goes through decide, and is answered Fetch.continueRequest or
Fetch.failRequest with Aborted — never another error reason, which would show an
error page. Nothing is asked of the page while a request is
paused (its renderer does not answer then).
Redirect hops. Each hop is its own Fetch.requestPaused, judged as the
navigation it is: a 307/308 keeps method and body, a 301/302/303 turns a POST into a
body-less GET. A site you approved that answers 302 to an unapproved origin is
refused before the request leaves — the destination sees nothing — and the audit row
names redirect_from, and navigate answers blocked_by_policy with redirected_to. A hop that stays with the origin that issued it (the same
origin, or http→https of the same host on default ports) is not judged again: it is
the request carrying on, and asking again would strand every trailing-slash redirect on
a form.
Uploads. Chrome puts a navigation's whole body into the event (as text and as base64: a 40 MB form is a 98 MB message). The transport reads such a message through without keeping it and the guard judges the event without the body (it only asks whether one exists). A 3, 12 and 40 MB multipart POST were refused when ungranted, delivered when granted, and the guard stayed up.
If the guard is lost. Chrome releases every request it holds, and every target
waiting for the debugger, the moment the connection ends, so a lost connection is a
browser running unguarded. The session therefore kills the browser, records
guard_lost in the audit trail and answers every call guard_lost (also to readers,
and to open_browser) until close_browser acknowledges it; the next browser is a
fresh, guarded one. A socket that ends because the browser quit (a crash, the user
closing the last window) is told apart without waiting: the process is already gone, or it
is running with no open tab and leaves within 300 ms. With a tab open nothing explains the
socket, so it is killed at once. Measured (scripts/verify_cdp_guard_e2e.py): socket
aborted, process gone in 1–2 ms, and a hostile page posting a form every 4 ms got 0 of
them through to its server in 3 of 3 runs (waiting 300 ms first let 72 through). Requests
already in flight at that instant were released by Chrome and are not judged. A sidecar
that cannot start, or a target it cannot guard, is the same event: the window is closed
rather than left open.
Debugging port. --remote-debugging-port is an open, unauthenticated service, and
this is what cdp costs.
What it exposes. Any process on the machine that finds the port can attach to the browser and do what the agent can and more: list tabs, read cookies (HttpOnly ones too), run script in any tab of the logged-in profile. Finding it needs no secret:
GET /json/versionhands out the WebSocket path. Withroutethere is no port; Playwright drives Chrome over a pipe only it holds. Reading the profile directly would also give a same-user process the cookies, so the difference is mostly other users of the host and sandboxed code that has loopback but not the profile directory; on a one-user workstation it is small, on a shared host it is the whole profile.What is done. Random port, chosen by Chrome, bound to
127.0.0.1only (::1refuses; measured). Chrome refuses a handshake that carries anOriginheader (a web page cannot connect, 403) or a foreignHost(no DNS rebinding, 500). The profile directory is made0700before launch;DevToolsActivePort(written0664, and left behind when Chrome exits) is removed before launch and as soon as it is read. The port and the path are never put in an exception, a log line, an audit row or an envelope.What remains. The port exists as long as the browser does; Chrome has no way to close it or to require a token. A second consumer cannot use the pipe (Chrome serves one, and Playwright holds it); putting a proxy in front of a pipe-launched Chrome means launching Chrome ourselves, which this does not do. Chrome 136 and later ignore the flag on their default profile directory because the port is a cookie-theft vector [Chrome's own announcement, not measured here]; this server never uses that directory.
Measured by the
exposuresection ofscripts/verify_cdp_guard_e2e.py, which attaches from a separate process the way a scanner would.
What neither backend sees. fetch()/XHR (not navigations), WebSockets, GETs with the
data in the URL, speculation-rules prefetch/prerender and the navigation that activates
one (above), and data:/about:/blob:/javascript: URLs (not requests). Workers never
issue a document request and are not attached; a page's own back_forward_cache is off
under Playwright's switches, and history navigations are judged with the cache on.
Choosing. cdp for sites behind DataDome, where redirect chains matter, or where
a page's service worker must not answer navigations; route where an open loopback
port is unacceptable. Every scripts/verify_*_e2e.py gate runs on either:
LYRA_BROWSER_GUARD=cdp python scripts/verify_tabs_e2e.py.
Installation
Developers working on this repo:
pip install -e ".[dev]" # dev includes playwright, for the parity gates
python -m playwright install chromium # only needed if you have no system Chrome
lyra-browser # serve over stdio (the default, what an MCP client spawns)
# or: lyra-browser --http --port 8765 # serve over HTTP for devA plain pip install lyra-browser installs no Playwright at all: the default driver
drives the Chrome or Edge already on the machine. Add [playwright] only to pin one of
the Playwright drivers.
An end user needs no pip and no playwright install when Chrome or Edge is installed:
the server drives that browser. If neither is found, the tools answer
browser_unavailable so the client can prompt for an install.
Claude Code: the repository is a Claude Code plugin and its own marketplace. One line installs it (Claude Code 2.1.275 or later):
claude plugin install lyra-browser --marketplace Intrect-io/lyra-browserThe plugin builds its own Python environment with uv (or python3 -m venv
without it) on the first session, drives your Chrome or Edge, and asks for every
site change and form send through Claude Code's own dialog. See
docs/CLAUDE_CODE_PLUGIN.md.
Any MCP client, through npx: the server is Python, and a small Node launcher
(npm/) starts the matching PyPI release with uv, pinned to the dependency set in
uv.lock:
claude mcp add lyra -- npx -y @intrect/lyra-browser # or "command": "npx", "args": ["-y", "@intrect/lyra-browser"]
npx -y @intrect/lyra-browser install-browser # only if there is no Chrome or EdgeNeeds Node 18+ and uv. Measured 2026-10-16 on Linux x86_64
(Node 24.20.0, npm 11.19.0, uv 0.11.8): the first start with empty caches answers
initialize in 3.6 to 8.7 s (8 runs), later starts in 1.4 to 3.4 s (29 runs); macOS and
Windows are not measured. python scripts/verify_npx_e2e.py reproduces it. See
npm/README.md.
Deploying it as a remote server (Cloudflare Containers): see docs/CLOUDFLARE.md.
Checking a setup: lyra-browser doctor
lyra-browser doctor says what a launch would use on this machine and what would stop
it, without launching a browser, importing a Playwright or writing anything (a probe
file in the data dir's nearest existing parent is created and removed). It reads the
same environment and flags as the server (--client, --data-dir, --headless). On
Linux over SSH (no display), 2026-11-01, asking for a window:
$ lyra-browser doctor --no-headless
lyra 0.2.0 (Python 3.12.3), client generic
driver native (auto: native is the first in the order (native, patchright, playwright))
browser chrome /opt/google/chrome/chrome
chrome /opt/google/chrome/chrome
msedge not found
bundled /home/me/.cache/ms-playwright/chromium-1208/chrome-linux64/chrome
window headful (display no)
guard cdp, consent auto
data dir /home/me/.lyra-browser (writable)
profile free
ERROR a window is asked for (headless off) but there is no display
fix: set LYRA_BROWSER_HEADLESS=1 (or --headless), or run under a desktop session or xvfb-run
NOT ok: a launch would failIt reports the driver auto would pick and why (the same decision a launch makes,
session.choose_driver), the browser binary for each channel in the order a launch
tries them — narrowed to one when an imported profile pins its browser — the data dir
and whether it can be written, whether another lyra browser holds profile.lock, the
display, the guard backend, the consent channel and the imported profile. Each finding
is an ERROR (a launch would fail) or a WARNING (it works, but not as one might
expect: a held profile means an empty private one) with its fix. --json prints the
same as JSON with a boolean ok; the exit code is 0 exactly when ok is true. Through
npx it is npx -y @intrect/lyra-browser doctor; the Claude Code plugin's
/lyra-browser:doctor runs it too.
Drivers
lyra drives the browser through one of three drivers, picked with LYRA_BROWSER_DRIVER
(the default is auto):
Driver | Needs | Notes |
| Chrome, Edge or Chromium; |
|
| Chrome, Edge or Chromium, nothing else | lyra's own driver ( |
| that package ( | Pins one. |
What native does differently, on purpose:
No
Runtime.enable. That is the one DevTools call Kasada refuses a browser on. Scripts run in an isolated world the page cannot see or tamper with. A script that reads the page's own globals (window.CKEDITOR) asks for the main world. The UAT recorder's console and page-error observers are the only thing that turnsRuntime.enableon, and only for the pages they observe.The sandbox stays on. Playwright always passes
--no-sandbox. This browser holds real logins, sonativekeeps Chrome's renderer sandbox unlessLYRA_BROWSER_SANDBOXis off (a container without user namespaces).The
cdpguard, always.nativehas nocontext.route. Asking for it selectsLYRA_BROWSER_GUARD=cdp, which judges every redirect hop.The same switches as Playwright otherwise. The list was measured from Playwright 1.64's launch. It keeps a shared profile's cookies readable across drivers and keeps the back/forward cache off, which the guard relies on.
Status: default. Selectors are css (open shadow roots pierced), text=,
role=…[name=…] (from Chrome's accessibility tree), xpath=, id=, data-testid=,
nth=, :visible, :has-text() and internal:control=enter-frame; any other engine is
refused by name. read_page(mode="tree") matched Playwright's on 75 of 75 elements in the
same in-view order, and its refs reach into same-site and cross-site iframes
(python scripts/verify_native_parity_e2e.py).
The repo's own gates run on it with LYRA_BROWSER_DRIVER=native python scripts/verify_<gate>_e2e.py, or with --driver native where a gate has that flag.
Measured 2026-10-23 on macOS with Chrome 155: 11 of 13 gates pass in full (1,855 checks),
including all 35 guard scenarios of verify_cdp_guard_e2e.py. verify_tabs_e2e.py stops
at the same headful-relaunch check the playwright driver stops at on that machine. The
rest of verify_cdp_guard_e2e.py reads /proc and runs on Linux only.
A page's own globals are not visible to page.evaluate on native by default, because it
evaluates in an isolated world as patchright does. Pass world="main" for them
(tools/forms._main_world picks the right keyword for each driver).
Using your own Chrome logins
lyra's profile starts empty. Rather than logging in again inside it, a person can bring the logins of one of their own browser profiles across, on the command line:
lyra-browser profile list # profiles on this machine
lyra-browser profile import --profile "Work" --dry-run # count what would move
lyra-browser profile import --profile "Work" # import; run again to sync
lyra-browser profile import --profile "Work" --sites github.com,linear.app
lyra-browser profile status # what was last importedIt is a command, not a tool: the model cannot pull a profile in, because whoever
runs it decides that the agent may act as them on those sites. --client and
--data-dir pick whose lyra profile receives it, as for the server. open_browser
then reports imported_from and says, in its note, that on those sites the agent
acts as the user.
What moves, from <profile> into lyra's profile/Default, and nothing else:
Component | How |
| Row by row, decrypted and sealed again for lyra (below). Session cookies are left out: Chrome deletes them when it starts. |
| Whole. It is one database for every site, so a |
| Per origin / per storage bucket, filtered by site. |
Saved passwords (Login Data), extensions, history and Preferences (absolute
paths, account tokens) are never copied. LYRA_BROWSER_DENIED_ORIGINS is applied at
import too: a refused site's cookies stay behind, and so does a domain cookie that
would be sent to it.
Sync means replace. Re-running the import replaces lyra's cookies for the sites
in scope (every site without --sites) and the other components with the source's,
so whatever lyra's own sessions stored there is overwritten; cookies for other sites
stay. The import takes <data_dir>/profile.lock and refuses while a lyra browser
holds it (close_browser first). It stages everything beside the profile and swaps
each component in, so a failed import leaves the old profile as it was.
The source is read-only and may be running. SQLite files are read through the
backup API; LevelDB directories are copied file by file (without LOCK) and copied
again if they changed meanwhile, so a running browser's last few writes may be
missing.
Cookie encryption (Linux). Both drivers launch Chrome with
--password-store=basic (the native driver copies Playwright's switches), so lyra's
cookies are sealed v10, with Chrome's fixed key.
A Chrome that found a desktop keyring seals them v11, with a secret in that
keyring; copied as they are they would never open in lyra. The import fetches that
secret from the Secret Service (D-Bus), opens each cookie and seals it again as
v10. Whether it decrypted is checked, not assumed: since cookie schema 24 each
plaintext starts with the SHA-256 of its domain. A cookie that does not open is
counted and left out. When the cookies are v11 and the login keyring is locked
(a session over SSH, say), the import refuses rather than bringing the storage
without the cookies; --unlock-keyring lets the keyring prompt for its own password
on the desktop (it gives up after 120 s when no prompt can be shown). lyra never asks
for or sees that password. macOS (Keychain) and
Windows (DPAPI, app-bound v20) cookies are not decrypted yet; they are reported as
undecryptable (the import reads Network/Cookies where those platforms keep the file).
Which Chrome opens it. Chrome migrates a profile forward only, so after an import
from Chrome (or Edge, or Chrome Beta) lyra launches that browser and no fallback; an
explicit LYRA_BROWSER_CHANNEL still wins. The import refuses when lyra's cookie
database has a different schema from the source's.
Measured 2026-10-28 on Linux (Chrome 154.0.8037.57, Playwright 1.63.0, cookie schema
24): scripts/verify_profile_import_e2e.py runs a real Chrome as the source, once
with v10 and once with v11 cookies (a throwaway gnome-keyring on a private D-Bus
session), imports, and checks that lyra's page is sent the cookie and reads
localStorage and IndexedDB back, that a re-import brings a new login and keeps cookies
outside --sites, and that the source's files did not change (26 checks, one run).
Re-run 2026-11-01 on both drivers (Chrome 154, LYRA_BROWSER_DRIVER=native and
=playwright with Playwright 1.63.0), now also checking that the named driver is the
one that ran: 66 checks each, all passed.
scripts/measure_profile_import.py imports a real profile into a throwaway data dir and
compares what lyra's Chrome holds (CDP Storage.getCookies, on about:blank) with what
was imported: on one v10 profile, 25 cookies in the source, 4 session-only, 21
imported, 14 unexpired, and Chrome reported those 14 with the same names and values.
Three real v11 profiles on the same machine (118 to 144 cookies) were not measured:
their keyring was locked over SSH, and the import refused them as described above. The
v11 path is measured by the gate only, with cookies a real Chrome sealed through a
throwaway keyring.
Configuration (env)
Var | Default | Meaning |
| detected |
|
| — | Base data dir (profile + audit). Overrides everything. |
|
| hermes: data goes to |
| auto | Run without a visible window (see Headless mode). Unset: headless for hermes and remote, and on Linux with no |
| unset | X display the window opens on instead of the inherited one, e.g. |
|
| Where |
|
|
|
| unset | Route the browser through a proxy, e.g. |
|
| Ask before risky actions. |
|
|
|
|
|
|
|
| Seconds a grant stays usable. Single-use ones ignore this. |
| — | Sites pre-approved for |
| — | Sites where sending is also pre-approved — |
| on | Claude Code client only: while Claude Code runs in bypassPermissions mode, consent requests are answered without a dialog ( |
| — | Sites refused whatever else says yes: the trusted lists, a live grant and the |
|
| Seconds a prompt waits for an answer before it counts as a denial ( |
|
| Seconds before a finished action's unused single-use scope is reclaimed. |
|
| Seconds a session may hold the browser without using it. Handing it on closes the window. |
| — | Where |
|
| Captures kept on disk; oldest are deleted first. |
| — | Where declared downloads are saved. Default |
|
| Saved downloads kept; oldest are deleted first, and only files this server saved (tracked in a ledger in the dir). |
|
| Largest file a download may be (200 MiB); a bigger one is cancelled and answered |
|
| Seconds a declared download may take to arrive once it has started before it is cancelled ( |
| — | Force one channel ( |
|
| Allow bundled-Chromium fallback (only exists after |
|
| Also write every audit record to stderr as one JSON line. For a host whose disk is ephemeral but whose stderr is collected (the Cloudflare image sets it). |
|
| Which driver launches the browser: |
| on |
|
|
| Where the navigation guard stands: |
|
| The Claude Code executable the |
|
| The Codex executable the |
UAT mode
lyra-uat plays a persona — who is visiting, from where, in what language, with what goal and how many steps — against a site, through the tools above, and writes report.json. The model that plays it is replaceable: Claude Code, Codex, the Anthropic API, OpenRouter, Ollama Cloud, or any OpenAI-compatible endpoint. What makes a run trustworthy is not: the server, not the model, keeps the trace. Whichever brain drives, every tool call is recorded as it happens, the step budget is enforced, a screenshot is taken after each action, a type_text value that looks like a payment-card number is refused, an upload outside the persona's directory is refused, and leaving the sites under test cannot be approved by the model's own confirm=true.
pip install -e ".[uat]"
lyra-uat demo-site --port 8787 & # a small site with things to find
lyra-uat run examples/uat/demo/run.yaml # one persona, Claude Code plays it
lyra-uat batch examples/uat/demo/batch.yaml # two personas in parallel, one process eachA run leaves a directory: report.json (and report.md), trace.jsonl, events.jsonl, captures/, network.jsonl, console.jsonl, and the browser's own audit.jsonl. Persona, target, brain, hooks and limits are YAML or JSON; lyra-uat schema run|batch|report prints the JSON Schemas. A run that should act without anyone to ask sets target.policy: autonomous; the default is closed, which keeps the run on its trusted sites. Details, the report format and each brain's requirements are in docs/UAT.md.
Headless mode
lyra-browser --headless # or LYRA_BROWSER_HEADLESS=true; the flag wins
lyra-browser --no-headless # force a window even if the env says otherwiseUnset, the mode follows the client and the host: --client hermes is headless
(Hermes reaches the user over chat and gives its servers no display), and so is
any Linux process with neither DISPLAY nor WAYLAND_DISPLAY — launching a
window there would crash, headless is a working session that says so — unless
the host keeps a persistent VNC display (next section).
A persistent VNC display for a host with no screen
On a server you reach over SSH, lyra would otherwise always be headless. One command keeps a display up that you watch from a VNC viewer:
lyra-browser vnc install # :98, 127.0.0.1:5902, 1920x1080x24
lyra-browser vnc install --listen 100.64.0.10 # e.g. a Tailscale or WireGuard address
lyra-browser vnc status # units, X socket, does RFB answer
lyra-browser vnc password # print the password again
lyra-browser vnc reset-password [--stdin] # the only thing that changes it
lyra-browser vnc uninstall # stop, remove the units, keep the passwordinstall writes two systemd user units — lyra-xvfb.service (Xvfb :98 -nolisten tcp) and lyra-x11vnc.service, bound to it, waiting for its X socket
before it starts, and restarted with it — enables and starts them, and records
display, port and listen address in ~/.config/lyra-browser/vnc/vnc.json. The
password is generated once (8 characters, the most VNC authentication uses), shown
once, and kept at mode 0600 in passwd next to it; re-running install with other
flags keeps it. x11vnc reads it with -passwdfile read:, so it is never on a command
line and a reset applies to the next viewer without a restart. Without lingering
(loginctl enable-linger $USER) the units stop when you log out; install warns.
The server never starts or stops these units. A generic or claude-code server
with no display of its own opens its window there whenever the X socket exists —
headful, attended=true — and open_browser answers with watch: {"vnc": "<listen>:<port>", "display": ":98"} and a note for the agent to give the user that
address. The password never reaches the model. Each launch judges the display again:
if the X server is gone, the session opens headless and open_browser adds
display_lost. Hermes and remote stay headless, and an inherited DISPLAY (a
desktop someone is looking at) always wins.
RFB is unencrypted and the password is short: the default listen address is
loopback (reach it with ssh -L 5902:127.0.0.1:5902 host), and a private-network
address is the alternative. Never listen on 0.0.0.0 on a network you do not trust.
Measured by scripts/verify_vnc_e2e.py (2026-11-03, Xvfb 21.1.12, x11vnc 0.9.16,
Google Chrome 154.0.8037.57; one full pass each with the native driver and with
Playwright 1.63.0, --driver): with DISPLAY and
WAYLAND_DISPLAY removed, open_browser reports attended=true and the address; a
real RFB client refuses a wrong password, and with the stored one sees the loaded page
on the framebuffer. With the X server killed (its socket left behind, so only Chrome's
"Missing X server" tells) and with it stopped cleanly, the next launch is headless with
display_lost rather than an error.
Headless does not merely hide the window — it means nobody is watching. The
collaboration tools have no human to reach, so they refuse with an unattended
envelope instead of reporting success for something no one will see:
Tool | Headless behaviour |
|
|
|
|
|
|
Headless Chrome also announces itself: its UA says HeadlessChrome, and
Cloudflare's managed challenge refuses on that token alone (13 of 34 login
pages in the earlier survey). The session therefore sends the UA the same
binary would send with a window — the real version, the real platform, only
that token dropped — so navigator.userAgentData and the Sec-CH-UA-* headers
stay the browser's own. The version is read from the running Chrome on the
first headless launch and remembered in <data_dir>/browser-ua.json; that
first launch, and the one after each Chrome update, relaunch once (about 0.7s).
Navigation, interaction, and reading are unaffected. open_browser reports which
mode you are in via its attended field so the agent never assumes an audience.
Looking like the user's Chrome
Sites that sort agents from people check the launch, not the behaviour. The
session is launched the way Playwright's own MCP server launches — with
--disable-blink-features=AutomationControlled, so navigator.webdriver is
false as in any Chrome a person opens, and without an emulated viewport in
headful mode, so the page sees the real screen instead of a window larger than
the screen it is on. Measured on 42 login pages behind Cloudflare, Akamai,
DataDome, PerimeterX and Kasada: of the 34 plain Chrome passes,
the Playwright defaults failed 6 headful and 19 headless; with these settings
the count is in the survey comment, per site.
What is deliberately not done: the --enable-automation infobar stays (it is
how a person at the shared window is told what is happening), and nothing is
forged — no WebGL or canvas noise, no patched CDP. Route interception and the
service-worker block contribute nothing to the Cloudflare and Akamai refusals; for
DataDome the route does matter, which is why the cdp guard backend exists.
Two vendors still refuse, and the cause of each is measured rather than guessed:
Kasada (hyatt.com) notices one CDP call,
Runtime.enable. Plain Chrome driven over raw CDP passes; the same Chrome withRuntime.enableon is refused; every other call Playwright makes on attach is fine. Playwright enables it on every page and has no switch not to. patchright is a drop-in fork that does not send it and evaluates in an isolated world instead; withpip install "lyra-browser[patchright]"the session launches through it (LYRA_BROWSER_DRIVER=auto), and hyatt passes in both modes. What patchright changes for this server:page.evaluateruns outside the page's own JavaScript world (our evaluate calls only touch the DOM, which is shared) and console messages are not delivered (we read none).DataDome (etsy.com; tripadvisor.com does not discriminate in the measurements) refuses on either of two causes, each sufficient on its own (N=5–6 per cell, headful, one IP):
context.routeswitches the HTTP cache off, so every document request carriesCache-Control/Pragma: no-cache(plain Chrome with onlyNetwork.setCacheDisabledis refused 0/6, while raw CDPFetch.enableon documents or on all requests is fine), and on a GPU-less host--enable-unsafe-swiftshadergives the page a WebGL that plain Chrome lacks. Thecdpguard backend removes both: no route, documents only over a second connection, and the switch dropped. Measured on that backend with the product itself (scripts/measure_compat.py, headful, fresh profile per visit, 12 s apart, N=5): etsy.com let in 5/5 against 0/5 for theroutelaunch; hyatt.com (patchright) 5/5 against 4/5 (the one miss was a 429). Dropping the switch removes WebGL on a host with no GPU, as in plain Chrome there; on a host with a GPU it is inert [INFERENCE: no GPU host here]. Headless is undecidable on the measuring host.
Development
ruff check . # lint
pre-commit install # enable hooksReal-browser gates live in scripts/verify_*_e2e.py, for
example python scripts/verify_browser_e2e.py --headless-only, or headful under
xvfb-run -a. .github/workflows/browser-gates.yml runs thirteen of them nightly
(and on demand) for both drivers, on ubuntu with Xvfb; a red nightly run is the signal
that a driver change regressed something the fake-page unit suite cannot see.
python scripts/verify_vnc_e2e.py is the persistent-display gate: its own Xvfb and
x11vnc on a free display and loopback port, never the host's install.
CDP guard gate —
python scripts/verify_cdp_guard_e2e.py(both modes,--driver) re-runs the browser, script-redirect and tabs gates on thecdpbackend, then checks what only it can do: redirect hops (301/302/303/307/308, with and without a body), popups at N=10, out-of-process iframes, service workers, large uploads, kill-the-socket fail-closed, a sidecar that cannot start, and what another local process can do with the debugging port.scripts/measure_compat.pyis the etsy/hyatt measurement above.Real-sites gate —
python scripts/verify_realsites.py(needs the internet) visits ten live sites and a loopback control, reads each as a tree and as text, clicks its first internal link through thearia-ref=the tree printed, and compares judged and blocked navigations and page sizes withscripts/realsites_baseline.json(±10% plus a small slack; success must match exactly). A site that cannot be reached isSKIP, and the gate fails only when fewer than eight ran. It runs against live sites, so a site editing its header can turn it red with no code change: treat it as a report to read, not a merge gate (the loopbackverify_*_e2e.pygates are the blocking ones).--update-baselinere-records it,--sites a,band--retries Nnarrow and steady a run,--driver patchrightand--headfulpick the driver and the window.
Status
The server, session manager, tools, permission layer, enforcement, bot-check
detection and audit are implemented, unit-tested (no browser needed) and exercised
end-to-end against real sites with a real Chrome — ten rounds over eleven sites, 187
judged navigations. Driven by a live MCP client that implements elicitation since
2026-10-15: as a Claude Code plugin (docs/CLAUDE_CODE_PLUGIN.md), a claude -p
session's Elicitation hook approved a navigation and a cancelled form denied one,
both audited as consent_channel=elicit/denied with no confirm fallback
(Claude Code 2.1.300, scripts/verify_claude_plugin_e2e.py).
License
MIT
Available Tools
29 toolsask_user_to_doA
Ask the user to perform a step you must not do yourself.
Use for credentials, CAPTCHAs, 2FA, or payment confirmation. Highlights
the relevant element when selector is given, then returns an
awaiting_user envelope. Relay the instruction, then poll the page
(read_page / get_url) to detect completion.
Requires an attended (headful) session — in headless mode there is no
user to ask, so this returns unattended rather than making you wait.
Inputs: instruction (required), selector. Returns: object
clickA
Click the first element matching selector (CSS or Playwright text=).
Set submits=true when the control sends a form or completes a
purchase — that asks for the permission it needs. If the page submits
anyway without you declaring it, the request is stopped, so declare it
when you mean it. reason is shown to the user when they are asked.
Fails fast instead of hanging. A selector that matches nothing answers
not_found, and a match that cannot be used hidden or disabled
— each after at most a few seconds' wait for a page that is still
building or enabling its controls, and before anyone is asked. Once
clicking, timeout_ms (default 10000, max 30000) bounds the wait, and
a failure answers timeout (the click may have landed — look at the
page before clicking again), element_not_actionable (covered,
detached) or page_closed.
The reply says how many elements matched (matches; the first is
clicked) and what was hit (clicked: tag, role, name), so a wrong
pick is visible.
If the click makes the page navigate and the browser refuses it — no
approval covers the destination, or a form was sent without
submits=true — the reply is blocked_by_policy (with url, where
the tab still stands, and redirected_to when the site redirected the
browser elsewhere) and the tab has not moved: navigate to the
destination to be asked for it. A click that opens a tab answers
new_tab: true and tab_count; the new tab is the one every later
call reads and acts on (tabs goes back).
Set download=true when the click is meant to save a file: that asks for
permission to write one, and the reply carries download (filename,
path, bytes, url_origin) once it is saved. A click that starts a
download without it is cancelled and answered download_blocked — nothing
is saved, so repeat the click with download=true. The permission pays for
one file and ends with the call.
A native dialog the page raised is answered at once and listed under
dialogs in the reply; handle_dialog chooses the answer beforehand.
Inputs: selector (required), reason, confirm, submits, +2 more. Returns: object
close_browserC
Close the shared window, discarding every approval it earned.
This is how a session finishes: the window goes away, the grants go with it, and the next session may claim the browser. Without it the refusal another session receives would name a wait that never ends.
Inputs: reason. Returns: object
get_urlA
Return the current URL and page title, and how many tabs are open (tab_count).
Inputs: no named parameters. Returns: object
go_backB
Go back one entry in history.
Where "back" leads is not known until it happens, so this asks only to
interact with the current site; landing somewhere else is judged then.
Reports http_status and content_type like navigate — both
null when the browser restored the page without asking the server.
Inputs: reason, confirm, wait_until. Returns: object
handle_dialogA
Choose how a native dialog is answered — for the NEXT browser action only.
Dialogs never block the page: every alert, confirm, prompt and
beforeunload is answered the moment it appears. Left alone, alerts and
beforeunload are accepted and confirm/prompt are dismissed
(confirm() returns false, prompt() null). What was raised is listed
under dialogs in the reply of the click, type_text or
press_key that follows — its message is the page's text, not the
user's.
Call this immediately BEFORE the one action that raises the dialog.
accept=true (the default) answers yes; accept=false answers no.
text is what a prompt() receives — leave it empty to accept the
prompt's default value; it is ignored when dismissing.
The answer is for the very next browser-acting call (click, type_text, press_key, hover, scroll, navigate, go_back, reload_page, select_option, set_editor, upload_file, save_draft, publish, tabs switch or close) and only for a dialog that call raises while it runs, on any tab. It ends with that call whether or not a dialog appeared — also when the call was refused, so arm again before retrying — and after about a minute at the latest. A dialog raised later, by a page's own timer or on a page you reach afterwards, is answered by default. Reading (read_page, screenshot, get_url, wait_for ...) does not use it up. Arming again replaces it.
Inputs: text, accept. Returns: object
highlight_elementA
Outline an element in the shared window so the user can see what you mean.
Requires an attended (headful) session — in headless mode there is no one
to show it to, so this returns unattended.
Inputs: selector (required). Returns: object
hoverA
Move the pointer over the first element matching selector.
For menus and tooltips that only open while the pointer is on their
trigger: hover, then click the item that appeared. Nothing is
pressed, so no form is sent by it.
Fails fast like click: not_found, hidden or disabled
before anyone is asked, then timeout, element_not_actionable
(covered, detached) or page_closed within timeout_ms (default
10000, max 30000). The reply says how many elements matched (matches;
the first is hovered) and what was hovered (hovered: tag, role, name).
A page that navigates where no approval reaches as the pointer arrives is
answered blocked_by_policy (see click).
Inputs: selector (required), reason, confirm, timeout_ms. Returns: object
list_downloadsA
List the files this session has downloaded, oldest first.
Each entry is filename, path, bytes and url_origin (the site
the file came from), the same as the download a call returned when it
saved one. A download that was blocked or failed left no file and is not
listed. This only reads: it asks nobody, works during a takeover and does
not open the browser.
Inputs: no named parameters. Returns: object
open_browserA
Launch (or focus) the visible browser window the user shares with you.
If no browser is installed this returns a browser_unavailable envelope
(relay it so the user can install Chrome) rather than failing.
Inputs: no named parameters. Returns: object
press_keyA
Press a keyboard key (e.g. 'Enter', 'Escape', 'Control+a') on the page.
No key is treated as special: Enter, Space and anything else all send
whatever the page decides to send, and that is judged when it happens.
Set submits=true if you are deliberately sending a form. A key that
makes the page navigate where no approval reaches is answered
blocked_by_policy and the tab has not moved (see click); one that
opens a tab answers new_tab: true and tab_count.
Set download=true if the key is meant to save a file: that asks for
permission to write one, and the reply carries download once it is
saved. A key that starts a download without it is cancelled and answered
download_blocked; repeat it with download=true.
A native dialog the page raised is answered at once and listed under
dialogs in the reply.
Inputs: key (required), reason, confirm, submits, +1 more. Returns: object
publishA
Make a draft public — the one action that sends content to an audience.
Kept apart from every other tool so that writing a draft can never publish
it: this asks for PUBLISH, which is single-use and implied by nothing,
and it is the only tool that buys it.
A site splits this across two controls and means neither of them alone.
Measured on KVR: the publish radio (input[name=is_draft][value='0'] on
a news item, input[name=is_live][value='1'] on a product) only arms the
form — the item travels on the form's own Submit. Switching the radio and
stopping would leave the page primed to publish on someone else's next
click, which is worse than not switching it at all. So give both:
selector— the control that carries the item live, fromread_form'sdraftslist.submit— the control that sends the form. Required:read_formreports the candidates insubmitText, and on KVR they are one button (Submiton a news item,Addon a product). This is the request that actually publicises the item, so nothing else in this tool surface may send a form whose publish control is set.
Call this only when a human has approved the exact item. Show them what
will go public and get their answer first; the approval prompt this raises
is the second gate, not the first. Afterwards read_form reads back the
state the page actually reached.
Inputs: selector (required), reason, submit, confirm. Returns: object
read_draftA
Collect what a form is about to publish, for a human to approve.
Approving an item means reading the whole of it, and on a real form that
means looking in several places at once: the visible fields, the rich-text
body (a separate document read_page never shows), the attachment slots,
and whether the item is currently a draft or already public. This gathers
all of it into one answer so the approval request can carry the actual
content rather than a description of it.
Nothing is changed or sent — this is a read, so it needs no approval.
Fields carry usable; one that is false is hidden or disabled in the
page right now and must be revealed before it can be set.
Inputs: max_chars. Returns: object
read_formA
List a form's fields with their names, types, labels and options.
read_page returns rendered text, which loses everything needed to
address a field: a large form arrives as labels with no name, and a
<select>'s options vanish. Use this before filling a form you have
not seen. root is a CSS selector for the form (default: the first
form on the page). Hidden inputs are counted, not listed.
Set whole_page=true for controls a page attached outside the form —
a picker or editor dialog is often appended to the body, and a field
found there is still one the form will post.
Each field carries usable: false means it is in the DOM but hidden or
disabled right now, so choosing it will fail. A form hides sections it has
not revealed yet — measured on KVR, a news form keeps event_country
hidden until the Deal/Offer type is chosen. Check usable before
addressing a field rather than discovering it through a timeout.
Inputs: root, max_fields, whole_page. Returns: object
read_imageA
Capture one element — an img, canvas, svg, a chart, a
map — as a PNG file and return its path.
This is how you look at a picture on the page: it captures the pixels
the element renders, so it works for anything drawn, not only img.
Nothing is fetched — no new request leaves the browser. Returns
not_found when selector matches nothing.
Inputs: selector (required), inline. Returns: object
read_pageA
Read the page as text, or as a map of what can be acted on.
mode="text" (default) returns the rendered text (innerText), not
HTML. links=true adds the page's visible links as {text, href}
— absolute, de-duplicated, at most 100 (tree lines carry their own href).
mode="tree" returns one line per visible thing you can act on —
links, buttons, inputs, selects, checkboxes, tabs, menu items — plus
headings::
aria-ref=e12 link "Pricing" -> /pricing
aria-ref=e15 button "Save"
aria-ref=e16 checkbox "Remember me" [checked]Pass the aria-ref=... token exactly as written as the selector of
click or type_text. It reaches what a text selector cannot name:
icon-only links, several links with the same text, elements inside open
shadow roots and iframes. A ref is good for the page it was read from and
only while its element stays; a region read (selector) replaces the
refs of the read before it. When a ref is rejected or matches nothing,
read the tree again. Elements on screen come first (in_viewport
counts them). Input values are never shown. refs: false means this
Playwright predates aria refs: lines then carry none, so address elements
with role=link[name="Pricing"] or text= selectors.
selector limits either mode to one region (first match); it returns
not_found at once when nothing matches. offset and max_chars
page through long output: total_chars is its full length,
truncated says more follows, next_offset is where to continue.
Tree pages hold whole lines, so a ref is never cut in half. Every call
reads the page as it is now, so pages read while it changes or scrolls
may not line up.
Inputs: mode, links, offset, selector, +1 more. Returns: object
reload_pageC
Reload the current page. Reports http_status and content_type
like navigate.
Inputs: reason, confirm, wait_until. Returns: object
request_takeoverB
Hand control to the user. Agent mutations are blocked until they hand back.
Requires an attended (headful) session. In headless mode the takeover is refused and no state changes: blocking every mutation while waiting for a user who cannot see the window would deadlock the run.
Inputs: reason (required). Returns: object
resume_after_takeoverB
Resume agent control after the user has handed the session back.
Releasing a takeover goes through the consent channel: an agent that can
clear its own lock can ignore the lock. On a client without elicitation
this still succeeds — the release is recorded as the model's own
assertion, which is what consent_channel=legacy in the audit means.
Inputs: reason. Returns: object
save_draftA
Save the item without showing it to anyone — the safe way to send a form.
A site with no separate "save draft" button means every form POST is the one that could publish, so the difference has to be read off the page rather than chosen by the caller. This sends the form only after confirming the publish control is still on its private setting, and refuses if it is not — so it cannot be used to publish, and a page left armed by anything else will not slip out through it.
Measured on KVR: a new news item opens with is_draft=1 and posting the
form then saves it privately.
submit names the control that sends the form — read_form reports it
in submitText (Submit on a news item, Add on a product). This
asks for SUBMIT but never for PUBLISH: saving is an ordinary
request, and keeping the two apart is what lets an operator write and keep
an item without ever approving a release.
Use publish instead when the item should go out to an audience.
Inputs: reason, submit, confirm. Returns: object
screenshotA
Capture the page as a PNG file and return its path.
Use it to see the page — layout, images, charts, anything
read_page cannot put into words. The file is what you look at;
inline=true adds base64 only for a client without access to this
machine's filesystem.
Inputs: inline, full_page. Returns: object
scrollA
Scroll the page. Give exactly one of to, by_y or selector.
to='top'/to='bottom': jump to that end of the document.by_y: scroll by that many pixels, positive down and negative up (at most 20000 either way per call), with the mouse wheel.selector: bring the first matching element into view.
Pages that load more as you reach the end (feeds, lazy lists) grow
after a scroll, so call scroll(to='bottom') again until the reply
says at_bottom. The reply is scroll_y, scroll_height and
at_bottom once the position has settled. A scroll that moved
nothing says so: the content may sit in an inner scrolling panel, which
selector on an element inside it reaches.
selector fails fast like click (not_found, hidden,
disabled, timeout, page_closed).
Inputs: to, by_y, reason, confirm, +2 more. Returns: object
select_optionA
Choose an option in a <select>, by value, label or index.
Give exactly one of the three. A dropdown is not a click target: this
sets the value and fires change, which is what a page listens for.
Set submits=true when the choice sends the form (an onchange
that posts) — that asks for the permission it needs.
Inputs: selector (required), index, label, value, +3 more. Returns: object
set_editorA
Write into a rich-text editor — the body a form actually posts.
For CKEditor, TinyMCE and similar the visible editor is an iframe
whose body is editable, and the <textarea> the form submits is hidden
and empty: type_text on that textarea does nothing. Leave selector
empty to use the first editor on the page, or pass the editor's own
iframe or [contenteditable] element.
html=true writes markup (links, paragraphs, images) and syncs the
editor's own data when it is a CKEditor instance; otherwise the text is
written verbatim. Set submits=true if the write sends the form.
Inputs: content (required), html, reason, confirm, +2 more. Returns: object
tabsA
List the browser's tabs, switch to one, or close one.
action="list"— every open tab with itsindex, URL, title and whether it is theactiveone (the one you read and act on).action="switch"— make tabindexthe active one, and bring it to the front so the user sees what you see.action="close"— close tabindex. Closing the active tab returns you to the tab that opened it, else the newest one; closing the last tab leaves a blank one.
Indexes shift whenever a tab opens or closes, so list again before using
one. Tabs a page opens are followed automatically, and a popup that
closes itself (a sign-in window) returns you to where you were — this is
for when you have to choose. Switching to or closing a tab asks for
permission to use that tab's site, like any other action there;
reason is shown to the user when they are asked.
Inputs: index, action, reason, confirm. Returns: object
type_textA
Fill selector with value. The value is redacted from the audit log.
Set submit=true to press Enter afterwards, which also asks for
permission to send the form. Note that some pages submit on typing alone;
that is stopped unless you declared it, and answered blocked_by_policy
(the tab has not moved; navigate to the destination to be asked for it).
Fails fast like click: not_found, hidden or disabled
before anyone is asked, then timeout, element_not_actionable
(read-only, not a text field, detached) or page_closed within
timeout_ms (default 10000, max 30000) for each step.
A native dialog the page raised is answered at once and listed under
dialogs in the reply.
Inputs: value (required), selector (required), reason, submit, +2 more. Returns: object
upload_fileA
Attach one or more local files to a file picker.
This hands a file on this machine to a page, which is why it asks for its
own permission, once per call. selector is the input[type=file];
a styled picker often keeps it hidden, and that is fine — a hidden input
still accepts files. Missing paths are reported before anything is
attached, so a gated call never leaves a picker half-filled.
Inputs: paths (required), selector (required), reason, confirm. Returns: object
wait_forA
Wait until the page is ready, instead of sleeping or clicking to pass time.
Give exactly one condition:
text: the phrase is in the page's visible text — whatread_pagereturns. Whitespace does not matter, case does.state="hidden"waits for it to be gone instead (a "Loading..." message).selector: an element matching it (CSS or Playwrighttext=) reachesstate:visible(default),hidden,attachedordetached.url: the tab's URL matches. A glob over the whole URL, so use**/checkout**orhttps://site.example/cart*, notcheckout.load_state:load,domcontentloadedornetworkidle.
Waits up to timeout_ms (default 10000, never more than 30000) and
returns {"status": "ok", "waited_ms"}. Running out of time is an
answer, not a failure: {"status": "timeout", "waited_ms", "last_seen"},
where last_seen is a short excerpt of the page text (the URL for a
url wait) so you can see why. Bad arguments return error.
Use it after navigate or an action when the page draws content late: a
page that renders 300ms after load looks empty to read_page until this
returns. It only reads, so it needs no approval and works while the user
has taken over.
Inputs: url, text, state, selector, +2 more. Returns: object
Tool Schema Changelog
Recent tool additions, removals, and schema changes observed during successful MCP inspections.
Loading tool schema changelog…
TDQS
Scored across 29 tools
Most tools have clearly distinct purposes within a browser-automation domain. Some potential for confusion exists between read_page (text/tree reading) and read_form (form field listing), or between navigate/go_back/reload_page. However, the detailed descriptions strongly clarify when to use each, keeping overlap manageable.
Mix of verb_noun (open_browser, close_browser, read_page, read_form, read_image) and verb-only (click, type_text, navigate, scroll, hover) patterns. The conventions are still readable and the verbs are clear, but there is no single predictable pattern across all 29 tools.
29 tools is at the high end for a browser automation server, especially since many are fine-grained actions (click, type_text, press_key, hover, scroll) that could be combined into a more general 'interact' tool. The count feels heavy for the domain, though each tool does have a specific role.
Covers a wide range of browser automation needs: navigation, reading, interaction, downloads, forms, tabs, dialogs, and session control. A few gaps remain (e.g., no explicit tool for executing JavaScript or managing cookies/local storage, no direct window resizing), but these are minor for most agent workflows.
Maintenance
Related MCP Connectors
Browser MCP for logged-in tasks. Uses your Chrome — credentials stay local. Zero-token replay.
Stealth web browser for agents: search, fetch, click, download and type in persistent MCP sessions.
Control a real browser from AI agents: navigate, click, type, screenshot, read the page.
Hosted real Google Chrome MCP with per-user persistent state. Navigate, click, type, screenshot.
Related MCP Servers
- AlicenseNot gradedqualityBmaintenanceEnables AI agents to see and control the user's real Chrome/Brave/Edge profile over MCP, so they can read pages, click, type, take screenshots, audit layouts, debug CSS, and scrape paginated or infinite-scroll data. Because it drives the normal browser via the DevTools protocol with real input events, it works on modern JavaScript apps and on sites where the user is logged in.20 npm1MIT
- AlicenseBqualityBmaintenanceA desktop browser shared by a human and an AI — same tabs, same live session. It bundles a stdio MCP server with 24 tools (page snapshot, click, type, screenshot, tabs, annotations, evaluate) so any MCP client can drive the browser the human is watching.2454,406 npm9MIT
- AlicenseNot gradedqualityBmaintenanceEnables any MCP-capable AI client to drive the user's own Chrome or Edge browser through a set of browse_* tools — navigating, clicking, typing, extracting, and managing tabs with their existing logins on their own machine. Runs locally and self-hosted with a live viewport and pause/approve controls so every verified step stays under user watch.4MIT
- AlicenseNot gradedqualityBmaintenanceEnables AI agents to drive a live, persistent self-hosted Chromium session over MCP, running browser actions via Playwright/CDP, capturing page snapshots, screenshots and videos, and managing uploads and downloads in the same tabs and signed-in accounts a human user can take over at any time.28 npmApache 2.0