# How to Run 10+ Playwright Agents on One Logged-In Browser (Without Getting Blocked)

> A multi-agent browser automation tutorial — how to share one logged-in Chromium across any number of Playwright agents for AI agent orchestration, with bonus connections for AI browsers, OpenClaw skills, and Claude Code MCP.

Content type: article
Source URL: https://www.agentpmt.com/articles/how-to-run-10-playwright-agents-on-one-logged-in-browser-without-getting-blocked
Markdown URL: https://www.agentpmt.com/articles/how-to-run-10-playwright-agents-on-one-logged-in-browser-without-getting-blocked?format=agent-md
Updated: 2026-04-30T18:41:00.884Z
Author: Stephanie Goodman
Tags: MCP, autonomous agents, Multi-Agent Workflows, AI Powered Infrastructure, Tutorials, Agent Orchestration

---

# How to Run 10+ Playwright Agents on One Logged-In Browser (Without Getting Blocked)

If you're orchestrating a fleet of agents — a bookkeeping agent pulling statements from various banks; a content design agent creating Canva documents, an AI system where worker agents all need to act as a logged-in user through the browser — you hit the same wall I did. Every agent needs an authenticated session, but giving each one its own browser fails three ways: they all start logged out, they collide if they share a profile directory, and ten parallel sessions launched by automation tooling can trip security on stricter sites even when the underlying activity is completely legitimate.

This tutorial walks through the pattern I run today: one real Chromium browser, logged into all your accounts, with any number of Playwright agents attached to it over the Chrome DevTools Protocol. They share the session. They don't collide. The browser looks like your normal browser because it is your normal browser — Playwright is just borrowing tabs.

This is the multi-agent browser automation pattern I reach for whenever an AI agent orchestration system has more than one worker that needs an authenticated browser session. It is the kind of agentic workflow that quietly breaks every other approach to running Playwright in parallel — and once it works, the rest of your AI agent automation stops fighting the browser and starts using it. The same architecture also acts as a clean browser relay for AI browsers, OpenClaw skills, and any other agent runtime that can speak the Chrome DevTools Protocol — more on that in the OpenClaw section below.

**Before you build all this, ask yourself one question.** Do your agents actually need a logged-in identity? If they only need to read public web pages — fetch a doc, scrape a product listing, read a news article — you do not need any of this setup. [AgentPMT's Live Web Page Browser](https://www.agentpmt.com/marketplace/live-web-page-browser) is a marketplace tool that gives agents stateless web access with no persistent login required. Use it when your agents are reading the open web. The AgentPMT web browsing tool provide access to sites the agent's internal browser is blocked from accessing. It returns markdown format, HTML, or screenshots, giving your agent access to exactly what they need. Use the pattern in this tutorial when your agents need to act as you with shared logins.

All the code from this tutorial is in my repo at [github.com/firef1ie/AI-Tutorials/tree/main/playwright-shared](https://github.com/firef1ie/AI-Tutorials/tree/main/playwright-shared).

* * *

## What You'll Build: A Multi-Agent Browser Automation Setup

By the end of this Playwright browser automation tutorial you will have:

1.  One Chromium browser running 24/7 on your machine, logged into all your sites
2.  A Node.js helper that lets any agent connect to that browser and open its own tab
3.  A working example that runs four agents in parallel, all logged in, all in the same shared browser
4.  An optional Xvfb setup so the browser never appears on screen
5.  A systemd service so it auto-starts at boot and restarts on crash
6.  Optional: a Claude Code MCP integration so Claude shares the same logged-in session

Time to complete: about 30 minutes if you're comfortable with Linux and Node.

* * *

## When You Need Multi-Agent Browser Automation

Running multiple AI agents that all act as the same logged-in user is harder than single-agent browser automation makes it look. The cases where you actually need this kind of multi-agent setup:

1.  **Content automation pipelines.** Five AI agents posting to LinkedIn, X, and Reddit simultaneously, all logged in as the same team account.
2.  **Authenticated web scraping.** Ten workers pulling data from a dashboard you pay for. Per-session rate limits make sharing one login necessary, and per-agent browser automation explodes your resource use.
3.  **QA / E2E test automation.** Multiple test workers hitting staging as the same authenticated user, in parallel.
4.  **AI agent orchestration.** A research agent that spawns sub-agents, all of which need authenticated browser access to fetch data — agentic workflows that hit a wall the moment session sharing is required.
5.  **Personal automation.** One social account, but a draft-reply script, a notifications monitor, and a scheduler all running concurrently.

The common requirement across every case: many parallel agents, one shared browser session, no collisions, and no trips to a "we noticed unusual activity" page.

* * *

## Why the Obvious Approaches Fail

### Each agent launches its own browser

Every agent starts with a blank profile — logged out. You would have to script the login in each one, and 2FA or captcha breaks that immediately. Ten simultaneous logins from one IP also looks enough like a credential-stuffing attempt that some sites will challenge or lock the account.

### Shared --user-data-dir

Pointing every agent at the same profile directory sounds like it would share cookies. It does not work. Chromium places a ProcessSingleton lock on the user data directory while it is open. Only one browser process can hold that lock at a time. The second agent errors out or hangs indefinitely.

### Playwright's storageState (cookies as JSON)

This is the path I tried first. Export your login cookies to a JSON file and load that file in every agent's new context. It worked fine on LinkedIn, Reddit, and Facebook. On X, the session got flagged.

The reason: even with valid cookies loaded, Playwright still launches its own Chromium build. That launch path injects navigator.webdriver = true, adds the --enable-automation flag, and uses a browser with a slightly different TLS fingerprint than a real Chromium install. Most sites do not care. Some sites — X among them — treat those signals as automation regardless of the activity being legitimate, and shut the session down.

If your activity is on the friendlier end of that spectrum, storageState is the simpler path. The next section is about the case where you need a real browser to keep your own legitimate work from getting flagged.

* * *

## The Solution: One Browser, Many Agents, via CDP + Xvfb

The fix is to stop letting Playwright launch the browser at all.

Launch one real Chromium yourself via a shell script, the same way a normal user would. Log into your sites once. Leave the browser running. Then have every Playwright agent connect to that already-running browser over the Chrome DevTools Protocol (CDP) — the same debugging interface your browser exposes when you open DevTools.

```text
One real Chromium (launched by you, not Playwright)
-- persistent profile: cookies, history, extensions all live here
-- remote-debugging-port=9222 exposed on localhost only

| CDP over localhost:9222
|
Agent 1 → opens a tab, does its work, closes the tab
Agent 2 → opens a tab, does its work, closes the tab
Agent N → opens a tab, does its work, closes the tab
```

Because Playwright never launched the browser, none of its automation signals are added: navigator.webdriver stays undefined, --enable-automation is never passed, and the TLS fingerprint matches a real Chromium binary. From any site's perspective, this looks like your browser — because it is your browser. Playwright is just borrowing tabs.

Xvfb (X Virtual Framebuffer) is an optional layer that runs the browser on a virtual display so the window never appears on your screen. Unlike --headless, Xvfb runs full rendering on a real X server with no physical monitor attached. From a website's perspective, a browser on Xvfb is indistinguishable from a browser on a monitor.

> **Security note.** The CDP endpoint on port 9222 should only ever be bound to localhost. Never expose it to a public network. Anyone who can reach that port can drive your browser as you.

* * *

## Prerequisites

1.  **Linux** — Ubuntu is used here; any modern distro works
2.  **Node.js 20+**
3.  **Chromium** — snap or apt install
4.  **Xvfb** — optional, only needed for hiding the browser window
5.  Basic comfort with a terminal

macOS and Windows work with the same architecture. The install commands and Xvfb section are Linux-specific. macOS can push the window off-screen with --window-position=-2000,-2000; Windows users can run inside WSL2.

* * *

## Step 1 — Install Everything

**Node.js.** Use nvm or your package manager. Verify:

```text
node --version # v20 or higher
```

**Chromium on Ubuntu via snap:**

```text
sudo snap install chromium
ls /snap/chromium/current/usr/lib/chromium-browser/chrome
```

Or via apt:

```text
sudo apt install chromium-browser
```

Note the binary path — you will need it in the launcher script.

**Xvfb (optional, for hiding the browser window):**

```text
sudo apt install xvfb
```

Playwright gets installed inside the project in the next step. No global install needed.

* * *

## Step 2 — Project Structure

Create the project directory and install dependencies:

```text
mkdir -p ~/playwright-shared
cd ~/playwright-shared
npm init -y
npm install playwright
npm install --save-dev typescript tsx @types/node
```

The final file layout:

```text
~/playwright-shared/
├── package.json
├── tsconfig.json
├── start-browser.sh ← launches the shared Chromium
├── src/
│ └── connect.ts ← the helper your agents import
└── examples/
└── parallel-agents.ts ← the parallel example
```

Edit package.json to set ESM mode and add startup scripts:

```text
{
"name": "playwright-shared",
"version": "1.0.0",
"type": "module",
"scripts": {
"start-browser": "./start-browser.sh",
"example": "tsx examples/parallel-agents.ts"
},
"dependencies": { "playwright": "^1.50.0" },
"devDependencies": {
"@types/node": "^22.0.0",
"tsx": "^4.19.0",
"typescript": "^5.7.0"
}
}
```

Create tsconfig.json:

```text
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"allowImportingTsExtensions": true,
"noEmit": true,
"lib": ["ES2022", "DOM"]
},
"include": ["src/**/*", "examples/**/*"]
}
```

* * *

## Step 3 — Write the Launcher Script

This is the shell script that starts your shared Chromium. Save it as start-browser.sh:

```text
#!/usr/bin/env bash
set -euo pipefail

CDP_PORT="${CDP_PORT:-9222}"
PROFILE="${PROFILE:-$HOME/.playwright-shared-profile}"
CHROMIUM="${CHROMIUM:-/snap/chromium/current/usr/lib/chromium-browser/chrome}"

if [ ! -x "$CHROMIUM" ]; then
echo "Chromium not found at $CHROMIUM"
echo "Override with: CHROMIUM=/path/to/chrome $0"
exit 1
fi

mkdir -p "$PROFILE"

echo "Launching Chromium on CDP port $CDP_PORT"
echo "Log into your sites in the browser window that opens. Leave it open."

exec "$CHROMIUM" \
--remote-debugging-port="$CDP_PORT" \
--user-data-dir="$PROFILE" \
--no-first-run \
--no-default-browser-check
```

Make it executable:

```text
chmod +x start-browser.sh
```

**What each flag does:**

1.  \--remote-debugging-port=9222 — opens the CDP endpoint that agents will connect to. By default Chromium binds this to localhost only.
2.  \--user-data-dir="$PROFILE" — uses a dedicated profile at ~/.playwright-shared-profile; cookies, history, and extensions persist here across restarts
3.  \--no-first-run and --no-default-browser-check — skip setup dialogs

Notice what is **not** in the script: --headless, --enable-automation, or any "automation-friendly" flags. Those add signals that some sites use to flag sessions. This launches Chromium exactly as a normal user would.

* * *

## Step 4 — Write the Agent Helper

Save this as src/connect.ts. Every agent imports this one function to get a fresh tab in the shared browser:

```text
import { chromium, type Browser, type BrowserContext, type Page } from 'playwright'

const DEFAULT_CDP_URL = 'http://localhost:9222'

export type ConnectOptions = {
cdpUrl?: string
/**
* 'shared' (default): join the browser's default context — the one with your logins.
* 'isolated': create a fresh ephemeral context (loses login, gains tab isolation).
*/
contextMode?: 'shared' | 'isolated'
}

export type Handle = {
browser: Browser
context: BrowserContext
page: Page
close: () => Promise<void>
}

export async function connectToSharedBrowser(opts: ConnectOptions = {}): Promise<Handle> {
const cdpUrl = opts.cdpUrl ?? DEFAULT_CDP_URL
const contextMode = opts.contextMode ?? 'shared'

let browser: Browser
try {
browser = await chromium.connectOverCDP(cdpUrl)
} catch (err) {
throw new Error(
`Could not connect to Chromium at ${cdpUrl}. ` +
`Did you run npm run start-browser? ` +
`Error: ${err instanceof Error ? err.message : String(err)}`
)
}

let context: BrowserContext
let isolated = false
if (contextMode === 'isolated') {
context = await browser.newContext()
isolated = true
} else {
const contexts = browser.contexts()
if (contexts.length === 0) throw new Error(`No default context found at ${cdpUrl}.`)
context = contexts[0]
}

const page = await context.newPage()

return {
browser,
context,
page,
close: async () => {
await page.close().catch(() => {})
if (isolated) await context.close().catch(() => {})
// Never close the browser here — other agents are using it.
},
}
}
```

The critical line is chromium.connectOverCDP() rather than chromium.launch(). That single distinction is what keeps the architecture clean. The default contextMode: 'shared' uses the browser's existing default context — the one that has your cookies from when you logged in. Each agent opens a new tab via context.newPage() and gets its own independent navigation. close() shuts down only that tab; the browser keeps running.

* * *

## Step 5 — First Run: Log Into Your Sites

Open a terminal and start the browser:

```text
cd ~/playwright-shared
npm run start-browser
```

A real Chromium window opens. Log into every site your agents will need: X, LinkedIn, Reddit, GitHub, any internal tools. Handle 2FA, captchas, and SSO exactly as you normally would — you are logging in as a real user.

Leave the window open. Your cookies are saved in ~/.playwright-shared-profile/. If you restart the browser later using the same script, those sessions remain (unless a site has expired them).

Verify the CDP endpoint is up:

```text
curl http://localhost:9222/json/version
```

You should get back a JSON object with a Browser field and a webSocketDebuggerUrl. That means the browser is reachable by agents.

* * *

## Step 6 — Your First Agent

With the browser still running in the first terminal, open a second terminal and create examples/single-agent.ts:

```text
import { connectToSharedBrowser } from '../src/connect.ts'

const { page, close } = await connectToSharedBrowser()

try {
await page.goto('https://x.com/home', { waitUntil: 'domcontentloaded' })
await page.waitForTimeout(2000)

const url = page.url()
const title = await page.title()
console.log('URL:', url)
console.log('Title:', title)

if (url.includes('/login') || url.includes('/i/flow/login')) {
console.error('NOT LOGGED IN — log into X in the shared browser first')
} else {
console.log('Logged in. Session is shared.')
}
} finally {
await close()
}
npx tsx examples/single-agent.ts
```

A tab briefly appears in the shared browser window, navigates to X, then closes. The terminal should print Logged in. Session is shared.

* * *

## Step 7 — Run Many Agents in Parallel

Save as examples/parallel-agents.ts:

```text
import { connectToSharedBrowser } from '../src/connect.ts'

async function runOneAgent(agentIndex: number): Promise<void> {
const { page, close } = await connectToSharedBrowser()
try {
console.log(`[agent ${agentIndex}] navigating`)
await page.goto('https://x.com/home', { waitUntil: 'domcontentloaded' })
await page.waitForTimeout(3000)

const title = await page.title()
console.log(`[agent ${agentIndex}] title: ${title}`)

// Each agent can navigate to different pages, scrape different things,
// or post different content — all from the same logged-in session.
} finally {
await close()
}
}

// Run 4 agents in parallel
await Promise.all([0, 1, 2, 3].map(runOneAgent))
console.log('all agents done')
npx tsx examples/parallel-agents.ts
```

Four tabs open at once in the shared browser. All navigate to X. All see the same logged-in feed. The terminal output interleaves their progress and ends with all agents done. That is multi-agent browser automation working as intended: many AI agents, one identity, no collisions, one shared authenticated session.

* * *

## Step 8 — Hide the Browser With Xvfb

A browser window appearing and stealing focus on a production server is a problem. Xvfb solves this by running the browser on a virtual display — a real X server rendering real pixels to no physical screen.

This is not the same as --headless. Headless mode disables real rendering and adds detectable signals. Xvfb provides full rendering on a real X server. From any website's perspective, a browser on Xvfb is indistinguishable from a browser on a monitor.

> **This step replaces the start-browser.sh you wrote in Step 3.** Overwrite it with the version below.

```text
#!/usr/bin/env bash
set -euo pipefail

CDP_PORT="${CDP_PORT:-9222}"
PROFILE="${PROFILE:-$HOME/.playwright-shared-profile}"
CHROMIUM="${CHROMIUM:-/snap/chromium/current/usr/lib/chromium-browser/chrome}"
USE_XVFB="${USE_XVFB:-1}"
XVFB_DISPLAY="${XVFB_DISPLAY:-:99}"
XVFB_GEOMETRY="${XVFB_GEOMETRY:-1920x1080x24}"

if [ ! -x "$CHROMIUM" ]; then echo "Chromium not found at $CHROMIUM"; exit 1; fi

mkdir -p "$PROFILE"

if [ "$USE_XVFB" = "1" ]; then
if ! command -v Xvfb >/dev/null; then
echo "Xvfb not installed. Run: sudo apt install xvfb"
echo "Or set USE_XVFB=0 to use your real display."
exit 1
fi
if ! xdpyinfo -display "$XVFB_DISPLAY" >/dev/null 2>&1; then
echo "Starting Xvfb on $XVFB_DISPLAY ($XVFB_GEOMETRY)"
Xvfb "$XVFB_DISPLAY" -screen 0 "$XVFB_GEOMETRY" -ac &
XVFB_PID=$!
for i in {1..50}; do
xdpyinfo -display "$XVFB_DISPLAY" >/dev/null 2>&1 && break
sleep 0.1
done
trap 'kill $XVFB_PID 2>/dev/null || true' EXIT
else
echo "Xvfb already running on $XVFB_DISPLAY, reusing"
fi
export DISPLAY="$XVFB_DISPLAY"
fi

echo "Launching Chromium (display: ${DISPLAY:-(real)})"
exec "$CHROMIUM" \
--remote-debugging-port="$CDP_PORT" \
--user-data-dir="$PROFILE" \
--no-first-run \
--no-default-browser-check
```

**Workflow for first-time login with Xvfb:**

```text
# Log in once on your real display:
USE_XVFB=0 npm run start-browser
# (a visible window opens — log into your sites, then ctrl+c)

# From then on, run hidden:
npm run start-browser
# (no window appears, but Chromium is running and fully logged in)
```

If you ever need to peek at what the hidden browser is doing, install x11vnc:

```text
sudo apt install x11vnc
x11vnc -display :99 -forever -nopw &
# Connect with any VNC client to localhost:5900
```

**Platform notes:**

1.  macOS: use --window-position=-2000,-2000 to push the window off-screen
2.  Windows: run inside WSL2 with Xvfb, or push the window off-screen
3.  Headless servers with no display: xvfb-run npm run start-browser and you're done

* * *

## Step 9 — Run It as a Service (Auto-Start at Boot)

For production, the browser needs to start at boot and restart automatically if it crashes. A systemd user service handles both without requiring root access.

Create the service file at ~/.config/systemd/user/playwright-shared-browser.service:

```text
[Unit]
Description=Shared Chromium for Playwright agents
After=graphical-session.target

[Service]
Type=simple
ExecStart=%h/playwright-shared/start-browser.sh
Restart=on-failure
RestartSec=5
Environment=USE_XVFB=1
Environment=XVFB_DISPLAY=:99
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=default.target
```

(%h is a systemd specifier that expands to your home directory.)

Enable and start it:

```text
systemctl --user daemon-reload
systemctl --user enable playwright-shared-browser
systemctl --user start playwright-shared-browser
systemctl --user status playwright-shared-browser
```

View logs:

```text
journalctl --user -u playwright-shared-browser -f
```

If you prefer to manage the browser from inside an existing Node.js service rather than a separate systemd unit, spawn it with child\_process.spawn() and wire up a child.on('exit') restart loop to handle crashes automatically.

* * *

## Connecting the Claude Code MCP Server (Optional)

If you use Claude Code, you can point the Playwright MCP server at the shared browser so Claude's interactive browser tools use the same logged-in session:

```text
claude mcp remove playwright -s user 2>/dev/null
claude mcp add -s user playwright -- npx -y @playwright/mcp@latest \
--browser chrome \
--cdp-endpoint http://localhost:9222
```

Verify it connected:

```text
claude mcp list | grep playwright
```

Restart Claude Code for the config to take effect. From that point on, any browser\_navigate or browser\_click call opens a tab in the shared browser with the same login — useful when Claude Code is one of the agents in your orchestration.

If you're building your own MCP servers around this stack — exposing the shared browser as a custom tool to a wider fleet — [AgentPMT's MCP Server Generation](https://www.agentpmt.com/services#mcp-server-generation) handles the scaffolding for you. You bring the capability; it produces the wired-up MCP server.

* * *

## Connecting OpenClaw Skills (Browser Relay)

The same shared Chromium acts as a browser relay for OpenClaw skills. Any OpenClaw skill that needs browser automation — clicking through an authenticated dashboard, posting content, scraping behind a login, taking a screenshot of a page only your account can see — can connect to the same CDP endpoint at localhost:9222 and open a tab in the shared session.

The OpenClaw browser relay pattern is straightforward when you're already running this setup:

1.  The shared Chromium is your single browser endpoint, exposing CDP on localhost:9222
2.  OpenClaw skills point their browser-automation calls at that endpoint
3.  Each skill gets its own tab via the default context — same logged-in cookies, same extensions, same browsing history
4.  No separate Chrome extension to install, no per-skill profile to maintain, no second login flow

This makes the shared browser useful for OpenClaw browser control workloads at the same time as your Playwright agents — both worlds end up sharing one identity. For OpenClaw users who only need stateless web access (a quick HTML fetch, a screenshot of a public page, no authenticated session), point at AgentPMT's [Live Web Page Browser](https://www.agentpmt.com/marketplace/live-web-page-browser) skill instead. Use the CDP-attach pattern only when the OpenClaw skill genuinely needs to act as you on a logged-in site.

For agents whose work crosses from browser automation into AI-generated content — drafting posts, writing replies, summarizing scraped pages — pair this stack with [AgentPMT's AI Writing Quality Check](https://www.agentpmt.com/marketplace/ai-writing-quality-check). It catches voice drift and brand-consistency issues in copy that AI agents produce, which matters once those agents start posting to your real social accounts through this shared browser.

* * *

## Troubleshooting

**"Could not connect to Chromium at localhost:9222"** The browser is not running, or it is on a different port.

```text
pgrep -af "chromium.*remote-debugging-port=9222"
curl http://localhost:9222/json/version
```

Start it with npm run start-browser or systemctl --user start playwright-shared-browser.

**Agents land on the login page instead of the feed** Your session expired or was invalidated. Stop the browser, run USE\_XVFB=0 npm run start-browser to get a visible window, log in again, then restart the service.

**Port 9222 is in use**

```text
sudo lsof -i :9222
```

Kill the occupying process, or change CDP\_PORT in your launcher.

**Xvfb "Server already active for display 99"** A previous Xvfb instance is still running, possibly stale.

```text
pgrep -af Xvfb
rm -f /tmp/.X99-lock
```

Or pick a different display: XVFB\_DISPLAY=:88 npm run start-browser.

**A site still flags the session despite this setup** At that point, the issue is most likely IP or account reputation, not the browser fingerprint. Are you running too many requests per second from one IP? Is the account brand new with no history? Datacenter IPs and VPN exits often get blanket-flagged regardless of what browser you use. Use a residential connection and an established account, and follow the rate-limit hygiene below.

**Tabs accumulate and are not cleaned up** Force-close the page explicitly:

```text
await page.close({ runBeforeUnload: false })
```

**High memory usage** Each Chromium tab is roughly 50–200 MB. Close tabs promptly after each agent finishes. Cap parallelism if you are running dozens of concurrent agents.

* * *

## Why This Looks Like Normal User Activity

This setup is not about evading anything. It is about doing browser automation in a way that does not introduce automation signals that get your own legitimate AI agent work false-flagged. Here is the mechanism.

Modern security stacks layer multiple signal categories:

1.  **JavaScript fingerprints** — navigator.webdriver, plugin count, Chrome API structure, canvas and WebGL fingerprints, screen resolution, timezone
2.  **Network fingerprints** — TLS JA3 hash (derived from the TLS ClientHello packet), HTTP/2 frame ordering, User-Agent consistency across headers and JavaScript
3.  **Behavioral signals** — mouse path curves, time between interactions, whether the page was scrolled before a click, focus and blur event timing
4.  **Account and IP signals** — account age, post history, IP reputation (datacenter vs. residential), concurrent session count

This setup is clean on the first two categories without any tweaking:

1.  navigator.webdriver stays undefined because Playwright only sets it during browser launch. connectOverCDP() attaches to a browser Playwright did not launch, so the flag is never set.
2.  \--enable-automation is absent because the launcher script never passes it.
3.  The TLS fingerprint matches the real Chromium binary installed on your system, not Playwright's bundled Chromium build (which has measurable differences).
4.  Cookies are real — set through actual browser login flows, not injected from a JSON file. Some security systems tag cookies differently based on how they were created.
5.  The process tree is normal: parent process is your shell or systemd, not Node.js. Some checks look at /proc/<pid>/status for a Node parent.

The architecture does not change anything about your behavior on the third and fourth categories. If your agents click instantly at machine speed with no mouse movement, that still looks automated. If you run 100 agents from a datacenter IP, that still looks suspicious. The hygiene section below is about staying on the right side of those signals.

The underlying insight: when Playwright calls launch(), it applies a long list of automation defaults internally. When it calls connectOverCDP() instead, none of those defaults apply — because Playwright did not launch the browser and has no authority to configure it. CDP is a debugging protocol. The browser does not expose any "I am being controlled" signal to the pages it loads via CDP.

* * *

## When You Don't Need This Pattern

CDP attach is the heaviest path. Use it only when you actually need a shared, persistent, logged-in browser. Three lighter options for the cases where you don't:

**Public web access with no login.** Your agents only need to read pages — fetch a public doc, read a product page, pull a news article. There's no account involved. Use [AgentPMT's Live Web Page Browser](https://www.agentpmt.com/marketplace/live-web-page-browser) — a marketplace tool that gives agents stateless web access without any of the setup in this tutorial.

**Sites that don't care about automation flags.** For internal tools, SaaS dashboards, admin panels, and most staging environments, Playwright's storageState is much simpler:

```text
// Bootstrap: log in once, save cookies
const browser = await chromium.launch({ headless: false })
const ctx = await browser.newContext()
// ... log in manually in the opened window ...
await ctx.storageState({ path: 'state.json' })

// Each agent: load the saved cookies
const browser = await chromium.launch()
const ctx = await browser.newContext({ storageState: 'state.json' })
```

No long-running browser process, no Xvfb, no service. Each agent gets full isolation with its own browser. Use this for friendlier sites and reach for the CDP pattern only when sites with stricter automation checks start flagging your sessions.

**One agent, not many.** If you're only running a single agent against a logged-in site, you don't need shared-session orchestration at all. Launch a persistent context with chromium.launchPersistentContext() and call it a day.

* * *

## Running 10+ Agents Without Getting Rate-Limited

Even when the browser fingerprint is clean, rate limits still apply at the IP level. A few patterns that help.

**Stagger launches with jitter** — do not throw 50 agents at Promise.all at once:

```text
async function runAgentsStaggered<T>(
agents: T[],
worker: (item: T, index: number) => Promise<void>,
options: { maxConcurrent?: number; minDelayMs?: number; maxDelayMs?: number } = {},
): Promise<void> {
const { maxConcurrent = 4, minDelayMs = 1000, maxDelayMs = 5000 } = options
let active = 0; let idx = 0
await new Promise<void>((resolve) => {
const tryNext = () => {
while (active < maxConcurrent && idx < agents.length) {
const i = idx++; active++
const delay = minDelayMs + Math.random() * (maxDelayMs - minDelayMs)
setTimeout(() => {
worker(agents[i], i)
.catch((err) => console.error(`agent ${i} failed:`, err))
.finally(() => {
active--
if (active === 0 && idx >= agents.length) resolve()
else tryNext()
})
}, delay)
}
}
tryNext()
})
}
```

**Add human-like delays inside each agent:**

```text
import { setTimeout as sleep } from 'node:timers/promises'

function humanDelay(min = 500, max = 2000) {
return sleep(min + Math.random() * (max - min))
}

await page.click('button.submit')
await humanDelay()
await page.fill('input.search', 'query')
```

**Cache shared reads.** If 10 agents all need the same feed data, fetch it once and share the result. One request, not ten.

**Keep one tab per agent session.** Open the tab at the start of an agent's work session and close it at the end. Opening and closing tabs per individual action is slower, wastes resources, and is less consistent with how a real user actually browses.

* * *

## What You End Up With

Six files plus a profile directory at ~/.playwright-shared-profile/. No databases, no external infrastructure, nothing beyond what ships on any standard Linux box. The browser carries real history, real cookies, and real extensions from actual use. Only the navigation is automated.

That is the whole shared-session multi-agent browser automation pattern. Many AI agents, one identity, no collisions, no false flags. It is the cleanest way I have found to scale Playwright automation past a single worker without reinventing session management or paying the resource cost of one browser per agent. The same setup doubles as a browser relay for OpenClaw skills and as the substrate for AI browsers that need shared logins — Playwright is just the most familiar way in. Full source is at [github.com/firef1ie/AI-Tutorials/tree/main/playwright-shared](https://github.com/firef1ie/AI-Tutorials/tree/main/playwright-shared). For more guides on building agent infrastructure, [view more tutorials](https://www.agentpmt.com/articles?tag=tutorials).