---
name: browserious
description: >
  Drive real Chromium browsers in the Browserious cloud from the `browserious` CLI or its local
  MCP server: open pages, click, type and fill, read content, take screenshots, record video
  proof, send hardware mouse and keyboard input, and tunnel to localhost.
---

# Browserious browser control

Browserious runs one real Chromium browser per session in the cloud. You drive it with the
`browserious` command-line tool from one Python package; an MCP server ships in the same package.

## Index

Line ranges are exact, so you can read one section with `sed -n 'A,Bp' SKILL.md`. Most used first.

- [Quick start](#quick-start): lines 37–73. Install, sign in, the session loop and core commands; every task.
- [Command inventory](#command-inventory): lines 74–158. Every command and flag, one per line.
- [Tabs cost memory](#tabs-cost-memory--reuse-one-and-close-it): lines 159–193. Tab limits, `LAST_PAGE` and `PAGE_LIMIT_REACHED`.
- [Screenshots](#screenshots): lines 194–244. MCP screenshot references, crops, file ids.
- [Humanized input](#humanized-input--the-default-while-recording): lines 245–275. The `humanize`, `humanized` and `humanize_input` options.
- [Profiles](#profiles--keep-a-login-across-sessions): lines 276–324. Staying logged in between sessions.
- [Record proof and attach it](#record-proof-and-attach-it): lines 325–432. Video proof, storyboards, clips, latency, attaching.
- [Hardware input](#hardware-input--input-play-playlists): lines 433–705. Drags, key chords, typing, scrolling, easing.
- [Coordinate conversion](#coordinate-conversion--click_element-and-convert_coordinate): lines 706–764. Clicking at screen pixels.
- [Machine sizes](#machine-sizes): lines 765–779. XS or S, cost and memory.
- [Cursor size](#cursor-size): lines 780–790. A bigger cursor in recordings and screenshots.
- [Troubleshooting](#troubleshooting): lines 791–810. Common errors and fixes.
- [Output, secrets, and exit status](#output-secrets-and-exit-status): lines 811–826. `--json`, redaction, exit codes.
- [Authentication](#authentication): lines 827–865. Login, tokens, API URL and credential order.
- [Browser environment](#browser-environment): lines 866–886. Timezone, locale, WebGL vendor.
- [Localhost tunnels](#localhost-tunnels): lines 887–911. Letting the browser reach your local server.
- [MCP-to-CLI parity](#mcp-to-cli-parity): lines 912–946. Which MCP tool matches which command.
- [Local and remote MCP compatibility](#local-and-remote-mcp-compatibility): lines 947–1008. MCP setup, local and remote.

## Quick start

Install and sign in once. Login uses a device code: it opens a browser page (or prints a URL and
code with `--no-browser` on a headless host) and stores a refreshing token.

```bash
pip install browserious          # or run without installing: uvx browserious ...
browserious login
browserious whoami
```

Sessions are server-owned and outlive the CLI process, so separate invocations share a session ID.
A new session starts with one tab, normally `page-0`. **Always delete the session when you are
done**: that saves its profile and stops compute billing.

```bash
browserious --json session create                    # prints session_id; add --profile-id NAME to keep a login
browserious page goto SESSION_ID page-0 https://example.com --wait-until load
browserious page click SESSION_ID page-0 'Sign in' --strategy text
browserious page fill SESSION_ID page-0 '#email' me@example.com
browserious page type SESSION_ID page-0 '#search' 'browser automation' --delay 40
browserious page wait SESSION_ID page-0 '#results' --state visible
browserious page content SESSION_ID page-0           # the page HTML
browserious page eval SESSION_ID page-0 'document.title'
browserious screenshot page SESSION_ID page-0 --output-file page.png
browserious session delete SESSION_ID
```

- `--json` before the command gives machine-readable output; errors go to stderr with a stable
  exit code (`5` not found, `6` conflict, `8` server or transport).
- Lost an ID? `browserious session list` and `browserious page list SESSION_ID`.
- Selector strategies: `css` (default), `xpath`, `text`, `role`, `js`.
- Use the DOM commands above first. Escalate to `browserious input click-element SESSION_ID
  SELECTOR` for a real hardware click, and to `input play` for drags, key chords and pixel work.
- Reuse a tab with `page goto` instead of opening more; a session holds at most 5.
- Need proof that something works? Record it: `browserious record on SESSION_ID`.

## Command inventory

Run `browserious --help` and `browserious GROUP --help` against the installed package for the
normative inventory. The complete 0.5.0 forms, one command per line, are:

```text
browserious [--api-url URL] [--access-token TOKEN] [--json|--output human|json] [--show-secrets] [--version] COMMAND
browserious login [--no-browser]
browserious logout
browserious whoami
browserious status
browserious mcp
browserious session create [--timeout N] [--profile-id ID] [--extension ID ...] [--user-agent UA] [--timezone TZ] [--locale LOCALE] [--language LANG ...] [--proxy-server URL] [--viewport-width N] [--viewport-height N] [--chrome-arg=--FLAG ...] [--block-resource TYPE ...] [--browser-environment-webgl-vendor STR] [--browser-environment-webgl-renderer STR] [--machine-size SIZE] [--cursor-size S|M|L|XL]
browserious session list
browserious session get SESSION_ID
browserious session delete SESSION_ID
browserious page create SESSION_ID [--url URL]
browserious page list SESSION_ID
browserious page close SESSION_ID PAGE_ID
browserious page goto SESSION_ID PAGE_ID URL [--wait-until STATE] [--timeout MS]
browserious page click SESSION_ID PAGE_ID SELECTOR [--strategy STRATEGY] [--timeout MS] [--humanize|--no-humanize]
browserious page type SESSION_ID PAGE_ID SELECTOR TEXT [--strategy STRATEGY] [--delay MS] [--clear] [--humanize|--no-humanize]
browserious page fill SESSION_ID PAGE_ID SELECTOR VALUE [--strategy STRATEGY] [--humanize|--no-humanize]
browserious page eval SESSION_ID PAGE_ID SCRIPT [--args JSON_OR_@FILE]
browserious page wait SESSION_ID PAGE_ID SELECTOR [--strategy STRATEGY] [--state STATE] [--timeout MS]
browserious page content SESSION_ID PAGE_ID
browserious page init-script SESSION_ID SCRIPT [--page-id PAGE_ID]
browserious screenshot page SESSION_ID [PAGE_ID] [--output-file PATH] [--cursor] [--store] [--full-page] [--fullscreen] [--crop-x N --crop-y N --crop-width N --crop-height N | --crop-selector SEL [--crop-strategy css|xpath|text|role|js] [--crop-padding N]] [--type png|jpeg] [--quality N]
browserious screenshot display SESSION_ID [--output-file PATH] [--cursor] [--store]
browserious input play SESSION_ID JSON_OR_@FILE
browserious input cursor SESSION_ID
browserious input element-at-point SESSION_ID PAGE_ID X Y
browserious input coordinate SESSION_ID X Y [--page-id PAGE_ID] [--space viewport|page]
browserious input click-element SESSION_ID SELECTOR [--page-id PAGE_ID] [--strategy STRATEGY] [--button 1|2|3] [--count N] [--move-duration-ms MS] [--timeout MS] [--no-verify-hit-target]
browserious captcha detect SESSION_ID PAGE_ID
browserious tunnel open SESSION_ID --local-port PORT [--remote-port PORT] [--detach]
browserious tunnel close SESSION_ID --remote-port PORT
browserious tunnel list
browserious daemon start
browserious daemon status
browserious daemon stop
browserious file list [--session-id ID] [--file-type TYPE] [--limit N]
browserious file get FILE_ID
browserious file delete FILE_ID
browserious file upload PATH [--session-id ID] [--file-type TYPE]
browserious profile list
browserious profile get PROFILE_ID
browserious profile delete PROFILE_ID
browserious profile save-session SESSION_ID
browserious profile import PROFILE_ID PATH
browserious profile export PROFILE_ID [--output-file PATH]
browserious extension list
browserious extension upload PATH
browserious extension delete EXTENSION_ID
browserious route create SESSION_ID PATTERN ACTION_JSON
browserious route list SESSION_ID
browserious route delete SESSION_ID ROUTE_ID
browserious route requests SESSION_ID ROUTE_ID
browserious record on SESSION_ID [PAGE_ID] [--fullscreen] [--profile NAME] [--max-duration 30m] [--label TEXT] [--crop-x N --crop-y N --crop-width N --crop-height N | --crop-selector SEL [--crop-strategy css|xpath|text|role|js] [--crop-padding N]] [--humanize-input|--no-humanize-input]
browserious record pause SESSION_ID
browserious record off SESSION_ID [--no-wait] [--output-file PATH]
browserious record mark SESSION_ID LABEL
browserious record status SESSION_ID
browserious recording list [--session SESSION_ID] [--state recording|paused|finalizing|ready|failed] [--limit N]
browserious recording get RECORDING_ID [--output-file PATH]
browserious recording delete RECORDING_ID
browserious recording storyboard RECORDING_ID [--changes | --interval | --markers] [--every SECONDS] [--from T] [--to T] [--width 320] [--columns 4] [--max-frames 24] [--label media|wall] [--type jpeg|png|webp] [--quality N] [--output-file PATH]
browserious recording frame RECORDING_ID --at T [--crop-x N --crop-y N --crop-width N --crop-height N] [--type png|jpeg|webp] [--quality N] [--output-file PATH]
browserious recording clip RECORDING_ID --from T --to T [--crop-x N --crop-y N --crop-width N --crop-height N] [--preset attach|full] [--max-bytes 9.5MB] [--output-file PATH]
browserious recording latency RECORDING_ID --marker T [--crop-x N --crop-y N --crop-width N --crop-height N] [--threshold 0.001] [--window SECONDS]
```

`--machine-size` is covered in [Machine sizes](#machine-sizes), `--cursor-size` in [Cursor size](#cursor-size).

Selector strategies accepted by page operations are `css`, `xpath`, `text`, `role`, and `js`.
JSON arguments can be literal JSON; `input play` and `page eval --args` also accept `@path`.

Use `session get SESSION_ID` to inspect an existing session and `page list SESSION_ID` to recover
page IDs across invocations. For saving and reusing logins, see
[Profiles](#profiles--keep-a-login-across-sessions). Upload an extension
ZIP with `extension upload PATH`, attach the returned ID to `session create --extension ID`
(repeat the option for multiple extensions), and remove it with `extension delete EXTENSION_ID`
after its sessions finish. `route requests SESSION_ID ROUTE_ID` lists requests captured by an
inspection route, which helps diagnose matching before you delete the route.

## Tabs cost memory — reuse one, and close it

A session runs in a container whose memory is set by its [machine size](#machine-sizes) (4 GB on the
default `S`, 2 GB on `XS`), shared by Chromium, its renderers and the node services. Every open tab
is a renderer process holding that budget, so a long run that keeps opening tabs ends in an
out-of-memory kill, a Chrome crash or sluggish input rather than a clean error.

Three habits keep a session healthy:

- **Reuse a tab.** `page goto SESSION_ID PAGE_ID URL` navigates an existing tab. Prefer it over
  `page create` whenever you are done with the current page's content.
- **Close what you finished with.** `browserious page close SESSION_ID PAGE_ID` closes the tab and
  frees its renderer.
- **Keep one tab open.** Closing the session's *only* tab is refused with exit 6 and
  `LAST_PAGE`: Chrome runs standalone on the node and quits when its last window closes, which would
  end the session. To free that tab's memory without ending anything, navigate it to `about:blank`
  (`browserious page goto SESSION_ID PAGE_ID about:blank`). To end the session, use
  `browserious session delete SESSION_ID`.

A session may hold **at most 5 tabs open at once** (the deployment default; a tenant limit can raise
or lower it). `page create` past that exits 6 with `PAGE_LIMIT_REACHED` and an error naming the open
page ids and the close verb, so recovery is one command — close a tab you are finished with and
retry. Nothing is auto-closed: evicting the oldest tab would invalidate a page id you are still
holding.

Page IDs are never reused, so a closed ID stays a `404` forever rather than silently addressing some
other tab. `page list SESSION_ID` shows what is actually open, including tabs the site opened itself
via `window.open` or `target=_blank` — those count against the limit too, and closing them is up to
you.

Use DOM commands first (`page click`, `page fill`, `page type`, `page eval`, `page wait`, `page
content`). Escalate to `input play` for real X11/XTEST cursor movement, key presses, Unicode typing,
drag-and-drop, and pixel-level interaction. See [Hardware
input](#hardware-input--input-play-playlists) for the accepted action JSON and easing names.

## Screenshots

`page_screenshot` and `display_screenshot` return a **reference plus metadata**, not image bytes:

```json
{"session_id": "SESSION", "page_id": "page-0", "format": "png", "size_bytes": 454931,
 "width": 1920, "height": 4200, "path": "/tmp/browserious-screenshots/SESSION-page-0.png",
 "path_scope": "mcp-client-host", "file_id": null, "retrievable": true, "inline": false,
 "hint": "Local stdio MCP: ...", "warnings": []}
```

Read the file yourself only if you actually need the pixels. Pass `inline=true` to get the image
back directly; above a size ceiling (`BROWSERIOUS_MCP_INLINE_MAX_BYTES`, default 1 MiB) that raises
a structured `screenshot_too_large_for_inline` error which still carries the reference, so the
capture is never lost. Prefer `full_page=false`, a crop, or `image_type="jpeg"` to keep captures
small. Every one of those options has a CLI flag too (`--full-page`, `--crop-*`, `--type`).

**Crop to an element** instead of measuring a rectangle: `crop_selector="#checkout", crop_padding=24`
(CLI `--crop-selector '#checkout' --crop-padding 24`). The server waits for the element, scrolls it
into view and crops its box plus padding — in the viewport, the whole page with `full_page`, or the
display with `fullscreen`. The reference's `crop` field (`{x, y, width, height}`; the CLI prints it
as `crop` too) is the rectangle actually applied, in capture pixels, so you can pass it back as
`crop_x/crop_y/crop_width/crop_height` for a stable region on the next capture. Pass one crop form,
never both.

**Where `path` resolves.** Over local stdio MCP the server runs on your machine, so `path` is a real
path in your own filesystem (`path_scope: "mcp-client-host"`; override the directory with
`BROWSERIOUS_SCREENSHOT_DIR`). Repeat captures of the same page reuse that path, and the directory
is pruned to the most recent captures. Over remote MCP the server shares no filesystem with you, so
`path` is `null` and `path_scope` is `"unavailable"` — use `get_file(file_id)` instead and download
its `download_url` immediately.

**When nothing is retrievable.** A `file_id` needs file storage enabled on the deployment, a
credential with an account identity, and an API new enough to accept `store`. When those are absent
the tool still returns the reference with `retrievable: false` and a `hint`; the capture is not
lost, it is just not addressable. Re-request with `inline=true` to get the pixels directly.

**`--store` on the CLI.** The same handle, reached the same way: `browserious screenshot page S P
--store` adds `file_id` to its output, which `browserious file get FILE_ID` consumes. Without the
flag the API still persists the capture in the background, but returns no id, so there is nothing to
address it with — the id is the whole difference. When the prerequisites above are missing the image
is still written and the CLI says on stderr that no id came back, rather than looking like success.

**Text needs fonts.** A browser container with no fonts installed lays a page out correctly and
renders every glyph invisibly — the screenshot looks structurally perfect and verifies nothing.
Installing `fonts-dejavu-core` and `fontconfig` grew a real text-heavy capture roughly 15x. The
tool does **not** guess at this from one capture: a correct but sparse page (login form, empty
state) sits within ~1.2x of a fontless render in bytes per pixel, so any absolute size threshold
would condemn correct screenshots. If you suspect it, capture the same page before and after
installing fonts and compare `size_bytes`.

## Humanized input — the default while recording

While a recording is open, `page click`, `page type` and `page fill` (MCP `page_click`,
`page_type`, `page_fill`) switch from instant CDP events to real hardware input. The cursor travels
on an eased, curved path, and text is typed one key at a time (25-60 ms apart, or exactly
`--delay`), so the video never shows a teleporting cursor or text appearing at once. With no
recording open they stay instant.

- **Per call:** `--humanize` / `--no-humanize` (MCP `humanize=true|false`) overrides the default.
  Leave it out to follow the recording.
- **Per recording:** `record on --no-humanize-input` (MCP `recording_set(..., humanize_input=false)`)
  keeps page operations instant for the whole recording; a call's own `--humanize` still wins. The
  setting is fixed when the recording starts.
- **Every response says what ran** in `humanized` (`true` for hardware input, `false` for CDP).

Turn it off for a recording when the video is evidence of a result rather than of the interaction,
or when a long form fill would take too long typed key by key. Turn it on without a recording when
a page reacts differently to real pointer and keyboard events than to synthetic ones.

Only text-like inputs, textareas and contenteditable elements are typed; dates, selects, colours and
other fields keep the instant fill and answer `humanized: false`. A humanized click checks that the
element is really what the cursor will land on. If something covers it, the click waits up to
`--timeout` for the overlay to clear, then fails with 409 `ELEMENT_OBSCURED`, naming the point and
the element that would receive the click. Close the overlay, or retry with `--no-humanize` for a CDP
click. `--timeout 0` still means no timeout.

`input click-element` (MCP `click_element`) always moves this way: each click draws a new random
cubic/quadratic curve for each axis, and unless you pass `--move-duration-ms`, the travel time scales
with distance (300-700 ms). In `input play`, add `"humanize": true` to a `move` or an `x`/`y`
`click` for the same motion; see [Hardware input](#hardware-input--input-play-playlists).

## Profiles — keep a login across sessions

A profile holds a browser's cookies, localStorage and native Chromium state. Name it when you
create the session, and a later session with the same name starts already logged in:

```bash
browserious --json session create --profile-id acme-admin   # log in inside this session
browserious session delete SESSION_ID                        # closing it saves the profile
browserious --json session create --profile-id acme-admin   # starts logged in
```

Without `--profile-id` the profile is named after the session ID, which you will not reuse, so
name every profile you want to come back to. A name that does not exist yet is created on first save.

**Closing a session saves its profile.** `session delete` returns once the browser has shut down
and uploaded its state, so a new session with that profile can start straight away. You do not need
to call save first.

**`profile save-session SESSION_ID` is a mid-session checkpoint**, not a prerequisite for closing.
Use it right after a hard-won login, before something that might crash the browser, or part-way
through a long run. On success it prints `{"status":"saved"}` and the name appears in
`profile list`. On failure it exits non-zero with a specific `error_code`:

| `error_code` | Meaning | What to do |
|---|---|---|
| `PROFILE_SAVE_UNAVAILABLE` | Node not reachable (`retryable`) | Retry after a few seconds |
| `PROFILE_SAVE_IN_PROGRESS` | Another save is running (`retryable`) | Wait, then retry |
| `SESSION_TERMINATED` | The session already ended | Nothing: it was saved on close if it shut down cleanly |
| `PROFILE_SNAPSHOT_FAILED` / `PROFILE_SAVE_FAILED` | Save failed; the stored profile is unchanged | Keep the session open, retry once, then report it |
| `PROFILE_SAVE_UNSUPPORTED` / `PROFILE_SAVE_NODE_REJECTED` | Server-side fault | Don't retry; report it. Closing the session still saves |

**Do not verify a save with `profile get`.** While a session using the profile is live,
`profile get` reads that live browser, not storage, so a 200 proves nothing was stored. Check
`profile list` for the name instead. For proof, close the session and read it back.

Two sharp edges:

- **Keep the tab you logged in on open until a mid-session save.** A mid-session checkpoint can miss
  localStorage for a site first opened in this session if its tab was already closed. Cookies are
  kept either way, and closing the session saves everything. Navigate that tab elsewhere rather than
  closing it, or rely on closing the session.
- **Use one live session per profile.** Two sessions opened with the same `--profile-id` each get
  their own copy, and whichever closes last overwrites the other. Delete the first session before
  opening the profile again.

`profile delete PROFILE_ID` removes a profile you no longer need. `profile import` and `profile
export` move cookies/localStorage state as JSON. Import is refused with `PROFILE_IN_USE` while a
session is using the profile.

## Record proof and attach it

**Experimental.** A session can be recorded as an MP4, which is the proof that a fix works: the
click, and what happened after it. Recording is on for every account by default. If it has been
turned off for yours, `on` fails with `RECORDING_DISABLED` (HTTP 503, CLI exit 8).

Recording is a switch. `record on` starts (or resumes), `record pause` pauses, `record off` stops
and waits for the MP4. Over MCP it is one tool, `recording_set(state="on"|"paused"|"off")`. **Pause
while typing passwords or tokens**: the paused stretch is simply absent from the video. While a
recording is open, page operations use
[humanized input](#humanized-input--the-default-while-recording) unless you turn it off.

**Read `warnings`.** `on` returns a `warnings` list that never blocks the call. When the machine has
fewer CPUs than recording needs, it holds `RECORDING_MACHINE_UNDERSIZED`, with a message naming the
size, its CPU count and kind, and the minimum. Today that means `XS` (2 shared vCPUs, which throttle
within minutes); the default `S` does not warn. Expect dropped frames and a slower page. Create the
next session with a larger `--machine-size`, or, on this one, turn recording off and start again
with `--profile low-cpu` (MCP `profile="low-cpu"`): the profile is fixed when a recording starts.

**Record a region, not the whole screen.** `on` takes the screenshot capture options. Crop to the
element you will click *and* to where the result will appear, with generous padding. The region
is resolved once, at start, to fixed screen pixels. It does not follow scrolling, and nothing
outside it is recorded: a toast in another corner, a dialog, a redirect. When you cannot predict
where the result shows up, drop the crop.

```bash
browserious record on S --crop-selector '#checkout' --crop-padding 120   # prints recording_id
browserious record mark S submit                                         # right before the action
browserious input click-element S '#checkout button[type=submit]'
browserious record off S --output-file proof.mp4                         # waits, then downloads
```

Over MCP:

```json
{"tool": "recording_set", "arguments": {"session_id": "S", "state": "on", "crop_selector": "#checkout", "crop_padding": 120}}
```

Then `recording_mark`, drive the browser, `recording_set(state="off")`, and
`recording_get(recording_id)`. Once the recording is `ready`, `recording_get` returns a fresh
`download_url`, valid about 10 minutes, that needs no credential. Fetch it at once with
`curl -fLo proof.mp4 "<download_url>"`, then attach the file. `recording_set` redacts the URL; only
`recording_get` returns a usable one. Never paste the URL anywhere public.

Keep the `recording_id` from the first `on`. It is also the MP4's `file_id` once the recording is
ready. A human can watch it in the portal at `https://browserious.com/dashboard/sessions`: each
session's page shows its recordings in one player, with the frames, clips and storyboards cut from
them and the session's other files.

`recording_list` / `recording list` lists recordings in every state (recording, paused, finalizing,
ready, failed), and `recording_delete` / `recording delete` deletes one in any state,
so a failed recording can be cleaned up; `list_files` and `delete_file` only see ready ones.
`recording_status(session_id)` / `record status S` shows the session's recorder: whether it is
recording now, its settings, and the current or last `recording_id`.
If you used `wait=false` (`--no-wait`), or the session ended before you turned recording off (it is
finalized for you), poll `recording_get(recording_id)` /
`browserious recording get ID --output-file proof.mp4` until it is `ready`. `profile` is a name the
server validates: `sharp` for small text, `latency` before measuring reaction latency, `low-cpu` on
a small machine; an unknown name's error lists the valid ones.

**Look at it, then cut the proof.** You cannot watch a video, so read a storyboard: one image of
labelled tiles, `#N timecode @marker`, where tile `#N` is `frames[N-1]`. Zoom into a tile with a
frame at full resolution, then cut a clip around the moment. Times are TimeRefs: seconds,
`1:02.5`, `@submit` (a marker), `@2` (the 2nd marker), `@submit-2`, `end`, `end-5`.

```bash
browserious recording storyboard R --output-file sb.jpg                       # changes mode: one tile per visible change
browserious recording frame R --at @submit+1 --output-file after.png          # add --crop-* (capture space) to zoom
browserious recording clip R --from @submit-2 --to @submit+5 --output-file proof.mp4   # attach preset: <= 9.5 MB
```

Over MCP: `recording_storyboard(recording_id, inline=true)` returns the image and `frames[]`;
`recording_frame(recording_id, at=frames[6].t)`; `recording_clip(recording_id, start="@submit-2",
end="@submit+5")` returns a `download_url` to curl. The same parameters return the cached file.
A range too long for the attach preset fails with `RECORDING_CLIP_TOO_LARGE`, naming the longest
range that fits. Each derivative response carries a `Server-Timing` header that breaks its time down
by phase, if you need to see where a slow one went.

**Find what you already cut.** Storyboards, frames and clips are stored files.
`file list --session-id S` (MCP `list_files(session_id=...)`) and `file get` return a `derivative`
summary for each: `kind`, `start`/`end` or `t` (media seconds), `mode`, `every_s`, `frame_count`,
`width`/`height`, `preset` and `cropped`, plus the recording it came from as `source_file_id`. Check
it before cutting the same range again.

**Every input action is a marker.** While recording, each click, drag, key, typing and scroll
that Browserious performs is logged as an action marker; `recording get` lists them in `actions[]`
with labels such as `action:click#2` (the 2nd click), and `@action:click#2` is a TimeRef.
`recording storyboard R --markers` shows one tile per marker and action. To measure how fast
the page reacted, record with `--profile latency` (60 fps), then:

```bash
browserious recording latency R --marker @action:click#1 --crop-x 280 --crop-y 40 --crop-width 160 --crop-height 160
```

`latency_ms` runs from the press to the first frame in which the cropped area changed; the
change happened at most `accuracy_ms` (one capture tick, 17 ms at 60 fps) earlier. Crop to where
the reaction appears, so a caret or clock elsewhere does not count.

**Attach it where the work is reviewed:**

- **GitHub:** `gh pr comment 42 --attach proof.mp4` (or `gh issue comment`). Needs gh 2.99 or later
  and an OAuth token or classic PAT. Free plans cap uploads at 10 MB.
- **[Takt](https://takt.sh):** `taktcli file upload proof.mp4` prints a file id. Reference it as
  `media:<id>.mp4` in a comment (`taktcli task comment TASK --body "Proof: media:<id>.mp4"`), or
  attach it with `taktcli file attach <id> TASK`.
- **Linear:** upload with the `fileUpload` GraphQL mutation and PUT the bytes to the URL it returns,
  or put a markdown link to a URL in the comment; Linear ingests the file itself.

## Hardware input — input play playlists

Hardware-level input uses `browserious input play SESSION_ID JSON_OR_@FILE` (or the compatibility
MCP tool `play_input`). Actions execute sequentially as a playlist, driven at the X11 level by a
native XTEST core — real cursor movement, real key presses. Execution is synchronous: a failed
action returns a per-action error with its index and reason (no silent success), so you can tell
exactly what landed.

All coordinates are **absolute screen-space pixels** (top-left = 0,0). Integer only.

For reusable playlists, save a strict JSON array to a file and pass it with `@`:

```bash
browserious input play SESSION_ID @actions.json
```

Literal JSON is also accepted as the final argument. Shell quoting rules apply. A failed action is
reported with its action index and reason; the playlist does not silently claim success.

### Action Types

| Type | Fields | Description |
|------|--------|-------------|
| `move` | `x`, `y`, `duration_ms`, `easing`, `humanize?` | Animated mouse movement (no `duration_ms` = instant jump) |
| `click` | `x?`, `y?`, `button` (1=left, 2=mid, 3=right), `count?`, `humanize?` | Click at position (with `x`/`y` the cursor jumps there unless `humanize`) |
| `mouse_down` | `button` | Press without releasing |
| `mouse_up` | `button` | Release button |
| `key` | `key` | Press and release (e.g. `Return`, `space`, `a`, `Tab`) |
| `key_down` | `key` | Hold key |
| `key_up` | `key` | Release key |
| `type_text` | `text`, `interval: {min_ms, max_ms}` | Human-like typing with random delays |
| `scroll` | `delta_x`, `delta_y` | Mouse wheel scroll |
| `sleep` | `duration_ms` | Pause between actions |

#### `humanize: true` — let the server pick the motion

`play_input` runs exactly what you send. A `move` with no `duration_ms` jumps instantly, and so
does a `click` with `x`/`y`. That looks broken in a recording. Add `"humanize": true` to a
`move` or an `x`/`y` `click`, and the server fills in what you left out:

- **Easing:** a fresh random pair from `ease_in_out_quad`, `ease_in_out_cubic`, `ease_out_quad`
  and `ease_out_cubic`, drawn separately for each axis and never the same on both. A different
  curve per axis bends the path.
- **Duration:** scaled with the distance from where the cursor is (or where the previous move
  in the playlist ended), Fitts-like, 300-700 ms.

An explicit `duration_ms` or `easing` on a humanized `move` still wins. The end point is always
exact.

```json
[{"type": "move", "x": 900, "y": 420, "humanize": true},
 {"type": "click", "x": 1210, "y": 640, "button": 1, "humanize": true}]
```

### Easing Functions (31)

Easing controls the acceleration curve of mouse movements. Applied independently to X and Y axes
for natural-looking curved trajectories.

```json
{"type": "move", "x": 500, "y": 300, "duration_ms": 800,
 "easing": {"x": "ease_out_cubic", "y": "ease_in_sine"}}
```

Different X and Y easing creates curved paths — mouse moves faster horizontally while
accelerating vertically, producing a natural arc.

**Available functions:** `linear` plus 10 families x 3 variants:

| Family | ease_in_* | ease_out_* | ease_in_out_* |
|--------|-----------|------------|---------------|
| sine | ease_in_sine | ease_out_sine | ease_in_out_sine |
| quad | ease_in_quad | ease_out_quad | ease_in_out_quad |
| cubic | ease_in_cubic | ease_out_cubic | ease_in_out_cubic |
| quart | ease_in_quart | ease_out_quart | ease_in_out_quart |
| quint | ease_in_quint | ease_out_quint | ease_in_out_quint |
| expo | ease_in_expo | ease_out_expo | ease_in_out_expo |
| circ | ease_in_circ | ease_out_circ | ease_in_out_circ |
| back | ease_in_back | ease_out_back | ease_in_out_back |
| elastic | ease_in_elastic | ease_out_elastic | ease_in_out_elastic |
| bounce | ease_in_bounce | ease_out_bounce | ease_in_out_bounce |

**Choosing easing:**
- `ease_out_cubic` / `ease_out_quad` — most natural for mouse movement (fast start, slow approach)
- `ease_in_out_sine` — smooth for long traversals
- Different X/Y functions — creates curved arcs (human mice rarely travel in straight lines)
- `linear` — mechanical, use only for very short moves (<50px)
- `ease_in_back` — slight overshoot, looks like correction

### Patterns

#### Human-like click on element

```json
[
  {"type": "move", "x": 742, "y": 385, "duration_ms": 350,
   "easing": {"x": "ease_out_cubic", "y": "ease_in_out_sine"}},
  {"type": "sleep", "duration_ms": 40},
  {"type": "mouse_down", "button": 1},
  {"type": "sleep", "duration_ms": 25},
  {"type": "mouse_up", "button": 1}
]
```

Using separate `mouse_down` + `sleep` + `mouse_up` instead of `click` is more human-like.
Real clicks have 20-80ms hold time.

#### Drag and Drop

```json
[
  {"type": "move", "x": 100, "y": 200, "duration_ms": 200,
   "easing": {"x": "ease_out_quad", "y": "ease_out_quad"}},
  {"type": "mouse_down", "button": 1},
  {"type": "sleep", "duration_ms": 100},
  {"type": "move", "x": 500, "y": 400, "duration_ms": 600,
   "easing": {"x": "ease_in_out_quad", "y": "ease_in_out_quad"}},
  {"type": "sleep", "duration_ms": 50},
  {"type": "mouse_up", "button": 1}
]
```

The sleep after `mouse_down` is important — it lets the drag handler initialize.

#### Keyboard Shortcuts

Key names follow X11 keysym naming:

Ctrl+C:

```json
[
  {"type": "key_down", "key": "Control_L"},
  {"type": "key", "key": "c"},
  {"type": "key_up", "key": "Control_L"}
]

```

Ctrl+Shift+K:

```json
[
  {"type": "key_down", "key": "Control_L"},
  {"type": "key_down", "key": "Shift_L"},
  {"type": "key", "key": "k"},
  {"type": "key_up", "key": "Shift_L"},
  {"type": "key_up", "key": "Control_L"}
]

```

Alt+Tab:

```json
[
  {"type": "key_down", "key": "Alt_L"},
  {"type": "key", "key": "Tab"},
  {"type": "key_up", "key": "Alt_L"}
]
```

Common key names: `Return`, `Tab`, `Escape`, `BackSpace`, `Delete`, `space`,
`Up`, `Down`, `Left`, `Right`, `Home`, `End`, `Prior` (page up), `Next` (page down),
`Control_L`, `Shift_L`, `Alt_L`, `Super_L`, `F1`-`F12`, `a`-`z`, `0`-`9`.

**DOM key-name aliases** also work, so you don't have to remember X11 keysym names — use
`Enter`, `ArrowLeft`/`ArrowRight`/`ArrowUp`/`ArrowDown`, `Backspace`, `Escape`, `PageUp`/
`PageDown`, `Delete`, `Home`, `End`, and modifiers `ctrl`/`shift`/`alt`/`meta`. An unknown
key name returns a per-action error rather than silently doing nothing.

**Chord shortcut** — a modifier chord can be a single `key` action instead of the down/up
sequence. The first example is equivalent to holding Control, pressing `b`, and releasing Control;
it can be passed directly as one playlist:

```json
[{"type": "key", "key": "ctrl+b"}]
```

The two-modifier form is another complete playlist:

```json
[{"type": "key", "key": "ctrl+shift+k"}]
```

#### Human-like Text Entry

```json
[
  {"type": "move", "x": 500, "y": 300, "duration_ms": 300,
   "easing": {"x": "ease_out_cubic", "y": "ease_in_out_sine"}},
  {"type": "click", "button": 1},
  {"type": "sleep", "duration_ms": 200},
  {"type": "type_text", "text": "hello world", "interval": {"min_ms": 35, "max_ms": 130}}
]
```

`type_text` inserts random delays between keystrokes within the min/max range,
producing a natural human typing cadence. It handles Unicode and non-ASCII text
(accents, Cyrillic, CJK, emoji — e.g. `"Zürich Москва 你好 🚀"`) as well as ASCII.

#### Scrolling

Positive `delta_y` scrolls down:

```json
[{"type": "scroll", "delta_x": 0, "delta_y": 5}]
```

Scroll up:

```json
[{"type": "scroll", "delta_x": 0, "delta_y": -3}]
```

Horizontal scroll:

```json
[{"type": "scroll", "delta_x": 3, "delta_y": 0}]
```

#### Multi-step Workflow

Click a button, wait, type in a field that appeared, submit:

```json
[
  {"type": "move", "x": 800, "y": 450, "duration_ms": 400,
   "easing": {"x": "ease_out_cubic", "y": "ease_in_out_sine"}},
  {"type": "click", "button": 1},
  {"type": "sleep", "duration_ms": 1500},
  {"type": "move", "x": 600, "y": 300, "duration_ms": 300,
   "easing": {"x": "ease_out_quad", "y": "ease_out_quad"}},
  {"type": "click", "button": 1},
  {"type": "sleep", "duration_ms": 100},
  {"type": "type_text", "text": "search query", "interval": {"min_ms": 40, "max_ms": 100}},
  {"type": "key", "key": "Return"}
]
```

#### Right-click Context Menu

```json
[
  {"type": "move", "x": 500, "y": 400, "duration_ms": 300,
   "easing": {"x": "ease_out_cubic", "y": "ease_in_out_sine"}},
  {"type": "click", "button": 3},
  {"type": "sleep", "duration_ms": 500},
  {"type": "move", "x": 550, "y": 440, "duration_ms": 200,
   "easing": {"x": "ease_out_quad", "y": "ease_out_quad"}},
  {"type": "click", "button": 1}
]
```

#### Double-click

```json
[
  {"type": "move", "x": 500, "y": 300, "duration_ms": 300,
   "easing": {"x": "ease_out_cubic", "y": "ease_in_out_sine"}},
  {"type": "click", "button": 1, "count": 2}
]
```

### Tips

- **Move duration**: 200-600ms for normal moves, 100-200ms for nearby targets, 600-1000ms for long traversals
- **Sleep between actions**: 30-100ms between move and click, 100-500ms between unrelated actions
- **Easing variety**: Vary easing between moves in the same session to look natural
- **Verify with screenshots**: After clicking, take a screenshot to confirm the expected result
- **get_cursor_position**: Check cursor position at any time to debug coordinate issues
- **display_screenshot**: Shows the full X11 display with cursor — use to verify cursor is where you expect

## Coordinate conversion — click_element and convert_coordinate

**Do not derive the offset by hand.** The server does it, and it refuses rather than guessing when
it cannot calibrate. Hand-rolled arithmetic silently mis-clicks instead.

### Clicking one element — use `click_element`

This is the whole job in one call (CLI: `browserious input click-element SESSION_ID SELECTOR`). It
resolves the selector, brings the page to front, converts the centre of the element's *visible*
area, and plays a humanized move + press/release: a fresh random cubic/quadratic curve for each
axis and, unless you pass `move_duration_ms` (`--move-duration-ms`), a travel time scaled with
distance (300-700 ms):

```
click_element(session_id, "#target")
click_element(session_id, "#menu", button=3)                    # right-click
click_element(session_id, "#file", count=2)                     # double-click
click_element(session_id, "//button[@id='x']", strategy="xpath")
```

It fails loudly rather than clicking the wrong place:

| `error_code` | Meaning |
|---|---|
| `ELEMENT_NOT_VISIBLE` | No layout box |
| `ELEMENT_OFF_SCREEN` | No part of it is inside the clickable region |
| `ELEMENT_OBSCURED` | An overlay/banner would receive the click — pass `verify_hit_target=false` (`--no-verify-hit-target`) to click anyway |
| `COORDINATE_CALIBRATION_UNAVAILABLE` | Geometry cannot be calibrated (e.g. `devicePixelRatio != 1`) |

### Converting points yourself — use `convert_coordinate`

Needed only for drags, custom playlists, or batching
(CLI: `browserious input coordinate SESSION_ID X Y`). It returns the `offset` itself, so N targets
cost **one** call:

```
convert_coordinate(session_id, 640, 400)
# -> {"x": 650, "y": 497, "offset": {"x": 10, "y": 97}, "on_screen": true, "geometry": {...}}
```

Then convert the rest locally — `screen = offset + viewport_point`:

```javascript
(() => Array.from(document.querySelectorAll(".item")).map(el => {
    const r = el.getBoundingClientRect();
    return {text: el.textContent.slice(0, 50), vx: Math.round(r.x + r.width/2), vy: Math.round(r.y + r.height/2)};
}))()
```

...then add `offset.x` / `offset.y` to each `vx` / `vy`.

`on_screen` reports whether a point is actually clickable — inside **both** the viewport and the
display. A point above the viewport top lands in the browser chrome, where a hardware press focuses
the omnibox and any following `type_text` goes into the URL bar; a point past the display edge is
silently clamped by xdotool.

For reference, the offset derives as `(screenX + chromeWidth // 2, screenY + chromeHeight)` and
measures **(10, 97)** on a default 1920x1080 node.

## Machine sizes

`session create --machine-size SIZE` (MCP `create_session(machine_size=...)`) picks the session's
machine. It is fixed for the session's lifetime.

| Size | CPU / memory | Compute credits | Suits |
|---|---|---|---|
| `XS` | 2 vCPU, 2 GB | 1× | short jobs of about 5 minutes or less; its shared CPU throttles after that |
| `S` (default) | 4 vCPU, 4 GB | 2× | ordinary browsing, recording, and anything longer than a few minutes |

Omit the option for the default, `S`. The balance needed to start a session and the hold taken at
create scale with the size's multiplier too. An unknown name returns 422 listing the allowed sizes,
and `session get` echoes the size as `machine_size`. Recording on `XS` returns a warning; see
[Record proof and attach it](#record-proof-and-attach-it).

## Cursor size

`session create --cursor-size S|M|L|XL` (MCP `create_session(cursor_size=...)`) sets the mouse
cursor's size for the session's lifetime: an arrow 24, 32, 48 or 64 px tall, `M` by default. It is
the browser's own system cursor, so recordings capture it, and `screenshot page --cursor` (MCP
`page_screenshot(cursor=true)`) draws the same arrow at the same size, its tip on the pointer's
pixel. Over links and text the recording shows the matching hand or I-beam at that size; the
screenshot always draws the arrow. Pick `L` or `XL` when a person or a vision model must find the
cursor in a video: the default recording profile fits the display into 1280x720, which shrinks the
cursor by a third. `session get` echoes the size as `cursor_size`.

## Troubleshooting

- `No Browserious credential is configured`: run `browserious login`; for headless use
  `browserious login --no-browser`.
- Authentication exit `3`: check `browserious whoami`, the selected API URL, and whether an explicit
  token is shadowing the stored login. Explicit/env credentials cannot refresh.
- `403`: the credential lacks the operation's scope. Device login requests `browser:read`,
  `browser:write`, and `tunnel`.
- `404` for a known session: sessions are owner-scoped; confirm the account/API origin and session
  ID. Browserious deliberately does not reveal another owner's session.
- `8` from a tunnel/operation: check `browserious status`, then `browserious daemon status` for a
  detached tunnel. Retry after connectivity returns; do not copy session tokens from output.
- Stale `uvx` behavior: pin the intended version and rerun with `uvx --refresh --from
  browserious==VERSION browserious ...`.
- MCP host starts CLI help: configure the explicit final argument `mcp`. The zero-argument bridge
  only applies when both standard streams are non-TTY and is temporary.
- Remote MCP tunnel error: use `browserious tunnel ...` or local `browserious mcp` stdio.
- Credential permission, ACL, symlink, or reparse-point error: fix ownership/security on the
  platform path; Browserious will not bypass validation.

## Output, secrets, and exit status

Human output is the default. Use `--json` or `--output json` for compact JSON on stdout. Diagnostics
and errors go to stderr. Returned secrets are recursively redacted by default, including tokens,
passwords, cookies, proxy credentials, CDP URLs, VNC URLs, and authorization headers.

`--show-secrets` is bounded to `session create`, `file get`, `profile get`, and `profile export`.
It warns before writing secrets to an interactive terminal. `profile export --output-file PATH`
requires `--show-secrets` and reports that the saved artifact contains secrets. Screenshots and
other non-JSON responses write to `--output-file` or `browserious-output.bin`.

Exit codes are stable classes: `0` success, `2` local validation/platform error, `3` authentication
or credential error, `4` forbidden, `5` not found, `6` conflict, `7` rate limited, `8` transport or
server failure, `9` other HTTP failure, and `130` interrupted foreground tunnel. Argparse usage
errors also use `2`.

## Authentication

`browserious login` uses RFC 8628 device authorization. It opens the verification page when
possible, prints the URL and user code to stderr, polls until approval, and stores an OAuth access
token plus rotating refresh token. Use `browserious login --no-browser` on a headless host. The CLI
refreshes an expiring stored token under a cross-process lock; a refresh retry re-reads the store so
concurrent invocations share the rotated credential family. `browserious logout` revokes the active
stored refresh family, stops the tunnel daemon, and deletes the local profile only after successful
server revocation.

API URL precedence is:

1. `--api-url`
2. `BROWSERIOUS_API_URL`
3. the current stored profile origin
4. `https://api.browserious.com`

Credential precedence is:

1. `--access-token`
2. `BROWSERIOUS_ACCESS_TOKEN`
3. the stored login for the selected API URL

Explicit and environment tokens are never refreshed, and an empty explicit value fails instead of
falling through to the stored login. Normal setup is `browserious login`.

Credential profiles are keyed by API origin. Linux uses
`$XDG_CONFIG_HOME/browserious/credentials.json` or `~/.config/browserious/credentials.json`, macOS
uses `~/Library/Application Support/browserious/credentials.json`, and Windows uses
`%APPDATA%\browserious\credentials.json`. The directory, credential file, and lock are validated as
owner-only. Windows uses a protected DACL containing only the current user's SID, rejects reparse
points and wrong owners, uses a cross-process byte-range lock, and retries atomic replacement when a
reader briefly holds the file. Permission/security failures stop rather than weakening protection.

Always delete a session when finished. CLI and MCP browser commands use authenticated shared REST
operations, which makes sessions usable across invocations without a process-local MCP registry.
Only the local tunnel owner obtains a short-lived, owner- and scope-bound tunnel capability and
forwards its opaque routing headers; capability and session secrets stay out of the credential store.

## Browser environment

`session create` can pin parts of the environment the page observes, so a run is reproducible
across heterogeneous hosts instead of varying with whatever GPU and locale the container happened
to get. Use it for environment emulation and cross-configuration testing — checking a WebGL-gated
feature renders on an Intel integrated stack and an NVIDIA discrete one, or that a date widget
behaves in a non-local timezone.

```bash
browserious session create \
  --timezone America/New_York \
  --locale en-US \
  --browser-environment-webgl-vendor "Google Inc. (NVIDIA Corporation)" \
  --browser-environment-webgl-renderer "ANGLE (NVIDIA, NVIDIA GeForce RTX 3060, OpenGL 4.5)"
```

Both WebGL options are independent — set either alone. `--user-agent`, `--timezone`, `--locale`
and `--language` shape the same emulated environment and can be combined freely. The MCP tool
takes the same two values as `browser_environment_webgl_vendor` and
`browser_environment_webgl_renderer`.

## Localhost tunnels

```bash
browserious tunnel open SESSION_ID --local-port 3000
```

The foreground command owns its WebSocket until interrupted and then closes its tunnels. The remote
browser reaches the forwarded service at `http://localhost:3000` (or the chosen `--remote-port`).
Use `--detach` for a persistent tunnel owned by the per-user local daemon:

```bash
browserious daemon start
browserious tunnel open SESSION_ID --local-port 3000 --remote-port 8080 --detach
browserious tunnel list
browserious tunnel close SESSION_ID --remote-port 8080
browserious daemon stop
```

Detached mode requires Unix-domain sockets; on an unsupported platform use foreground mode. The
daemon validates owner-only runtime directory, socket, and state paths and safely handles missing or
stale socket state. It proactively refreshes tunnel capabilities and atomically replaces the
transport during refresh; unrecoverable transport or refresh failures drop ownership. Its PID file
is a descriptor, not a reconnection mechanism. Stop, logout, and session deletion close owned
tunnels. `tunnel list` safely reports a stopped daemon as an empty list.

## MCP-to-CLI parity

The 0.5.0 package enumerates 36 MCP tools. Runtime MCP tool enumeration is normative. Every tool
delegates to the same operation as its CLI equivalent:

| MCP tool | CLI equivalent |
|---|---|
| `create_session` / `list_sessions` / `delete_session` | `session create` / `session list` / `session delete` |
| `create_page` / `close_page` | `page create` / `page close` |
| `page_goto` / `page_click` / `page_type` / `page_fill` | `page goto` / `page click` / `page type` / `page fill` |
| `page_eval` / `page_wait_for_selector` / `page_content` | `page eval` / `page wait` / `page content` |
| `add_init_script` | `page init-script` |
| `page_screenshot` / `display_screenshot` | `screenshot page` / `screenshot display` |
| `play_input` / `get_cursor_position` / `element_at_point` | `input play` / `input cursor` / `input element-at-point` |
| `click_element` / `convert_coordinate` | `input click-element` / `input coordinate` |
| `captcha_detect` | `captcha detect` |
| `open_tunnel` / `close_tunnel` | `tunnel open` / `tunnel close` |
| `list_files` / `get_file` / `delete_file` | `file list` / `file get` / `file delete` |
| `recording_set` (`state` on/paused/off) | `record on` / `record pause` / `record off` |
| `recording_mark` | `record mark` |
| `recording_status` | `record status` |
| `recording_get` / `recording_list` / `recording_delete` | `recording get` / `recording list` / `recording delete` |
| `recording_storyboard` / `recording_frame` / `recording_clip` | `recording storyboard` / `recording frame` / `recording clip` |
| `recording_latency` | `recording latency` |

The CLI additionally exposes session inspection, page listing, files/uploads, profiles, extensions,
routes, daemon management, and authentication/status commands.

`get_file` and `file get` return owner-scoped file metadata plus a presigned download URL; neither
streams the stored file content. `file get` redacts the URL unless you pass `--show-secrets`. The
`get_file` MCP tool returns it usable: a fresh, read-only URL for that one file, valid about 10
minutes (`download_url_expires_at`). Call `get_file` right before downloading, fetch it at once with
`curl -fLo <name> "<download_url>"`, call again if it expired, and never paste the URL anywhere
public. Every other secret-shaped value in the response stays redacted.

## Local and remote MCP compatibility

MCP is optional compatibility, not a richer interface or a second package. The canonical local
stdio command is:

```bash
browserious mcp
```

It uses the same credential precedence, store, refresh lifecycle, capability client, operations,
scopes (`browser:read browser:write tunnel`), and revocation path as the CLI. Configure a local MCP
host with a pinned wheel when reproducibility matters:

```json
{
  "mcpServers": {
    "browserious": {
      "command": "uvx",
      "args": ["--refresh", "--from", "browserious==0.5.0", "browserious", "mcp"]
    }
  }
}
```

The package also keeps two deprecated, transitional bridges:

- `python -m browserious_mcp`
- invoking `browserious` with zero arguments while both stdin and stdout are non-TTY

Both start the same stdio server and emit a deprecation diagnostic. Interactive zero-argument
`browserious` and `browserious --help` show CLI help. Do not configure the old no-argument form for
new hosts. Eventual MCP retirement removes the `mcp` verb, the temporary entry points, the
`browserious_mcp` module, MCP dependencies, parity/compatibility tests, and MCP documentation from
this same distribution; it does not migrate users to another artifact.

Optional remote MCP is available at `https://api.browserious.com/mcp`. It is stateless Streamable
HTTP. An OAuth-capable client follows this exact flow:

1. An unauthenticated MCP request returns `401` with `WWW-Authenticate: Bearer resource_metadata="https://api.browserious.com/.well-known/oauth-protected-resource/mcp"`.
2. Fetch that RFC 9728 protected-resource document. Its `resource` is
   `https://api.browserious.com/mcp` and its `authorization_servers` points to
   `https://api.browserious.com`.
3. Fetch RFC 8414 metadata from
   `https://api.browserious.com/.well-known/oauth-authorization-server`.
4. Register a public client at the advertised registration endpoint using the client's exact
   redirect URI.
5. Start authorization at the advertised endpoint with scopes
   `browser:read browser:write tunnel`, an authorization code challenge, and
   `code_challenge_method=S256`.
6. Exchange the code at the advertised token endpoint with the original PKCE verifier; rotate
   refresh tokens normally and revoke through the advertised revocation endpoint.

To add it to Claude Code:

```bash
claude mcp add --transport http browserious https://api.browserious.com/mcp
```

Cursor and Windsurf onboarding remains manual-only; no completed client-specific flow is claimed.
Remote and local MCP enumerate the same tools, but remote `open_tunnel` and `close_tunnel` always
fail safely with guidance to use the CLI or local stdio MCP because a service-host process cannot
own a client-local port.
