Skip to main content

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

Optional. Each notifier gets the stream URL when a handoff starts.

from browser_handoff.notifiers import 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"],
        ),
    ],
)

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:

JSONYAML
{
  "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.py for a working Claude OAuth flow that pairs browser-handoff with ccauth.

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.1.0.tar.gz (32.0 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.1.0-py3-none-any.whl (42.6 kB view details)

Uploaded Python 3

File details

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

File metadata

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

File hashes

Hashes for browser_handoff-0.1.0.tar.gz
Algorithm Hash digest
SHA256 d7378b085f7d7593c90aa4c5e5c0618a418919108a0982cd14c3831228ab67bf
MD5 5917c878099f95ccc2668770ddae986d
BLAKE2b-256 3dc64d33ef77e8868d7fe4b467676083043b68166f0f0f2c8f06f09b205c2777

See more details on using hashes here.

File details

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

File metadata

File hashes

Hashes for browser_handoff-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 4464e46dc756828a680cdd7c95cd2995c683bac7fd542c6c5040bcf77923814e
MD5 938b91c4226e15037fb7c69e386290e6
BLAKE2b-256 b7a03f9284cc1b36e217d7b4836b3c17efe57a056cf52c15c306fd594237aa5e

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