Spunto Browserheadless Chrome you can watch

Headless Chrome,
with the window on.

Headless browsers are fine right up until something goes wrong, and then all you have is a stack trace and a screenshot taken three steps too late. This is one Docker image that gives that browser a real window: a tab bar, an address bar, a live screen you can click into, and full DevTools — while your scripts and your agents drive the very same browser.

one imageone portreal DevToolsdrives over CDP
one command · your machine
$ docker run -p 3000:3000 ghcr.io/spuntodotnet/browser-remote:latest

the image is published under its repo name, browser-remote

01What you get

A tab bar. An address bar. A page you can click.

Point your own browser at the container and you’re driving. Tabs, back, forward, reload, an address bar you can type into, and the page streamed live as you interact with it — the sort of view you’d get from screen-sharing someone’s laptop.

The Spunto Browser control UI: a tab bar with four tabs, an address bar, a DevTools button, and a live view of the Spunto blog
the actual UI — four real tabs, and the page streaming live
tabs
real ones — open, switch, close; a tab your script opens shows up here immediately
live view
Chrome's own screencast, with your mouse and keyboard forwarded back
devtools
the full panel, docked in the same page, on the exact tab that's misbehaving
inside
Chromium from apt — the BSD-licensed one, no separate binary to trust

read this bit

There is no authentication in front of any of this. Anyone who can reach the port has full control of the browser, including whatever it happens to be logged into. It’s made for a private network or something you put in front of it — not for the open internet as is.

02Why not browserless or VNC

Chrome already knows how to do this.

The usual ways to get a watchable headless browser are a session-pooling service wrapped around the Chrome you already have, or a full X server plus a VNC server plus a web client — streaming an entire virtual desktop to look at one window.

Both work. Both are more infrastructure than the problem needs. Chrome has a built-in way to stream its own pixels and forward input back — the DevTools Protocol. This is that, wired up directly: Chromium plus a thin Node server, in one image.

how it’s wired

screencast
Page.startScreencast makes Chrome push a JPEG frame on every repaint; the server forwards them over a WebSocket to a canvas.
input
Input.dispatchMouseEvent / dispatchKeyEvent go back the other way. The round-trip is fast enough that you stop noticing it isn’t your browser.
tab bar
Target.getTargets, activateTarget, closeTarget — no automation library needed, just the socket that was already open.
devtools
Chrome bakes the host it expects into its own DevTools frontend, so a small proxy rewrites the Host header on the way through. Same iframe works on localhost or three proxies deep.
Spunto Browser with the DevTools panel docked on the right, Elements tab open, inspecting a live page
full DevTools, on the page that's misbehaving — not a recreation of it
03Driving it

Two hands on the same wheel.

/json/* and /devtools/* are proxied straight through, so a Puppeteer or Playwright script attaches to the exact browser the tab bar is showing. A tab your script opens appears in the bar immediately; a tab you opened by hand is just another entry in browser.pages().

Which means you can hand off mid-session without losing cookies or state: let the script get stuck, then finish the flow by hand in the same tab.

browserURL
Don’t hand it straight to puppeteer.connect(). Chrome’s /json/version reports its internal WebSocket address — ws://127.0.0.1:9222/… — which only makes sense inside the container. Fetch it yourself and swap the host in, as above.
close vs disconnect
browser.close() sends Browser.close and kills the Chrome process — every tab, including whatever’s open in the UI. You’re borrowing someone else’s browser here, so .disconnect() is almost always what you want.
attach.mjs
import puppeteer from "puppeteer-core";

const CHROME_URL = "http://localhost:3000"; // wherever the container is

// Chrome reports its *internal* ws address, so swap in the host you're using
const { webSocketDebuggerUrl } = await (await fetch(`${CHROME_URL}/json/version`)).json();
const browserWSEndpoint = `${CHROME_URL.replace(/^http/, "ws")}${new URL(webSocketDebuggerUrl).pathname}`;

const browser = await puppeteer.connect({ browserWSEndpoint });
const page = await browser.newPage();
await page.goto("https://example.com");

await browser.disconnect(); // detach — leave Chrome (and the container) running
docker-compose.yml
services:
  browser:
    image: ghcr.io/spuntodotnet/browser-remote:latest
    ports:
      - "3000:3000"

A handful of env vars exist for later: a custom CA so it trusts your local mkcert certificates, a different Chrome binary path, and an opt-in anti-fingerprinting flag that’s off by default. All in the README.

04Where it earns its keep

The window an agent can’t give you.

An agent driving a headless browser is a black box: it says the click didn’t work, and you have a stack trace. Run this next to it and there’s a real page, in a real tab, that you can watch it work in — and click into yourself when it gets stuck, same session, same cookies, no restart.

That’s how this site is tested, incidentally: every screenshot on it was taken by a script driving this browser, in a Spunto workspace, and the same browser is what a human opens to check the result.

also from Spunto