If you're using Claude Code for browser automation, Playwright CLI + Skills is the only way to go

📌 Updated/Completely revised on 2026-05-05
After publishing, when I tried to incorporate this procedure into reusable Claude Code Skills, it turned out that with Chrome 147 and later, the attach process from the old article doesn't work at all as it is. After struggling for half a day, I switched to the "dedicated user-data-dir + Cookie copy method" and it worked instantly, so I have completely rewritten it for the production version.
In addition, I have added the pitfalls I encountered after publishing this article:
Freezing issue with the Chrome crash recovery bubble
Freezing issue with the `beforeunload` dialog during reload
The `--instance` feature for running multiple tasks in parallel
The old procedure was deleted because it doesn't work in the current version of Chrome and causes confusion. I have only kept the parts about "why I switched from MCP" and the "benefits".
1. Graduating from Playwright MCP
Hello everyone. I usually write various automations with Claude Code, but recently I graduated from Playwright MCP and switched to Playwright CLI + official Skills.
To conclude, token consumption is reduced, you can inherit the login state of your daily-use Chrome, and the ease of writing Skills is on a different level, so I can't go back to MCP anymore.
What was difficult with Playwright MCP
Playwright MCP is the type that registers `@playwright/mcp` as a Claude Code MCP server. It is a mechanism where dedicated tools like `browser_navigate` / `browser_click` / `browser_snapshot` can be used by the agent.
It's not bad, but there are a few points that bothered me during operation:
It consumes context with tool schemas. Thousands of tokens are taken from the beginning of the conversation
Connecting to daily-use Chrome is troublesome. Although it connects with the `--extension` mode + dedicated extension combination, the connection dies if you accidentally blow away the bridge tab
Need to re-enter login information every time. There is a risk of Bot detection, and it's simply a hassle
What is Playwright CLI + Skills
There is a separate official Playwright CLI package, and you can operate it by connecting to the Chrome you launched yourself with `attach --cdp=...`. You can use your daily-use Cookie / LocalStorage as is.
In addition, the CLI has a command called `playwright-cli install --skills` that automatically deploys agent-optimized Skills, and a set of `~/.claude/skills/playwright-cli/SKILL.md` and references/ are expanded. Claude Code loads Skills only when necessary, so resident tool schemas like MCP are not needed — this is the secret behind the "difference in token efficiency".
2. Four problems solved by the `/chrome` Skills
Now for the main topic. What this article ultimately discusses is a custom skill called `/chrome`, which single-handedly eliminates all four of the following problems:
The problem of not being able to simply attach to Chrome 147+ — `--remote-debugging-port` is completely ignored in the default user-data-dir
The freezing problem with the crash recovery bubble — If you kill and restart Chrome, the "Restore session?" prompt appears and freezes it
The freezing problem with the `beforeunload` dialog during `reload` — If you reload while in a dirty state like in notes or the GAS editor, it stops at the confirmation dialog
The problem where a single Chrome instance cannot run separate tasks in parallel — I want to operate Studio and notes simultaneously with different agents
The diagram at the beginning shows the overall picture of this skill. In the center is the "dedicated Chrome," with cookies copied from your daily-use Chrome on the left, and Claude Code performing a CDP attach from the right. Keywords for crash bubbles and dialog suppression are scattered in small text. The bottom half is a diagram of parallel startup (`--instance=studio` / `--instance=note`).
3. Production-ready code
Contents of `~/.claude/skills/chrome/setup-and-attach.sh` (key points only):
#!/usr/bin/env bash
set -euo pipefail
INSTANCE="default"
REFRESH_COOKIES=false
for arg in "$@"; do
case "$arg" in
--refresh) REFRESH_COOKIES=true ;;
--instance=*) INSTANCE="${arg#--instance=}" ;;
esac
done
# instance 名から決定的に値を決める
if [ "$INSTANCE" = "default" ]; then
SESSION_NAME="chrome"
PROFILE_DIR="$HOME/.playwright-chrome-profile"
PORT=9222
else
SESSION_NAME="chrome-${INSTANCE}"
PROFILE_DIR="$HOME/.playwright-chrome-profile-${INSTANCE}"
HASH=$(printf '%s' "$INSTANCE" | cksum | awk '{print $1}')
PORT=$((9223 + (HASH % 8)))
fi
SRC_PROFILE="$HOME/Library/Application Support/Google/Chrome/Profile 2"
# Step 0. 既に attach 済みなら何もしない
if [ "$REFRESH_COOKIES" = false ] && \
playwright-cli list 2>&1 | grep -A 3 "^- ${SESSION_NAME}:" | grep -q "(attached)"; then
exit 0
fi
# Step 1. 当該 instance の Chrome を SIGTERM 先行で正常終了 → 必要なら SIGKILL
if pgrep -af "Google Chrome.*--user-data-dir=${PROFILE_DIR}" > /dev/null; then
pkill -TERM -f "Google Chrome.*--user-data-dir=${PROFILE_DIR}" 2>/dev/null || true
for i in 1 2 3 4 5; do
sleep 1
pgrep -af "Google Chrome.*--user-data-dir=${PROFILE_DIR}" > /dev/null || break
done
pkill -9 -f "Google Chrome.*--user-data-dir=${PROFILE_DIR}" 2>/dev/null || true
fi
# Step 2. 初回 or --refresh 時のみ: 普段使い Profile から Cookie 等をコピー
if [ ! -d "$PROFILE_DIR/Default" ] || [ "$REFRESH_COOKIES" = true ]; then
mkdir -p "$PROFILE_DIR/Default"
for f in "Cookies" "Cookies-journal" "Local Storage" "Login Data" "Login Data-journal" \
"Web Data" "Web Data-journal" "Preferences" "Secure Preferences" "Sessions" \
"Bookmarks" "Local Extension Settings"; do
[ -e "$SRC_PROFILE/$f" ] && cp -R "$SRC_PROFILE/$f" "$PROFILE_DIR/Default/" 2>/dev/null
done
fi
# Step 2.5. Preferences のクラッシュフラグを Normal に書き換え
PREF="$PROFILE_DIR/Default/Preferences"
if [ -f "$PREF" ]; then
TMP=$(mktemp)
jq '.profile.exit_type = "Normal"
| .profile.exited_cleanly = true
| .session.restore_on_startup = 5
| .session.startup_urls = []' "$PREF" > "$TMP" && mv "$TMP" "$PREF"
fi
# Step 3. Chrome を起動(バブル抑止フラグ群を付与)
rm -f "$PROFILE_DIR/SingletonLock" "$PROFILE_DIR/SingletonCookie" \
"$PROFILE_DIR/SingletonSocket" "$PROFILE_DIR/DevToolsActivePort"
nohup "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
--user-data-dir="$PROFILE_DIR" \
--remote-debugging-port="$PORT" \
--remote-allow-origins='*' \
--no-first-run \
--no-default-browser-check \
--hide-crash-restore-bubble \
--disable-session-crashed-bubble \
--restore-last-session=false \
> "/tmp/chrome-${SESSION_NAME}.log" 2>&1 &
disown
# Step 4. port が listen するまで待つ
for i in $(seq 1 15); do
curl -sS -m 1 "http://127.0.0.1:${PORT}/json/version" > /dev/null 2>&1 && break
sleep 1
done
# Step 5. playwright-cli attach
playwright-cli -s="${SESSION_NAME}" attach --cdp="http://127.0.0.1:${PORT}"
# Step 6. JS dialog の auto-accept ハンドラを context レベルで仕込む
playwright-cli -s="${SESSION_NAME}" run-code "async page => {
const ctx = page.context();
const accept = (d) => d.accept().catch(() => {});
ctx.on('page', (p) => p.on('dialog', accept));
for (const p of ctx.pages()) p.on('dialog', accept);
}" > /dev/null 2>&1 || trueI will explain the mechanism of each step of what is happening one by one in §4 below.
4. Detailed explanation of the mechanism
4-1. Chrome 147+ constraints and user-data-dir / Cookie copy strategy
Since Chrome 147, the `--remote-debugging-port` flag has been completely ignored in the default user-data-dir. The following appears in Chrome's stderr:
DevTools remote debugging requires a non-default data directory.
Specify this using --user-data-dir.Even if you turn ON "Allow remote debugging for this browser instance" in `chrome://inspect/#remote-debugging`, this constraint is not lifted. You must create a dedicated user-data-dir in a separate directory (this was not written in the old article).
However, when starting with a dedicated dir, the login state is empty, so I copy Cookies / LocalStorage / Login Data, etc., from my daily-use Chrome Profile (in my case `~/Library/Application Support/Google/Chrome/Profile 2`) only the first time. This allows the Playwright Chrome side to start immediately while already logged into YouTube / Google / note, etc.
Cookie copying is only done once at the beginning. If you copy it every time, the state that increased on the Playwright side (history, additional cookies) will be erased, so it is designed to be updated only when explicitly re-copied with the `--refresh` flag.
4-2. Eradication of the crash recovery bubble, the reason for the three-tiered approach
In the old version of the skill, I was force-quitting Chrome with `killall -9 "Google Chrome"`. Because of this, Chrome would judge it as an "abnormal termination," and the next time it started, the "Restore session?" bubble would appear and freeze (I wasted 30 minutes on this).
Empirically, you cannot eliminate it unless you suppress both the termination side and the startup side. Three-tiered approach:
Termination side: Give Chrome a chance (5 seconds) to terminate normally with SIGTERM. Since the write-out to the `Last Session` file is completed inside Chrome, it is judged as "finished properly" at startup
Recording side: Just in case, rewrite `Preferences` so that `profile.exit_type` is `"Normal"` and `profile.exited_cleanly` is `true`. This is where Chrome determines if it "exited properly last time".
Display side: To ensure no bubbles appear on the screen even if 1 or 2 are bypassed, suppress the display itself using the launch arguments `--hide-crash-restore-bubble` `--disable-session-crashed-bubble` `--restore-last-session=false`.
Using only `--hide-crash-restore-bubble` leaves the internal state as a crash, which triggers an automatic `Last Session` restore and opens strange tabs. Therefore, the three-pronged approach of "not letting the internal state be treated as a crash" is cleaner.
4-3. Automatic handling of JS dialogs — Layer structure of playwright-cli and SDK
When automating sites that use `beforeunload`, such as note editors or GAS editors, running `playwright-cli reload` causes a freeze due to the "Changes have not been saved" dialog. This is a JS dialog similar to `alert()` / `confirm()`, a mechanism where the browser stops until someone accepts or dismisses it.
It is easier to understand if you look at the layer structure
When you execute `playwright-cli goto URL`, internally it is just calling the SDK's `page.goto(URL)`.

In the Playwright SDK, you can handle it with `page.on('dialog')`
With the Playwright SDK, if you register a handler in advance, you can handle it as an event even if a dialog appears:
page.on('dialog', d => d.accept());
// 以降、ダイアログが出てもイベントハンドラで即 accept されるReference: Playwright Page.on('dialog') / Dialog class
`playwright-cli` is designed to fail safe and stop
Since `playwright-cli` is a wrapper intended for manual use by humans, when a dialog appears, it sets a unique flag called modal state and rejects all subsequent `evaluate` / `click` / `snapshot` commands, stating "cannot move while in modal." Since it would be an accident to proceed without reading the contents of the `alert()`, it is structured so that a human must explicitly process it by typing `dialog-accept` / `dialog-dismiss` for safety.
Trap: Pre-registration does not work with `playwright-cli reload` alone
`page.on('dialog')` is an event handler at the SDK layer. On the other hand, `playwright-cli reload` is designed to run the modal state detection in the wrapper before the SDK event fires. As a result, when you run reload:
`playwright-cli reload` starts
Chrome fires `beforeunload` → dialog displayed
Wrapper detects modal state → sets "in modal" flag
All subsequent commands are rejected → freeze
Solution: Go directly down to the SDK layer with `run-code`
`playwright-cli` has an official command called `run-code`, which serves as an escape hatch for executing arbitrary SDK code as a one-liner.
playwright-cli -s=chrome run-code "async page => {
page.on('dialog', d => d.accept());
await page.reload({ waitUntil: 'domcontentloaded' });
}"The key is to encapsulate the dialog handler registration and reload within a single run-code. Since it touches the SDK's `page` object directly without going through a wrapper, no modal state is set, the handler fires first to accept the dialog, and the reload completes successfully.
If you set this up at the context level on the Skills side, you can automate 99% of it.
What is done in Step 6 of `setup-and-attach.sh` is dialog handler registration at the context level:
const ctx = page.context();
const accept = (d) => d.accept().catch(() => {});
ctx.on('page', (p) => p.on('dialog', accept)); // 将来開く page にもハンドラ自動付与
for (const p of ctx.pages()) p.on('dialog', accept); // 既存 page にもハンドラ付与With this, alerts/confirms triggered by `goto` / `click` / `fill` etc. are processed completely automatically. The only thing left is the `reload` exception, and if you adopt the practice of writing it via `run-code`, everything can be automated.
Summary Table

`run-code` is also a universal escape hatch when you need SDK APIs not provided by the wrapper, such as `page.context().addInitScript(...)` (scripts that are always executed in a new page) or `page.waitForResponse(...)` (waiting for a specific API response).
4-4. Parallel startup (`--instance`) — Separate tasks in separate Chrome instances simultaneously
This is a further application, but if you call the `/chrome` Skills with the `--instance=<name>` option, you can launch a completely independent, separate Chrome process.
~/.claude/skills/chrome/setup-and-attach.sh --instance=studio
# → session=chrome-studio, port=9223〜9230 (hash 採番), dir=~/.playwright-chrome-profile-studio
~/.claude/skills/chrome/setup-and-attach.sh --instance=note
# → session=chrome-note, port=別、dir=~/.playwright-chrome-profile-note
playwright-cli -s=chrome-studio goto "https://studio.youtube.com/..."
playwright-cli -s=chrome-note goto "https://note.com/..."I use this in Claude Code by launching two sub-agents in parallel, where one operates YouTube Studio and the other operates the note editing screen simultaneously. If you use two tabs in the same Chrome, the playwright-cli commands are serialized and wait, but with separate instances, they are completely parallel.
Points to note:
Ports are assigned deterministically by the hash of the instance name (they won't collide). There are 8 slots from 9223 to 9230, so for use cases with an extremely large number of instances, you need to solve collisions separately.
The user-data-dir is also separate for each instance. Since cookies are copied individually from your daily-use profile, the state increased by playwright is isolated.
Calls without arguments (existing single-instance operation) work with the default = `session=chrome / port=9222 / dir=~/.playwright-chrome-profile`. Backward compatibility is maintained.
5. Application Example — A structure where Skills stack up
Once you have `/chrome` Skills (connection layer), you can layer site-specific automation Skills on top of it.
An example I am running: a Skill called `/youtube-link-shorts` that goes through the Shorts list in YouTube Studio and "bulk sets the corresponding horizontal video as a related video." This endpoint does not exist in the YouTube Data API (I confirmed this by querying the official documentation in context7). Therefore, there is no other way than to hit the Studio Web UI with Playwright.
The design is clean because `/chrome` (connection) and `/youtube-link-shorts` (operation) are separated, and the connection Skills can be reused for other purposes. Other Studio tasks (getting analytics, replacing thumbnails, bulk editing descriptions) can also be added without re-attaching the same session. A structure is created where you stack site-specific Skills on top of `/chrome` in the same way, like `/note-update` or `/gas-new`.
If I write down the points where you are likely to get stuck when operating Studio:
Do not navigate directly to the edit page URL. When I ran `goto /video/$ID/edit` 30 times in a row, I hit a bug where the related video values on the Studio side bled into other videos. The correct approach is to calmly repeat the process within a single tab: click the list thumbnail → navigate to edit → `go-back` to return to the list.
The Studio list uses pagination. I thought all items would appear by scrolling, but it was actually 30 items per page pagination.
6. Migration guide for existing MCP-based Skills
If your custom Skills are written assuming `browser_navigate` type MCP tools, replace them with `playwright-cli` commands.
browser_navigate(url) → `playwright-cli -s=chrome goto <url>`
browser_snapshot → `playwright-cli -s=chrome snapshot --filename=foo.yml`
browser_click(ref) → `playwright-cli -s=chrome click <ref>`
browser_fill(ref, val) → `playwright-cli -s=chrome fill <ref> "<val>"`
browser_press_key(key) → `playwright-cli -s=chrome press <key>`
browser_evaluate(js) → `playwright-cli -s=chrome eval "<js>"`
By adding `-s=chrome`, you can use multiple Chrome instances by session name. If you go through the `/chrome` Skills, it is simpler to just operate with a fixed `chrome` session.
Additionally, you can remove the reading of credentials.json for automatic login. If you assume that the cookies from your daily-use Chrome are already loaded, you don't need to handle passwords from the script. This is better for both security and peace of mind.
7. Benefits I felt after actually trying it
After switching, I've been running draft posts on note.com and fetching YouTube Studio analytics via Claude, and the experience is completely different:
Fast — No clean browser startup; if it's already attached, operations start immediately.
Quiet — You can see your daily Chrome tabs as they are.
Fewer accidents — Claude uses the state where you are already logged in, so there's no worry about it stopping due to authentication errors.
Clean log output — Since it's just CLI standard output, it's easy to track what was done later.
8. Points to note
Broad permission scope — The attached Chrome can be completely controlled by Claude. This includes saved data, cookies, and navigation to arbitrary URLs. Use only for trusted tasks.
Avoid having tabs open with sensitive information — Close tabs for banks or password management tools.
Simply turning on 'Allow remote debugging' is not enough — This is a setting for Chrome DevTools MCP and does not work for external CLI attachment. Launch arguments '--remote-debugging-port' + a dedicated user-data-dir are mandatory.
Cookie copying should generally be done only the first time — If you copy every time, cookies and sessions added in the Playwright-side Chrome will be overwritten and lost.
9. Summary
If you are using Playwright from Claude, the current default choice is CLI + Skills. While I think there are still use cases where MCP makes more sense, for daily automation, the CLI side is the clear winner.
However, for Chrome 147+, 'dedicated user-data-dir + cookie copy from daily profile + --remote-debugging-port + --remote-allow-origins='*'' is required.
Once you turn it into a Skill, you can enter the attached state without any clicks every time.
To suppress crash bubbles, use a three-pronged approach: 'SIGTERM first + rewrite Preferences + launch arguments'.
For JS dialogs, set up `page.on('dialog')` at the context level + only run `reload` via `run-code`
If parallel startup is required, launch separate Chrome instances independently using `--instance=<name>`.
The reduction in token consumption is also quietly pleasing; for someone like me who leaves Claude Code running from morning to night, this baseline reduction is effective.
For those who are also exhausted by Playwright MCP, please try switching over.
10. Tech Stack & Reference Links
Tech Stack
@playwright/cli v0.1.8
Claude Code (CLI version)
Chrome 147+ (remote debugging restrictions apply; the workarounds in this article are mandatory)
Reference links
Chrome DevTools Protocol — communication protocol between Playwright and Chrome
いいなと思ったら応援しよう!
さらなるスキルアップへの投資、もしくはスタバのおしゃれフリーランスへの変身に活用させて頂きます。