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.
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.
https://browser.agentics.dk (Coolify app-name: browser)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.
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.
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.
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:
| Field | Notes |
|---|---|
url or html | mutually exclusive; baseUrl resolves relative assets for html |
profile / profiles | a name, or an inline object inheriting the fields it omits |
format | png (default), jpeg, pdf |
fullPage, selector, clip | whole page, one element, or a rectangle — selector and clip are mutually exclusive, and pdf takes none of them |
waitUntil, waitForSelector, waitForTimeout | when the page counts as ready |
waitForFonts | defaults to true (document.fonts.ready) |
styles | injected CSS, applied last — this is how you hide a cookie banner |
headers, cookies, userAgent | sent with the navigation |
return | url (default, an artifact id) or base64 if the caller genuinely wants bytes |
labels | arbitrary 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.
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.
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.
| Variable | Default | Meaning |
|---|---|---|
BROWSER_API_TOKEN | — | shared bearer token. Also the only credential the CDP websocket and artifact query-string can carry. |
OIDC_ISSUER | empty | Keycloak realm URL. Set it and JWTs are accepted alongside the static token. |
OIDC_ALLOWED_CLIENTS | empty | which client ids may call — empty means any client in the realm |
OIDC_AUDIENCE | empty | checked when set |
OIDC_REQUIRED_SCOPE | empty | checked when set |
ALLOW_ANONYMOUS | 0 | local development only |
PORT | 8080 | |
ARTIFACT_DIR | /data/artifacts | |
SESSION_DATA_DIR | /data/sessions | |
MAX_CONCURRENCY | 4 | concurrent renders — each is a Chromium tab |
RENDER_TIMEOUT_MS | 30000 | ceiling on one render including navigation |
QUEUE_TIMEOUT_MS | 60000 | wait before answering 503 instead of piling up |
MAX_SESSIONS | 4 | concurrent browsers behind door B |
SESSION_TTL_MS | 900000 | 15 min; clients may ask for less, never more |
SESSION_MAX_TTL_MS | 3600000 | hard ceiling |
ARTIFACT_TTL_MS | 86400000 | the store is a cache, not an archive |
ALLOWED_HOSTS | empty | the exception list: internal names deliberately allowed. Empty is the safe default. |
ALLOW_PRIVATE_NETWORK | 0 | turns the egress policy off entirely |
ALLOW_INSECURE_TLS | 0 | permits insecureTls: true per request |
PUBLIC_BASE_URL | empty | used to build artifact and CDP links behind a proxy |
MAX_HTML_BYTES | 5000000 | |
LOG_LEVEL | info |
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.
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.
Every pks-agent-browser endpoint — health, profiles, the two render doors, the session lifecycle, the CDP upgrade and the artifact store.
From an empty box to a screenshot, four device profiles and a recorded browser job — in about ten minutes, without putting Chromium in your own image.