Quickstart

preview

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.

Author: Poul Kjeldager
Platform: linuxmacoswindowsdocker
Category: agents

Quickstart

Everything below assumes the service is reachable at $BROWSER with $BROWSER_API_TOKEN set. Locally that is http://localhost:8080; in Coolify it is the internal name of the container — v1 deliberately has no ingress.

export BROWSER=http://localhost:8080
export BROWSER_API_TOKEN=…      # the service will not start without one

0. Run it

Coolify builds the image from the repo root Dockerfile (mcr.microsoft.com/playwright:v1.57.0-noble plus ffmpeg) straight from git — there is no published image to pull. To run it yourself, build it:

git clone https://github.com/pksorensen/pks-agent-browser
cd pks-agent-browser
docker build -t pks-agent-browser .

docker run -d --name browser \
  -p 8080:8080 \
  -e BROWSER_API_TOKEN="$BROWSER_API_TOKEN" \
  -v browser-data:/data \
  --shm-size=1g \
  pks-agent-browser

Or run it without Docker at all — npm ci && npm run build && node dist/index.js in src/browser-server, with a local Playwright Chromium.

--shm-size=1g is not optional decoration. Chromium's default 64 MB of /dev/shm inside a container is where tab crashes come from, and a tab that crashes mid-render looks exactly like a flaky page.

Check it:

curl -s $BROWSER/healthz
# {"ok":true,"version":"0.1.0","sessions":0,"renders":{"inFlight":0,"queued":0},"uptimeSec":12}

/healthz is the one route without auth, because a health check that needs a secret is a health check that silently stops working when the secret rotates.

1. One screenshot

curl -s $BROWSER/v1/render \
  -H "Authorization: Bearer $BROWSER_API_TOKEN" \
  -H 'content-type: application/json' \
  -d '{"url":"https://agentics.dk/da","profile":"desktop"}'
{
  "profile": "desktop",
  "artifact": { "id": "6a1f…", "url": "/v1/artifacts/6a1f…", "bytes": 412880, "contentType": "image/png" },
  "ms": 1752
}

You got an id, not an image. That is the point: the bytes stay on the service's disk, and the caller — often an agent whose context is the scarce resource — carries 120 bytes of JSON instead of an image worth tens of thousands of tokens.

Fetch it when you actually need it:

curl -s "$BROWSER/v1/artifacts/6a1f…?token=$BROWSER_API_TOKEN" -o shot.png

The ?token= form exists for the two callers that cannot set a header: an <img src> in a preview UI, and CDP clients whose WebSocket layer drops headers.

2. Four profiles, one call

curl -s $BROWSER/v1/render/batch \
  -H "Authorization: Bearer $BROWSER_API_TOKEN" \
  -H 'content-type: application/json' \
  -d '{"url":"https://quickform.agentics.dk/","profiles":["desktop","laptop","tablet","mobile"]}'

Measured against that page: 776 / 699 / 927 / 775 ms, four artifact ids, no image bytes in the response.

Batch degrades per profile. If tablet times out, its slot holds {"profile":"tablet","error":"…"} and the other three still come back. A responsive check that fails entirely because one viewport was slow is a check you stop trusting.

3. Render HTML you have in hand

No URL needed — this is how a form designer previews a definition that has not been published anywhere:

curl -s $BROWSER/v1/render \
  -H "Authorization: Bearer $BROWSER_API_TOKEN" \
  -H 'content-type: application/json' \
  -d '{
        "html": "<h1 style=\"font-family:Geist\">Hello</h1>",
        "baseUrl": "https://agentics.dk/",
        "profile": "mobile"
      }'

baseUrl is injected as a <base> tag, so relative stylesheets, fonts and images resolve against a real origin instead of about:blank.

4. A PDF

curl -s $BROWSER/v1/render \
  -H "Authorization: Bearer $BROWSER_API_TOKEN" \
  -H 'content-type: application/json' \
  -d '{"url":"https://agentics.dk/da/products","profile":"a4","format":"pdf"}' 

pdf rejects selector, clip and fullPage — a PDF is paginated, so "the whole page as one image" is not a thing it can mean. The a4 profile carries the paper size.

5. A real multi-step job

Door B. Open a session, drive it with any CDP client, let the service record it.

SESSION=$(curl -s $BROWSER/v1/sessions \
  -H "Authorization: Bearer $BROWSER_API_TOKEN" \
  -H 'content-type: application/json' \
  -d '{"profile":"laptop","record":true,"ttlMs":300000,"labels":{"job":"signup"}}')
ID=$(echo "$SESSION" | jq -r .id)
CDP=$(echo "$SESSION" | jq -r .cdpUrl)
import { chromium } from "playwright-core";

const browser = await chromium.connectOverCDP(process.env.CDP);
const page = browser.contexts()[0].pages()[0];

await page.goto("https://quickform.agentics.dk/");
await page.fill("#email", "someone@example.com");
await page.click("text=Continue");

Your playwright-core does not have to match the service's. Measured: a 1.56.1 client driving the service's 1.57.0 Chromium. That version freedom is why door B speaks CDP and not the Playwright protocol.

Take a screenshot mid-job — from the service, so it stays out of your context:

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

Close it, and collect everything:

curl -s -X DELETE "$BROWSER/v1/sessions/$ID" -H "Authorization: Bearer $BROWSER_API_TOKEN"
# {"id":"…","artifacts":[{…"contentType":"video/mp4"},{…"image/png"}]}

The mp4 is stitched on close from frames the service captured with Page.startScreencast. Playwright's own recordVideo cannot do this over CDP — it is an option on creating a context, and a CDP client is handed one that already exists (playwright#29065).

6. Before you put it on a network

Two different questions, two different settings. Who may call the service is authentication. Where the browser may go is egress policy. Neither answers the other, and the second is the one people skip.

Who may call it

Either a shared bearer token, or Keycloak client credentials — or both while you migrate:

BROWSER_API_TOKEN=…                              # shared secret
# and/or
OIDC_ISSUER=https://login.agentics.dk/realms/agentics
OIDC_ALLOWED_CLIENTS=quickform-designer,pks-agent-meeting
OIDC_AUDIENCE=browser                            # optional, checked when set
OIDC_REQUIRED_SCOPE=browser.render               # optional, checked when set

With OIDC_ISSUER set, a Bearer header carrying a JWT is verified against the realm's JWKS: signature, exp/nbf, issuer, audience, and the client behind azp/client_id/sub against OIDC_ALLOWED_CLIENTS. Only asymmetric algorithms are accepted (RS/PS/ES × 256/384/512), so alg: none and HMAC confusion are rejected by omission rather than by a special case. Anything that is not a JWT falls through to the static-token comparison, which is why both can be live at once — and why the two callers that can only pass a token in a query string (the CDP websocket, GET /v1/artifacts/<id>?token=…) keep working.

A JWT is per-client, revocable at the identity provider, and shows up in the access log as client=quickform-designer. A shared token is none of those things. Prefer the first, and leave BROWSER_API_TOKEN set only for the two query-string paths above.

Set none of them and the service exits at boot rather than starting open. ALLOW_ANONYMOUS=1 overrides that, for a laptop and nothing else.

Where the browser may go

Authentication does not help here, and it is worth being precise about why: the caller is authenticated — it is an agent — and the URL it renders came from whatever a user typed into a chat box. A prompt injection that talks that agent into rendering http://169.254.169.254/latest/meta-data/ presents a perfectly valid token while doing it.

So the default is: the public internet is open, the private address space is closed. Loopback, link-local (cloud metadata), RFC1918, CGNAT, IPv6 ULA and the IPv4-mapped and NAT64 spellings of all of them are refused, by resolved address rather than by hostname — http://2130706433/ and evil.example.com A 10.0.0.5 are the same block.

ALLOWED_HOSTS still exists, but it is now the exception list, not an allow-list:

ALLOWED_HOSTS=quickform            # an internal container we deliberately allow

Empty is the safe value and the right default. Read that inversion carefully if you knew the old behaviour: empty used to mean "anything", and now means "the public internet, and nothing on this network". An entry matches the exact hostname or any subdomain of it. ALLOW_PRIVATE_NETWORK=1 turns the whole policy off; there is no good reason to set it outside a test.

Enforcement is not a Playwright route handler. That was tried and measured first: with route("**/*") installed, a navigation to a host that 302s elsewhere fires the handler once, for the first hop — Chromium follows the rest of the chain inside the network stack, so a redirect to the metadata endpoint is never seen. Instead Chromium is launched behind an in-process proxy (--proxy-server plus --proxy-bypass-list=<-loopback>), so every navigation, redirect hop, subresource, fetch() from injected script and websocket is judged — including in browser contexts a door-B CDP client creates for itself.

MAX_SESSIONS=4
SESSION_TTL_MS=900000

Sessions are whole browsers. The reaper runs every 15 s and closes expired ones, which is what keeps a crashed client from leaving Chromiums behind. Clients may ask for a shorter TTL, never a longer one.

Verified in production

https://browser.agentics.dk runs the image built by Coolify from this repository — the first build anywhere, since the local build was deliberately skipped to keep a heavy docker build off the RAID5 array this box shares with production. Measured on that deployment:

  • GET /healthz answers unauthenticated; every other route without a token is 401 {"error":"unauthorized"}.

  • POST /v1/render on an allowed host returns an artifact, and GET /v1/artifacts/<id>?token=… streams the same byte count back.

  • POST /v1/render/batch with profiles: ["mobile","tablet","desktop"] returns three results from one call.

  • POST /v1/sessions returns 201 with a cdpUrl, and DELETE closes it — sessions drops back to 0 in /healthz. The egress policy was measured separately, against a locally running build, because two of its cases need a redirector and a CDP client:

  • A literal 169.254.169.254, 10.0.0.5, ::1, ::ffff:127.0.0.1 and 64:ff9b::169.254.169.254 are all refused with the range named in the error; 8.8.8.8 and 2606:4700::1111 are not.

  • http://2130706433/ is refused as 127.0.0.1 is loopback 127.0.0.0/8 — decimal-integer host spellings do not get past a check that classifies addresses.

  • A page that redirects to the metadata endpoint is refused in 149 ms. With the route-handler guard this was a 30-second navigation timeout, because the handler never ran on the second hop. That measurement is why the proxy exists.

  • A fetch("http://169.254.169.254/x") injected into an already-loaded page is refused too.

  • Over connectOverCDP, a client that navigates to 10.0.0.1.nip.io gets resolves to 10.0.0.1, which is private 10.0.0.0/8 — and so does a page in a browser context the client created itself, which is the case no in-context guard can cover.

  • insecureTls: true still works through the proxy's CONNECT tunnel: https://www.agentics.dk/en fails ERR_CERT_AUTHORITY_INVALID without it and renders in 148 ms with it.

Not verified yet

Straight, because a quickstart that overstates is worse than none:

  • No token-efficient CLI has been pointed at door B yet. The CDP proxy has been driven by playwright-core; agent-browser and browser-use have not.
  • Door C — Playwright-protocol connect() — is still v2. Only connectOverCDP has been driven against this service.

One trap the first production render walked straight into: internal Agentics hosts answer with Traefik's default certificate, so a render of https://www.agentics.dk/en fails with ERR_CERT_AUTHORITY_INVALID unless the request itself carries "insecureTls": true. ALLOW_INSECURE_TLS=1 on the server only permits that flag; it never relaxes TLS on its own.