Chrome DevTools Protocol · Lesson 3

Targets & Sessions

One WebSocket, many tabs — attach with flatten:true and route every command by a top-level sessionId. From a live trace.

So far one WebSocket has meant one target. In Lesson 1 you opened ws://…/devtools/page/<id> and everything you sent went to that one tab. But a real automation tool — Puppeteer, or chrome-devtools-mcp — drives many pages over a single connection. This lesson shows how: the Target domain and sessions.

The one-sentence mental model

A session is a routing channel to one attached target. You open the browser-level WebSocket, attach to a target to get a sessionId, then tag each command with that sessionId so the browser knows which tab it's for.src

1 · The browser endpoint, and listing targets

Recall the two WebSocket flavors from /json/version and /json/list: a page socket talks to one tab; the browser socket (ws://…/devtools/browser/<id>) is the control plane for the whole browser. On it, Target.getTargets enumerates everything debuggable:

# on the BROWSER socket: { "id": 1, "method": "Target.getTargets" } # result — an array of TargetInfo: { "id": 1, "result": { "targetInfos": [ { "targetId": "26709735…", "type": "page", "title": "about:blank", "url": "about:blank", "attached": false }, … ] } }

Each TargetInfo has a targetId, a type (page, iframe, service_worker, …), and whether anyone is attached. This is the browser-level cousin of the noisy /json/list you filtered in Lesson 1.

2 · Attaching — how a sessionId is born

To drive a target you attach to it. We create a fresh tab first, then attach with the all-important flatten:true. This is the real exchange:

# make a new tab → get its targetId { "id": 2, "method": "Target.createTarget", "params": { "url": "about:blank" } } { "id": 2, "result": { "targetId": "53C4EBAF…" } } # attach to it, flat mode → get a sessionId { "id": 3, "method": "Target.attachToTarget", "params": { "targetId": "53C4EBAF…", "flatten": true } } { "id": 3, "result": { "sessionId": "D9C48710…" } }
Two places the sessionId shows up — don't confuse them

Attaching also fires a Target.attachedToTarget event, and it carries the new session id inside params (params.sessionId) — because the event itself arrives on the root browser connection, which has no session of its own. When you later route a command, the sessionId goes at the top level of the message instead. Same value, two positions, two meanings: in params = "here is a new session"; at top level = "route this to that session."

3 · Routing — the whole trick of flat mode

Now the payoff. To talk to that tab, add the sessionId as a top-level field on any ordinary command. No wrapping, no special method — just one extra key. The browser routes it, and the response echoes the same sessionId so you know which tab answered. Real capture:

# navigate THAT tab — note sessionId is top-level, beside id/method { "id": 10, "sessionId": "D9C48710…", "method": "Page.navigate", "params": { "url": "https://example.com/" } } { "id": 10, "sessionId": "D9C48710…", "result": { "frameId": "53C4EBAF…" } } ← reply echoes the session # run JS in the same tab, same way { "id": 11, "sessionId": "D9C48710…", "method": "Runtime.evaluate", "params": { "expression": "document.title", "returnByValue": true } } { "id": 11, "sessionId": "D9C48710…", "result": { "result": { "type": "string", "value": "Example Domain" } } }

Attach to five tabs and you hold five sessionIds; every message you send or receive is stamped with one of them. That is how a single connection multiplexes a whole browser — and exactly the model chrome-devtools-mcp uses when its list_pages / select_page tools juggle multiple tabs for an agent.

Auto-attach, for the children you didn't ask for

Pages spawn their own targets — iframes, workers. Rather than polling getTargets, call Target.setAutoAttach with { "autoAttach": true, "flatten": true, "waitForDebuggerOnStart": … } and the browser automatically attaches to related targets as they appear, firing attachedToTarget for each.docs Sessions form a hierarchy: close a parent session and all its child sessions close with it.

Flat vs nested

The old "nested" mode wrapped every routed call inside Target.sendMessageToTarget. Flat mode (flatten:true) replaced that with the top-level sessionId you just used. Chrome plans to make flat the only mode and retire nested — so always pass flatten:true.docs

🔧 Try it yourself (≈ 5 minutes)

A new driver verb runs the whole flow — create a tab, attach flat, and drive it purely by sessionId — printing every message. From the cdp/ directory:

# start a throwaway Chrome bash assets/cdp-ws.sh up # create + attach + route by sessionId, all on the browser socket bash assets/cdp-ws.sh sessions https://example.com/ bash assets/cdp-ws.sh down

In the output, find the moment the sessionId is minted, then watch it ride on every following SEND and RECV. Where does it sit on the attachedToTarget event versus on the routed responses? Ask me if it surprises you.

Check yourself

In flat mode, how do you route a command to an attached target?
What identifier does Target.attachToTarget hand back?
To drive many tabs at once, which debugging WebSocket do you open?
Primary source — read this next

Getting Started with CDP — Targets & Sessions, the clearest account of attaching and flat mode, and the official Target domain reference for attachToTarget, setAutoAttach, and the TargetInfo shape. You've now driven the whole flow live, so both read as a map of familiar ground.