Agent-readable docs index: /llms.txt. Full docs in one file: /llms-full.txt. Download /docs.zip to grep all markdown files locally.

Multi-Agent Browser Sessions with Isolated State

Sessions let you run multiple agents at once without interference. Each session is an isolated sandbox with its own state object. Variables, pages, and listeners persist between calls. Browser tabs are shared, but state is not.

Creating sessions

playwriter session new # => 1 playwriter session new # => 2 # List all sessions with their stored keys playwriter session list # ID State Keys # -------------- # 1 page, userData # 2 -
Pass -s <id> to all commands to use a specific session:
playwriter -s 1 -e "state.users = ['Alice', 'Bob']" playwriter -s 2 -e "console.log(state.users)" # undefined (isolated)

State persistence

The state object persists between execute calls within the same session. Use it to store pages, data, listeners, and anything else you need across calls:
// Call 1: store data state.page = await context.newPage() await state.page.goto('https://example.com') state.results = [] // Call 2: data is still there console.log(state.results.length) // 0 state.results.push(await state.page.title())

Pages are shared, state is not

context.pages() returns all browser tabs with Playwriter enabled, shared across all sessions. Multiple agents see the same tabs. There is no default page. Always create your own page:
// First call: create a tab and navigate immediately state.page = await context.newPage() await state.page.goto('https://example.com') // Use state.page for ALL subsequent operations
Navigate in the same call as creating the page.

Handle page closures

The user may close your tab. Always check before using it:
if (!state.page || state.page.isClosed()) { state.page = await context.newPage() } await state.page.goto('https://example.com')

Using existing pages

Only use a page from context.pages() if the user points you to a specific tab. Find it by URL. findLast picks the most recently opened match:
state.page = context.pages().findLast((p) => p.url().includes('myapp.com')) if (!state.page) throw new Error('No myapp.com page found')

Tab groups per session

Tabs a session creates are grouped in a Chrome tab group named playwriter by default. Use the shortest clear single-word name with no spaces, such as docs, shop, test, or scrape.
# Tabs created by this session join a group named "docs" playwriter session new --tab-group docs # Pick the group color yourself (any Chrome color) playwriter session new --tab-group docs --tab-group-color blue # Rename the group later — moves the session's existing tabs playwriter session update 1 --tab-group research # Change only the color — also works on the default playwriter group playwriter session update 1 --tab-group-color red
Park work on another screen. Give the session its own group, then right-click the group and choose Move group to new window. New tabs from that session follow the group, even if the window is minimized:
playwriter session new --tab-group scrape --tab-group-color grey
Split concurrent agents. Each agent should use its own --tab-group. That keeps many open tabs readable instead of one mixed strip:
playwriter session new --tab-group agent1 --tab-group-color blue playwriter session new --tab-group agent2 --tab-group-color pink
Collapse groups you don't care about. Click the group name in Chrome to collapse it. Use a clear name so leftover work takes less space without closing the tabs:
playwriter session new --tab-group done --tab-group-color grey
Renaming a session that still uses the default playwriter group only moves tabs that session created. Manually enabled tabs and other sessions' tabs stay in the default group. Renaming a custom group moves everything in it, including tabs you dragged in.
Without --tab-group-color, each custom group gets a deterministic color derived from its name (the default playwriter group stays green). The flag accepts every Chrome group color: grey, blue, red, yellow, green, pink, purple, cyan, orange. playwriter session list shows the group per session in the GROUP column.
Tab groups are cosmetic:
  • Dragging a tab between playwriter groups is fine — the connection is kept and the tab simply adopts the new group.
  • Dragging a tab out of all playwriter groups disconnects it (same as before).
  • Dragging an unrelated tab into any playwriter group connects it.
  • Moving a whole group to another window (Chrome's "Move group to new window") keeps the tabs connected.
Only extension sessions support tab groups. Headless, direct CDP, and cloud sessions have no browser UI to group.

Node API

Use connectViaExtension() from the playwriter package. It starts the relay if needed, waits for the Chrome extension, creates a session, and connects over CDP.
import { connectViaExtension } from 'playwriter' await using connection = await connectViaExtension({ tabGroup: 'email-check', tabGroupColor: 'grey', }) const page = await connection.browser.contexts()[0].newPage() await page.goto('https://example.com') // connection closes automatically when the scope ends, also on throw
tabGroupColor is typed as Chrome's tab group colors: grey, blue, red, yellow, green, pink, purple, cyan, orange. await using calls close() at scope end: it closes the tabs this connection opened (never other sessions' tabs), disconnects CDP, and deletes the session. It needs Node 24+ (or a TypeScript 5.2+ / tsx build). Without await using, call await connection.close() in finally.

Keeping tabs across processes

Pass closeTabsOnEnd: false to keep the opened tabs after close(). A later process finds them in context.pages():
// process A await using a = await connectViaExtension({ tabGroup: 'job', closeTabsOnEnd: false }) await (await a.browser.contexts()[0].newPage()).goto('https://example.com') // process B, later await using b = await connectViaExtension({ tabGroup: 'job' }) const page = b.browser.contexts()[0].pages().findLast((p) => p.url().includes('example.com'))
b.close() never closes tabs that b did not open, so the reused tab stays open.

Choosing a browser

If the extension runs in more than one browser or profile (Chrome, Brave, Ghost, a work profile), pass selectBrowser. It gets every connected browser and returns the one to use. Without it, connecting fails when more than one browser is connected.
import { connectViaExtension, listBrowsers } from 'playwriter' await using connection = await connectViaExtension({ selectBrowser: (browsers) => { // [{ id, browser: 'Brave', email: 'me@work.com', activeTabs, playwriterVersion }] return browsers.find((b) => b.browser === 'Brave' && b.email === 'me@work.com') }, }) console.log(connection.extensionBrowser.id) // same list, without connecting const browsers = await listBrowsers()
id is stable across restarts, so you can save it and match on it later. email is null when the profile is not signed in.
Do not POST /cli/session/new yourself. Do not call chromium.connectOverCDP(getCdpUrl(...)) unless you already have a session from the CLI.

Session management

# Reset a session (reconnect browser, clear state) playwriter session reset 1 # Delete a session playwriter session delete 1 # List all sessions playwriter session list

Context variables

Every session has access to these globals:
VariableDescription
statePersisted object, isolated per session
contextBrowser context, access all pages via context.pages()
requireLoad Node.js modules (path, fs, crypto, etc.)
fetchStandard fetch API
Buffer, URL, URLSearchParamsStandard globals
setTimeout, setIntervalTimers
crypto, processNode.js globals
There is no default page global. Reading page throws. Store your own page in state.page.
Popup windows (window.open, OAuth login flows, target=_blank) are auto-relocated to tabs in the main window by the extension. The new tab appears in context.pages():
await state.page.locator('button:has-text("Login with Google")').click() await state.page.waitForTimeout(1000) // New tab is the last page const pages = context.pages() const loginPage = pages[pages.length - 1] await loginPage.locator('[data-email]').first().click()
You'll receive a [WARNING] New page opened from current page (index N, initial url: ...) message pointing to the new tab.

Clean up

Always clean up listeners at the end of your message to prevent memory leaks:
state.page.removeAllListeners()
Never call browser.close() or context.close(). Only close pages you created yourself.