> ## Documentation Index
> Fetch the complete documentation index at: https://docs.webrouter.net/llms.txt
> Use this file to discover all available pages before exploring further.

# Browser sessions and browse

> Open a browser session to drive yourself, on a hosted vendor or a local Chromium, or hand a task to a hosted browser agent.

`open_browser()` is the one method that returns neither an answer nor a job but
a **resource**: a live browser, and a handle on it. Drive it with the handle's
own page actions, or hand its `cdp_url` to Playwright, browser-use or
Stagehand. A cloud session bills for as long as it is open, so the handle is a
context manager that releases it however the block ends:

```python theme={null}
from web_router import WebRouter

with WebRouter().open_browser(timeout=300) as browser:
    browser.goto("https://example.com")
    print(browser.provider, browser.snapshot().tree)
# released here -- on an exception too, and on router.close()
```

A session opened with no `timeout` gets `BROWSER_TIMEOUT`, ten minutes, rather
than the vendor's own limit, which on some backends is hours: a session you
forgot to release should cost ten minutes of credit, not an afternoon's. Pass a
larger `timeout` to go past it. Releasing twice is a no-op, and a handle
garbage-collected while still open warns with `ResourceWarning`.

**Connect once per session.** Several backends (Browserbase and Hyperbrowser
among them) end a session when its CDP client disconnects, so a driver that
reconnects for each action finds the session gone after the first. The handle's
own actions already work this way: the first one connects, and the rest reuse
that connection until the handle is released.

`cdp_url` is a credential (whoever holds it drives the browser, and Steel puts
your API key in it), so `BrowserSession.cdp_url` is a `SecretStr`, and neither
the session's `repr` nor its serialization carries it or the raw payload.
`browser.cdp_url` returns the plain string, because asking for it by name is the
one moment you need it.

## Session backends

Opening a session on a vendor goes over its REST API with httpx, which `[all]`
installs; `local` needs only the standard library and a Chrome or Chromium. The
page actions need the `[browser]` extra, which is Playwright.

| Pass | Name | Provider | Credentials | Options mapped | Verified how |
| - | - | - | - | - | - |
| `Provider.AUTO` *(default)* | `auto` | none | whatever its chain holds; none for `local` | every option some member maps, routed per request; `provider_options` is refused | Falls through only when a member refuses to open (no key, a 4xx, no connection); anything that may have opened a session is raised |
| `Provider.STEEL` | `steel` | [Steel Sessions](https://docs.steel.dev) | `STEEL_API_KEY` | `timeout`, `idle_timeout`, `solve_captchas`, `proxy_country` (no enumerated country list; `stealth` not mapped: Steel has no on/off or tiered stealth field), `profile` (an id), `persist_profile` | `profile`, `persist_profile` and `control_url()` **live**, 2026-10-20; the rest from documentation; `solve_captchas`/`proxy_country` await a live run |
| `Provider.BROWSERBASE` | `browserbase` | [Browserbase Sessions](https://docs.browserbase.com) | `BROWSERBASE_API_KEY` | `timeout`, `stealth` (`low`/`high`, via `browserSettings.verified`; `advancedStealth` is deprecated), `solve_captchas`, `proxy_country`, `profile` (a context id), `persist_profile` | `profile`, `persist_profile` and `control_url()` **live**, 2026-10-20; the rest from documentation; `stealth`/`solve_captchas`/`proxy_country` await a live run |
| `Provider.HYPERBROWSER` | `hyperbrowser` | [Hyperbrowser Sessions](https://hyperbrowser.ai/docs) | `HYPERBROWSER_API_KEY` | `timeout` (rounded up to whole minutes), `stealth`, `solve_captchas`, `proxy_country`, `profile` (an id), `persist_profile` | `profile`, `persist_profile` and `control_url()` **live**, 2026-10-20; the rest from documentation; `stealth`/`solve_captchas`/`proxy_country` await a live run |
| `Provider.ANCHOR` | `anchor` | [Anchor Browser](https://docs.anchorbrowser.io) | `ANCHOR_API_KEY` | `timeout`, `idle_timeout` (rounded up to whole minutes), `stealth`, `solve_captchas`, `proxy_country`, `profile` (a name), `persist_profile` | `profile`, `persist_profile` and `control_url()` **live**, 2026-10-20; the rest from documentation; `stealth`/`solve_captchas`/`proxy_country` await a live run. Headful and recorded by default, and left that way |
| `Provider.KERNEL` | `kernel` | [Kernel Browsers](https://kernel.sh/docs) | `KERNEL_API_KEY` | `idle_timeout` (whole seconds, 10 s to 72 h), `stealth` (one tier, which also brings Kernel's CAPTCHA solver and proxy), `headless`, `viewport`, `profile` (an id), `persist_profile`. `timeout`, `solve_captchas` and `proxy_country` raise | **Live**, 2026-10-20: open, drive, status, release, `control_url()`, and every mapped option |
| `Provider.LOCAL` | `local` | Google Chrome or Chromium on this machine | none (`CHROME_PATH` optional) | `headless`, `timeout`, `viewport` | **Live** against Google Chrome on macOS: launch, CDP `/json/version`, release, lifetime expiry, exit cleanup. `headless=False` is not exercised by a test |

`auto` opens through the first vendor you hold a key for, in the order **Steel,
Browserbase, Hyperbrowser, Anchor, Kernel**, and then **`local`**. A key in the
environment says which backend you want, so `local` is the fallback, never a
substitute for a vendor you are paying for. Each member is checked against the
request on its class before anything opens, so a member that does not map an
option you set is passed over: `idle_timeout` skips Browserbase, Hyperbrowser
and `local`, `timeout` skips Kernel, and `headless` every vendor but Kernel. The ten-minute default does not count as an
option you set. `provider_options` is refused, because it is one backend's own
fields and `local` would read it as Chrome switches. Name the backend you
wrote it for. `profile` and `persist_profile` are refused for the same reason:
a profile is saved on one backend. `auto` never opens a second session in one call: it moves on only
after a failure that proves nothing opened, and a read timeout, a 5xx or an
unreadable answer, which may have left a session billing, is raised instead.
The handle it returns is the member's own, so `browser.provider` names the
backend that served it.

`stealth` is a level, not a switch: `"off"`, `"low"` (the backend's standard
stealth) or `"high"` (its strongest, often plan-gated), and unset leaves the
backend's own default. A backend without the level you ask for raises
`UnsupportedParameterError` naming it rather than serving another one, and
`auto` passes such a member over, as it does one that serves an option only
beside another you left unset. Hyperbrowser, Browserbase, Anchor and Kernel map it; Steel does not.

`provider_options` on a named backend merges beneath the mapped options, and
setting a field a mapped option also sets (including a `timeout` filled in
from the default) raises `ConfigurationError` rather than one side silently
winning.

## Logging in once, and reusing the login

A site behind a login needs a person to log in once. `control_url()` hands back
a page where that person sees the session and can type into it; with `profile`
and `persist_profile=True`, what they leave behind (cookies, local storage)
is saved when the session is released, and a later session opened on the same
`profile` starts logged in:

```python theme={null}
from web_router import WebRouter

with WebRouter("browserbase") as router:
    with router.open_browser(profile="<context id>", persist_profile=True) as browser:
        print("Log in here:", browser.control_url())
        input("Press Enter once you are logged in. ")
    # Released: the login is saved to the profile. Give it a few seconds.

    with router.open_browser(profile="<context id>") as browser:
        browser.goto("https://example.com/account")  # already logged in
```

`control_url()` is a credential, like `cdp_url`: whoever opens it drives the
browser, and on Steel, Kernel, Hyperbrowser and Anchor no account login stands
in the way. Give it only to the person logging in, and do not log it. It is
`None` on a backend that offers no such page. `live_view_url` stays for pages
that show a session without granting control: today Steel's dashboard page,
which asks for a Steel login. Browserbase hands out its page only on request,
so `control_url()` asks for it the first time it is called; every other
backend returned it on open. On an `AsyncBrowserHandle`, `await
browser.control_url()`.

| Backend | `profile` is | `control_url()` is | Make a profile |
| - | - | - | - |
| `browserbase` | a context id | `debuggerFullscreenUrl`, fetched on first call | `POST /v1/contexts` |
| `steel` | a profile id | `debugUrl` | open a session with `provider_options={"persistProfile": True}` and read `raw["profileId"]` |
| `hyperbrowser` | a profile id | `liveUrl` | `POST /api/profile` |
| `anchor` | a name you choose | its `live_view_url` | none: the first session that saves to a name creates it |
| `kernel` | a profile id | `browser_live_view_url`, for a session with `headless=False` | `POST /profiles` |
| `local` | none | `None`: open it with `headless=False` and the window is on your screen | none |

WebRouter takes a profile you already have; making, listing and deleting them
stays with each vendor's own API or dashboard. `persist_profile` without a
`profile` raises, rather than making a new one where the vendor would (Steel).
Each backend saves as the session ends, not at the release call, so wait a few
seconds before opening on the profile again; Steel answers 409 until its upload
reports `READY`. Two sessions writing to one profile at once overwrite each
other. Every row was checked live on 2026-10-20: a cookie and a localStorage
entry set in one session were there in the next.

## Driving the page

A handle can drive its session's page itself, the same way whichever backend
serves it: the actions run over the session's CDP URL through Playwright, once
for every backend, never through a vendor's own endpoints. They need the
`[browser]` extra, and nothing else: no `playwright install`, because the
browser is the session's.

```python theme={null}
from web_router import WebRouter

with WebRouter("local").open_browser() as browser:
    browser.goto("https://news.ycombinator.com")  # PageState: url, title, status_code
    page = browser.snapshot()  # PageSnapshot: url, title, tree
    print(page.tree)
    # - link "Hacker News" [ref=e6]
    # - textbox [ref=e312]
    browser.click(ref="e6")  # or selector="a.morelink"
    browser.type("web-router", ref="e312", submit=True)
    browser.press("Escape")
    png = browser.screenshot(full_page=True).png  # Screenshot: png, width, height, url
    text = browser.read().content  # ExtractedDocument; format="text" or "html"
```

`snapshot()` returns the page's accessibility tree, one element per line, each
tagged with a ref to pass to `click` or `type`. Unnamed wrapper elements and
link URLs are left out to keep it short. Treat a ref as opaque: it is good
until the next snapshot or navigation, and one used after that, or after its
element was re-rendered, raises `StaleElementError` rather than clicking
whatever now sits there. A real page's tree runs to tens of thousands of
characters; `snapshot(max_chars=...)` cuts it at a line boundary and sets
`truncated`, and the refs past the cut are not offered. A `Screenshot`
serializes to JSON with its PNG as base64.

Every action waits up to `BROWSER_ACTION_TIMEOUT` (30 seconds) unless you pass
`timeout=`, except `press`, which waits on nothing. Sync handles on one thread
share one Playwright, each with its own connection. The sync handle refuses to
run inside an event loop, because Playwright's sync API does; use
`AsyncWebRouter` there, whose handle has the same actions, awaited:

```python theme={null}
async with AsyncWebRouter("local") as router, await router.open_browser() as browser:
    await browser.goto("https://example.com")
    page = await browser.snapshot()
```

Failures raise a `BrowserActionError`, carrying the `provider`, `session_id`
and `action`, with Playwright's error on `__cause__`, or one of its
subclasses, so an agent can tell them apart: `StaleElementError`,
`ElementNotFoundError` (a selector matched nothing in time), `NavigationError`
(`goto` could not load the page), `BrowserActionTimeoutError` and
`PageClosedError` (the page, the connection or the session is gone).

[`examples/browser_session.py`](https://github.com/Nimbleway/webrouter/blob/main/examples/browser_session.py) runs all of this
against `local`, sync and async, with no key.

## Session backend notes

> [accordion] Steel
>
> **Steel.** Its `cdp_url` carries your API key in its query string, which is
> Steel's own connection scheme. It keeps a session across a reconnect, as
> `local` does. Proxies, stealth, CAPTCHA solving, profiles, viewport and
> headless are reachable through `provider_options` until a live call confirms a
> mapping for them. `profile` and `persist_profile` map onto `profileId` and
> `persistProfile`. `live_view_url` is its `sessionViewerUrl`, a dashboard page
> that asks for a Steel login; its `debugUrl` needs none, so it is
> `control_url()`.

> [accordion] Browserbase
>
> **Browserbase.** Its `cdp_url` is the `connectUrl` it returns, which is enough
> on its own to drive the session. It solves CAPTCHAs unless
> `browserSettings.solveCaptchas` is set to `false` through `provider_options`;
> it ends a session when its CDP client disconnects, unless `keepAlive` is set;
> and it has no idle timeout, so `idle_timeout` is refused. It reports a session
> that ended because its client disconnected the same way as one that was
> released, so `status()` says `"released"` for both. `profile` is a context id,
> sent with `persist_profile` as `browserSettings.context`. Its live view lets
> whoever opens it drive the session and takes a second request, so
> `live_view_url` stays `None` and `control_url()` makes that request when first
> called; `expires_at` is the `expiresAt` it reports.

> [accordion] Hyperbrowser
>
> **Hyperbrowser.** Its `cdp_url` is the `wsEndpoint` it reports, which carries a
> session token. It takes its lifetime in whole minutes, so `timeout` is rounded
> up, and it has no idle timeout, so `idle_timeout` raises. A session ends when
> its CDP client disconnects and when every page in it is closed; Hyperbrowser
> keeps one alive across a disconnect only with `keepAlive=true` on the connect
> URL, which WebRouter does not add. Its `liveUrl` lets whoever opens it drive
> the session, so it is `control_url()`, never `live_view_url`. `profile` and
> `persist_profile` map onto `profile.id` and `profile.persistChanges`.
> `stealth="off"` sends `useStealth: false` and `useUltraStealth: false`,
> `"low"` sends `useStealth: true`, `"high"` sends `useUltraStealth: true`
> (enterprise-only on Hyperbrowser's own plans). `solve_captchas` maps onto
> `solveCaptchas`, sent as `false` too when asked for. `proxy_country` turns
> `useProxy` on and sends `proxyCountry` upper-cased, refused outside the
> country list Hyperbrowser's API reference publishes. All three are mapped from
> documentation, unverified against a live call.

> [accordion] Anchor
>
> **Anchor.** Both lifetimes are whole minutes, rounded up. Its `idle_timeout`
> starts counting only once the last connection closes, so a client that stays
> connected but idle keeps the session, and its bill, alive until `timeout`.
> `stealth`, `solve_captchas` and `proxy_country` are mapped from documentation
> alone, **unverified against a live call**. Anchor has one stealth tier, so `"low"` and `"high"` both turn on
> `browser.extra_stealth.active`, and `"off"` turns it off explicitly. Anchor
> silently disables extra stealth unless its proxy is active, so asking for
> `stealth="low"` or `"high"` without `proxy_country` raises before a session
> opens rather than opening one with less stealth than asked for.
> `proxy_country` is sent lower-case, as Anchor's guide shows it, and is checked
> against every country its proxy guide lists. `profile` is a profile's name,
> sent with `persist_profile` under `browser.profile`. Its live view is
> interactive unless the session was opened read-only, so it is `control_url()`,
> never `live_view_url`.

> [accordion] Kernel
>
> **Kernel.** Its `cdp_url` is the `cdp_ws_url` it returns, which carries a
> session token. Kernel has no lifetime limit, only an idle one: `idle_timeout`
> maps onto `timeout_seconds`, which counts down only while no CDP, WebDriver or
> live-view client is connected, and Kernel deletes the session when it runs
> out (after 60 seconds if you leave it unset), so connect soon after opening.
> `timeout` raises, and the ten-minute default is not sent. Kernel has one
> stealth switch, so `"low"` and `"high"` both send `stealth: true`, and `"off"`
> sends `false`; stealth also turns on Kernel's CAPTCHA solver and its stealth
> proxy, which is why `solve_captchas` raises rather than claiming a separate
> switch. `proxy_country` raises too: a country needs a proxy created first
> through Kernel's Proxies API, and an existing one can be passed as
> `provider_options={"proxy": {"id": ...}}`. `profile` is a Kernel profile's
> id (pass a name as `provider_options={"profile": {"name": ...}}`) and
> `persist_profile` sends `profile.save_changes`, refused without a `profile`.
> Its live view lets whoever opens it drive the session, so it is
> `control_url()`, never `live_view_url`; Kernel offers one only for a session
> with `headless=False`. A deleted session, whether released or
> idled out, reads as `"released"`: Kernel answers 404 for both and does not say
> which. Every mapping was checked live on 2026-10-20.

> [accordion] local
>
> **`local`** starts a fresh browser process per session, with a temporary
> profile and its DevTools port pinned to `127.0.0.1`, and stops it and deletes
> the profile on release, when its `timeout` runs out, or when your Python
> process exits. It looks for `executable_path` in `provider_config`, then
> `CHROME_PATH`, then the usual Chrome and Chromium install locations, then
> Playwright's downloaded Chromium, and raises `ConfigurationError` naming
> `uvx playwright install chromium` when it finds none. It is headless unless you
> pass `headless=False`: the one place an unset option is filled in because the
> binary's own default, a window, would open on your screen. It maps `viewport`
> through CDP's `Browser.setContentsSize` once the browser is up, which gives a
> page of exactly that size; `--window-size` cannot, because it sizes the outer
> window and even headless Chrome renders a smaller page inside it (800x600 gives
> an 800x513 page). A browser too old to have that command fails the open. Its
> `provider_options` are Chrome command-line switches, so
> `{"window-size": "800,600"}` sets the outer size if that is what you want --
> though not beside `viewport`, which refuses every switch that sizes the window.

## Browse

`browse()` hands a task to a vendor's browser agent, which drives one of the
vendor's own browsers through it and answers minutes later. It is research's
shape on the same job core: `browse()` blocks until the answer is in, and
`submit_browse()` hands back a `BrowseJob` to poll instead.

```python wrap theme={null}
response = router.browse(
    "find the title of the top story and how many points it has",
    start_url="https://news.ycombinator.com",
)

response.answer  # prose, when the agent answered in prose
response.structured  # an object, only when the backend says it fits a schema you asked for
response.steps  # what the backend reported doing, in order: action, url, detail, screenshot_url
response.outcome  # 'complete', or 'partial' only when the backend said it stopped early
response.provider  # who ran it -- never 'auto'
response.raw  # every untouched payload; kept out of repr and serialization

job = router.submit_browse("...", start_url="https://example.com")
job.result(timeout=60)  # raises BrowseTimeoutError and leaves the task going
```

Waiting defaults to `BROWSE_TIMEOUT`, fifteen minutes. Running out raises
`BrowseTimeoutError` carrying the job id, and leaves the task going rather than
cancelling it; `job.cancel()` stops one where the backend can. `raw` stays out
of `repr` and serialization because a task payload can carry the URL of the
session the agent ran in, and live-view, session and recording-control URLs
are never promoted to plain fields.

A backend that cannot start without a page raises `MissingParameterError` when
`start_url` is unset, before anything is billed. One with no structured output
refuses `output_schema` rather than handing back prose under the name of a
schema. [`examples/browse.py`](https://github.com/Nimbleway/webrouter/blob/main/examples/browse.py) runs a task both ways.

### Browse backends

Every task bills, so there is no keyless tier. Each backend talks to its REST
API over httpx, which `[all]` installs.

| Pass | Name | Provider | Credentials | Options mapped | Verified how |
| - | - | - | - | - | - |
| `Provider.AUTO` *(default)* | `auto` | none | Browser Use, Skyvern or Browserbase, whichever you hold | whatever the member it routes to maps | Routes around a member before any task starts; with no member left it raises, naming why each was skipped |
| `Provider.BROWSER_USE` | `browser-use` | [Browser Use Cloud API v2](https://docs.browser-use.com/cloud/api-v2-overview) | `BROWSER_USE_API_KEY` | `start_url`, `output_schema`, `max_steps`, `allowed_domains` | **Live**, 2026-10-21: a prose task, and each mapped option |
| `Provider.SKYVERN` | `skyvern` | [Skyvern run-task API](https://skyvern.com/docs/sdk-reference/tasks/run-task) | `SKYVERN_API_KEY` | `start_url` (optional) | **Documentation only**: no key was available. `output_schema`, `max_steps` and `allowed_domains` are refused; most answers are only in `raw` (see below) |
| `Provider.BROWSERBASE` | `browserbase` | [Browserbase Agents](https://docs.browserbase.com/platform/agents/overview) | `BROWSERBASE_API_KEY` | none beyond the task | **Live**, 2026-09-30 and 2026-10-21: a prose task. `start_url` is refused; a schema goes through `provider_options` as `resultSchema`, which awaits a live run |

`auto` tries **Browser Use, Skyvern, then Browserbase**, only those you hold a
key for, and nothing after them. Browser Use comes first because it answers in
prose and reports the steps it took; Browserbase last, because its Agents API
has no start URL. A member is skipped, on its class and before any task is
billed, when it does not map an option you set, refuses `start_url`, or needs
one you left out; with none left, `MissingParameterError` or
`UnsupportedParameterError` lists why each was passed over. It falls through to
the next member only on a failure that proves no task was created (a missing
credential, a 4xx, a connection that never reached it), and once a task has
started no second one is. The answer and the job id name the backend that took
the task.

### Browse backend notes

> [accordion] Browser Use
>
> **Browser Use** runs each task in a session it creates for it, and cancelling a
> task stops that session too. Its steps carry the page, the screenshot, the
> agent's note on the step (its `memory`) and, for a step that took one action,
> that action as Browser Use wrote it; the full list of stringified actions, the
> step number and the always-empty `nextGoal` are in each step's `extra`.
> `output_schema` is sent as Browser Use's `structuredOutput`, and its answer
> comes back as `structured`; an answer that is not JSON stays in `answer`
> rather than being dropped. `allowed_domains` blocks navigation elsewhere, but
> the agent may still answer from what it already knows, so check
> `steps` when it matters where an answer came from. A finished task is
> `complete` even when Browser Use's self-reported `isSuccess` is `false`, which
> stays in `raw`.

> [accordion] Skyvern
>
> **Skyvern** answers in an object whose keys its agent chose unless a
> `data_extraction_schema` is sent, and that is not mapped yet, so most Skyvern
> answers are only in `raw[0]["output"]`: `answer` is set only when the output is
> a string, and `structured` stays empty, because an object nobody asked the
> shape of is not structured output. Its recording URL is a presigned link
> carrying credentials, so it also stays in `raw`. `timed_out` covers both the
> `max_steps` limit and the clock, with nothing to tell them apart, so it fails
> the task rather than reading as `partial`. Its `data_extraction_schema`,
> `max_steps`, engine and browser session are reachable through
> `provider_options` until a live call confirms a mapping.

> [accordion] Browserbase
>
> **Browserbase**'s Agents API has no start URL, so `start_url` raises
> `UnsupportedParameterError` rather than being folded into the task. A prose
> `result` is the `answer`; an object or list `result` is `structured`, because
> Browserbase returns one only for a `resultSchema`, which reaches it through
> `provider_options` alongside `agentId`, `variables` and `browserSettings` until
> a live call confirms mapping `output_schema`. A run started with no `agentId`
> may create a new Agent in your Browserbase account each time. `steps` is empty:
> the transcript is a separate endpoint this provider does not read.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.