Documentation

Supernatural docs

REST + WebSocket API for stealth browser sessions, plus first-party Python and Node SDKs, an authenticated MCP server, and direct integrations with Playwright, Puppeteer, Browser-Use, and Stagehand. Base URL https://api.supernatural.sh. All request bodies are JSON unless noted; file uploads use multipart/form-data. All responses are JSON.

Quick start

  1. Sign in and go to Settings → API keys → New key. Copy the sk_… value — it's shown only once.
  2. Settings → IP whitelist: add the public IP of the machine that will open CDP connections, or POST /api/v1/settings/ip-whitelist with {"ip":"current"} from that machine.
  3. Start a session: POST /api/v1/sessions. Poll GET /api/v1/sessions/<id> until status="running" (typically 3–8s on a warm worker).
  4. Connect any CDP client (Playwright, Puppeteer, chrome-remote-interface) to wss://api.supernatural.sh/api/v1/sessions/<id>/cdp. Do not use the cdp_url field from the response — that's an internal pointer.
  5. Stop with DELETE /api/v1/sessions/<id>. For persistent sessions, wait for storage_status="synced" before resuming.
Prefer an SDK for production — it handles polling, IP whitelisting, and Playwright/Puppeteer wiring for you.

Authentication

API Key (recommended)

Primary auth for the REST API, SDKs, and MCP. Mint one in Settings → API keys. Bypasses the must-change-password gate; never expires until revoked.

Authorization: ApiKey sk_…

JWT Bearer

Accepted on every protected route — useful for short-lived dashboard-driven calls. Required for the API-keys endpoints themselves (keys can't mint or list other keys).

Authorization: Bearer eyJ…

IP whitelist (CDP only)

The CDP WebSocket upgrade can't reliably carry an Authorization header through every client, so we authenticate by source IP instead. Add yours in Settings → IP whitelist, or call POST /api/v1/settings/ip-whitelist {"ip":"current"} from the machine that will connect.

wss://api.supernatural.sh/api/v1/sessions/<id>/cdp

Conventions & errors

  • Timestamps are RFC 3339 UTC (2026-05-10T12:00:00Z).
  • IDs are UUID v4 strings.
  • Bodies use snake_case JSON; file uploads use multipart/form-data.
  • Request body size is capped at 1 MiB (100 MiB on the file-upload route).
  • DELETE endpoints return either {"status":"deleted"} or 204 No Content — both are success; the shape is documented per endpoint.

Every error response is {"error":"<reason>"} with an HTTP status. Some errors also carry a stable error code (e.g. payment_not_configured, no_payment_method) plus a human message. Status meanings used across the API:

  • 400 — validation failure (bad enum, missing required field, conflicting parameters).
  • 401 — missing or invalid Authorization.
  • 402 — account is not active (no current subscription).
  • 403 — authenticated, but blocked: plan limit (profiles), IP not in CDP whitelist, or unauthorized resource.
  • 404 — resource not found (or not visible to your account — we don't distinguish to prevent enumeration).
  • 409 — billing-state conflict (e.g. switching plans while past-due or mid-cancellation).
  • 429 — rate limit, or plan-usage cap (concurrency, monthly minutes, persistent profiles, BYO-proxy plan check, bandwidth).
  • 503 — transient infrastructure issue (no worker capacity, sidecar unreachable, billing provider not configured). Safe to retry with backoff.

API reference

Sessions

Browser session lifecycle. Create returns asynchronously — poll GET /sessions/:id until status="running" before opening CDP (or let the SDK do it).

POST/api/v1/sessions

Start a new browser session. Returns 201 immediately with status="creating"; container provisioning typically takes 3–8s on a warm worker.

Body fields

osenum"macos" or "windows" — drives the fingerprint. Default "macos".
locationstringIANA timezone for the session fingerprint (e.g. "Europe/London", "America/New_York", "Asia/Jerusalem"). Non-BYO callers must pick a timezone served by the proxy pool — see GET /sessions/locations. BYO-proxy callers can pass any valid IANA timezone. Default "America/New_York".
labelstringOptional pool-proxy label — pins the (location, label) pair to a specific pool row (e.g. "Kentucky, US", "Brazil — Recife"). The dashboard's location picker surfaces these. Persisted on the session so resume re-resolves the same label. Ignored when BYO proxy fields are set.
persistentbooleanWhen true, browser data (cookies, storage, localStorage, IndexedDB) is uploaded to object storage on stop. Use POST /sessions/:id/resume to relaunch with the same data dir. Default false.
session_namestringDisplay name. Required when persistent=true.
captcha_solverbooleanAttach an in-container NopeCHA solver to this session (charged per solve). Requires a plan with the solver enabled. Default false.
isolated_worldbooleanRun automation in a V8 isolated world hidden from the page. Injected functions, variables, and stack traces stay invisible to anti-bot scripts. Set to false ONLY when you need to read page-defined JavaScript globals or call functions the page exposes on window.* — main-world execution is detectable. Default true.
proxy_protocolenum"HTTP" or "SOCKS5" — switches into BYO-proxy mode. Requires a plan with BYO proxies.
proxy_hoststringBYO proxy host or IP.
proxy_portintegerBYO proxy port. Required when proxy_protocol is set.
proxy_usernamestringOptional BYO proxy auth.
proxy_passwordstringOptional BYO proxy auth. Encrypted at rest with AES-GCM; never returned in responses.

Request

{
  "os": "macos",
  "location": "America/New_York",
  "persistent": false
}

Response

{
  "id": "9d8f3c12-…",
  "user_id": "…",
  "profile_id": null,
  "status": "creating",
  "os": "macos",
  "location": "America/New_York",
  "persistent": false,
  "captcha_solver": false,
  "isolated_world": true,
  "session_name": null,
  "data_dir_size": 0,
  "storage_status": "none",
  "chrome_port": 9300,
  "sidecar_port": 32790,
  "cdp_url": "ws://localhost:9300",
  "uptime_seconds": 0,
  "started_at": "2026-05-10T12:00:00Z",
  "stopped_at": null,
  "created_at": "2026-05-10T12:00:00Z",
  "proxy": {
    "protocol": "HTTP",
    "host": "proxy.example.com",
    "port": 8080,
    "username": "user",
    "address": "HTTP://proxy.example.com:8080"
  }
}

curl

curl -X POST https://api.supernatural.sh/api/v1/sessions \
  -H "Authorization: ApiKey sk_…" \
  -H "Content-Type: application/json" \
  -d '{"os":"macos","location":"America/New_York"}'

Errors

400Invalid OS, invalid location, BYO proxy fields incomplete, or session_name missing for a persistent session.
402Account is not active (subscription required) — enforced by the active-subscription gate.
429Plan limit hit: concurrent session cap, monthly minute cap, persistent-profile cap, BYO-proxy plan check, or bandwidth cap.
503No worker capacity right now, or the container/sidecar layer is unreachable. Safe to retry with backoff.
Proxy and WebRTC IP are configured at Chrome launch and cannot be changed mid-session — kill and recreate to switch. The cdp_url in the response is an internal pointer; connect to wss://api.supernatural.sh/api/v1/sessions/<id>/cdp instead.
GET/api/v1/sessions

List every session for the authenticated account (any status). Response: { "sessions": [<session>, …] }.

GET/api/v1/sessions/active

List only sessions whose status is "running". Response: { "sessions": [<session>, …] }.

GET/api/v1/sessions/history

Paginated history of stopped, failed, and ephemeral sessions, newest first. Query: page (default 1), per_page (default 25). Response includes total, page, per_page.

GET/api/v1/sessions/resumable

Stopped persistent sessions whose saved data dir is available to resume. Query: limit (default 10).

GET/api/v1/sessions/persistent

Every persistent session regardless of state — both currently running and stopped. Powers the dashboard's Profiles page. Query: limit (default 50).

GET/api/v1/sessions/locations

IANA timezones currently served by the proxy pool — the set non-BYO callers must pick from. Response: { "locations": ["America/New_York", "America/Los_Angeles", "Europe/London", "Europe/Paris", "Europe/Berlin", "Asia/Tokyo"] }.

Each entry selects a managed pool proxy in that timezone. BYO-proxy callers (POST /sessions with proxy_host set) can pass any valid IANA timezone, not just the pool-served set.
GET/api/v1/sessions/:id

Current state of a single session. Poll until status="running" before connecting CDP. ~500ms intervals are fine; sessions typically become ready within 3–8 seconds.

DELETE/api/v1/sessions/:id

Stop a running session. For persistent sessions, the data dir is uploaded to object storage before the container is removed.

Returns once the API has accepted the stop. For persistent sessions, storage_status moves "uploading" → "synced" in the background — wait for "synced" before calling /sessions/:id/resume.
POST/api/v1/sessions/:id/resume

Relaunch a stopped persistent session with its saved profile. Accepts optional os, location, label, captcha_solver, isolated_world overrides — changing os/location rebuilds the pinned fingerprint (intentional one-time identity drift); a location change on a pool session also re-assigns a pool proxy for the new region. Errors: 400 not persistent or already running, 404 not found, 429 concurrency/minutes/bandwidth cap, 503 no capacity.

DELETE/api/v1/sessions/:id/persist

Permanently delete a stopped persistent profile and its saved data dir. Irreversible — the session must be stopped first.

Files

Move files in and out of a running session. Files live in tmpfs and are wiped when the container stops — sync anything you need to keep.

GET/api/v1/sessions/:id/files

List files in the session — union of /home/chromium/Downloads/<id>/ (Chrome-written) and /home/chromium/Uploads/<id>/ (caller-pushed).

Response

{
  "files": [
    { "name": "report.pdf",  "size": 245120, "kind": "download" },
    { "name": "invoice.pdf", "size": 12480,  "kind": "upload"   }
  ],
  "count": 2
}
Only available while the session is running (returns 400 otherwise). Symlinks are filtered out. The same filename can appear in both kinds — the kind discriminator is load-bearing.
GET/api/v1/sessions/:id/files/:filename

Stream a file out of the session as Content-Disposition: attachment. Searches Downloads first, then Uploads.

curl

curl https://api.supernatural.sh/api/v1/sessions/<id>/files/report.pdf \
  -H "Authorization: ApiKey sk_…" \
  -o report.pdf
Filename must match exactly (case-sensitive, no path separators). Symlinks and directories are rejected.
POST/api/v1/sessions/:id/files

Upload a file into the session's Uploads dir. Body: multipart/form-data with field name file. Returns 201 with a path you can pass to CDP DOM.setFileInputFiles.

curl

curl -X POST \
  https://api.supernatural.sh/api/v1/sessions/<id>/files \
  -H "Authorization: ApiKey sk_…" \
  -F "file=@./document.pdf"

Response

{
  "name": "document.pdf",
  "size": 245120,
  "path": "/home/chromium/Uploads/<session-id>/document.pdf"
}
Per-request cap is 100 MB; per-session quota is 500 MB. Filenames may not contain path separators, NUL, control chars, or "..". Pass the returned path to DOM.setFileInputFiles to attach to a Chrome <input type="file">.

CDP

Direct WebSocket access to Chrome DevTools Protocol. Authenticated by IP whitelist — the upgrade handshake can't carry an Authorization header reliably, so we authenticate by source IP instead.

WSwss://api.supernatural.sh/api/v1/sessions/:id/cdp

Browser-level Chrome DevTools Protocol WebSocket. Compatible with Playwright, Puppeteer, chrome-remote-interface, and any CDP client — connect once, then walk into the existing default context (Target/Browser/Page/DOM/Runtime/Network/Input/Accessibility/Emulation domains are all reachable).

Real client IP is taken from Cloudflare's CF-Connecting-IP header. Add yours with POST /api/v1/settings/ip-whitelist {"ip":"current"}, or use the SDK's auto_whitelist option. The cdp_url field returned by /sessions endpoints is the orchestrator's internal pointer (ws://localhost:<port>) — always construct the wss://… URL above from the session id, or call the SDK's cdpUrl() / cdp_url() helper.

Errors

400Session is not running.
403Source IP is not in the account's whitelist (or the whitelist is empty).
404Session not found.

Profiles

Reusable fingerprint configurations applied at session-create time. Profiles are templates; persistent sessions are state. The two are independent.

GET/api/v1/profiles

List all profiles owned by the account.

{ "profiles": [
  { "id": "…", "user_id": "…", "name": "scraper-eu", "os": "macos", "location": "Europe/Paris",
    "persona": "casual", "template_id": "macos-stealth-1",
    "config_json": { … }, "created_at": "…", "updated_at": "…" }
] }
POST/api/v1/profiles

Create a profile. Either template_id or config_json must be supplied.

Body fields

name *stringDisplay name.
template_idstringBase the profile on a template from GET /templates. Mutually exclusive with config_json.
config_jsonobjectRaw fingerprint config. Mutually exclusive with template_id.
descriptionstringOptional human description.
osenum"macos" or "windows". Inherited from the template when not given.
locationstringDefault location for sessions created from this profile.
personastringPersona key applied as an override on top of the template.

Errors

400Neither template_id nor config_json supplied, or template not found.
403Plan profile cap reached (e.g. "profile limit reached (5/5 for Pro plan)").
GET/api/v1/profiles/:id

Get a single profile.

PUT/api/v1/profiles/:id

Partial update — only the supplied fields are modified.

{ "name": "scraper-eu-v2", "config_json": { … } }
POST/api/v1/profiles/:id/duplicate

Clone a profile under a new ID. Returns 201 with the copy.

DELETE/api/v1/profiles/:id

Delete a profile. Returns 204 No Content on success.

Sessions previously created from the profile are unaffected — only future creates that reference this id will fail.

Templates

Pre-built fingerprint bundles curated by Supernatural. Used as the basis for new profiles. Read-only — templates are managed server-side.

GET/api/v1/templates

List available templates.

{ "templates": [
  { "id": "macos-stealth-1", "name": "macOS Stealth (default)",
    "os": "macos", "description": "…" }
] }

API keys

Programmatic-access tokens. Use the ApiKey scheme for SDK / MCP / direct REST clients. Bypasses must_change_password, so they keep working even while the password owner is mid-rotation. These endpoints accept JWT only — an API key cannot enumerate or rotate other API keys.

GET/api/v1/apikeys

List the account's API keys. No secret material is returned — only metadata.

{ "api_keys": [
  {
    "id": "…",
    "name": "Automation",
    "key_prefix": "sk_57502",
    "last_used_at": "2026-05-18T10:14:00Z",
    "created_at": "2026-05-01T12:00:00Z",
    "revoked_at": null
  }
] }
POST/api/v1/apikeys

Mint a new API key. Returns 201 with the full secret EXACTLY ONCE — store it immediately, the server cannot show it again.

Request

{ "name": "Automation" }

Response

{
  "id": "…",
  "name": "Automation",
  "key": "sk_2342fddcccbb91d8…",
  "key_prefix": "sk_2342f",
  "created_at": "2026-05-18T10:14:00Z"
}
Keys are hashed with bcrypt at rest. The 8-char prefix is indexed for O(1) lookup, so validation is constant-time with no timing oracle.
DELETE/api/v1/apikeys/:id

Revoke an API key. Effective immediately — no grace period.

IP whitelist

Source-IP allowlist for the CDP WebSocket. Required because the upgrade handshake can't reliably carry Bearer auth (browsers, transparent proxies). MCP and the REST API do NOT consult this list — only the raw /sessions/:id/cdp socket does.

GET/api/v1/settings/ip-whitelist

List whitelisted IPs.

{ "entries": [
  { "id": "…", "user_id": "…",
    "ip_address": "203.0.113.7",
    "label": "Office", "created_at": "…" }
] }
POST/api/v1/settings/ip-whitelist

Add an IP. Use ip="current" to auto-detect the caller's IP from CF-Connecting-IP. Returns 201 with the new entry.

Body fields

ip *stringIPv4 or IPv6 address, or the literal string "current". The legacy field name ip_address is also accepted.
labelstringOptional human label (e.g. "office", "CI runner").
{ "ip": "current", "label": "My Office" }
Supports single IPv4 / IPv6 addresses (validated by net.ParseIP). CIDR ranges are not supported — add each public IP individually.
DELETE/api/v1/settings/ip-whitelist/:id

Remove a whitelist entry. Returns 204 No Content on success.

Usage

Monthly hours and bandwidth metering, plus accrued overage charges.

GET/api/v1/usage

Current calendar month usage with plan context.

{
  "total_minutes": 120,
  "plan_minutes": 180000,
  "overage_minutes": 0,
  "month": "2026-05-01",
  "plan": "pro_monthly",
  "usage_percentage": 0.07,
  "allow_hours_overage": true,
  "profile_count": 3,
  "max_profiles": 25,
  "bandwidth_rate_cents": 250,
  "hours_overage_rate_cents": 15,
  "bandwidth_bytes": 4823945,
  "bandwidth_charge_amount_cents": 0,
  "hours_overage_minutes": 0,
  "hours_overage_amount_cents": 0
}
BYO-proxy traffic is not counted toward bandwidth_bytes — only pool-proxy egress is metered. usage_percentage is on a 0–100 scale (rounded to two decimals).
GET/api/v1/usage/history

Per-month rollups for the trailing 12 months, oldest first.

{ "months": [
  { "month": "2026-04-01", "total_minutes": 1200,
    "plan_minutes": 180000, "overage_minutes": 0 }
] }

Account

Account profile, settings, and password rotation. Same surface the dashboard uses.

GET/api/v1/user/me

Account profile plus computed billing state.

{
  "id": "…",
  "email": "[email protected]",
  "name": "Sam",
  "plan": "pro_monthly",
  "role": "user",
  "allow_hours_overage": true,
  "byo_proxy_only": false,
  "bandwidth_rate_cents": 250,
  "hours_overage_rate_cents": 15,
  "must_change_password": false,
  "email_verified": true,
  "account_status": "active",
  "oauth_provider": null,
  "created_at": "2026-04-01T00:00:00Z"
}
PUT/api/v1/user/me

Update the display name on the account. Body: { "name": "Sam" }.

GET/api/v1/user/me/settings

Same payload as GET /user/me. Kept symmetric with PUT /user/me/settings.

PUT/api/v1/user/me/settings

Update account-level toggles.

Body fields

allow_hours_overagebooleanWhen true (default), sessions keep running past the plan's monthly minute allowance and the overage is billed. When false, session creates are refused once the plan ceiling is hit.
byo_proxy_onlybooleanWhen true, lock new sessions to BYO-proxy mode — the platform pool is off-limits. Existing sessions are unaffected.
{ "allow_hours_overage": true, "byo_proxy_only": false }
PUT/api/v1/user/change-password

Rotate the account password. Valid only for password accounts (OAuth accounts have no local password). Per-user rate limit: 5/min.

Body fields

current_password *stringCurrent password (max 64 chars).
new_password *stringNew password (8–64 chars).
{
  "message": "password updated",
  "access_token": "eyJ…",
  "refresh_token": "…",
  "token_type": "Bearer"
}
All existing refresh tokens for the account are revoked atomically. The fresh tokens in the response have must_change_password=false so programmatic clients can resume without re-login.

Billing

The only billing endpoint customer code should call directly is the public plan catalog. Subscription management (checkout, portal, invoices, cancel/reactivate, plan changes) runs through Stripe-hosted pages driven by the dashboard — there is no programmatic surface for it.

GET/api/v1/billing/plansPublic · no auth

Public plan catalog (no auth). Excludes the internal "pending" sentinel.

{ "plans": [
  {
    "id": "…",
    "name": "pro_monthly",
    "display_name": "Pro · Monthly",
    "minutes_per_month": 180000,
    "max_concurrent_sessions": 10,
    "max_profiles": 25,
    "bandwidth_rate_cents": 250,
    "hours_overage_rate_cents": 15,
    "price_cents": 19900,
    "is_default": false,
    "provider_plan_ids": { … }
  }
] }
Useful for building a pricing page or pre-selecting a plan in your own funnel. The actual subscribe flow runs in the dashboard.

SDKs

Python & Node SDKs

Official first-party SDKs for Python and Node.js. Both wrap the full HTTP API (sessions, profiles, templates, IP whitelist, usage, user, API keys, file transfer) and ship a Playwright-over-CDP helper that handles waiting for the session, registering your IP, connecting, and cleaning up.

Node: Playwright and Puppeteer both supported (optional peer dependencies). Python: Playwright only — sync and async clients are both first-class. Both packages are sprntrl (npm + PyPI), currently at 0.1.1 alpha.

Install

Python

pip install sprntrl
# Optional — for the Playwright helper:
pip install 'sprntrl[playwright]' && playwright install chromium

Node.js / TypeScript

npm install sprntrl
# Optional — pick whichever browser library you use:
npm install playwright
npm install puppeteer    # or puppeteer-core

Node requires >=18 (global fetch / FormData); for await using, Node >=24 and TypeScript >=5.2. Python requires >=3.9.

Configuration

Both SDKs read SPRNTRL_API_KEY from the environment by default. Override via the constructor. SPRNTRL_BASE_URL defaults to https://api.supernatural.sh. Auth uses the ApiKey scheme (same as the REST API and MCP).

Python

from sprntrl import Sprntrl, AsyncSprntrl

client = Sprntrl()                              # reads SPRNTRL_API_KEY
client = Sprntrl(api_key="sk_...")              # or pass explicitly
client = Sprntrl(timeout=30.0, max_retries=2)   # timeout in SECONDS

Node.js

import { Sprntrl } from "sprntrl";

const client = new Sprntrl();                                   // reads SPRNTRL_API_KEY
const client = new Sprntrl({ apiKey: "sk_..." });               // or pass explicitly
const client = new Sprntrl({ timeout: 30_000, maxRetries: 2 }); // timeout in MILLISECONDS
Timeout units differ: Python takes seconds (float), Node takes milliseconds (number). Defaults: 60s / 60000ms.

Quick start

The browser_session / browserSession helper is the fast path — it waits for the session to be ready, registers your IP (opt-in), connects Playwright, and tears the browser down on exit. The session itself stays alive — caller owns its lifecycle.

Python (sync)

from sprntrl import Sprntrl

with Sprntrl() as client:
    session = client.sessions.create(os="macos", location="America/New_York")

    # Context manager: waits, whitelists IP, connects, cleans up.
    with client.sessions.browser_session(session["id"], auto_whitelist=True) as browser:
        page = browser.contexts[0].new_page()
        page.goto("https://bot.sannysoft.com")
        page.screenshot(path="out.png")

    client.sessions.stop(session["id"])

Node.js

import { Sprntrl } from "sprntrl";
import type { Browser } from "playwright";

const client = new Sprntrl();

const session = await client.sessions.create({ os: "macos", location: "America/New_York" });
const handle = await client.sessions.browserSession(session.id, { autoWhitelist: true });
try {
  const browser = handle.browser as Browser;
  const page = await browser.contexts()[0].newPage();
  await page.goto("https://bot.sannysoft.com");
  await page.screenshot({ path: "out.png" });
} finally {
  await handle.close();
  await client.sessions.stop(session.id);
}

Python (async)

import asyncio
from sprntrl import AsyncSprntrl

async def main():
    async with AsyncSprntrl() as client:
        session = await client.sessions.create(os="macos", location="America/New_York")
        async with client.sessions.browser_session(session["id"], auto_whitelist=True) as browser:
            page = await browser.contexts[0].new_page()
            await page.goto("https://example.com")
        await client.sessions.stop(session["id"])

asyncio.run(main())

Node.js — await using (Node 24+ / TS 5.2+)

import type { Browser } from "playwright";

{
  await using handle = await client.sessions.browserSession(session.id, { autoWhitelist: true });
  const browser = handle.browser as Browser;
  const page = await browser.contexts()[0].newPage();
  await page.goto("https://example.com");
} // Symbol.asyncDispose closes the browser here

Puppeteer (Node only)

Pass framework: "puppeteer" to browserSession or connect. The SDK tries puppeteer-core first, then falls back to the full puppeteer package.

import { Sprntrl } from "sprntrl";
import type { Browser } from "puppeteer";

const client = new Sprntrl();
const session = await client.sessions.create({ os: "macos", location: "America/New_York" });

const handle = await client.sessions.browserSession(session.id, {
  framework: "puppeteer",
  autoWhitelist: true,
});
try {
  const browser = handle.browser as Browser;
  const [page] = await browser.pages();
  await page.goto("https://example.com");
} finally {
  await handle.close();
  await client.sessions.stop(session.id);
}
The Python SDK is Playwright-only. connect(framework="puppeteer") raises SprntrlError. Use the Node SDK, or call cdp_url() and connect your own client.

Session create — full parameters

Both SDKs accept the same options on sessions.create. Proxy can be a flat URL string or a dict / object — the SDK normalises both into the API's flat field shape.

Python

session = client.sessions.create(
    os="macos",                       # "macos" | "windows"
    location="America/New_York",
    persistent=True,                  # keep data dir across stop/resume
    session_name="invoice-scraper",   # required when persistent=True
    captcha_solver=True,              # spawn the NopeCHA-backed solver daemon
    isolated_world=False,             # opt out of stealth JS isolation (default True)
    proxy="http://user:pw@host:8080", # or ProxyConfig dict
)

Node.js

const session = await client.sessions.create({
  os: "macos",                          // "macos" | "windows"
  location: "America/New_York",
  persistent: true,
  session_name: "invoice-scraper",      // required when persistent: true
  captcha_solver: true,
  isolated_world: false,                // opt out of stealth JS isolation (default true)
  proxy: "http://user:pw@host:8080",    // or { protocol, host, port, username, password }
});
Proxy passwords are encrypted at rest (AES-GCM). For BYO proxies, the session response includes a proxy summary field (protocol/host/port/username — password is never returned). Pool proxies omit the summary entirely.

Lower-level helpers

If you want to manage the browser yourself, plug into a different CDP client, or escape the helpers entirely, use connect(), cdp_url() / cdpUrl(), or the raw request() escape hatch.

Python

# Returns a connected Playwright Browser — you own teardown.
browser = client.sessions.connect(session_id, auto_whitelist=True)
try:
    ...
finally:
    browser.close()

# Wait for status="running" without connecting:
client.sessions.wait_until_ready(session_id, timeout=60.0)

# Raw CDP WebSocket URL — hand to any CDP client.
url = client.sessions.cdp_url(session_id)
# wss://api.supernatural.sh/api/v1/sessions/<id>/cdp

Node.js

const browser = await client.sessions.connect(session.id, { autoWhitelist: true });
try { /* ... */ } finally { await (browser as { close(): Promise<void> }).close(); }

await client.sessions.waitUntilReady(session.id, { timeout: 60_000 });

const url = client.sessions.cdpUrl(session.id);
// wss://api.supernatural.sh/api/v1/sessions/<id>/cdp

// Low-level REST escape hatch for endpoints the SDK doesn't wrap:
const data = await client.request<{ foo: string }>({
  method: "GET",
  path: "/api/v1/some/route",
  query: { limit: 10 },
});
Node exposes a public client.request(). Python has no direct equivalent — the matching internal helper is not part of the SDK's stability contract.

Resource reference

Identical surface in both SDKs (naming follows language conventions: snake_case in Python, camelCase in Node). Each tile shows the Python form / Node form.

client.sessions

create, list, list_active / listActive, list_history / listHistory, list_resumable / listResumable, list_persistent / listPersistent, list_locations / listLocations, get, stop, resume, delete_persistent / deletePersistent, wait_until_ready / waitUntilReady, connect, browser_session / browserSession, cdp_url / cdpUrl

client.sessions.files

list, download, upload

client.profiles

create, list, get, update, duplicate, delete

client.templates

list (read-only — templates are managed server-side)

client.ip_whitelist / ipWhitelist

list, add (defaults ip="current"), remove

client.usage

current, history

client.user

me, update, get_settings / getSettings, update_settings / updateSettings, change_password / changePassword

client.api_keys / apiKeys

list, create (full key returned ONCE), revoke

Profiles

Profiles are reusable fingerprint configurations (distinct from persistent sessions, which keep the data dir). Create one from a server-side template, optionally overriding fields.

Python

templates = client.templates.list()
tpl = next(t for t in templates if t["os"] == "macos")

profile = client.profiles.create(
    name="scraper-eu",
    template_id=tpl["id"],
    description="EU residential, macOS persona",
    os="macos",
    location="Europe/Paris",
    persona="casual",        # optional behavioural persona
    config={...},            # optional config_json override
)

Node.js

const templates = await client.templates.list();
const tpl = templates.find((t) => t.os === "macos")!;

const profile = await client.profiles.create({
  name: "scraper-eu",
  template_id: tpl.id,
  description: "EU residential, macOS persona",
  os: "macos",
  location: "Europe/Paris",
  persona: "casual",
  config: { /* ... */ },
});

File transfer examples

File transfer is out-of-band: uploads land in the container at /home/chromium/Uploads/<session_id>/<name>, downloads come from Chrome's download dir. upload returns no value (raises / rejects on failure). Per-request limit 100 MB, per-session quota 500 MB.

Python

# Upload a local file — no return value; raises on error.
with open("invoice.pdf", "rb") as f:
    client.sessions.files.upload(session_id, "invoice.pdf", f)

# List files (union of Uploads/ and Downloads/):
for f in client.sessions.files.list(session_id):
    print(f["name"], f["size"])

# Download a file Chrome wrote:
data = client.sessions.files.download(session_id, "report.pdf")
with open("report.pdf", "wb") as out:
    out.write(data)

Node.js

import { readFile, writeFile } from "node:fs/promises";

// Upload — Promise<void>, throws on error.
await client.sessions.files.upload(
  session.id,
  "invoice.pdf",
  await readFile("invoice.pdf"),
);

// List + download (download returns ArrayBuffer):
const files = await client.sessions.files.list(session.id);
const data = await client.sessions.files.download(session.id, "report.pdf");
await writeFile("report.pdf", Buffer.from(data));

Error handling

Both SDKs expose a typed error hierarchy. Transient errors (5xx, 429, 409, 408, connection errors) are retried automatically up to max_retries / maxRetries with exponential backoff.

Python

from sprntrl import (
    Sprntrl,
    SprntrlError,             # base (also raised for non-HTTP failures)
    APIError,                 # status != 2xx (has .status and .body)
    BadRequestError,          # 400
    AuthenticationError,      # 401
    PermissionDeniedError,    # 403
    NotFoundError,            # 404
    ConflictError,            # 409
    UnprocessableEntityError, # 422
    RateLimitError,           # 429 — plan/concurrency/bandwidth caps live here
    InternalServerError,      # 5xx
    APIConnectionError, APIConnectionTimeoutError,
)

try:
    client.sessions.create(os="macos", location="America/New_York")
except RateLimitError as e:
    print("hit a plan limit:", e.body)
except APIError as e:
    print("api error:", e.status, e)

Node.js

import {
  SprntrlError,
  APIError,
  BadRequestError, AuthenticationError, PermissionDeniedError,
  NotFoundError, ConflictError, UnprocessableEntityError,
  RateLimitError, InternalServerError,
  APIConnectionError, APIConnectionTimeoutError,
} from "sprntrl";

try {
  await client.sessions.create({ os: "macos", location: "America/New_York" });
} catch (err) {
  if (err instanceof RateLimitError) console.log("hit a plan limit:", err.body);
  else if (err instanceof APIError)   console.log("api error", err.status, err.body);
  else throw err;
}
APIError deliberately does not retain the live HTTP response — the request object carries the Authorization: ApiKey header, so keeping it would leak the key into any captured exception. Only status and the parsed (server-controlled) body are exposed.

Python / Node parity at a glance

ConcernPythonNode
Namingsnake_casecamelCase
AsyncAsyncSprntrl + async withEvery method returns Promise
Resource cleanupwith / async withawait using (Node 24+/TS 5.2+) or try/finally + handle.close()
Browser frameworksPlaywright onlyPlaywright or Puppeteer (optional peer deps)
Timeout unitsseconds (float)milliseconds (number)
Session shapedictsession["id"]typed object — session.id
Low-level escapeinternal helper (no stability contract)public client.request<T>()
Download returnbytesArrayBuffer (wrap with Buffer.from)

Gotchas

CDP access is IP-whitelist gated

The WebSocket at /api/v1/sessions/:id/cdp does not accept Bearer auth. Your public IP (as Cloudflare sees it via CF-Connecting-IP) must be in your account's whitelist. Pass auto_whitelist=True / autoWhitelist: true to have the SDK register your current IP before connecting, or call client.ip_whitelist.add("current") once at startup.

Sessions start asynchronously

sessions.create returns immediately with status: "creating". Call wait_until_ready / waitUntilReady before connecting, or use connect / browser_session which wait for you.

API keys are shown once

api_keys.create / apiKeys.create returns the full key field exactly once. Store it immediately — the server cannot show it again. List endpoints only return the prefix.

Persistent sessions sync on stop

Calling stop on a persistent session returns immediately. The data dir uploads to object storage in the background — wait for storage_status: "synced" before resume if you want zero-loss continuity.

CDP URL: use the helper, not session.cdp_url

The session response includes a cdp_url field, but it's the worker's internal host:port and is not reachable from outside the API host. Always use client.sessions.cdp_url(id) / cdpUrl(id) — that returns the public wss://api.supernatural.sh/api/v1/sessions/<id>/cdp path.

MCP

Model Context Protocol

Supernatural runs an authenticated MCP (Model Context Protocol) server at https://api.supernatural.sh/mcp. AI agents — Claude Code, Cursor, VS Code, Claude Desktop, or your own MCP client — get 24 tools across six groups: session lifecycle, browser control, configuration (locations, profiles, WebRTC), cookies + raw CDP, file transfer, and IP whitelist. Every session runs on the same stealth Chromium as the dashboard and SDK paths.

Authentication uses an API key. Mint one in Settings → API keys first; the full key is shown only once at creation.

Endpoint & auth model

POST/GEThttps://api.supernatural.sh/mcp
  • Transport: Streamable HTTP (MCP spec). Works directly with Cursor, VS Code, web MCP clients, and the official Python / TypeScript MCP SDKs.
  • Auth header: Authorization: ApiKey sk_…. JWT Bearer is also accepted but API keys are the intended path for agents.
  • Anonymous handshake is allowed so agents can call initialize and tools/list to discover the catalogue. Tool calls without auth return not authenticated.
  • An invalid Authorization header is rejected with 401 — no silent fallback to anonymous, so brute-forcing API keys is visible.
  • Rate limit: 60 requests / IP / minute. Fails closed on Redis outage.
  • The /mcp route is mounted on the root engine, outside /api/v1, so it bypasses the active-subscription gate. A valid API key is enough — billing state is not checked.
  • Server identity on handshake: sprntrl v1.0.0.

Setup by client

Cursor

Command Palette (Cmd/Ctrl+Shift+P) → "Open MCP Configuration File". Add:

{
  "mcpServers": {
    "sprntrl": {
      "url": "https://api.supernatural.sh/mcp",
      "headers": {
        "Authorization": "ApiKey YOUR_API_KEY"
      }
    }
  }
}

The Supernatural tools should appear in Cursor's MCP panel within a few seconds.

VS Code

Use any extension that speaks Streamable HTTP (the official Anthropic extension, Continue, etc.). Configure the server:

{
  "mcpServers": {
    "sprntrl": {
      "url": "https://api.supernatural.sh/mcp",
      "headers": {
        "Authorization": "ApiKey YOUR_API_KEY"
      }
    }
  }
}

Claude Code

Claude Code cannot speak Streamable HTTP directly today (its HTTP transport forces OAuth), so use the mcp-remote stdio bridge. Add to .claude.json under mcpServers:

{
  "mcpServers": {
    "sprntrl": {
      "type": "stdio",
      "command": "npx",
      "args": [
        "-y",
        "mcp-remote",
        "https://api.supernatural.sh/mcp",
        "--header",
        "Authorization: ApiKey YOUR_API_KEY"
      ]
    }
  }
}
The first run downloads mcp-remote via npx. Pin a version by replacing mcp-remote with [email protected].

Claude Desktop

Edit claude_desktop_config.json (Settings → Developer → Edit Config):

{
  "mcpServers": {
    "sprntrl": {
      "type": "stdio",
      "command": "npx",
      "args": [
        "-y",
        "mcp-remote",
        "https://api.supernatural.sh/mcp",
        "--header",
        "Authorization: ApiKey YOUR_API_KEY"
      ]
    }
  }
}

Restart Claude Desktop. Tools appear under the plug icon.

Python / Node (programmatic)

Connect to the MCP server from your own code with the official MCP SDKs.

Python

pip install mcp
import asyncio
from mcp.client.streamable_http import streamablehttp_client
from mcp import ClientSession

async def main():
    async with streamablehttp_client(
        "https://api.supernatural.sh/mcp",
        headers={"Authorization": "ApiKey YOUR_API_KEY"}
    ) as (read, write, _):
        async with ClientSession(read, write) as session:
            await session.initialize()
            tools = await session.list_tools()
            print(f"Available tools: {len(tools.tools)}")

            res = await session.call_tool("session_create", {
                "os": "macos", "location": "America/New_York"
            })
            print(res.content[0].text)

asyncio.run(main())

Node.js

npm install @modelcontextprotocol/sdk
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";

const transport = new StreamableHTTPClientTransport(
  new URL("https://api.supernatural.sh/mcp"),
  { requestInit: { headers: { Authorization: "ApiKey YOUR_API_KEY" } } },
);
const client = new Client({ name: "demo", version: "1.0.0" }, { capabilities: {} });
await client.connect(transport);

const tools = await client.listTools();
console.log("tools:", tools.tools.map((t) => t.name));

const res = await client.callTool({
  name: "session_create",
  arguments: { os: "macos", location: "America/New_York" },
});
console.log(res.content[0].text);

Tool reference

24 tools across six groups. Required parameters are marked *.

Session lifecycle

Create, list, inspect, and tear down browser sessions. Every browser_* tool needs a session_id — start here.

session_createProvision a new stealth Chromium session. Returns immediately with status="creating"; poll session_info until status="running" (3–8s warm).
session_listList the caller's sessions (scoped to the authenticated user — multi-tenant safe).
session_infoGet a single session by ID. Use to poll for status="running" after session_create.
session_killStop a session and tear down its container. Persistent sessions sync the data dir BEFORE teardown.
session_resumeBring a stopped persistent session back online. Only works on persistent sessions whose status is "stopped".

Browser control

Drive Chrome via the CDP socket. Each call opens its own short-lived WebSocket through the sidecar.

browser_navigateNavigate to a URL and wait for Page.loadEventFired. Only http(s), about:, data: schemes accepted.
browser_snapshotAccessibility-tree snapshot. Returns an indented outline with [ref=N] tags on interactive elements — pass those into browser_click.
browser_contentPage content as markdown. Best for reading articles, extracting text, scraping tables. Cheaper than a screenshot.
browser_screenshotScreenshot the page or a specific element. Returns inline image content + embedded resource. JPEG via quality 1–100; PNG otherwise.
browser_clickClick an element by ref (preferred) or explicit x/y. Custom humanlike mouse trajectory.
browser_typeType text into the currently focused element. Variable inter-key delays are always on.
browser_evaluateRun a JS expression via Runtime.evaluate. DOM expressions only — non-DOM expressions run in main world and are detectable.
browser_waitWait for a CSS selector to appear, or wait a fixed timeout. Polled in-page until non-null or deadline.

Configuration

Discover plan-allowed locations, manage persistent profiles, and override WebRTC IPs.

locations_listList location values the caller's plan allows. BYO-proxy plans may additionally pass any IANA timezone.
profile_listList all of the caller's persistent browser profiles (running and stopped).
profile_deletePermanently delete a persistent profile. Must be stopped first — call session_kill if still running.
webrtc_set_ipOverride the WebRTC IP for this session. Affects NEW connections only.

Cookies & raw CDP

Direct cookie access and a Chrome DevTools Protocol escape hatch.

cookie_getGet cookies for the current page or a list of URLs (Network.getCookies).
cookie_setSet a cookie (Network.setCookie). Useful for restoring authenticated state without a full login flow.
cdp_rawSend an arbitrary CDP command. Page.navigate is scheme-allowlisted; every other method passes through verbatim.

File transfer

Move small files in and out of a session. MCP wire format is base64, capped at 5 MB — for bigger files use the HTTP /files endpoints.

file_listList files in the session's Chrome Downloads directory.
file_downloadDownload a file as base64. Capped at 5 MB. For larger files use GET /api/v1/sessions/{id}/files/{name}.
file_uploadUpload a base64-encoded file (up to 5 MB) into the session's Uploads directory. Pair with cdp_raw + DOM.setFileInputFiles.

IP whitelist

Manage source IPs allowed to open the raw CDP WebSocket. MCP itself authenticates by API key and does NOT consult this list.

ip_whitelist_listList the caller's whitelisted source IPs.
ip_whitelist_addWhitelist a source IP. Must parse as valid IPv4 or IPv6 — no "current" auto-detect on this MCP tool (use the REST endpoint for that).
ip_whitelist_removeRemove a whitelist entry by its ID.

End-to-end walkthrough

A typical agent flow — find a piece of information on a website and screenshot the result:

# 1. Discover the IANA timezones the proxy pool serves
locations_list
# → { "locations": ["America/New_York","America/Los_Angeles","Europe/London", …], … }

# 2. Spin up a session
session_create { os: "macos", location: "America/New_York" }
# → { id: "9d8f…", status: "creating", … }

# 3. Poll until ready (3–8s on a warm worker)
session_info { session_id: "9d8f…" }
# → { status: "running", … }

# 4. Navigate
browser_navigate { session_id: "9d8f…", url: "https://news.ycombinator.com" }

# 5. Read the page structure
browser_snapshot { session_id: "9d8f…" }
# → - link "Top" [ref=12]
#   - article
#     - link "Show HN: …" [ref=42]

# 6. Click the first story (snapshot just before clicking — refs are fragile)
browser_click { session_id: "9d8f…", ref: 42 }

# 7. Pull the article text in readability mode
browser_content { session_id: "9d8f…", readability: true }

# 8. Capture proof
browser_screenshot { session_id: "9d8f…", filename: "hn-top.png" }

# 9. Tear it down (data dir is synced first for persistent sessions)
session_kill { session_id: "9d8f…" }

Stealth gotchas

browser_evaluate splits between isolated and main worlds — be intentional

The stealth Chromium layer auto-routes DOM expressions (document.*, querySelector, element properties, getBoundingClientRect) into an isolated world — undetectable. Non-DOM expressions (window.*, page globals, navigator / screen reads, custom site variables) execute in the main world and are detectable by bot detectors. Only use browser_evaluate for DOM access. For an intentional main-world read (accepting the detection risk), do it through cdp_raw with Runtime.callFunctionOn.

Authoring rules in the isolated world (prevent hangs): bare expressions preferred; if you wrap, use arrow IIFEs only — function () {} IIFEs hang on Window serialization; use querySelectorAll instead of getElementsByClassName / getElementsByTagName (live HTMLCollections hang on property access; spread with [...coll] if you must); return primitives or JSON.stringify(...) output, never raw DOM nodes.

Navigation scheme allowlist

Both browser_navigate and cdp_raw with Page.navigate run through the same validateNavigationURL check. Allowed: http://, https://, about: (including about:blank), data:. Blocked (case-insensitive): file://, chrome://, chrome-extension://, chrome-devtools://, devtools://, view-source:, javascript:, ftp://, and any unknown scheme.

Proxy and WebRTC are launch-time

Proxy is wired into Chrome at launch — there is no CDP method to change it mid-session. To switch proxies, session_kill and session_create with the new fields. webrtc_set_ip can change the WebRTC IP override but only affects new WebRTC peer connections; existing ones keep the old IP.

cdp_raw is an escape hatch, not a wide-open pipe

Page.navigate goes through the same scheme allowlist as browser_navigate. Every other CDP method passes through verbatim — use it for things like Emulation.setGeolocationOverride, Network.setExtraHTTPHeaders, or DOM.setFileInputFiles (pairs with file_upload).

Refs from browser_snapshot are short-lived

The [ref=N] values are Chrome backendDOMNodeIds. They are stable only until the DOM mutates — re-renders, navigation, hydration, intersection-observer reveals all invalidate them. Always browser_snapshot just before browser_click; never reuse a ref across navigation.

Troubleshooting

Tools don't appear after configuration

Confirm the API key is correct (starts with sk_) and the scheme is ApiKey, not Bearer. For stdio bridges (mcp-remote), restart the client after editing the config — most clients don't hot-reload MCP servers.

Tool call returns "not authenticated"

The handshake (initialize / tools/list) is allowed anonymously, but every tool that touches user data requires a valid key. mcp-remote silently ignores the --header flag if the syntax is wrong — pass it as a single quoted string in the form Authorization: ApiKey sk_….

HTTP 401 instead of an anonymous handshake

A wrong or expired key fails closed with 401 — we deliberately do not silently fall through to anonymous, because that pattern lets attackers brute-force keys invisibly. Either omit the header entirely (handshake works) or send a valid one.

HTTP 429 (rate limit)

60 requests / IP / minute on /mcp. Keep one MCP session per agent instead of reconnecting per tool call. If you run multiple agents from the same egress IP, throttle on your side or split egress IPs.

browser_click hits the wrong target or returns "ref not found"

The accessibility tree changes whenever the DOM does. Re-snapshot just before each click — don't reuse refs across navigations or after pages re-render. If DOM.getBoxModel can't resolve the ref, the tool returns an error pointing at the stale ID.

browser_evaluate hangs

Almost always one of: a function () {} IIFE wrapping DOM access, accessing .length on a live HTMLCollection, or trying to return a raw DOM node. See the Stealth gotchas section for the authoring rules.

Integrations

Framework integrations

Every Supernatural session exposes a browser-level Chrome DevTools Protocol WebSocket at wss://api.supernatural.sh/api/v1/sessions/<id>/cdp. Anything that speaks CDP — Playwright, Puppeteer, Stagehand, Browser-Use, chrome-remote-interface, or a hand-rolled WebSocket client — connects to it the same way.

Easiest path: the official SDKs ship a browser_session / browserSession helper that waits for the session, registers your IP, connects Playwright (or Puppeteer in Node), and tears the browser down on exit. Use the raw integrations below only when you need a framework the SDK doesn't wrap.

Universal prerequisites

Every integration on this page assumes you have done these three things — the same regardless of framework, so we cover them once.

1

Whitelist your client IP

The CDP WebSocket is authenticated by source IP — no Bearer token on the socket. Add the public IP of the machine that will open CDP connections (or call the endpoint from that machine with {"ip":"current"}, which uses CF-Connecting-IP). See API Reference → IP whitelist.

curl -X POST https://api.supernatural.sh/api/v1/settings/ip-whitelist \
  -H "Authorization: ApiKey $SPRNTRL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"ip":"current"}'

Without this, the WebSocket upgrade is rejected with 403 ip address not whitelisted for this account.

2

Create a session and wait until it's running

POST /sessions returns immediately with status: "creating". Container provisioning takes 3–8s on a warm worker, longer on a cold one. Poll GET /sessions/:id until status === "running" before opening CDP.

Python

import os, time, httpx

API_KEY  = os.environ["SPRNTRL_API_KEY"]
BASE_URL = "https://api.supernatural.sh"
HEADERS  = {"Authorization": f"ApiKey {API_KEY}"}

def create_session_and_wait(timeout_s: float = 60.0) -> str:
    with httpx.Client(base_url=BASE_URL, headers=HEADERS, timeout=30.0) as http:
        r = http.post("/api/v1/sessions", json={"os": "macos", "location": "America/New_York"})
        r.raise_for_status()
        sid = r.json()["id"]

        deadline = time.monotonic() + timeout_s
        while time.monotonic() < deadline:
            s = http.get(f"/api/v1/sessions/{sid}").json()
            if s["status"] == "running":
                return sid
            if s["status"] in ("stopped", "failed"):
                raise RuntimeError(f"session entered terminal state: {s['status']}")
            time.sleep(0.5)
        raise TimeoutError("session did not become ready in time")

session_id = create_session_and_wait()
cdp_url    = f"wss://api.supernatural.sh/api/v1/sessions/{session_id}/cdp"

Node / TypeScript

const API_KEY  = process.env.SPRNTRL_API_KEY!;
const BASE_URL = "https://api.supernatural.sh";
const HEADERS  = {
  "Authorization": `ApiKey ${API_KEY}`,
  "Content-Type":  "application/json",
};

async function createSessionAndWait(timeoutMs = 60_000): Promise<string> {
  const created = await fetch(`${BASE_URL}/api/v1/sessions`, {
    method: "POST",
    headers: HEADERS,
    body: JSON.stringify({ os: "macos", location: "America/New_York" }),
  });
  if (!created.ok) throw new Error(`create failed: ${created.status}`);
  const { id } = await created.json();

  const deadline = Date.now() + timeoutMs;
  while (Date.now() < deadline) {
    const s = await (await fetch(`${BASE_URL}/api/v1/sessions/${id}`, { headers: HEADERS })).json();
    if (s.status === "running") return id;
    if (s.status === "stopped" || s.status === "failed") {
      throw new Error(`session entered terminal state: ${s.status}`);
    }
    await new Promise((r) => setTimeout(r, 500));
  }
  throw new Error("session did not become ready in time");
}

const sessionId = await createSessionAndWait();
const cdpUrl    = `wss://api.supernatural.sh/api/v1/sessions/${sessionId}/cdp`;
3

Stop the session when you're done

Sessions are billed for as long as they're running. Always issue DELETE /sessions/:id in a finally block so a crashing script doesn't leave one burning capacity.

# Python
httpx.delete(f"{BASE_URL}/api/v1/sessions/{session_id}", headers=HEADERS)

// Node
await fetch(`${BASE_URL}/api/v1/sessions/${sessionId}`, { method: "DELETE", headers: HEADERS });

The CDP endpoint is the browser-level WebSocket — connect once, then walk into the existing default context via browser.contexts()[0] / browser.contexts[0] to reach the page that's already open. Don't call browser.newContext(); new contexts launch outside the stealth Chromium layer and lose fingerprinting.

Playwright

Playwright connects to Supernatural with connect_over_cdp / connectOverCDP. The default browser context contains the page Supernatural already opened — use it directly.

Python

pip install playwright
import asyncio
from playwright.async_api import async_playwright

# session_id + cdp_url from step 2 above

async def main():
    async with async_playwright() as p:
        browser = await p.chromium.connect_over_cdp(cdp_url)
        try:
            context = browser.contexts[0]
            page    = context.pages[0] if context.pages else await context.new_page()

            await page.goto("https://bot.sannysoft.com")
            await page.screenshot(path="out.png")
            print(await page.title())
        finally:
            await browser.close()  # disconnects Playwright; session stays alive

asyncio.run(main())
# Remember to DELETE the session when you're done with it (step 3).

TypeScript / Node.js

npm install playwright
import { chromium } from "playwright";

// sessionId + cdpUrl from step 2 above

const browser = await chromium.connectOverCDP(cdpUrl);
try {
  const [context] = browser.contexts();
  const page      = context.pages()[0] ?? await context.newPage();

  await page.goto("https://bot.sannysoft.com");
  await page.screenshot({ path: "out.png" });
  console.log(await page.title());
} finally {
  await browser.close();
}

Puppeteer

Puppeteer attaches via puppeteer.connect. Use puppeteer-core — you don't need the bundled Chromium because Supernatural already runs one.

npm install puppeteer-core
import puppeteer from "puppeteer-core";

// sessionId + cdpUrl from step 2 above

const browser = await puppeteer.connect({ browserWSEndpoint: cdpUrl });
try {
  const [page] = await browser.pages();
  await page.goto("https://bot.sannysoft.com");
  console.log(await page.title());
} finally {
  await browser.disconnect();  // leaves Chrome running; stop the Supernatural session via the API
}

Browser-Use

Browser-Use is an LLM-driven browser agent. Point its Browser at the Supernatural CDP URL and hand it to an Agent.

pip install browser-use
import asyncio
from browser_use import Agent, Browser, ChatOpenAI

# session_id + cdp_url from step 2 above

async def main():
    browser = Browser(cdp_url=cdp_url)
    agent = Agent(
        task="Go to Hacker News and return the title of the top story.",
        llm=ChatOpenAI(model="gpt-4.1-mini"),
        browser=browser,
    )
    history = await agent.run()
    print(history.final_result())

asyncio.run(main())
# Stop the Supernatural session via the API afterwards (step 3).
Browser-Use also ships ChatBrowserUse (their hosted LLM — no OpenAI key required). Swap ChatOpenAI(...) for ChatBrowserUse() and set BROWSER_USE_API_KEY in the environment.

Stagehand

Stagehand layers act/observe/extract on top of Playwright. Supernatural works with the TypeScript client's LOCAL mode, which accepts a cdpUrl in localBrowserLaunchOptions.

Python Stagehand is not supported. As of v3, the Python SDK only drives its bundled local Chrome or a Browserbase session — there is no third-party CDP attach point. From Python, use the Playwright example above (or the Supernatural SDK).
npm install @browserbasehq/stagehand
import { Stagehand } from "@browserbasehq/stagehand";

// sessionId + cdpUrl from step 2 above

const stagehand = new Stagehand({
  env: "LOCAL",
  modelName: "openai/gpt-4.1-mini",
  modelClientOptions: { apiKey: process.env.OPENAI_API_KEY },
  localBrowserLaunchOptions: { cdpUrl },
});

try {
  await stagehand.init();
  const page = stagehand.page;        // Playwright Page on the live tab

  await page.goto("https://news.ycombinator.com");

  const stories = await page.extract({
    instruction: "Extract the titles and URLs of the first 5 stories",
    schema: {
      type: "object",
      properties: {
        stories: {
          type: "array",
          items: {
            type: "object",
            properties: { title: { type: "string" }, url: { type: "string" } },
            required: ["title", "url"],
          },
        },
      },
      required: ["stories"],
    },
  });
  console.log(stories);

  await page.act("Click on the first story title");
} finally {
  await stagehand.close();  // closes the Playwright connection only
}

Any CDP client (chrome-remote-interface)

For low-level work, talk to the WebSocket directly. The endpoint is browser-level, so Target.*, Browser.*, and (per attached target) Page.* / DOM.* / Runtime.* / Network.* / Input.* are all available.

npm install chrome-remote-interface
import CDP from "chrome-remote-interface";

// sessionId + cdpUrl from step 2 above

const client = await CDP({ target: cdpUrl });
try {
  const { Page, Runtime } = client;

  await Page.enable();
  await Page.navigate({ url: "https://example.com" });
  await Page.loadEventFired();

  const { result } = await Runtime.evaluate({ expression: "document.title" });
  console.log(result.value);
} finally {
  await client.close();
}
cdp_raw-style scheme restrictions (rejecting file://, chrome://, javascript:, etc.) only apply through the MCP server. The raw CDP WebSocket passes commands through unchanged — mind your inputs.