Skip to main content

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

Opens the-internet.herokuapp.com/login — a public testing site that displays its own credentials on the page (tomsmith / SuperSecretPassword!). The handoff fires as soon as the page loads, prints a stream URL for you to open, and resumes once you sign in successfully.

https://github.com/user-attachments/assets/d45b9f42-1a3c-4553-9b7a-a00872b0f112

import asyncio

from playwright.async_api import async_playwright

from browser_handoff import Handoff, Scenario
from browser_handoff.detection import Detection


async def main() -> None:
    handoff = Handoff()  # reusable: holds server + notifier config, nothing page-specific

    async with async_playwright() as pw:
        browser = await pw.chromium.launch(headless=False)
        page = await browser.new_page()
        await page.goto("https://the-internet.herokuapp.com/login")

        # Watch the page; hand off to a human when a trigger fires.
        result = await handoff.run(
            page,
            scenarios=[
                Scenario(
                    name="Heroku App Login",
                    trigger=Detection.url(path_contains=["/login"]),
                    complete=Detection.url(path_contains=["/secure"]),
                ),
            ],
            timeout=10,
        )
        if result.was_blocked and not result.timed_out:
            print(f"Human completed: {result.scenario_name} in {result.duration:.1f}s")

        # Back in script mode — confirm we landed on the post-login page.
        print(f"Now at: {page.url}")
        await browser.close()


asyncio.run(main())

How it works

A Handoff holds your transport config — the streaming server and notifiers — and is reusable across pages and runs. You decide what to watch for per call, so the same Handoff serves any number of scenarios.

Let the library detect the moment with handoff.run(page, scenarios=[...]). A Scenario is a pair: a trigger that says "stop, a human is needed" and a complete that says "OK, they're done." run watches every 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 that scenario's complete matches — or until server.session_timeout elapses, in which case the result has timed_out=True. It never raises on timeout; check the result.

Already know a human is needed? Skip trigger detection and stream right away with handoff.wait_for_completion(page, on=...). This is the right call when something upstream already decided — e.g. an AI agent navigated to the payment page itself — so watching for a trigger would be redundant:

await handoff.wait_for_completion(
    page,
    on=Detection.url(path_contains=["/payment_done"]),
    reason="Payment page reached",
)

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 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(
    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
        session_timeout=600,                          # max session lifetime / human wait (s)
        jpeg_quality=75,
        every_nth_frame=1,
    ),
)

Access control

The stream URL carries a high-entropy capability token (…/?t=<token>): whoever holds the link can view and control the page, so treat it like a password. The token is unguessable, decoupled from internal ids, and expires when the handoff finishes or session_timeout elapses — a stale link stops working. When exposing beyond loopback (0.0.0.0, a tunnel, or a sandbox preview URL), serve over HTTPS/WSS so the token isn't readable in transit; set public_base to your public https:// origin and the operator link is built from it. There is no second factor yet — one leaked, still-active link grants control, so deliver it over a trusted channel.

Examples

  • Claude OAuth login handoff — 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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

browser_handoff-0.3.9.tar.gz (59.2 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

browser_handoff-0.3.9-py3-none-any.whl (72.1 kB view details)

Uploaded Python 3

File details

Details for the file browser_handoff-0.3.9.tar.gz.

File metadata

  • Download URL: browser_handoff-0.3.9.tar.gz
  • Upload date:
  • Size: 59.2 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.5.4

File hashes

Hashes for browser_handoff-0.3.9.tar.gz
Algorithm Hash digest
SHA256 26fdb254d4d1a57f4109d2c038ef38b4754b8de1a83a593a80f5356ec09af876
MD5 3b3bd5d54f51bc05cd64f3fa6775e118
BLAKE2b-256 1492e6185a3d7daae1f87012f299eb2b6c87e3c73c0ac472f54040f22b961fa4

See more details on using hashes here.

File details

Details for the file browser_handoff-0.3.9-py3-none-any.whl.

File metadata

File hashes

Hashes for browser_handoff-0.3.9-py3-none-any.whl
Algorithm Hash digest
SHA256 dcac488a9a9986b844d9892b12bad135d94d98cbcd55c380a8de2c0e05cef32f
MD5 cc98f171478b29e57db9c338c82c8b0b
BLAKE2b-256 fcd91e8b1ec5cbc88791b4d7771f2d7f6b35941a083545861a6e25f3308d72a2

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page