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.
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
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.
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.
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.
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.
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.
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).
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.
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.
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.
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.
Straight, because a quickstart that overstates is worse than none:
playwright-core; agent-browser and browser-use have not.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.