Every pks-agent-browser endpoint — health, profiles, the two render doors, the session lifecycle, the CDP upgrade and the artifact store.
Base path is /v1. All bodies are JSON.
Every route except GET /healthz requires the service token, as either
Authorization: Bearer <token> or ?token=<token>. The query form exists for
the two callers that provably cannot send a header: an <img> tag pointing at an
artifact, and CDP clients whose WebSocket layer drops them
(browser-use#3111).
Comparison is constant-time.
ALLOW_ANONYMOUS=1 disables the check for local development. In every other
setting the service refuses to boot without BROWSER_API_TOKEN, because a
service that fetches arbitrary URLs on the internal network is an SSRF primitive
for anyone who can reach it.
Failures answer {"error":"<message>"} with a status mapped from the thrown
class:
| Status | Cause |
|---|---|
400 | bad request body, or an unknown/invalid profile |
401 | missing or wrong token |
404 | no such route, session or artifact |
429 | MAX_SESSIONS reached |
503 | render queue waited past QUEUE_TIMEOUT_MS |
500 | anything unhandled — logged with a stack, returned without one |
GET /healthzThe only unauthenticated route.
{
"ok": true,
"version": "0.1.0",
"sessions": 2,
"renders": { "inFlight": 1, "queued": 0 },
"uptimeSec": 41207
}
renders.queued is the number worth alerting on: it is renders waiting for a
concurrency slot, and a queue that never drains means MAX_CONCURRENCY is below
what the callers actually ask for.
GET /v1/profilesReturns the full profile table — viewport, deviceScaleFactor, isMobile,
hasTouch, colorScheme, locale, timezone and (for a4) the paper size.
Built in: desktop, desktop-dark, desktop-2x, laptop, wide, tablet,
mobile, mobile-dark, mobile-small, a4. All carry da-DK and
Europe/Copenhagen.
POST /v1/renderOne input, one profile, one artifact.
Body
| Field | Type | Notes |
|---|---|---|
url | string | http/https only. Mutually exclusive with html. |
html | string | Up to MAX_HTML_BYTES. Mutually exclusive with url. |
baseUrl | string | Injected as <base> so relative assets resolve. html only. |
profile | string | object | A name, or an inline object. An inline object inherits from desktop, or from the named profile in its name field. |
format | "png" | "jpeg" | "pdf" | Default png. |
quality | number | jpeg only, 1–100. |
fullPage | boolean | Whole scroll height. Not valid with pdf. |
selector | string | Screenshot one element. Mutually exclusive with clip; not valid with pdf. |
clip | {x,y,width,height} | A rectangle. Not valid with pdf. |
omitBackground | boolean | Transparent background. png only in practice. |
waitUntil | "load" | "domcontentloaded" | "networkidle" | "commit" | Navigation gate. |
waitForSelector | string | Wait for an element before capturing. |
waitForTimeout | number | A flat wait, in ms. The last resort, not the first. |
waitForFonts | boolean | Default true — awaits document.fonts.ready. |
styles | string | CSS injected after load. How you hide a cookie banner. |
headers | object | Extra request headers. |
cookies | array | Playwright cookie objects. |
userAgent | string | Overrides the profile's. |
insecureTls | boolean | Only honoured when ALLOW_INSECURE_TLS=1. |
return | "url" | "base64" | Default url. |
labels | object | Stored on the artifact. |
Response
{
"profile": "desktop",
"artifact": {
"id": "6a1f8c…",
"url": "/v1/artifacts/6a1f8c…",
"bytes": 412880,
"contentType": "image/png",
"createdAt": "2026-08-15T09:14:02.113Z",
"labels": {}
},
"contentType": "image/png",
"bytes": 412880,
"ms": 1752
}
With "return":"base64" the payload carries a base64 field instead of
artifact. Prefer the default: an artifact id costs the caller ~120 bytes, the
same image costs tens of thousands of tokens.
There is deliberately no per-request timeout field. Navigation and capture
are governed by the server's RENDER_TIMEOUT_MS, so one caller cannot hold a
render slot open for as long as it likes. Unknown body fields are ignored rather
than rejected.
waitForFonts is on by default because the failure it prevents is the one
nobody catches in review — the layout is right, the type is the fallback face,
and the screenshot looks almost correct.
The public internet is reachable; the private address space is not. A URL whose
host resolves into loopback, link-local (169.254.0.0/16, the cloud metadata
endpoint), RFC1918, CGNAT, IPv6 ULA — or the IPv4-mapped/NAT64 spellings of any
of them — is refused with 400 and a body naming the range:
{ "error": "evil.example.com resolves to 10.0.0.5, which is private 10.0.0.0/8" }
The check classifies resolved addresses, not hostname strings, and checks
every address a name resolves to, so http://2130706433/ and a name with one
public and one private A record are both caught.
ALLOWED_HOSTS is the exception list — internal names deliberately let
through (quickform for a container on the same Coolify network). Entries match
the exact hostname or any subdomain. Empty is the safe default. This inverts the
pre-0.1.2 meaning, where empty meant "anything" and non-empty meant "only
these".
The 400 above is a courtesy: it fails fast and names the host. Enforcement is a
proxy Chromium is launched behind, so redirect hops, subresources, injected
fetch() and everything a door-B CDP client drives are judged the same way —
including in browser contexts that client creates for itself.
ALLOW_PRIVATE_NETWORK=1 disables the whole policy.
POST /v1/render/batchSame body, but profiles: string[] | object[] instead of profile.
{
"results": [
{ "profile": "desktop", "artifact": { … }, "ms": 776 },
{ "profile": "laptop", "artifact": { … }, "ms": 699 },
{ "profile": "tablet", "error": "Timeout 30000ms exceeded" },
{ "profile": "mobile", "artifact": { … }, "ms": 775 }
]
}
Profiles run through Promise.allSettled, so one failure never takes the
others down — it occupies its own slot with an error. A responsive check that
returns nothing because one viewport was slow is a check people stop running.
POST /v1/sessionsOpens a real browser and returns a CDP endpoint. 201 Created.
Body
| Field | Type | Notes |
|---|---|---|
profile | string | object | As above. |
ttlMs | number | Clamped to SESSION_MAX_TTL_MS; defaults to SESSION_TTL_MS. |
record | boolean | object | Server-side screencast → mp4 on close. Object form takes fps, quality, maxFrames. |
url | string | Navigated before the URL is handed back. |
headers, cookies | Applied to the context. | |
insecureTls | boolean | ALLOW_INSECURE_TLS=1 only. |
labels | object | Carried onto every artifact the session produces. |
Response
{
"id": "d3153014714571dbad5de6d4",
"profile": "laptop",
"createdAt": "2026-08-15T08:19:11.707Z",
"expiresAt": "2026-08-15T08:34:11.707Z",
"recording": true,
"frames": 0,
"pages": 1,
"attached": 0,
"artifacts": [],
"cdpUrl": "wss://browser.agentics.dk/v1/sessions/d315…/cdp?token=3fe43f07…"
}
The token in cdpUrl is the session's own, 24 random bytes minted at
creation and dead with the session. The service-wide token is never placed in a
URL. PUBLIC_BASE_URL decides the scheme and host of that link when the service
sits behind a proxy.
GET /v1/sessions{"sessions":[…]} — every live session in the describe shape above, without
cdpUrl.
GET /v1/sessions/:idOne session, same shape. 404 once it is closed or reaped.
DELETE /v1/sessions/:idStops the recorder before the browser (the reverse loses the tail of the video), stitches the mp4, closes Chromium and returns everything produced:
{ "id": "d315…", "artifacts": [ { "contentType": "video/mp4", … }, { "contentType": "image/png", … } ] }
POST /v1/sessions/:id/screenshotCaptures from the service side, so the image never crosses into the client's context.
Body: selector, fullPage, format (png | jpeg), label.
Response: one artifact descriptor. Also refreshes the session's TTL.
POST /v1/sessions/:id/touchExtends the lease. Optional ttlMs, still clamped by SESSION_MAX_TTL_MS.
Returns the session.
Long jobs rarely need this: every inbound CDP frame already touches the session, so a client that is actively driving the browser cannot be reaped mid-step. It exists for the job that goes quiet while it waits on something external.
GET|POST /v1/sessions/:id/cdp (WebSocket upgrade)The door itself. Upgrade here with ?token=<session token> — the service-wide
token is also accepted — and raw CDP frames are piped to that session's Chromium
on loopback. The browser's own debugging port is never published, so the session
token is the only way in.
const browser = await chromium.connectOverCDP(cdpUrl);
Client and server versions do not have to match. Measured: playwright-core
1.56.1 driving the service's 1.57.0 Chromium. chromium.connect() — the
Playwright protocol, which does require major.minor agreement — is door C and is
not open in v1.
Non-matching path, unknown session or bad token: the upgrade is refused at the socket, not answered with a JSON error.
GET /v1/artifacts/:idStreams the bytes with the stored content-type and
cache-control: private, max-age=3600. Accepts ?token= so a preview UI can
point an <img> at it.
The store is a cache, not an archive — ARTIFACT_TTL_MS defaults to 24
hours. Anything you need to keep, copy out.