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.
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
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:
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.
sessionId is bornTo 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:
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."
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:
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.
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.
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
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:
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.
Target.attachToTarget hand back?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.