Pause browser automation, hand the page to a human, resume when they're done.
Project description
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
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file browser_handoff-0.7.0.tar.gz.
File metadata
- Download URL: browser_handoff-0.7.0.tar.gz
- Upload date:
- Size: 80.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.5.4
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
eed343f0ea96fab97129dd86c9951c65c1bd06fcedb60a23ef29d58ba63ccfd1
|
|
| MD5 |
ad7cd4ace65b3b6cf53f96d2adf28df9
|
|
| BLAKE2b-256 |
05fd0ca278e9f1bcf0bafe73649a97afa9c39641893cb99d211e508bd2239ac0
|
File details
Details for the file browser_handoff-0.7.0-py3-none-any.whl.
File metadata
- Download URL: browser_handoff-0.7.0-py3-none-any.whl
- Upload date:
- Size: 94.7 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.5.4
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
8d1a1e8f6d9c484cbb65ec186cf42fae6b009e7e196bca972438521860d5a490
|
|
| MD5 |
c18d2141dd3ce0604401abdde45fa74d
|
|
| BLAKE2b-256 |
577c592a5f517797e0f4eb1798883ec3cc2b3906315fb1ccdfcc95354a192f6d
|