browser-handoff
Pause your browser automation, hand the page to a human, resume when they're done.
When your script or agent hits something only a human should do — login, 2FA, OAuth consent, payment, identity check — browser-handoff streams the live browser to an operator over the web, waits for them to finish, then gives control back to your code.
Install
pip install browser-handoff
LLM-based detection (optional): pip install browser-handoff[llm]
30-second example
Opens a login page, streams it to a human, and resumes when they sign in.
import asyncio
from playwright.async_api import async_playwright
from browser_handoff import Detection, Handoff
async def main() -> None:
h = Handoff() # reusable: holds server + notifier config, nothing page-specific
async with async_playwright() as pw:
browser = await pw.chromium.launch(headless=True)
page = await browser.new_page()
await page.goto("https://the-internet.herokuapp.com/login")
# Stream the page and pause until the human lands on /secure.
result = await h.pause(
page,
until=Detection.url(path_contains=["/secure"]),
reason="Sign in with the credentials shown on the page",
)
if not result.timed_out:
print(f"Human signed in in {result.duration:.1f}s")
print(f"Now at: {page.url}")
await browser.close()
asyncio.run(main())
How it works
A Handoff is a reusable transport config — the streaming server, notifiers, viewport — shared across pages and runs. It exposes two methods for the two shapes of handoff:
h.pause(page, until=...) — you already know a human is needed. Common when an AI agent hits a wall and calls a request_human_help tool. Streams the page immediately, pauses until the detection matches.
h.guard(page, scenarios=[...]) — you want the library to decide. Give it one or more Scenario pairs (start condition + resume condition). It watches; if a scenario fires, it hands off. If nothing fires within trigger_timeout, your script keeps going.
from browser_handoff import Detection, Handoff, Scenario
h = Handoff()
# Explicit: your code decides. A fuzzy resume condition works well here —
# a vision model polls the page against `condition` and pause returns
# the moment it sees the described state.
await h.pause(
page,
until=Detection.llm(condition="user is signed in"),
reason="Sign in to continue",
)
# Declarative: library watches for a login page and hands off if it appears.
await h.guard(
page,
scenarios=[
Scenario(
name="login",
on=Detection.url(path_contains=["/login"]),
until=Detection.url(path_contains=["/secure"]),
),
],
trigger_timeout=10,
)
Both return a HandoffResult. On a clean finish: was_blocked=True, timed_out=False.
On timeout the result has timed_out=True and timeout_cause names the timer that fired — "access" (nobody opened the link) or "completion" (they opened it but didn't finish). Neither method ever raises on timeout; check the result.
Passthrough mode (cloud substrates)
When the page lives in a cloud browser substrate (Kernel, Browserbase, Steel, Cua, …) and browser-handoff runs on your machine, every CDP frame would have to travel from the substrate's datacenter to your machine and back out to the operator — a double WAN hop that's observably unusable in practice.
Most substrates already ship their own first-class viewer. Passthrough mode delegates streaming to that viewer while browser-handoff keeps the detection, notification, and lifecycle responsibilities.
Same Heroku login, this time on a Kernel cloud browser:
import asyncio
from kernel import AsyncKernel
from playwright.async_api import async_playwright
from browser_handoff import Detection, Handoff
async def main() -> None:
h = Handoff()
kernel = AsyncKernel()
kernel_browser = await kernel.browsers.create()
async with async_playwright() as pw:
browser = await pw.chromium.connect_over_cdp(kernel_browser.cdp_ws_url)
# Kernel browsers boot with one context + one page already attached.
page = (browser.contexts[0] or await browser.new_context()).pages[0]
await page.goto("https://the-internet.herokuapp.com/login")
result = await h.pause(
page,
until=Detection.url(path_contains=["/secure"]),
reason="Sign in with the credentials shown on the page",
stream_url=kernel_browser.browser_live_view_url, # ← passthrough
)
if not result.timed_out:
print(f"Human signed in in {result.duration:.1f}s")
await kernel.browsers.delete_by_id(kernel_browser.session_id)
asyncio.run(main())
What changes when stream_url is set:
- No local CDP screencast pump — the substrate's viewer owns frames.
- The operator opens a
browser-handoff-served wrapper URL that iframes the substrate's viewer, cropped to just the page content. - Window maximization at handoff start gives the crop math a clean, deterministic rect.
- LLMDetection stays stealthy — its in-page observer arms only after the operator opens the wrapper, so the substrate's viewer URL alone can't drive vision calls.
stream_url works on both entry points — h.pause(stream_url=...) and h.guard(stream_url=...). Everything else (detections, notifiers, timers) is identical.
Scope: what this is not
browser-handoff is for flows gated by credentials or session state — login pages, 2FA prompts, OAuth consent screens, payment forms, identity verification, T&C acceptance.
It is not an anti-bot bypass. Sites that fingerprint Playwright/CDP sessions as automation will keep refusing the flow even after a human solves a CAPTCHA, Cloudflare Turnstile, or similar challenge — the session itself is flagged, not the response. If that's your problem, you need an anti-detection browser, not a handoff tool.
Detection
Detection is the factory for conditions:
Detection.url(host_equals=["accounts.google.com"], path_contains=["/oauth"])
Detection.element(present=["input[type=password]"], visible=[".consent-modal"], missing=[".user-menu"])
Detection.content(title_contains=["Sign In"], body_matches=[r"verify.*you"])
Detection.llm(model="anthropic/claude-sonnet-4-5", condition="Login form is visible")
Combine them:
Detection.any([d1, d2]) # OR
Detection.all([d1, d2]) # AND
Detection.not_(d1) # NOT
Notifications
If you pass no notifiers, the library falls back to a built-in ConsoleNotifier that prints a rich panel to stdout with the stream URL — so the link is always somewhere obvious. When you do pass notifiers, the library stays out of the way and only fires what you configured.
from browser_handoff.notifiers import (
ConsoleNotifier, DiscordNotifier, EmailNotifier, SlackNotifier,
)
Handoff(
notifiers=[
SlackNotifier(webhook_url="https://hooks.slack.com/..."),
DiscordNotifier(webhook_url="https://discord.com/api/webhooks/..."),
EmailNotifier(
smtp_host="smtp.gmail.com", smtp_port=587,
username="bot@x.com", password="...",
to=["ops@x.com"],
),
ConsoleNotifier(), # explicit — add alongside others if you also want a local panel
],
)
Server
Defaults to 127.0.0.1:8080 (loopback only) with a 10-minute access budget and a 30-minute post-connect completion budget. Set host="0.0.0.0" to expose on the LAN — e.g. for phone access or tunnel forwarding.
from browser_handoff import ServerConfig
Handoff(
server=ServerConfig(
host="127.0.0.1", # "0.0.0.0" to expose on LAN
port=8080,
public_base="https://my-tunnel.example.com", # what notifiers link to
access_timeout=600, # pre-connect bound (s)
completion_timeout=1800, # post-connect work budget (s)
jpeg_quality=75,
every_nth_frame=1,
),
)
Access control
Token in the URL. The stream URL carries a high-entropy capability token (…/?t=<token>). Whoever holds the link can view and control the page — treat it like a password.
Token lifetime. The token is unguessable, decoupled from internal ids, and expires when the handoff ends or the worst-case session lifetime (access_timeout + completion_timeout) elapses. Stale links stop working.
Exposure beyond loopback. Serving on 0.0.0.0, a tunnel, or a sandbox preview URL? Use HTTPS/WSS so the token isn't readable in transit; set public_base to your public https:// origin and notifier links are built from it.
No second factor yet. One leaked, still-active link grants control. Deliver it over a trusted channel.
Examples
Claude OAuth login handoff — a working Claude OAuth flow paired with ccauth.
local.py— runs on your machine.in_daytona.py— runs the samelocal.pyinside a Daytona sandbox, so the human can sign in from anywhere via the sandbox's preview URL.
Browser-Use assisted shopping — a browser-use agent buys a t-shirt on automationexercise.com. browser-handoff is exposed to the agent as a custom tool; the agent decides on its own when to call it (login wall, card form), and a human takes over for those steps while the agent drives the rest.
local.py— local Chromium.using_kernel.py— Kernel cloud browser in passthrough mode; the operator's wrapper iframes Kernel's WebRTC live view directly, no double-hop streaming.
License
Metadata
Release files for browser-handoff 0.7.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| browser_handoff-0.7.0.tar.gz | 80.8 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| browser_handoff-0.7.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 175.5 kB
Release files / browser_handoff-0.7.0.tar.gz
| Download URL | browser_handoff-0.7.0.tar.gz |
|---|---|
| Size | 80.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
eed343f0ea96fab97129dd86c9951c65c1bd06fcedb60a23ef29d58ba63ccfd1
|
|
BLAKE2b-256 checksum How to use checksums |
05fd0ca278e9f1bcf0bafe73649a97afa9c39641893cb99d211e508bd2239ac0
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.5.4
|
Release files / browser_handoff-0.7.0-py3-none-any.whl
| Download URL | browser_handoff-0.7.0-py3-none-any.whl |
|---|---|
| Size | 94.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
8d1a1e8f6d9c484cbb65ec186cf42fae6b009e7e196bca972438521860d5a490
|
|
BLAKE2b-256 checksum How to use checksums |
577c592a5f517797e0f4eb1798883ec3cc2b3906315fb1ccdfcc95354a192f6d
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.5.4
|