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.
$ 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}'
{
"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
}
| Field | What it is |
|---|---|
cdp_ws | The browser-level DevTools WebSocket. This is the one you connect to. |
debug_port | The TCP port parsed out of that URL, or null if it has none — convenient for tools that want a port rather than a URL. |
headless | What this launch actually did. Omit headless from the request and you get a headless browser: an unattended start is the assumption on this API. |
pid | The browser process id, when the backend knows one. |
started_at | Unix 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:
Target.getTargets— take the first entry whosetypeis"page".Target.createTargetwith{"url": "about:blank"}if there is none.Target.attachToTargetwith{"targetId": …, "flatten": true}— thesessionIdit returns goes on every later frame.
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
webdriverfield 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.
$ 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}'
| Action | Fields |
|---|---|
move | x, y, optional width |
click | x, y, optional button, click_count, width |
type | text |
scroll | x, 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 hello@scalebrowser.net 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:
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:
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.