---
title: "Migrate from Playwright or Puppeteer"
description: "Attach Playwright or Puppeteer code to a Scalebrowser profile over CDP, remove the stealth plugin, and move strict targets to direct CDP with human input."
canonical: "https://scalebrowser.net/docs/migrate/playwright"
---

> ## Documentation Index
> Fetch the complete documentation index at: https://scalebrowser.net/llms.txt
> Use this file to discover all available pages before exploring further.

# Migrate from Playwright or Puppeteer

> Attach Playwright or Puppeteer code to a Scalebrowser profile over CDP, remove the stealth plugin, and move strict targets to direct CDP with human input.

Playwright and Puppeteer can both attach to a Chromium that is already running, and a Scalebrowser profile is one. The first step keeps your script and changes only where the browser comes from. Patchright takes the same call with the same arguments as Playwright.

## Start a profile and attach

Start the profile through the daemon, then hand its `cdp_ws` to your tool. The address is new at every start, so read it from the start response each time. [Direct CDP](/docs/cdp#get-a-connection) describes the call and the fields it returns.

<CodeGroup>
```python Playwright (Python)
import os
from playwright.sync_api import sync_playwright
from scalebrowser import ScalebrowserClient

sb = ScalebrowserClient(base_url="http://127.0.0.1:8787", token=os.environ["SCALEBROWSER_TOKEN"])
PROFILE_ID = "prf_7d2a…"   # an existing profile; sb.create_profile(...) makes a new one

started = sb.start_profile(PROFILE_ID)
try:
    with sync_playwright() as p:
        browser = p.chromium.connect_over_cdp(started.cdp_ws, no_defaults=True)
        context = browser.contexts[0]            # the profile's own context
        page = context.pages[0] if context.pages else context.new_page()
        page.goto("https://example.com")
        print(page.title())
        browser.close()                          # disconnects; the browser keeps running
finally:
    sb.stop_profile(PROFILE_ID)
```

```ts Playwright (Node)
import { chromium } from 'playwright';
import { ScalebrowserClient } from '@scalebrowser/sdk';

const sb = new ScalebrowserClient({
  baseUrl: 'http://127.0.0.1:8787',
  token: process.env.SCALEBROWSER_TOKEN,
});
const profileId = 'prf_7d2a…';

const started = await sb.startProfile(profileId);
try {
  const browser = await chromium.connectOverCDP(started.cdp_ws, { noDefaults: true });
  const context = browser.contexts()[0];
  const page = context.pages()[0] ?? (await context.newPage());
  await page.goto('https://example.com');
  console.log(await page.title());
  await browser.close();
} finally {
  await sb.stopProfile(profileId);
}
```

```ts Puppeteer
import puppeteer from 'puppeteer-core';
import { ScalebrowserClient } from '@scalebrowser/sdk';

const sb = new ScalebrowserClient({
  baseUrl: 'http://127.0.0.1:8787',
  token: process.env.SCALEBROWSER_TOKEN,
});
const profileId = 'prf_7d2a…';

const started = await sb.startProfile(profileId);
try {
  const browser = await puppeteer.connect({
    browserWSEndpoint: started.cdp_ws,
    defaultViewport: null,
  });
  const [page] = await browser.pages();
  await page.goto('https://example.com');
  console.log(await page.title());
  await browser.disconnect();
} finally {
  await sb.stopProfile(profileId);
}
```
</CodeGroup>

`puppeteer-core` is enough for Puppeteer: it attaches and never downloads a browser of its own.

## Five things that change when you attach

- **Use the existing context, not a new one.** `browser.contexts[0]` is the profile: its cookies, its storage, its logins. `new_context()` creates a separate incognito context that is thrown away when you disconnect, so nothing done there reaches the profile.
- **Tell your tool to leave the context alone.** Without `no_defaults=True` (Python) or `noDefaults: true` (Node), available since Playwright 1.60, Playwright points downloads at a temporary folder and turns on focus and media emulation for the default context. Puppeteer without `defaultViewport: null` gives every page an 800 by 600 viewport that no longer matches the window.
- **Launch options do not apply.** `proxy`, `args`, `headless` and `user_data_dir` belong to a launch, and nothing is launched. Store the proxy on the profile ([Proxies & exits](/docs/proxies)) and pass `headless` to the start call.
- **Closing disconnects.** `browser.close()` in Playwright and `browser.disconnect()` in Puppeteer leave the browser running. Stop the profile through the daemon, or it keeps holding one of your plan's browsers.
- **The address is new every start.** Never cache `cdp_ws` across a stop and a start.

## Remove the stealth plugin

`playwright-stealth` in Python and `playwright-extra` with `puppeteer-extra-plugin-stealth` in Node patch browser properties with scripts. On a profile they do harm:

- **Their values replace the profile's.** Both ship fixed defaults: languages `en-US`, platform `Win32`, a WebGL vendor `Intel Inc.` with the renderer `Intel Iris OpenGL Engine`, and a rewritten user agent. A profile reports the GPU and display of the machine it runs on, and the machine's own rendering can be compared against what the plugin claims.
- **They mostly miss the attached context.** Both hook the creation of new contexts and pages, so the default context of an attached profile is not patched unless you apply the plugin to it yourself (read from their source, September 2026).

The `playwright-stealth` README says it plainly: "Don't expect this to bypass anything but the simplest of bot detection methods." Delete the import and the call; the profile needs neither.

## What the page still sees

Attaching changes the browser, not how your script types and clicks. Three habits of these tools are visible to the page, measured on our own bench with every tool driving the same form:

| Tool | What the page receives |
| --- | --- |
| Playwright `fill` | The text arrives in one piece, with no key events at all |
| Playwright and Puppeteer `type` | Capital letters with no Shift key pressed |
| Puppeteer clicks | A `pointerdown` with pressure 0 while a button is held, where the specification requires 0.5 |

You can see what your own tool leaves behind on [/ai-agent-check](https://scalebrowser.net/ai-agent-check), without an account.

## Move strict targets to the SDK driver

For a site that grades input, drive the page with the [SDK](/docs/sdks) instead. It speaks CDP directly, never calls `Runtime.enable`, and sends every click and keystroke through the daemon's [human input layer](/docs/behaviour). A selector becomes a box, and the click lands inside it:

<CodeGroup>
```python Python
import os
from scalebrowser import ScalebrowserClient

sb = ScalebrowserClient(base_url="http://127.0.0.1:8787", token=os.environ["SCALEBROWSER_TOKEN"])

BOX = """(() => {
  const r = document.querySelector('#email').getBoundingClientRect();
  return { x: r.x, y: r.y, width: r.width, height: r.height };
})()"""

with sb.launch("prf_7d2a…") as page:
    page.navigate("https://example.com/login")
    box = page.evaluate(BOX, isolated=True)
    page.humanize_click(box["x"] + box["width"] / 2, box["y"] + box["height"] / 2)
    page.humanize_type("name@example.com")
```

```ts Node
import { ScalebrowserClient } from '@scalebrowser/sdk';

const sb = new ScalebrowserClient({
  baseUrl: 'http://127.0.0.1:8787',
  token: process.env.SCALEBROWSER_TOKEN,
});

const BOX = `(() => {
  const r = document.querySelector('#email').getBoundingClientRect();
  return { x: r.x, y: r.y, width: r.width, height: r.height };
})()`;

await using session = await sb.launch('prf_7d2a…');
await session.cdp.navigate('https://example.com/login');
const box = await session.cdp.evaluate<{ x: number; y: number; width: number; height: number }>(
  BOX,
  { isolated: true },
);
await session.cdp.humanizeClickElement(box);
await session.cdp.humanizeType('name@example.com');
```
</CodeGroup>

`isolated: true` reads the box from an isolated world, so the page's own scripts never see the query. `launch` starts the profile and stops it when the block ends.

If an AI agent is doing the driving rather than your code, skip the script entirely: the [MCP server](/docs/agents/mcp-server) gives the agent the page as a text map and the same input layer behind every action.

## Next

- [Direct CDP](/docs/cdp): the endpoint, and why the SDKs speak CDP themselves.
- [Profiles & personas](/docs/profiles): what a profile carries instead of the options you used to pass.
- [Verification challenges](/docs/agents/verification): what gets through, measured per challenge type.
