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
- Sign in and go to Settings → API keys → New key. Copy the
sk_…value — it's shown only once. - Settings → IP whitelist: add the public IP of the machine that will open CDP connections, or POST
/api/v1/settings/ip-whitelistwith{"ip":"current"}from that machine. - Start a session:
POST /api/v1/sessions. PollGET /api/v1/sessions/<id>untilstatus="running"(typically 3–8s on a warm worker). - Connect any CDP client (Playwright, Puppeteer, chrome-remote-interface) to
wss://api.supernatural.sh/api/v1/sessions/<id>/cdp. Do not use thecdp_urlfield from the response — that's an internal pointer. - Stop with
DELETE /api/v1/sessions/<id>. For persistent sessions, wait forstorage_status="synced"before resuming.
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>/cdpConventions & errors
- Timestamps are RFC 3339 UTC (
2026-05-10T12:00:00Z). - IDs are UUID v4 strings.
- Bodies use
snake_caseJSON; file uploads usemultipart/form-data. - Request body size is capped at 1 MiB (100 MiB on the file-upload route).
- DELETE endpoints return either
{"status":"deleted"}or204 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).
/api/v1/sessionsStart a new browser session. Returns 201 immediately with status="creating"; container provisioning typically takes 3–8s on a warm worker.
Body fields
| os | enum | "macos" or "windows" — drives the fingerprint. Default "macos". |
| location | string | IANA 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". |
| label | string | Optional 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. |
| persistent | boolean | When 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_name | string | Display name. Required when persistent=true. |
| captcha_solver | boolean | Attach an in-container NopeCHA solver to this session (charged per solve). Requires a plan with the solver enabled. Default false. |
| isolated_world | boolean | Run 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_protocol | enum | "HTTP" or "SOCKS5" — switches into BYO-proxy mode. Requires a plan with BYO proxies. |
| proxy_host | string | BYO proxy host or IP. |
| proxy_port | integer | BYO proxy port. Required when proxy_protocol is set. |
| proxy_username | string | Optional BYO proxy auth. |
| proxy_password | string | Optional 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
| 400 | Invalid OS, invalid location, BYO proxy fields incomplete, or session_name missing for a persistent session. |
| 402 | Account is not active (subscription required) — enforced by the active-subscription gate. |
| 429 | Plan limit hit: concurrent session cap, monthly minute cap, persistent-profile cap, BYO-proxy plan check, or bandwidth cap. |
| 503 | No worker capacity right now, or the container/sidecar layer is unreachable. Safe to retry with backoff. |
cdp_url in the response is an internal pointer; connect to wss://api.supernatural.sh/api/v1/sessions/<id>/cdp instead./api/v1/sessionsList every session for the authenticated account (any status). Response: { "sessions": [<session>, …] }.
/api/v1/sessions/activeList only sessions whose status is "running". Response: { "sessions": [<session>, …] }.
/api/v1/sessions/historyPaginated history of stopped, failed, and ephemeral sessions, newest first. Query: page (default 1), per_page (default 25). Response includes total, page, per_page.
/api/v1/sessions/resumableStopped persistent sessions whose saved data dir is available to resume. Query: limit (default 10).
/api/v1/sessions/persistentEvery persistent session regardless of state — both currently running and stopped. Powers the dashboard's Profiles page. Query: limit (default 50).
/api/v1/sessions/locationsIANA 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"] }.
proxy_host set) can pass any valid IANA timezone, not just the pool-served set./api/v1/sessions/:idCurrent state of a single session. Poll until status="running" before connecting CDP. ~500ms intervals are fine; sessions typically become ready within 3–8 seconds.
/api/v1/sessions/:idStop a running session. For persistent sessions, the data dir is uploaded to object storage before the container is removed.
storage_status moves "uploading" → "synced" in the background — wait for "synced" before calling /sessions/:id/resume./api/v1/sessions/:id/resumeRelaunch 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.
/api/v1/sessions/:id/persistPermanently 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.
/api/v1/sessions/:id/filesList 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
}
kind discriminator is load-bearing./api/v1/sessions/:id/files/:filenameStream 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
/api/v1/sessions/:id/filesUpload 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"
}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.
wss://api.supernatural.sh/api/v1/sessions/:id/cdpBrowser-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).
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
| 400 | Session is not running. |
| 403 | Source IP is not in the account's whitelist (or the whitelist is empty). |
| 404 | Session not found. |
Profiles
Reusable fingerprint configurations applied at session-create time. Profiles are templates; persistent sessions are state. The two are independent.
/api/v1/profilesList 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": "…" }
] }
/api/v1/profilesCreate a profile. Either template_id or config_json must be supplied.
Body fields
| name * | string | Display name. |
| template_id | string | Base the profile on a template from GET /templates. Mutually exclusive with config_json. |
| config_json | object | Raw fingerprint config. Mutually exclusive with template_id. |
| description | string | Optional human description. |
| os | enum | "macos" or "windows". Inherited from the template when not given. |
| location | string | Default location for sessions created from this profile. |
| persona | string | Persona key applied as an override on top of the template. |
Errors
| 400 | Neither template_id nor config_json supplied, or template not found. |
| 403 | Plan profile cap reached (e.g. "profile limit reached (5/5 for Pro plan)"). |
/api/v1/profiles/:idGet a single profile.
/api/v1/profiles/:idPartial update — only the supplied fields are modified.
{ "name": "scraper-eu-v2", "config_json": { … } }
/api/v1/profiles/:id/duplicateClone a profile under a new ID. Returns 201 with the copy.
/api/v1/profiles/:idDelete a profile. Returns 204 No Content on success.
Templates
Pre-built fingerprint bundles curated by Supernatural. Used as the basis for new profiles. Read-only — templates are managed server-side.
/api/v1/templatesList 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.
/api/v1/apikeysList 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
}
] }
/api/v1/apikeysMint 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"
}/api/v1/apikeys/:idRevoke 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.
/api/v1/settings/ip-whitelistList whitelisted IPs.
{ "entries": [
{ "id": "…", "user_id": "…",
"ip_address": "203.0.113.7",
"label": "Office", "created_at": "…" }
] }
/api/v1/settings/ip-whitelistAdd 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 * | string | IPv4 or IPv6 address, or the literal string "current". The legacy field name ip_address is also accepted. |
| label | string | Optional human label (e.g. "office", "CI runner"). |
{ "ip": "current", "label": "My Office" }
net.ParseIP). CIDR ranges are not supported — add each public IP individually./api/v1/settings/ip-whitelist/:idRemove a whitelist entry. Returns 204 No Content on success.
Usage
Monthly hours and bandwidth metering, plus accrued overage charges.
/api/v1/usageCurrent 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
}
bandwidth_bytes — only pool-proxy egress is metered. usage_percentage is on a 0–100 scale (rounded to two decimals)./api/v1/usage/historyPer-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.
/api/v1/user/meAccount 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"
}
/api/v1/user/meUpdate the display name on the account. Body: { "name": "Sam" }.
/api/v1/user/me/settingsSame payload as GET /user/me. Kept symmetric with PUT /user/me/settings.
/api/v1/user/me/settingsUpdate account-level toggles.
Body fields
| allow_hours_overage | boolean | When 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_only | boolean | When 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 }
/api/v1/user/change-passwordRotate the account password. Valid only for password accounts (OAuth accounts have no local password). Per-user rate limit: 5/min.
Body fields
| current_password * | string | Current password (max 64 chars). |
| new_password * | string | New password (8–64 chars). |
{
"message": "password updated",
"access_token": "eyJ…",
"refresh_token": "…",
"token_type": "Bearer"
}
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.
/api/v1/billing/plansPublic · no authPublic 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": { … }
}
] }
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.
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
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);
}
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 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 },
});
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.sessionscreate, 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.fileslist, download, upload
client.profilescreate, list, get, update, duplicate, delete
client.templateslist (read-only — templates are managed server-side)
client.ip_whitelist / ipWhitelistlist, add (defaults ip="current"), remove
client.usagecurrent, history
client.userme, update, get_settings / getSettings, update_settings / updateSettings, change_password / changePassword
client.api_keys / apiKeyslist, 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
| Concern | Python | Node |
|---|---|---|
| Naming | snake_case | camelCase |
| Async | AsyncSprntrl + async with | Every method returns Promise |
| Resource cleanup | with / async with | await using (Node 24+/TS 5.2+) or try/finally + handle.close() |
| Browser frameworks | Playwright only | Playwright or Puppeteer (optional peer deps) |
| Timeout units | seconds (float) | milliseconds (number) |
| Session shape | dict — session["id"] | typed object — session.id |
| Low-level escape | internal helper (no stability contract) | public client.request<T>() |
| Download return | bytes | ArrayBuffer (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.
Endpoint & auth model
https://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
initializeandtools/listto 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
/mcproute 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"
]
}
}
}
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.
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.
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.
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`;
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).
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.
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.