Hybrid Automation
Hybrid automation lets you pause an automated script, hand control to a human via a secure live URL, and resume automation once they finish. Use it for login flows, 2FA, CAPTCHAs, or any step that requires human judgment.
- A Browserless API token from your account dashboard
- Puppeteer or Playwright installed locally
Minting a LiveURL keeps the browser available for the requested LiveURL timeout, but it doesn't override the session's original timeout. The viewer still closes at the browser session's absolute maximum duration.

How It Works
Create a liveURL through CDP and Browserless returns a short-lived link that opens in a browser tab, with no API token embedded. You can also embed the Live URL in your app. For multi-stage workflows, read-only monitoring, and bandwidth optimization, see Advanced Hybrid Automation Configurations.
| Behavior | Default | Override |
|---|---|---|
| Viewport | Resizes to match the end user's screen | resizable: false to keep the current viewport |
| Interaction | Click, type, scroll, touch, and tap enabled | interactable: false to block all input (view-only mode) |
| Stream quality | Compressed JPEG frames at quality 70 | quality: 1-100 (lower values use less bandwidth, useful on mobile), type: 'png' for lossless frames that use more bandwidth |
| Instructions panel | On interactable links, shows Finish interacting with the page, then choose Done. with Done and Couldn't finish buttons | instructions: '...' to tell the person what to do |
| Tab scope | Starts on the page used to create the URL and switches to new tabs as they open | One link per tab: see Multi-Tab Workflows. All tabs in one link: showBrowserInterface: true (may increase CAPTCHA challenges) |
| Events | None | Listen for Browserless.liveComplete and Browserless.captchaFound |
Basic Implementation
- Puppeteer
- Playwright
import puppeteer from 'puppeteer-core';
const browser = await puppeteer.connect({
browserWSEndpoint: 'wss://production-sfo.browserless.io?token=YOUR_API_TOKEN_HERE',
});
const page = await browser.newPage();
await page.goto('https://practicetestautomation.com/practice-test-login/');
const cdp = await page.createCDPSession();
// Close the browser on every exit, including a failed handoff.
try {
const { liveURL, error } = await cdp.send('Browserless.liveURL', {
// Give the person enough time to finish; the default is 30 seconds.
timeout: 120000,
instructions: 'Log in with your username and password, then choose Done.',
});
if (error) throw new Error(error);
// Arm the listener before sharing, since earlier events are not replayed.
const completion = new Promise((r) => cdp.once('Browserless.liveComplete', r));
// Local testing only. In production, send the link to the person privately instead of logging it.
console.log('Share this URL:', liveURL);
// userDone and userFailed come from the viewer's Done and Couldn't finish buttons.
const { reason } = await completion;
if (reason !== 'userDone') throw new Error(`Handoff ended without Done: ${reason}`);
// Anyone with the link can choose Done, so check the page before continuing.
await page.waitForSelector('h1.post-title', { timeout: 5000 });
// Continue automation...
} finally {
await browser.close();
}
import { chromium } from 'playwright-core';
const browser = await chromium.connectOverCDP(
'wss://production-sfo.browserless.io/chromium/stealth?token=YOUR_API_TOKEN_HERE'
);
const [context] = await browser.contexts();
const page = await context.newPage();
await page.goto('https://practicetestautomation.com/practice-test-login/');
const cdpSession = await context.newCDPSession(page);
// Close the browser on every exit, including a failed handoff.
try {
const { liveURL, error } = await cdpSession.send('Browserless.liveURL', {
// Give the person enough time to finish; the default is 30 seconds.
timeout: 120000,
instructions: 'Log in with your username and password, then choose Done.',
});
if (error) throw new Error(error);
// Arm the listener before sharing, since earlier events are not replayed.
const completion = new Promise((r) => cdpSession.once('Browserless.liveComplete', r));
// Local testing only. In production, send the link to the person privately instead of logging it.
console.log('Share this URL:', liveURL);
// userDone and userFailed come from the viewer's Done and Couldn't finish buttons.
const { reason } = await completion;
if (reason !== 'userDone') throw new Error(`Handoff ended without Done: ${reason}`);
// Anyone with the link can choose Done, so check the page before continuing.
await page.waitForSelector('h1.post-title', { timeout: 5000 });
// Continue automation...
} finally {
await browser.close();
}
Close LiveURL Programmatically
This script waits for Live URL completion or a specific selector on the page. When the selector appears, it programmatically closes the Live URL. Completion alone does not prove the user finished the task.
- Puppeteer
- Playwright
import puppeteer from "puppeteer-core";
const browser = await puppeteer.connect({
browserWSEndpoint: `wss://production-sfo.browserless.io?token=YOUR_API_TOKEN_HERE`,
});
const page = await browser.newPage();
await page.goto("https://practicetestautomation.com/practice-test-login/");
const cdp = await page.createCDPSession();
const { liveURL, liveURLId } = await cdp.send("Browserless.liveURL", {
timeout: 120000,
});
console.log("Share this URL:", liveURL);
// Wait for live URL completion OR a successful login
const result = await Promise.race([
new Promise((r) =>
cdp.once("Browserless.liveComplete", () => r("live_completed"))
),
page.waitForSelector("h1.post-title", { timeout: 0 })
.then(() => "login_detected"),
]);
if (result === "login_detected") {
await cdp.send("Browserless.closeLiveURL", { liveURLId });
}
console.log(`LiveURL closed via: ${result}`);
await browser.close();
import { chromium } from "playwright-core";
const browser = await chromium.connectOverCDP(
`wss://production-sfo.browserless.io?token=YOUR_API_TOKEN_HERE`
);
const [context] = browser.contexts();
const page = await context.newPage();
await page.goto("https://practicetestautomation.com/practice-test-login/");
const cdpSession = await context.newCDPSession(page);
const { liveURL, liveURLId } = await cdpSession.send("Browserless.liveURL", {
timeout: 120000,
});
console.log("Share this URL:", liveURL);
// Wait for live URL completion OR a successful login
const result = await Promise.race([
new Promise((r) =>
cdpSession.once("Browserless.liveComplete", () => r("live_completed"))
),
page.waitForSelector("h1.post-title", { timeout: 0 })
.then(() => "login_detected"),
]);
if (result === "login_detected") {
await cdpSession.send("Browserless.closeLiveURL", { liveURLId });
}
console.log(`LiveURL closed via: ${result}`);
await browser.close();
Handle Completion Reasons
Browserless.liveComplete supplies { liveURLId, reason }. The example below uses the page's cdp session from the Puppeteer example above (use cdpSession for Playwright).
Arm the listener after the mint response and before sharing the link. Re-minting on the same page reuses the ID and completes the old mint with closed. A listener armed earlier can receive that old completion instead.
const { liveURL, error } = await cdp.send('Browserless.liveURL', {
timeout: 60000,
interactable: true,
});
if (error) throw new Error(error);
const completion = new Promise((resolve) =>
cdp.once('Browserless.liveComplete', resolve)
);
console.log('Share this link:', liveURL);
const { reason } = await completion;
switch (reason) {
case 'timeout':
console.log('The link expired; retry the handoff if needed.');
break;
case 'closed':
console.log('The link was closed or replaced; check the page state.');
break;
case 'viewerDisconnected':
console.log('The viewer left; verify the task before continuing.');
break;
case 'userDone':
console.log('The viewer reported done; verify the result.');
break;
case 'userFailed':
console.log('The viewer reported a problem; handle it before continuing.');
break;
}
userDone and userFailed are the person's own verdict from the Done and Couldn't finish buttons. Either one closes the link and leaves the browser session running, so your script can carry on. Completion fires at most once per mint, not once per viewer. viewerDisconnected fires only after the last viewer stays gone for two seconds; an ordinary viewer reconnect within that grace cancels it. The URL remains reopenable until expiry while the browser session is available, but reopening does not re-arm completion. Credential-protection teardown blocks new viewers for the session, so reopening cannot cancel or re-arm that completion. Verify the expected page state rather than treating disconnect as success. See the full reason contract.
Tell the person what to do
Pass instructions to show your own text in the viewer's Instructions panel. The person reads it, does the task, then chooses Done or Couldn't finish. Without it, an interactable link still shows the panel with Finish interacting with the page, then choose Done.
const { liveURL, liveURLId, error } = await cdp.send('Browserless.liveURL', {
timeout: 300000,
instructions: '1. Log in with your work account.\n2. Approve the 2FA prompt.\n3. Choose Done.',
});
if (error) throw new Error(error);
| Rule | Behavior |
|---|---|
| Type | Must be a string. Anything else returns error: "The 'instructions' value must be a string.". |
| Length | Trimmed, then truncated to 2,048 characters. |
| Line breaks | Preserved, so \n puts each step on its own line. |
| View-only links | With interactable: false, the panel shows only when you pass instructions, and it has no buttons. |
To change the text while the link is open, call Browserless.setLiveURLInstructions. Connected viewers see the new text right away, so one link can cover several steps:
await page.waitForSelector('#otp');
await cdp.send('Browserless.setLiveURLInstructions', {
liveURLId,
instructions: 'Enter the code we texted you, then choose Done.',
});
Response:
{ "error": null, "liveURLId": "..." }
Mint a Live URL for a running browser over HTTP
You can use this browser-management route to create a view-only Live URL for a browser you own:
curl -X POST \
"https://production-sfo.browserless.io/browser/BROWSER_ID/live?token=YOUR_API_TOKEN_HERE"
{
"liveURL": "https://production-sfo.browserless.io/e/.../live/index.html?i=...&t=...&showBrowserInterface=true",
"liveURLId": "..."
}
The route selects the focused page, shows the browser interface, and sets interactable: false. The URL lasts for up to 15 minutes, capped by the browser session's remaining lifetime. It returns 404 when the browser doesn't exist or is not accessible. See the POST /browser/{id}/live reference.
End-User Authentication
LiveURL links authenticate using a short-lived ?i=<id> parameter that is unique to the session. This ID expires automatically after the LiveURL timeout elapses or when Browserless.closeLiveURL is called. LiveURL paths do not require your API token. Do not embed your token in LiveURL links shared with end users.
With interactable: true, anyone holding the link controls the browser session, including any logged-in state inside it. They can also choose Done and move your script forward. Send the link over a trusted private channel, keep it out of logs and tickets, and always verify the page state after completion instead of trusting the reason alone. The examples on this page print the link with console.log for local testing. In production, deliver it to the person directly.
After Browserless fills a stored credential, it immediately closes active Live URL streams and refuses new viewers or new Live URLs for that session. See the 1Password security model.
Network Endpoints & VPN/Proxy Tips
The LiveURL page loads over HTTPS and then upgrades a same-origin WebSocket to stream frames and input. When diagnosing corporate firewalls, VPNs, or upstream proxies, both of the URLs below must be reachable from the end user's browser:
| Surface | URL pattern | Protocol |
|---|---|---|
| LiveURL page | https://<your-browserless-host>/live/index.html?i=<id> | HTTPS |
| LiveURL stream | wss://<your-browserless-host>/live/<id> | WebSocket (WSS, TLS) |
For the Browserless-managed fleet, <your-browserless-host> resolves to production-sfo.browserless.io, production-lon.browserless.io, or production-ams.browserless.io depending on the region you connected to. Self-hosted Enterprise deployments use the same /live/* paths on whatever host they are deployed to.
If end users report an intermittent Couldn't establish a secure connection to the server. error on the LiveURL page:
- Allowlist WebSockets to
/live/*, not just HTTPS. Some VPNs and TLS-inspecting proxies pass HTTPS traffic but terminate WebSocket upgrades, which produces exactly this error. - Some VPN exit nodes interfere with specific regions. Switching VPN node/region – or routing LiveURL traffic directly – usually resolves it when the failure is tied to a particular upstream proxy.
- The server sends WebSocket pings every 30s to keep the connection alive through idle-timeout-sensitive middleboxes. After an initial connection failure or an established stream drops, the LiveURL page shows Reconnecting… and retries three times after 1, 2, and 4 seconds before showing a terminal error.
- Prefer a stable region close to the end user (
production-sfo,production-lon,production-ams) for the underlying Browserless connection. The LiveURL WSS runs on the same host, so a closer region reduces the number of hops where VPN/proxy interference can occur.
FAQ & Troubleshooting
How do I enable liveURL for my account or token?
There's nothing to enable. Call the liveURL BQL mutation or the Browserless.liveURL CDP command shown above.
Can a human interact with the session without writing code?
Yes, that's what the live URL is for. Request it with interactable: true and share the link; anyone who opens it can watch the browser and click, type, and scroll in it directly, with no code or Browserless account needed. Useful for hand-completing logins or reviewing what an automation is doing.
Why was a viewer rejected with 429?
Each Live URL accepts five concurrent viewers. Close an existing viewer before connecting another one. Reloads and reconnects can briefly overlap, so wait for the old connection to close before retrying.
Why didn't Browserless.liveComplete fire?
Register the listener on the same CDP session before sharing the URL; earlier events are not replayed. The event fires at most once for a CDP-created link: on timeout, Browserless.closeLiveURL, replacement by another live URL on the same page, the viewer choosing Done or Couldn't finish, or about two seconds after the last viewer disconnects (including credential-protection teardown). A reconnect within that grace period cancels the pending viewer-disconnect completion. Links nobody opens still complete on timeout, close, or replacement. Completion does not prove the human task succeeded; verify the expected page state before continuing.
Why am I getting a 403 Forbidden error?
Your API token is missing or expired. Pass it as a ?token= query parameter in the WebSocket or HTTP URL. Verify the token in your account dashboard.
My script works locally but fails on Browserless
Local browser settings may differ from the Browserless environment. Use launch parameters to match your local setup (viewport, user agent, timezone). See launch parameters for the full list.