Chrome DevTools Protocol · Lesson 1

Connect & Command

Launch a real Chrome, find a tab, open its debugging socket, and send one CDP command by hand — every byte from a live local trace.

You already drive browsers through wrappers — Puppeteer, Playwright. This track goes one layer down, to the protocol those wrappers speak: the Chrome DevTools Protocol. By the end of this lesson you will have connected to a live Chrome yourself and sent a raw CDP command over a WebSocket, and you'll be able to name every part of the exchange. That's the foundation the Domains & events lesson, and later the chrome-devtools-mcp bridge, sit on top of.

The one-sentence mental model

CDP is just JSON messages over a WebSocket. You ask the browser to find you a target (a tab) over plain HTTP, open the WebSocket URL it hands back, and then trade JSON: you send commands, it sends back responses and events. Puppeteer is a friendly face over exactly this.src

1 · Turning CDP on — the launch flag (and its teeth)

Chrome only speaks CDP over the network if you ask it to, with one flag:

# a throwaway, headless Chrome that serves CDP on port 9222 "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \ --headless=new \ --remote-debugging-port=9222 \ --user-data-dir=/tmp/cdp-teach-profile \ ← REQUIRED since Chrome 136 about:blank &
Treat the debug port like a shell

The debugging port has no authentication. Anyone who can reach it gets full, silent control of that browser — read cookies, run JS, open pages. Attackers abused this to steal cookies, so from Chrome 136 the flag is ignored against your normal profile and now requires a non-standard --user-data-dir.chrome Always use a throwaway profile, and never bind the port to a public interface. This is the CDP cousin of the "MUST validate Origin" warning from your MCP track.

2 · Finding a target — plain HTTP first

Before any WebSocket, you discover what's debuggable over ordinary HTTP GETs. Two endpoints matter. First, /json/version — who the browser is, plus the browser-level socket:

GET http://127.0.0.1:9222/json/version { "Browser": "Chrome/151.0.7922.174", "Protocol-Version": "1.3", "webSocketDebuggerUrl": "ws://127.0.0.1:9222/devtools/browser/bdf6f319-…" }

Then /json/list — every target: tabs, but also workers and extension pages. This is the real, unedited list from our Chrome, and it's noisy:

GET http://127.0.0.1:9222/json/list [ { "type": "background_page", "title": "Chrome Web Store Payments", … }, { "type": "service_worker", "title": "Service Worker …", … }, { "type": "browser_ui", "title": "Omnibox Popup", … }, { "type": "page", "title": "about:blank", ← the tab we want "id": "4982E002B77B26E6B72E071236BF6806", "webSocketDebuggerUrl": "ws://127.0.0.1:9222/devtools/page/4982E002…" } ]
Filter for type: "page"

A "target" is anything debuggable — a tab, an iframe, a service worker, an extension's background page, or the browser itself.docs To drive a normal tab you want the one whose type is page. You don't build the webSocketDebuggerUrl — the browser hands it to you.

3 · Your first command — over the WebSocket

Now open that WebSocket and send a command. Every command is one JSON object with an id, a method of the form Domain.command, and optional params.src Here is a real one — asking the browser who it is — and its reply:

# send (client → browser), over ws://…/devtools/browser/… { "id": 1, "method": "Browser.getVersion" } # reply (browser → client) — note the SAME id:1 { "id": 1, "result": { "protocolVersion": "1.3", "product": "Chrome/151.0.7922.174", "jsVersion": "15.1.206.23", … } }

That's the whole request/response game: you pick an id, the browser echoes it back on the result so you can match reply to command. Send another command with a different id and the two replies can come back in any order — the id is how you tell them apart.

4 · The third shape — events

Commands and responses aren't the only traffic. Watch what happens when we enable the Page domain and navigate. This is the real stream, in the order it arrived:

# we send two commands: { "id": 1, "method": "Page.enable" } { "id": 2, "method": "Page.navigate", "params": { "url": "https://example.com" } } # the browser streams back — responses AND events, interleaved: { "method": "Page.frameStartedNavigating", "params": {…} } ← event: no id { "method": "Page.frameStartedLoading", "params": {…} } ← event: no id { "id": 1, "result": {} } ← response to Page.enable { "id": 2, "result": { "frameId": "4982E002…" } } ← response to navigate { "method": "Page.frameNavigated", "params": {…} } ← event { "method": "Page.loadEventFired", "params": {…} } ← event: page is loaded { "method": "Page.frameStoppedLoading","params": {…} } ← event
Demultiplex by the id

Three message shapes share one socket: a command you send (id + method + params), a response (id + result), and an event (method + params, no id).src Notice two events arrived before the id:1 response — ordering isn't request/response ping-pong. The rule: has id → a response to something you asked; no id → an unsolicited event. If you know MCP, this is request-vs-notification wearing a different hat.

Events are opt-in

You saw those Page.* events only because we sent Page.enable first. Each domain stays silent until you call its Domain.enable command — Network.enable for network traffic, and so on. Skip the enable and the command still works, but the events never come.

🔧 Try it yourself (≈ 5 minutes)

A driver script mirrors the MCP track's mcp-curl.sh. From the cdp/ directory:

# 1. launch a throwaway headless Chrome (safe: its own temp profile) bash assets/cdp-ws.sh up # 2. discovery over plain HTTP — see the noisy target list, filtered to real tabs bash assets/cdp-ws.sh targets # 3. your first raw command — the browser tells you who it is bash assets/cdp-ws.sh browsercmd '{"id":1,"method":"Browser.getVersion"}' # 4. navigate a tab and WATCH the event stream (responses + events interleaved) bash assets/cdp-ws.sh navigate https://example.com # 5. run JS in the page and read the value back (Runtime.evaluate under the hood) bash assets/cdp-ws.sh eval 'document.title' # when done: bash assets/cdp-ws.sh down

In step 4, find one line with an id and one without. Which is the response, which is the event? Bring me anything that surprises you.

Check yourself

On the WebSocket, how do you tell an event from a command's response?
You navigated but saw no Page.* events. What did you most likely forget?
In the noisy /json/list, which entry do you open to drive a browser tab?
Primary source — read this next

Getting Started with Chrome DevTools Protocol, by Puppeteer maintainer Andrey Lushnikov. The clearest hands-on account of the wire protocol: targets, the WebSocket, the command/response/event shapes, and (soon for us) sessions. Read the "Targets & Sessions" section — you've now seen on the wire everything it opens with. For the exact method and parameter names, the reference is the official CDP docs.