Skip to content
Documentation

Docs · Interfaces

Direct CDP

Start a profile, take its WebSocket URL, and drive the browser over the Chrome DevTools Protocol — with the Python and Node SDKs, or with your own client.

You get a socket, not a driver. The daemon starts the browser and hands back Chromium's own browser-level DevTools endpoint. Everything above that — targets, navigation, evaluation — is plain CDP, so any client that speaks the protocol works. Nothing is wrapped, and nothing is injected into the page.

Get a connection

One call. POST /v1/profiles/<id>/start launches the profile and answers with its endpoint. The body is optional, and the only field it reads is headless. Every /v1 call carries the daemon's bearer token in an Authorization header — the daemon prints it on first start, or mints one with --generate-token; see Install & run.

bash
$ curl -sX POST http://127.0.0.1:8787/v1/profiles/$ID/start \
    -H "Authorization: Bearer $SCALEBROWSER_BEARER_TOKEN" \
    -H "Content-Type: application/json" \
    -d '{"headless": true}'
json
{
  "profile_id": "prf_7d2a…",
  "cdp_ws": "ws://127.0.0.1:51234/devtools/browser/8f3c…",
  "debug_port": 51234,
  "headless": true,
  "pid": 48122,
  "started_at": 1754352019
}
FieldWhat it is
cdp_wsThe browser-level DevTools WebSocket. This is the one you connect to.
debug_portThe TCP port parsed out of that URL, or null if it has none — convenient for tools that want a port rather than a URL.
headlessWhat this launch actually did. Omit headless from the request and you get a headless browser: an unattended start is the assumption on this API.
pidThe browser process id, when the backend knows one.
started_atUnix seconds.

Stopping is POST /v1/profiles/<id>/stop, and it is idempotent — stopping a profile that is not running is a success, not an error. Do stop it: one left running holds a capacity slot and its share of RAM. The full request and response shapes are on REST API.

Where that endpoint lives

On the daemon's own machine, on loopback, on a port Chromium picks fresh at every launch. The daemon starts the engine with --remote-debugging-port=0 and --remote-debugging-address=127.0.0.1, then reads back the port the browser chose. Three consequences worth knowing before you build around it:

  • The URL changes every launch. Never cache it across a stop and a start — read it from the start response each time.
  • It is reachable only from the daemon's host. If the daemon runs on a server and your code does not, forward the port; an SSH tunnel is enough. The REST API can be reached over TLS from anywhere, this socket cannot.
  • It carries no token of its own. As with Chromium's DevTools port everywhere else, whatever can reach the socket can drive the browser. That is exactly why it stays on loopback — the bearer token protects /v1, not this.

Attach to a page

The endpoint is browser-level: it can list and create targets, but Page.navigate needs a session bound to a page. Three commands get you there, and they are the ones both SDKs send:

  1. Target.getTargets — take the first entry whose type is "page".
  2. Target.createTarget with {"url": "about:blank"} if there is none.
  3. Target.attachToTarget with {"targetId": …, "flatten": true} — the sessionId it returns goes on every later frame.
python
import asyncio, json, websockets   # no SDK, one dependency

CDP_WS = "ws://127.0.0.1:51234/devtools/browser/8f3c…"

async def main():
    async with websockets.connect(CDP_WS, max_size=None) as ws:
        n = 0

        async def send(method, params=None, session=None):
            nonlocal n
            n += 1
            frame = {"id": n, "method": method, "params": params or {}}
            if session:
                frame["sessionId"] = session
            await ws.send(json.dumps(frame))
            while True:                        # events share the socket
                msg = json.loads(await ws.recv())
                if msg.get("id") == n:
                    return msg["result"]

        targets = await send("Target.getTargets")
        page = next(t for t in targets["targetInfos"] if t["type"] == "page")
        s = (await send("Target.attachToTarget",
                        {"targetId": page["targetId"], "flatten": True}))["sessionId"]

        await send("Page.navigate", {"url": "https://example.com"}, s)
        title = await send("Runtime.evaluate",
                           {"expression": "document.title", "returnByValue": True}, s)
        print(title["result"]["value"])

asyncio.run(main())

Your WebSocket client must not send an Origin header. Chromium's DevTools endpoint refuses that handshake, and the failure reads like a plain connection error. Node's built-in global WebSocket sends one — choose a client that lets you leave it off, or build the handshake yourself.

One protocol habit is worth copying from the SDKs: they never call Runtime.enable. It is not needed, because Runtime.evaluate works without it, and enabling it is observable from inside the page. For code that should not be visible to the page at all, open an isolated world with Page.createIsolatedWorld and pass the returned executionContextId to Runtime.evaluate as contextId.

Why direct CDP and not Playwright

Because the automation framework is itself a signal. Anti-bot stacks fingerprint the shape of the control plane that a Playwright or Puppeteer client presents, and they block on it regardless of how good the browser's fingerprint is. A perfectly coherent profile driven through a Playwright control plane still fails those gates, and no amount of work on the browser fixes it — the tell is not in the browser.

So the recommended path speaks CDP directly, the way nodriver-style tools do, and both SDKs carry their own small CDP client instead of wrapping someone else's. Two things follow for you:

  • You can still point Playwright at cdp_ws. It is a normal DevTools socket and nothing stops you — you simply give up the argument this product makes. Treat it as a way to reuse existing code against forgiving targets, not as the way to reach sharp ones.
  • There is no chromedriver. None is shipped, for any platform, so a Selenium script has to bring its own. The AdsPower adapter returns an empty webdriver field for this exact reason.

Human input goes through the daemon

Dispatching Input.dispatchMouseEvent yourself works, and it moves the pointer in a straight line at an inhuman speed with a machine-perfect cadence. The daemon has a second endpoint for exactly that reason: POST /v1/profiles/<id>/input builds the gesture — approach curve, overshoot, per-key typing rhythm, momentum scrolling — and dispatches it over the profile's CDP socket for you.

bash
$ curl -sX POST http://127.0.0.1:8787/v1/profiles/$ID/input \
    -H "Authorization: Bearer $SCALEBROWSER_BEARER_TOKEN" \
    -H "Content-Type: application/json" \
    -d '{"action": "click", "x": 120, "y": 240, "width": 88}'
ActionFields
movex, y, optional width
clickx, y, optional button, click_count, width
typetext
scrollx, y, optional delta_x, delta_y

width is the width of the thing you are aiming at — a button's bounding box, not a point. A real pointer lands somewhere inside its target and takes longer to reach a small one; passing the width is what keeps that true. Adding "humanize": false to any of the four gives you the minimal direct sequence instead; it defaults to true.

The response acknowledges the gesture and reports whether it reached the browser — dispatched, plus a detail when it did not (a stopped profile, an unreachable socket, no page target). What the timings are modelled on, and why added jitter is a tell rather than a disguise, is on Human input.

The SDKs

There is a Python SDK and a Node/TypeScript one. Each is a typed REST client plus a direct-CDP driver — the same path this page describes, with the target attach, the frame bookkeeping and the humanized input already wired up.

Neither package is public. They are not on PyPI or npm, and they are not installable from a public URL — both are proprietary, and both carry a guard that refuses publication outright. Write to [email protected] and you get an archive to install from. Until then the REST calls and the raw CDP above are the whole interface, and everything the SDKs do is built on those two.

Python — the scalebrowser package, Python 3.10 or newer:

python
from scalebrowser import ScalebrowserClient, CreateProfileBody

sb = ScalebrowserClient(base_url="http://127.0.0.1:8787", token="…")
profile = sb.create_profile(CreateProfileBody(name="acct-01"))

# start → connect → attach to a page; the block exit stops the profile
with sb.launch(profile.id, headless=True) as page:
    page.navigate("https://example.com")
    print(page.evaluate("document.title"))
    page.humanize_click(120, 240)

sb.close()

An AsyncScalebrowserClient mirrors it method for method for asyncio code.

Node — the @scalebrowser/sdk package, Node 18 or newer, ESM and CJS with type declarations:

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

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

const profile = await sb.createProfile({ name: 'acct-01' });

// `await using` stops the profile and closes CDP when the block exits
await using session = await sb.launch(profile.id, { headless: true });

await session.cdp.navigate('https://example.com');
const title = await session.cdp.evaluate<string>('document.title');
await session.cdp.humanizeClick(120, 220);

Without await using, startProfile returns the same cdp_ws shown at the top of this page, connectCdp(cdp_ws, profileId) opens the session, and you close it and call stopProfile yourself.

Next

  • REST API — creating profiles, proxies and groups, bulk operations, error codes.
  • Human input — what the humanized gestures are modelled on.
  • MCP server — if an AI agent is the thing driving, that is the better interface: it deliberately never hands out a CDP endpoint, so every input stays on the humanized path.
  • Coherence & proxies — what is checked before a launch is allowed at all.