Cookbook: live browser
01 / Live service
Most environments only need a warm ready state — imports resolved, caches hot, so a fork skips the install/import tax. A live-service environment goes one step further: a long-running process — here a browser — is started at bake time and left running, so the ready-state snapshot captures it alive. Every fork then wakes with the browser already up, and a rollout attaches to it instead of launching it.
This is the reference recipe for a browser template. Use it as a template for
any live-service environment.
The idea
Section titled “The idea”bake: boot guest → run warm command → SNAPSHOT (browser is running) → publishfork: restore snapshot → browser is already alive → rollout attachesThe warm command must (1) start the browser detached so it survives the command returning, and (2) block until the browser is actually serving CDP, so the snapshot is taken only once it is ready — not merely spawned. See the warm-command contract.
The warm script
Section titled “The warm script”The whole recipe is one baked script, /usr/local/bin/collimate-warm. Five
details matter, and each is a common way to get this wrong:
#!/bin/shset -e
# 1. Bring loopback up. At bake time the guest's `lo` is not yet up; the CDP# server binds to it, so without this the endpoint is unreachable.ip link set lo up 2>/dev/null || true
# 2. Resolve Chromium's real binary via Playwright — never hard-code the path,# it moves between releases (e.g. .../chromium-1228/chrome-linux64/chrome).CH=$(python3 -c "from playwright.sync_api import sync_playwright as x; p=x().start(); print(p.chromium.executable_path); p.stop()")
# 3. Launch DETACHED (setsid) so it outlives this script and lands in the snapshot.# Chromium ≥132 has ONLY the new headless mode: --remote-debugging-address is# ignored and the CDP server always binds 127.0.0.1:9222. Don't fight it with# bind flags — publish the endpoint with a relay (next step).# --remote-allow-origins=* is REQUIRED for remote CDP: since Chromium 111 the# DevTools WebSocket returns 403 for any handshake with an Origin header not on# the allow-list, and every remote client (Playwright connect_over_cdp) sends# one. Without it, remote attach fails; in-guest cdp-nav is unaffected.setsid "$CH" --headless --no-sandbox --disable-gpu --disable-dev-shm-usage \ --remote-debugging-port=9222 --remote-allow-origins=* \ --user-data-dir=/tmp/cdp-profile about:blank \ >/var/log/cdp.log 2>&1 < /dev/null &
# 4. Publish the loopback-only CDP endpoint on the guest NIC (10.0.2.15) so# connect-from-outside (`exposePorts` → per-fork DNAT → NIC) reaches it.# cdp-relay is a ~40-line baked stdlib TCP relay: NIC:9222 → 127.0.0.1:9222,# detached like Chromium so it lives in the snapshot, retrying its bind while# the NIC comes up. Version-proof: no dependency on Chromium bind behavior.setsid /usr/local/bin/cdp-relay 10.0.2.15:9222 127.0.0.1:9222 \ >/var/log/cdp-relay.log 2>&1 < /dev/null &
# 5. Block until CDP actually answers, so the snapshot captures a READY browser.for i in $(seq 1 60); do if python3 -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:9222/json/version', timeout=1)" 2>/dev/null; then echo cdp-ready; exit 0 fi sleep 1doneecho cdp-timeout >&2; exit 1Point the template's ready command at it when you bake:
from collimate_rl import Ready, Template, connect
client = connect() # a col_pro_* key via COLLIMATE_API_KEY
handle = Template.bake( client, name="browser", image="ghcr.io/you/browser@sha256:…", # pin by digest ready=Ready( cmd="/usr/local/bin/collimate-warm", # launch detached → block → exit 0 ports=[9222], # expose CDP for connect-from-outside ), width=32,)handle.wait(timeout=600)ports=[9222] is what makes 9222 reachable from a client (via cdp_url() /
Remote browser); it also gives the template a guest
NIC, so the warm relay's 10.0.2.15 address exists. Omit it and a fork is
in-guest only — cdp-nav still works, but a remote connect_over_cdp mint
returns 409 port_not_exposed.
Run this script at build time as a self-test, too, so a green build proves Chromium launches and CDP answers before the image is ever baked.
Using it from the SDK
Section titled “Using it from the SDK”A rollout attaches to the already-running browser — it does not launch one.
from collimate_rl import connect
client = connect(api_key="col_...")
# The rollout body runs inside the sandbox. It connects to the live browser that# the ready-state snapshot is already running — no per-fork browser launch.ROLLOUT = """from playwright.sync_api import sync_playwright
with sync_playwright() as pw: browser = pw.chromium.connect_over_cdp("http://127.0.0.1:9222") page = browser.new_context().new_page() # isolated context per rollout page.goto("https://example.com") print(page.title()) browser.close() # closes the connection, not the browser"""
sb = client.create_sandbox("browser")result = client.exec(sb["id"], commands=[["python3", "-c", ROLLOUT]], timeout_seconds=60)print(result["stdout"])client.delete_sandbox(sb["id"])Fan a warmed parent out across many rollouts with fork — every child inherits
the same live browser:
# count is the per-call fan-out, capped by tier (Pro 32, Ent 64). For a wider# group, call fork again (or use Session.fork_group to chunk it for you).children = client.fork(sb["id"], count=32)["children"]for child in children: client.exec(child["id"], commands=[["python3", "-c", ROLLOUT]], timeout_seconds=60)Give each rollout its own new_context() (fresh cookies/storage) and close the
context, not the browser — the browser is the shared warm service.
A note on latency
Section titled “A note on latency”Attaching to the pre-running browser skips the Chromium spawn a cold
chromium.launch() pays. Two ways to attach, fastest last:
Measured in a browser sandbox on Collimate prod, in-guest, median of 3 runs
(attach + navigate to a fixed page, so the number is attach cost, not network):
| Attach path | Command | Median attach + navigate |
|---|---|---|
Raw CDP (cdp-nav) | ["cdp-nav", url] | ~235 ms |
Playwright connect_over_cdp | ["python3", "-c", rollout] | ~1350 ms |
- Raw CDP — talk to
ws://127.0.0.1:9222with a stdlib WebSocket client and sendPage.navigatedirectly. No Node driver, so attach-and-drive is ~235 ms — roughly 5.7× faster than Playwright. Reach for it when per-rollout latency is the bottleneck. - Playwright
connect_over_cdp(...)— convenient (selectors, waits, screenshots), but almost all of its ~1.35 s is its Node driver booting each call. Still far faster than a coldchromium.launch().
Pro browser-RL: authentication & secrets
Section titled “Pro browser-RL: authentication & secrets”The ready-state snapshot is a shared artifact, so it must never contain credentials — never bake cookies, tokens, or a logged-in profile into the image or the warm state. Two patterns keep auth per-tenant and per-fork:
-
Per-fork login +
storage_state. Log in inside the rollout on first use and persist Playwright'sstorage_stateto the fork's own disk (per-fork, never shared), so later steps in the same episode reuse the session:import osctx = browser.new_context(storage_state="/tmp/state.json" if os.path.exists("/tmp/state.json") else None)# ...perform the login flow using a runtime-injected credential...ctx.storage_state(path="/tmp/state.json") # persist for subsequent steps in this fork -
Runtime credential injection. Keep the credential itself out of your code: store it in Collimate's secret store and inject it at sandbox-create time, where it reaches the sandbox through the sealed credential proxy — never the artifact, never a log.
Browser networking is a Pro capability: demo sandboxes have no outbound network, so real navigation (and therefore login) needs a Pro key with egress enabled:
sb = client.create_sandbox("browser", egress={"allow": ["example.com"]})See Per-fork egress for the full policy model.
Adapting to other live-service environments
Section titled “Adapting to other live-service environments”The same warm command works for any long-running service — swap the launch line and the readiness probe, keep the shape:
| Service | Launch (detached) | Readiness probe |
|---|---|---|
| Jupyter kernel | setsid jupyter kernel … | connection file / kernel_info over ZMQ |
| Postgres | setsid pg_ctl start … | pg_isready |
| Model server | setsid python -m your_server … | GET /health returns 2xx |
Keep it launch detached → block until serving → exit 0, and the service lands in the ready-state snapshot exactly like the browser.
Next steps
Section titled “Next steps”- Ready states — the warm vs live-service concept.
- Live fork — fork one warmed browser into N live children.
- Per-fork egress — lock down what a browser rollout can reach.