Skip to main content
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:
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. 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:
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 runs all of this against local, sync and async, with no key.

Session backend notes

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. 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. 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. 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. 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 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.
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 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 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 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’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.