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

# Playwright, raw CDP and localhost tunnels

Connect to a session using a Chrome DevTools Protocol client such as Playwright, Puppeteer, or chromedp. The connection grants browser control, including access to tabs, cookies, and script evaluation. Connection URLs contain short-lived session tokens.

Use CDP for interactive automation that needs the full browser API. For protected-page reads, `POST /v1/fetch` provides recovery and a typed content outcome. Main-client navigation receives automatic recovery when `cdp_observe: true`, the default. Setting it to `false` disables that behavior. Observer commands pass directly to the browser. Recovery never switches exits on a [custom-proxy session](https://docs.tilion.dev/sessions/custom-proxy.md). Call `fetch` before attaching the main client, then attach to work with the page it opened.

## The URLs and the token

`POST /v1/session` returns two URLs; `GET /v1/session/{id}/connect` mints fresh ones for a live session.

* `connect_url`: `wss://back.tilion.dev/v1/session/{id}/cdp?token=...`, a browser-level CDP endpoint on port 443. For `connectOverCDP`, Puppeteer `browserWSEndpoint`, chromedp, or your own socket.
* `playwright_url`: `wss://back.tilion.dev/v1/session/{id}/playwright?token=...`, a native Playwright server endpoint for `chromium.connect(...)`.

The token is the same in both, is bound to this one session and your account, and lasts `connect_expires_in` seconds (1800). It is not single use. A bad or expired token is refused at the WebSocket handshake with HTTP 403 and the body `{"detail": {"error": "invalid_token", "message": "missing, expired or invalid token for this session; mint a new one"}}`; a handshake for an ended session is refused the same way with the session's 410 or 404 answer. Mint a new URL with `/connect` whenever a process might have waited more than half an hour.

The session lifetime is independent of the token: `idle_timeout` is measured from the last drive or CDP activity, and `ttl` is the absolute lifetime. The accepted `ttl` range is 5 to 604800 seconds, subject to the plan cap. Playwright activity resets the idle clock. Playwright's `browser.close()` on a CDP-connected browser disconnects that client; the session keeps running and billing until `DELETE /v1/session/{id}` or its deadline. The raw CDP command `Browser.close` has different behavior, described below.

## Multiple CDP clients

A session supports up to three CDP clients when the feature is enabled for the account: one main client and two observers. These roles apply to the `connect_url` CDP endpoint.

| Client | Connection URL |
| - | - |
| Main client | `connect_url` unchanged |
| Observer | `connect_url` with `&role=observer` appended |

Clients can connect and disconnect in any order. An observer can connect before the main client and remain connected after the main client leaves.

| Capability | Main client | Observer |
| - | - | - |
| Send CDP commands and receive events | Yes | Yes |
| Automatic block recovery on its page loads | With `cdp_observe: true` (default), including healing and exit switching; disabled when `false` | No; commands pass directly to the browser |
| Raw `Browser.close`, `Browser.crash`, or `Browser.crashGpuProcess` | Allowed; `Browser.close` shuts down Chrome for every client | Refused with a CDP error, regardless of how the method name is written |
| `Target.sendMessageToTarget` or `Target.exposeDevToolsProtocol` | Allowed | Refused with a CDP error; address targets with `sessionId` instead |
| Frames | Any | Text only, one JSON object per frame with no repeated keys; violations return CDP error `-32700` |
| Holds the session against REST drive calls | Yes; REST returns `409 session_driven_by_client` | No |

All clients share the same browser. Observers can change page state; coordinate commands to avoid interfering with another client's actions. Custom-proxy sessions keep their proxy exit during recovery.

Disconnect the main client before driving over REST. Observers can remain connected. A native Playwright client or VNC client also holds the session; native Playwright connections return close code `4409` when the browser is busy.

For raw CDP observers, close the WebSocket to disconnect. For Playwright clients attached over CDP, `browser.close()` disconnects the client without sending the raw `Browser.close` command. See [Connect multiple CDP clients](https://docs.tilion.dev/guides/multiple-cdp-clients.md) for Python and Node.js examples.

### Observer protocol restrictions

Observers cannot send `Browser.close`, `Browser.crash`, or `Browser.crashGpuProcess`, regardless of how the method name is written. They also cannot use `Target.sendMessageToTarget` or `Target.exposeDevToolsProtocol`, which can carry another command such as `Browser.close`. Address targets with `sessionId` instead, as Playwright and Puppeteer already do.

Each observer WebSocket frame must be text containing one JSON object with no repeated keys. Binary frames, other JSON values, multiple objects in one frame, and repeated keys are refused with CDP error code `-32700`.

### Connection close codes

| Code | Meaning |
| - | - |
| 4409 | A main client is already connected. Connect as an observer or wait for it to disconnect. |
| 4429 | Two observers are already connected. Disconnect one before adding another. |
| 4403 | Multiple clients per session are not enabled for the account. |

## Python: connect\_over\_cdp

```python theme={"dark"}
import os, requests
from playwright.sync_api import sync_playwright

KEY = os.environ["TILION_API_KEY"]
H = {"Authorization": f"Bearer {KEY}"}
s = requests.post("https://back.tilion.dev/v1/session", json={}, headers=H).json()
sid, connect_url = s["session_id"], s["connect_url"]     # connect_expires_in: 1800

try:
    with sync_playwright() as p:
        browser = p.chromium.connect_over_cdp(connect_url, timeout=60000)
        context = browser.contexts[0]                      # the session's own context: its cookies, storage, saved logins
        page = context.pages[0] if context.pages else context.new_page()
        page.goto("https://example.com/", wait_until="domcontentloaded")
        print(page.title())                                # Example Domain
        browser.close()                                    # disconnects this client only
finally:
    requests.delete(f"https://back.tilion.dev/v1/session/{sid}", headers=H)
```

Default context or a new one. A fresh session has exactly one context with one page. `browser.contexts[0]` is the session: the cookies `/save-auth` and `/context` work with, the persona, the exit, the page the live view shows. `browser.new_context()` gives a second context with an empty cookie jar and storage, isolated from the first but in the same browser and on the same exit; it costs a renderer. Use the default context unless you need isolation inside one session.

## Python: native Playwright connect

`playwright_url` is a Playwright server endpoint, so `chromium.connect` works too. It reports no existing context (`contexts=0`), so create one; it lives in the same browser.

```python theme={"dark"}
browser = p.chromium.connect(playwright_url, timeout=60000)
context = browser.contexts[0] if browser.contexts else browser.new_context()
page = context.pages[0] if context.pages else context.new_page()
page.goto("https://example.com/", wait_until="domcontentloaded")
```

The native endpoint matches a gateway to your client's Playwright version; a version it does not have is refused with close code 1008 and a reason naming the supported range.

## Node: connectOverCDP

With `playwright-core` (no local browser needed):

```js theme={"dark"}
const { chromium } = require('playwright-core');

(async () => {
  const connectUrl = process.env.TILION_CONNECT_URL;          // from POST /v1/session or GET /v1/session/{id}/connect
  const browser = await chromium.connectOverCDP(connectUrl, { timeout: 60000 });
  const context = browser.contexts()[0];                       // the session's own context
  const page = context.pages()[0] ?? await context.newPage();
  await page.goto('https://example.com/', { waitUntil: 'domcontentloaded' });
  console.log(await page.title());                             // Example Domain
  await browser.close();                                       // the session stays alive
})();
```

## Raw CDP for other clients

`connect_url` is an ordinary CDP browser endpoint. One frame in, one out:

```jsonl theme={"dark"}
{"id": 1, "method": "Browser.getVersion"}
{"id":1,"result":{"protocolVersion":"1.3","product":"Chrome/153.0.8010.36","revision":"@507c6ee3e2f3b2ca0e660547e5b9ea4820c67f4c","userAgent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36","jsVersion":"15.3.76.10"}}
```

Puppeteer attaches with `puppeteer.connect({ browserWSEndpoint: connectUrl })` and chromedp with the URL as the remote allocator. Two things the socket enforces: a `Page.navigate` or `Target.createTarget` to a private, loopback or link-local address is refused (the command gets an error reply), and a vanilla client's `Runtime.enable` is an automation tell; if that matters for your target, use a stealth-configured client or drive the session over REST. CDP traffic is billed as the session's egress.

Troubleshooting:

* `connect_url` is null on create: raw CDP is not enabled for your key. Retrying will not change it.
* Handshake refused with HTTP 403 `invalid_token`: the token expired (1800 s) or belongs to another session. Mint one with `/connect`.
* Handshake refused with HTTP 410 or 404: the session ended or never existed. Create a new one.
* Socket accepted then closed with 4409: a main client already holds the session. Use `&role=observer` or wait for it to disconnect.
* Socket closed with 4429: both observer slots are occupied.
* Socket closed with 4403: multiple clients per session are not enabled for the account.
* Socket closed with 4410 `the session's browser crashed`: create a new session.
* Socket closed with 1011: the session has no live browser.
* `page.goto()` remains on a challenge or block page: disconnect the main client and read the page with `POST /v1/fetch`, preferably with `async_mode: true`. For a challenge on the current page, `POST /v1/solve` can attempt clearance after the main client disconnects. Observers can remain connected.

## POST /v1/session/\{session\_id}/tunnel

Allow one session to reach selected localhost ports on the machine running your tunnel client. Auth: Bearer API key.

Request:

```json theme={"dark"}
{"ports": [3000, 5173]}
```

Use 1 to 16 ports, each from 1 to 65535. The response contains `session_id`, `ports`, `expires_in` (1800 seconds), and a tokenized `tunnel_url` WebSocket URL. Open that URL from the machine hosting the local service; minting it alone does not forward traffic.

The tunnel client receives a text frame `{"type": "ready", "session_id": "...", "ports": [3000, 5173]}`. Subsequent messages are binary: one byte message type, four-byte stream id (big endian), then payload.

| Type | Value | Direction | Payload and handling |
| - | - | - | - |
| OPEN | 1 | server to client | Two-byte port, big endian. Connect to that port on local loopback. |
| OPEN\_OK | 2 | client to server | Empty payload; the local connection succeeded. |
| ERR | 3 | client to server | UTF-8 reason when the local connection failed. |
| DATA | 4 | both | Relay these bytes between the stream and the local TCP connection. |
| CLOSE | 5 | both | Empty payload; close the stream. |

Reply to OPEN with OPEN\_OK or ERR within 10 seconds. Up to 64 streams can be open per tunnel. Only the listed ports are allowed. A second tunnel socket for the session replaces the first; the tunnel ends when the session ends.

With the tunnel socket open, create a **new browser context** over CDP or Playwright:

```python theme={"dark"}
browser = await playwright.chromium.connect_over_cdp(session["connect_url"])
context = await browser.new_context()
page = await context.new_page()
await page.goto("http://localhost:3000/")
```

The default browser context and REST navigation routes cannot reach tunnelled ports. Those REST requests answer 409 `tunnel_needs_browser_context`. Other private addresses remain refused.

Errors: 404 for an unknown session or unavailable tunnel support; 409 when the session cannot support a tunnel; 422 validation list for an invalid port list; 503 when a tunnel URL cannot be minted. Invalid or altered tunnel tokens are refused at the WebSocket handshake.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.