LelantosLelantos
Browser

CDP Wire Contract

How the /cdp WebSocket maps to the in-sandbox browser daemon, and the bearer-subprotocol auth a raw CDP client must speak.

Every browser sandbox exposes the Chrome DevTools Protocol (CDP) over a single WebSocket endpoint. Most users never touch this wire contract directly - the SDK and the @lelantos-ai/cdp-adapter bridge handle it. This page documents the raw contract for clients that connect without the SDK.

Endpoint

wss://{sandboxID}.{domain}/cdp

Open the WebSocket against the bare {sandboxID} subdomain with the literal path /cdp. The control-plane proxy rewrites that exact path to the in-sandbox daemon's native route and targets the daemon's CDP passthrough port (7879) inside the VM:

/cdp  ->  /session/{sandboxID}/cdp   (port 7879)

The session name is always the sandbox ID - a browser sandbox is exactly one session, so the rewrite is deterministic and the daemon's session model never leaks into the URL you connect to. The rewrite only fires on the bare {sandboxID} subdomain (no {port}- prefix); on any other path or port the request is a blind pipe to the sandbox.

You receive the wsEndpoint (the wss://…/cdp URL) and the accessToken from POST /browser-sandboxes and on GET /browser-sandboxes/{sandboxID}. See the API reference.

Authentication

Auth is carried as a WebSocket subprotocol, not in the URL:

Sec-WebSocket-Protocol: bearer.<accessToken>

The token is the same per-sandbox JWT returned as accessToken (it equals Sandbox.envdAccessToken from POST /sandboxes). The proxy forwards the Sec-WebSocket-Protocol header to the in-sandbox daemon untouched, and the daemon byte-compares the token against its on-disk token file at handshake time.

The proxy also accepts these carriers on non-WebSocket requests, in priority order: X-Access-Token, X-Sandbox-Access-Token, Authorization: Bearer …. For a WebSocket upgrade the subprotocol form is the one a browser-origin client can set, so it is the recommended carrier for CDP.

Token-in-URL is rejected. Do not append the token as a query parameter - it leaks through logs, the Referer header, and browser history. The bearer-subprotocol form is the only supported carrier on the upgrade.

When the platform's traffic-token enforcement (SEC-017) is on, a /cdp upgrade with no token returns 401. A token whose subject does not match the sandbox ID returns 403.

Readiness on a fresh boot

Right after a browser sandbox boots there is a brief window where the daemon's per-session /cdp route can answer 404 even though the session was already created. The proxy absorbs this: on the first /cdp upgrade it dials the daemon, reads the upstream status line before completing the handshake with your client, and retries a non-101 response a small number of times (~3 attempts over ~1.5 s). Your client connection is untouched until the proxy holds a real 101, so the retry is invisible to you.

In practice this means a connect attempt immediately after create either succeeds or, very rarely, returns a 502 sandbox unreachable after the retry budget is exhausted - retry the connect in that case.

Connecting with Playwright or Puppeteer

Playwright's connectOverCDP() and Puppeteer's connect() do not expose a way to set a WebSocket subprotocol, so they cannot send bearer.<token> on the upgrade themselves. Bridge them through @lelantos-ai/cdp-adapter, which stands up a loopback WebSocket that injects the subprotocol:

import { connect as cdpConnect } from '@lelantos-ai/cdp-adapter';
import { chromium } from 'playwright';

// wsEndpoint + accessToken come from POST /browser-sandboxes
const bridge = await cdpConnect({ wsEndpoint, accessToken });
const browser = await chromium.connectOverCDP(bridge.localWsUrl);

// ... drive `browser` as usual ...

await browser.close();
await bridge.close();

The SDK does all of this for you via lel.browser.createBrowser() and lel.browser.connect() - prefer those unless you are building a custom client.

A client that speaks the subprotocol natively (for example a hand-rolled ws connection) can skip the adapter and pass the subprotocol directly:

import WebSocket from 'ws';

const ws = new WebSocket(wsEndpoint, [`bearer.${accessToken}`]);
// ws now carries raw CDP frames once the 101 upgrade completes.

See also

On this page