Pause Playwright automation, hand the browser 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 automation 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 script.
Install
pip install browser-handoff
LLM-based detection (optional): pip install browser-handoff[llm]
30-second example
from playwright.async_api import async_playwright
from browser_handoff import Handoff, Scenario
from browser_handoff.detection import Detection
handoff = Handoff(
scenarios=[
Scenario(
name="login",
trigger=Detection.url(path_contains=["/login"]),
complete=Detection.url(path_contains=["/dashboard"]),
),
],
)
async with async_playwright() as pw:
browser = await pw.chromium.launch(headless=False)
page = await browser.new_page()
await page.goto("https://example.com/start")
result = await handoff.run(page, timeout=30)
if result.was_blocked and not result.timed_out:
print(f"Human completed: {result.scenario_name}")
# Continue automation
await page.click("#continue")
How it works
A Scenario is a pair: a trigger that says "stop, a human is needed" and a complete that says "OK, they're done."
handoff.run(page, timeout=...) watches the page for any scenario's trigger. If none fires within timeout seconds, it returns HandoffResult(was_blocked=False) and your script keeps going. If one fires, it starts a local streaming server, surfaces the URL (printed to logs and pushed to your notifiers), and waits until the matching complete condition matches — or until server.completion_timeout elapses, in which case the result has timed_out=True. handoff.run never raises on completion timeout; check the result.
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(
scenarios=[...],
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 human-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(
scenarios=[...],
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
completion_timeout=600, # max human wait (s)
jpeg_quality=75,
every_nth_frame=1,
),
)
Config files
JSON or YAML, with ${VAR} interpolation:
| JSON | YAML |
|---|---|
{
"scenarios": [{
"name": "login",
"trigger": {
"type": "any",
"conditions": [
{ "type": "url",
"path_contains": ["/login"] },
{ "type": "element",
"present": ["input[type=password]"] }
]
},
"complete": {
"type": "not",
"condition": {
"type": "url",
"path_contains": ["/login"]
}
}
}],
"server": {
"port": 8080,
"public_base": "${HANDOFF_URL}"
},
"notifiers": [
{ "type": "slack",
"webhook_url": "${SLACK_WEBHOOK}" }
]
}
|
scenarios:
- name: login
trigger:
type: any
conditions:
- type: url
path_contains: ["/login"]
- type: element
present: ["input[type=password]"]
complete:
type: not
condition:
type: url
path_contains: ["/login"]
server:
port: 8080
public_base: ${HANDOFF_URL}
notifiers:
- type: slack
webhook_url: ${SLACK_WEBHOOK}
|
handoff = Handoff.from_file("handoff.yaml")
# or: Handoff.from_json(s) / Handoff.from_yaml(s) / Handoff.from_dict(d)
Examples
See examples/claude_oauth_login_handoff/ for a working Claude OAuth flow that pairs browser-handoff with ccauth — local.py runs the flow on your machine; in_daytona.py runs the exact same local.py inside a Daytona sandbox so the human can log in from anywhere via the sandbox's preview URL.
License
MIT — see 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.1.1.tar.gz.
File metadata
- Download URL: browser_handoff-0.1.1.tar.gz
- Upload date:
- Size: 33.4 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.5.4
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
6c2fc19f8889748ac79440f0ac5467e40f8f0fbef85f865f8dd5701a26099f90
|
|
| MD5 |
6a0116a2adbaa6307b60c35d42a1a608
|
|
| BLAKE2b-256 |
c57b7ac92ae9de8aa9dd855c00e0d395bfd64845d0e7ae1181e1f2ddaad4888e
|
File details
Details for the file browser_handoff-0.1.1-py3-none-any.whl.
File metadata
- Download URL: browser_handoff-0.1.1-py3-none-any.whl
- Upload date:
- Size: 44.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.5.4
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
ffc95eb054972f37228c94f1e12384869bc9b5de07b1f88fc54efafa77b2f5fd
|
|
| MD5 |
a1ca4410ceb315fcce9423c609fe9ead
|
|
| BLAKE2b-256 |
48a8c9a37566aaa40f7816013a582c7f346eb9ec1c179324869701c37fce2c12
|