API reference

preview

Every pks-agent-browser endpoint — health, profiles, the two render doors, the session lifecycle, the CDP upgrade and the artifact store.

Author: Poul Kjeldager
Platform: linuxmacoswindowsdocker
Category: agents

API reference

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.

Errors

Failures answer {"error":"<message>"} with a status mapped from the thrown class:

StatusCause
400bad request body, or an unknown/invalid profile
401missing or wrong token
404no such route, session or artifact
429MAX_SESSIONS reached
503render queue waited past QUEUE_TIMEOUT_MS
500anything unhandled — logged with a stack, returned without one

GET /healthz

The 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/profiles

Returns 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/render

One input, one profile, one artifact.

Body

FieldTypeNotes
urlstringhttp/https only. Mutually exclusive with html.
htmlstringUp to MAX_HTML_BYTES. Mutually exclusive with url.
baseUrlstringInjected as <base> so relative assets resolve. html only.
profilestring | objectA 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.
qualitynumberjpeg only, 1–100.
fullPagebooleanWhole scroll height. Not valid with pdf.
selectorstringScreenshot one element. Mutually exclusive with clip; not valid with pdf.
clip{x,y,width,height}A rectangle. Not valid with pdf.
omitBackgroundbooleanTransparent background. png only in practice.
waitUntil"load" | "domcontentloaded" | "networkidle" | "commit"Navigation gate.
waitForSelectorstringWait for an element before capturing.
waitForTimeoutnumberA flat wait, in ms. The last resort, not the first.
waitForFontsbooleanDefault true — awaits document.fonts.ready.
stylesstringCSS injected after load. How you hide a cookie banner.
headersobjectExtra request headers.
cookiesarrayPlaywright cookie objects.
userAgentstringOverrides the profile's.
insecureTlsbooleanOnly honoured when ALLOW_INSECURE_TLS=1.
return"url" | "base64"Default url.
labelsobjectStored 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.

Egress policy

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/batch

Same 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/sessions

Opens a real browser and returns a CDP endpoint. 201 Created.

Body

FieldTypeNotes
profilestring | objectAs above.
ttlMsnumberClamped to SESSION_MAX_TTL_MS; defaults to SESSION_TTL_MS.
recordboolean | objectServer-side screencast → mp4 on close. Object form takes fps, quality, maxFrames.
urlstringNavigated before the URL is handed back.
headers, cookiesApplied to the context.
insecureTlsbooleanALLOW_INSECURE_TLS=1 only.
labelsobjectCarried 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/:id

One session, same shape. 404 once it is closed or reaped.

DELETE /v1/sessions/:id

Stops 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/screenshot

Captures 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/touch

Extends 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/:id

Streams 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 archiveARTIFACT_TTL_MS defaults to 24 hours. Anything you need to keep, copy out.