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:
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.
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:
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().
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.
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:
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 runs all of this
against local, sync and async, with no key.
Session backend notes
Steel
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().Browserbase
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.Hyperbrowser
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.Anchor
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.Kernel
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.local
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.
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 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.
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
Browser Use
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.Skyvern
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.Browserbase
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.