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

Demo — login handoff

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 same local.py inside 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.pyKernel cloud browser in passthrough mode; the operator's wrapper iframes Kernel's WebRTC live view directly, no double-hop streaming.

License

Apache 2.0 — see LICENSE and NOTICE.

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.7.0.tar.gz (80.8 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.7.0-py3-none-any.whl (94.7 kB view details)

Uploaded Python 3

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

Hashes for browser_handoff-0.7.0.tar.gz
Algorithm Hash digest
SHA256 eed343f0ea96fab97129dd86c9951c65c1bd06fcedb60a23ef29d58ba63ccfd1
MD5 ad7cd4ace65b3b6cf53f96d2adf28df9
BLAKE2b-256 05fd0ca278e9f1bcf0bafe73649a97afa9c39641893cb99d211e508bd2239ac0

See more details on using hashes here.

File details

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

File metadata

File hashes

Hashes for browser_handoff-0.7.0-py3-none-any.whl
Algorithm Hash digest
SHA256 8d1a1e8f6d9c484cbb65ec186cf42fae6b009e7e196bca972438521860d5a490
MD5 c18d2141dd3ce0604401abdde45fa74d
BLAKE2b-256 577c592a5f517797e0f4eb1798883ec3cc2b3906315fb1ccdfcc95354a192f6d

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