Browser

preview

One shared Chromium on your own network — screenshots, PDFs and real multi-step browser jobs over HTTP, so no application image has to carry a browser of its own.

Author: Poul Kjeldager
Platform: linuxmacoswindowsdocker
Category: agents

Browser

pks-agent-browser is one Chromium on the box, borrowed by everything else.

It exists because the browser is the heaviest dependency nobody wanted. It starts with a single screenshot; it ends with three unrelated images each carrying their own Chromium, their own Playwright version and their own ffmpeg. None of them are browser products. They just needed something to call.

So: one container runs the browser, on the internal network, behind one token. Everything else calls it over HTTP.

The two doors

Door A — stateless render. One call, one image. You send a URL or some HTML and a set of profiles, and get artifact ids back. Nothing to clean up.

Door B — session. You get a CDP websocket and drive a real multi-step job with whichever client you prefer (Playwright, agent-browser, browser-use). The service records the session itself and hands you an mp4 when you close it.

Door C — the Playwright protocol (connect()), which is what a remote e2e suite would need — is deliberately not open yet. connect() requires the client and server to agree on major.minor; connectOverCDP() does not, and version freedom is what makes door B useful to callers we do not control.

Why the recording is server-side

Playwright's recordVideo is an option on creating a BrowserContext, and a CDP client is handed a context that already exists — there is nowhere to pass it (playwright#29065). browserless solves this with a proprietary CDP command. We do the same thing in the open: Page.startScreencast plus ffmpeg, on the service side.

Authentication

Everything except /healthz sits behind Authorization: Bearer $BROWSER_API_TOKEN.

The service refuses to start without that token. It will fetch any URL the box can reach, which makes it an SSRF primitive for anyone who can reach it — so an unauthenticated boot is a configuration mistake we decline to make on your behalf. ALLOW_ANONYMOUS=1 exists for local development and nowhere else.

Two callers cannot set a header — an <img> tag pointing at an artifact, and a CDP client whose WebSocket layer drops them (browser-use#3111). Both may pass ?token=… instead.

Door A — render

curl -s https://browser.agentics.dk/v1/render \
  -H "Authorization: Bearer $BROWSER_API_TOKEN" \
  -H 'content-type: application/json' \
  -d '{
        "url": "https://agentics.dk/da",
        "profile": "desktop",
        "format": "jpeg",
        "quality": 80,
        "waitUntil": "networkidle",
        "styles": "[data-consent-banner]{display:none!important}"
      }'
{
  "profile": "desktop",
  "artifact": { "id": "…", "url": "/v1/artifacts/…", "bytes": 184213, "contentType": "image/jpeg" },
  "contentType": "image/jpeg",
  "bytes": 184213,
  "ms": 1752
}

POST /v1/render/batch takes the same body with profiles: [...] and runs the same input through each. It degrades per profile: one profile that fails returns {profile, error} in its own slot rather than failing the request.

The fields that do the work:

FieldNotes
url or htmlmutually exclusive; baseUrl resolves relative assets for html
profile / profilesa name, or an inline object inheriting the fields it omits
formatpng (default), jpeg, pdf
fullPage, selector, clipwhole page, one element, or a rectangle — selector and clip are mutually exclusive, and pdf takes none of them
waitUntil, waitForSelector, waitForTimeoutwhen the page counts as ready
waitForFontsdefaults to true (document.fonts.ready)
stylesinjected CSS, applied last — this is how you hide a cookie banner
headers, cookies, userAgentsent with the navigation
returnurl (default, an artifact id) or base64 if the caller genuinely wants bytes
labelsarbitrary key/values stored on the artifact

Fonts are the single most common cause of a screenshot that looks almost right — the layout is correct but the metrics belong to the fallback face. That is why waiting for them is the default rather than an option you remember.

Door B — sessions

curl -s https://browser.agentics.dk/v1/sessions \
  -H "Authorization: Bearer $BROWSER_API_TOKEN" \
  -H 'content-type: application/json' \
  -d '{"profile":"laptop","record":true,"labels":{"job":"signup"}}'
{
  "id": "d3153014714571dbad5de6d4",
  "cdpUrl": "wss://browser.agentics.dk/v1/sessions/d315…/cdp?token=3fe43f07…",
  "expiresAt": "2026-08-15T08:34:11.707Z"
}

The token in that URL is the session's own, minted at creation and dead with the session. The service-wide bearer token never appears in a URL, a log or a shell history.

Drive it with anything that speaks CDP:

import { chromium } from "playwright-core";
const browser = await chromium.connectOverCDP(session.cdpUrl);
const page = browser.contexts()[0].pages()[0];
await page.goto("https://…");

Screenshots during the session are taken by the service, not the client. That is the whole token argument: POST /v1/sessions/:id/screenshot returns an artifact id of roughly 120 bytes of JSON, where the same image in the client's context is tens of thousands of tokens.

curl -s -X POST "$BROWSER/v1/sessions/$ID/screenshot" \
  -H "Authorization: Bearer $BROWSER_API_TOKEN" \
  -H 'content-type: application/json' -d '{"label":"step-3"}'

Close the session when the job is done. DELETE stops the recording, stitches the mp4 and hands back every artifact at once. If you don't, the reaper harvests the session when its TTL runs out — but a session nobody closes is a Chromium nobody closes.

Profiles

desktop · desktop-dark · desktop-2x · laptop · wide · tablet · mobile · mobile-dark · mobile-small · a4

All carry da-DK and Europe/Copenhagen, so dates, prices and number formats render the way they do for a Danish visitor. A profile can also be sent inline as an object; the fields you omit are inherited from desktop, or from whichever named profile you set in name.

Configuration

VariableDefaultMeaning
BROWSER_API_TOKENshared bearer token. Also the only credential the CDP websocket and artifact query-string can carry.
OIDC_ISSUERemptyKeycloak realm URL. Set it and JWTs are accepted alongside the static token.
OIDC_ALLOWED_CLIENTSemptywhich client ids may call — empty means any client in the realm
OIDC_AUDIENCEemptychecked when set
OIDC_REQUIRED_SCOPEemptychecked when set
ALLOW_ANONYMOUS0local development only
PORT8080
ARTIFACT_DIR/data/artifacts
SESSION_DATA_DIR/data/sessions
MAX_CONCURRENCY4concurrent renders — each is a Chromium tab
RENDER_TIMEOUT_MS30000ceiling on one render including navigation
QUEUE_TIMEOUT_MS60000wait before answering 503 instead of piling up
MAX_SESSIONS4concurrent browsers behind door B
SESSION_TTL_MS90000015 min; clients may ask for less, never more
SESSION_MAX_TTL_MS3600000hard ceiling
ARTIFACT_TTL_MS86400000the store is a cache, not an archive
ALLOWED_HOSTSemptythe exception list: internal names deliberately allowed. Empty is the safe default.
ALLOW_PRIVATE_NETWORK0turns the egress policy off entirely
ALLOW_INSECURE_TLS0permits insecureTls: true per request
PUBLIC_BASE_URLemptyused to build artifact and CDP links behind a proxy
MAX_HTML_BYTES5000000
LOG_LEVELinfo

ALLOW_INSECURE_TLS exists because internal targets routinely present a certificate Chromium rejects: a Coolify container reached on its internal name, a preview deploy — and, verified, www.agentics.dk itself, which has no FQDN in Coolify and answers with Traefik's default certificate.

Two of those settings answer different questions, and it is worth keeping them apart. BROWSER_API_TOKEN and OIDC_* answer who is calling. The egress policy answers where the browser may go — and no amount of the first substitutes for the second, because the authenticated caller here is an agent rendering a URL that came out of end-user text. The service starts with the public internet open and the private address space closed, so cloud metadata and the neighbouring containers on a Coolify network are refused by default; ALLOWED_HOSTS names the few internal hosts you want back. Enforcement sits in a proxy Chromium is launched behind rather than in a Playwright route handler, because a route handler does not see redirect hops.

What it is not

Not an agent. Not a scraper — there is no stealth and no proxy rotation, and that is precisely the surface being declined. Not a replacement for pks-agent-meeting. Not public: v1 has no ingress and lives on the internal network only.