pluckor
v0.25.0
Published
Give an AI agent a real Chrome to drive, over MCP — browse, read, extract, and act on any page, getting through Cloudflare and sites that block headless/Playwright, with no automation fingerprint on reads.
Maintainers
Readme
pluckor
Give an AI agent a real browser. Extract from anything — even Cloudflare.
pluckor is an MCP server that hands an AI
agent (Claude Code, Claude Desktop, or any MCP host) a real Chrome to drive.
It launches Chrome for Testing with the Pluckor extension loaded, then exposes
tools to navigate, read rendered HTML, run JavaScript, click, type, scroll, and
wait — and returns the extracted data to the agent.
The page loads in a real browser on your machine — real profile, real residential
IP — and reads happen through a content script, so there's no CDP and no
navigator.webdriver. That gets through sites that block headless browsers and
Playwright, including Cloudflare-protected pages.
# Claude Code — one step:
/plugin marketplace add pluckor/pluckor-plugin
/plugin install pluckor@pluckorPluckor vs. Claude for Chrome
Short version: Claude for Chrome puts Claude in your browser; Pluckor gives your agent its own. They're complementary, not competitors.
- Whose browser — Claude for Chrome drives the everyday Chrome you already use (your profile, tabs, logins). Pluckor launches a separate, dedicated Chrome it owns, with its own persistent profile.
- What it is — Claude for Chrome is a Claude product feature (you pairing with Claude in your session). Pluckor is an open MCP server you wire into any agent — Claude Code, Cursor, Codex, or your own harness.
- Primary job — Claude for Chrome assists with general browsing. Pluckor is built for data extraction/automation, and for getting through anti-bot: reads use content-script/tab APIs with no CDP and no automation fingerprint, so it clears Cloudflare and sites that block headless/Playwright.
- Isolation & scale — Pluckor gives each agent its own tabs (lanes) and even
fully separate browsers (
PLUCKOR_INSTANCE), so fleets of agents don't collide. Claude for Chrome is your one browser. - Where it runs — Pluckor is self-hosted,
127.0.0.1-only; you own the browser and the data.
The soundbite: Claude for Chrome automates your browsing; Pluckor is a browser your agent can automate. Reach for Pluckor when an autonomous agent needs a clean, scraping-grade browser as a programmable tool.
Install
Pluckor has two parts: the MCP server (the browser tools, run via
npx -y pluckor mcp) and a skill (SKILL.md) — the workflow
patterns and gotchas (wait for async content, robust selectors, Cloudflare
handling, the persistent-login flow) that make an agent reliably good at
extraction. Set up both for your tool. Requires Node ≥ 20.11; the first tool
call downloads Chrome for Testing (~150 MB).
Claude Code — one step (recommended)
A plugin installs the server and the skill together:
/plugin marketplace add pluckor/pluckor-plugin
/plugin install pluckor@pluckorMCP-only alternative: claude mcp add pluckor -- npx -y pluckor mcp, then copy
SKILL.md to ~/.claude/skills/pluckor/SKILL.md.
Cursor
- MCP server — add to
~/.cursor/mcp.json(global) or.cursor/mcp.json(project):{ "mcpServers": { "pluckor": { "command": "npx", "args": ["-y", "pluckor", "mcp"] } } } - Skill — put
SKILL.mdat.cursor/skills/pluckor/SKILL.md.
Codex
- MCP server:
codex mcp add pluckor -- npx -y pluckor mcp - Skill — put
SKILL.mdat~/.codex/skills/pluckor/SKILL.md(personal) or.agents/skills/pluckor/SKILL.md(project).
Any other MCP host
{ "mcpServers": { "pluckor": { "command": "npx", "args": ["-y", "pluckor", "mcp"] } } }The SKILL.md ships in this package and in the
plugin repo, which carries it in
each tool's skill location. Cookies/logins persist across runs, so log in once
and every session inherits it.
The plk CLI — a warm, shared browser
Under the hood there are two pieces: a daemon that owns the browser, and the MCP server the agent connects to. The daemon means the browser stays warm between sessions, multiple agents can share it, and you can log into sites and watch what's happening.
plk start # launch the browser daemon in the background
plk start --tail # …and follow its logs live
plk status # is it running? reachable? which version? browser connected?
plk logs --tail # follow the daemon log
plk stop # close the browser and stop the daemon
plk restart # stop then start — recovers a stale or outdated daemon
plk mcp # the MCP server the agent spawns (auto-starts the daemon)
plk call <tool> [json-args] # invoke one tool, print its JSON result
plk pipe # a persistent NDJSON session on stdin/stdoutYou don't have to run plk start — plk mcp auto-starts the daemon on first use.
But starting it yourself lets you:
- Log in once. Run
plk start, log into the sites you care about in the window that opens; every agent session reuses those sessions. - Keep it warm. No relaunch between agent sessions.
- Share it. Multiple concurrent MCP sessions drive the same browser.
- Watch it.
plk logs --tailshows every navigation and tool call.
(pluckor and plk are the same CLI — pluckor mcp ≡ plk mcp.)
Driving it without MCP — plk call and plk pipe
MCP is for agents. When the caller is a program — a Python orchestrator, a
job runner, a shell script — call and pipe give it the same tools over the
same daemon and the same warm, logged-in browser. Both auto-start the daemon
exactly like plk mcp, and both resolve tools through the one registry the MCP
proxy uses, so a tool that exists for an agent exists here, with the same
schema, the same tab / timeoutMs params, and the same typed errors.
Channels. stdout is the machine channel and carries results only; every log, warning, and diagnostic goes to stderr.
plk call — one shot
plk call navigate '{"url":"https://example.com"}'
plk call extract '{"container":".product","fields":{"name":"h2","price":".price"}}'
plk call list_tabs// stdout — the result, and nothing else
{"url":"https://example.com","finalUrl":"https://example.com/","settled":true}Exit 0 on success. On failure, exit 1 and stdout carries a machine-readable
envelope (stderr gets the human line):
{"ok":false,"error":{"code":"SELECTOR_NOT_FOUND","message":"…","retryable":true}}Branch on error.code — NO_TAB, TIMEOUT, NAV_TIMEOUT, NOT_CONNECTED,
UNKNOWN_COMMAND, BAD_MESSAGE, … — never on the message text. An unknown
tool name lists the real ones in error.detail.knownTools.
Quoting on Windows. PowerShell: single-quote the JSON, as above.
cmd.exehas no single quotes — escape the inner ones:plk call navigate "{\"url\":\"https://example.com\"}". A caller that spawns the process with an argv array (Python'ssubprocesswith a list) has no quoting problem at all.
plk pipe — a persistent session
call pays for a daemon connection per invocation and gets a fresh lane (so a
fresh default tab) each time. pipe keeps one session open: one request per
line in, one response per line out, with the connection, its tabs, and their
logged-in state alive until stdin closes. This is the mode to build an
orchestrator on.
// stdin
{"id":1,"tool":"open_tab","args":{"url":"https://example.com/list"}}
{"id":2,"tool":"extract","args":{"tab":"t2","container":".row","fields":{"t":"h2"}}}
// stdout
{"id":1,"ok":true,"result":{"tab":"t2","finalUrl":"https://example.com/list"}}
{"id":2,"ok":true,"result":{"records":[]}}
// …or, per request:
{"id":2,"ok":false,"error":{"code":"NO_TAB","message":"…","retryable":true}}- Requests are not serialized. They are dispatched as they arrive; the
daemon handles each control call independently and the extension dispatches
per command. Ten tabs can navigate and extract at once through one session
(measured: 10 parallel
navigates finish in the time of one). - Responses come back in completion order — correlate by
id, never by position. - A malformed line costs that line, not the session. It gets one
{"id":null,"ok":false,"error":{"code":"BAD_MESSAGE",…}}and the session carries on. Blank lines are ignored. - In-flight calls are drained when stdin closes, so no response is lost.
- One session is one lane (see Multiple tabs) — every
request shares one default tab, so
open_tabfirst and pass thattabhandle on every call rather than relying on the default. - Managing the daemon stays on the CLI:
plk status,plk restart,plk logs --tail— those are commands, not tools.
Replaying a script, and resuming a halted one
The scripting tools are callable too — run_script is the reason most
orchestrators want a session at all. Replay a saved script; when it halts, patch
the offending step and resume from exactly where it stopped:
// stdin — replay
{"id":1,"tool":"run_script","args":{"script":{"version":1,"steps":[…]},"secrets":{"password":"…"}}}
// stdout — halted at step 2, with the failing contract and a page snapshot
{"id":1,"ok":true,"result":{"outcome":"halted","haltedAt":2,"failure":{…},"steps":[…]}}
// stdin — patch step 2, then resume from the fault
{"id":2,"tool":"run_script","args":{"script":{"version":1,"steps":["…patched…"]},"from":2}}
{"id":2,"ok":true,"result":{"outcome":"completed","startedAt":2,"data":[…]}}secretsfills${SECRET:name}in step params. Values reach the page and nothing else — they appear in no result channel, and neither mode logs request args.- No overall time budget. A script is many calls, so the per-call
timeoutMs + 15_000rule does not apply to it; each step carries its own, exactly as under MCP. A long replay is never cut short by the front end. - Safe resume. A mutating step at the
fromboundary is not re-fired — its state contracts are re-checked instead, so resuming can't double-submit. record_start/record_stopneedpipe, notcall: a draft accumulates across requests, so one-shot mode could only discard it and returns a typed error saying so. Recording also assumes sequential requests — don't fan out while one is open.
# the shape of an orchestrator
proc = subprocess.Popen(["plk", "pipe"], stdin=PIPE, stdout=PIPE, text=True, bufsize=1)
proc.stdin.write(json.dumps({"id": 1, "tool": "open_tab", "args": {"url": url}}) + "\n")
resp = json.loads(proc.stdout.readline()) # match resp["id"] against what you sentTools
| Tool | What it does | Returns |
|------|--------------|---------|
| navigate | Load a URL; waits for load and settles past Cloudflare-style interstitials | { url, finalUrl, settled, onChallenge } |
| get_html | Rendered HTML of the page (or a CSS selector) — content script, no CDP | { html, url, truncated } |
| get_markdown | Clean, token-efficient Markdown of the page's main content (auto-detects the article/main root) — content script, no CDP. ~10× smaller than the HTML | { markdown, url, title, chars, truncated, root } |
| run_js | Evaluate a JS expression; returns its value. captureConsole:true also returns the script's own logs + any thrown error | { value } (or { value, logs, error }) |
| wait_for_selector | Wait until a selector appears (optionally visible), or time out | { found, waitedMs } |
| snapshot | A ref-stamped map of the page's actionable elements (+ heading/landmark context) so you act by ref, not by guessing selectors. Content-script DOM walk, no CDP | { tree, count, url, title, truncated } |
| click | Trusted click (isTrusted = true) at an element's center — by CSS selector or a snapshot ref | { clicked } |
| type | Trusted text entry into a field (fires real input events) — by selector or ref | { typed, into } |
| scroll | Scroll the page (wheel default, or CDP gesture mode) | { scrolled, mode } |
| screenshot | Capture the page as an image — viewport (no CDP), scroll:true for a stitched long shot (no CDP), fullPage:true for a one-shot full page (CDP), or selector for an element (CDP). Pass filename (or save:true for a default name) to save it to disk instead of returning it inline | image + { width, height, format, bytes }, or { path, filename, … } when saved |
| extract | Structured data from a field→selector map — add container for one record per element (a listing). No code, no CDP | { record } or { records, count } |
| extract_links | Harvest deduped, absolute links (filter by pattern) for list→detail crawls. No CDP | { links, count } |
| capture_requests | Inspect the page's network — recent request metadata (no CDP), or record/stop a CDP session to capture response bodies (the JSON an API returned) | { requests, count } |
| wait_for_response | Block until a request matching pattern completes — a smarter wait for API-driven pages. No CDP | { matched, url, status, waitedMs } |
| wait_for_network_idle | Block until the network goes quiet (≤ threshold in-flight for idleMs) — the "data loaded" wait when no single request/selector fits. No CDP | { idle, inFlight, waitedMs } |
| wait_for_human | Hand off to a human for an interactive Cloudflare Turnstile, login, or 2FA — blocks until the challenge clears or an until selector appears. Focuses the window, notifies, and shows an on-page banner. No CDP | { resolved, reason, waitedMs, onChallenge } |
| capture_console | Read the page's own console output + uncaught errors, buffered since load. No CDP | { entries, count } |
| press_key | Press a key (trusted) — Enter to submit, Escape, Tab, arrows, or a character; optional selector/ref to focus first | { pressed } |
| select_option | Choose an option in a native <select> by value/label/index (fires change — React/Vue see it) — by selector or ref. No CDP | { selected, value, label } |
| hover | Move the mouse over an element to reveal menus/tooltips/hover content — by selector or ref | { hovered } |
| go_back / go_forward | Navigate history and wait for load. No CDP | { url, finalUrl, settled, onChallenge } |
| reload | Reload the tab (optional bypassCache) and wait for load. No CDP | { url, finalUrl, settled, onChallenge } |
| wait_for_function | Poll a JS expression until it's truthy, or time out | { satisfied, value, waitedMs } |
| get_cookies / set_cookie | Read/set cookies for the page (persist in the profile). No CDP | { cookies, count } / { set, name } |
| get_local_storage / set_local_storage | Read/write the page's localStorage. No CDP | { items, count } / { set, key } |
| download | Download a URL to disk (a "Download CSV/export" or media file) and return the path. No CDP. filename is relative to the download folder | { path, filename, mime, bytes } |
| save_pdf | Render the current page to a PDF and save it to disk (CDP). filename relative to the download folder | { path, filename, bytes } |
| open_tab | Open a new tab (optionally load a url) and get a handle to drive it. No CDP | { tab, url, finalUrl, settled, onChallenge, isLaneDefault } |
| list_tabs | List your tabs (handle, url, title, active, which is the default). No CDP | { tabs, count } |
| close_tab | Close one of your tabs by handle. No CDP | { closed, tab } |
| run_script | Replay a workflow: an ordered list of tool-call steps, each optionally guarded by pre / verified by an expect effect contract. from resumes from a step. No CDP beyond what each step's own tool uses | { ok, outcome, data, assertions, steps, failure? } |
| record_start / record_stop | Record your tool calls into a replayable draft script, with candidate assertions inferred from what actually changed | { recording } / { script } |
Reads (navigate, get_html, get_markdown, wait_for_selector, snapshot, extract, extract_links,
wait_for_response, wait_for_network_idle, wait_for_human, capture_console, select_option, go_back, go_forward,
reload, get_cookies, set_cookie, get_local_storage, set_local_storage,
download, capture_requests metadata, and screenshot's default
viewport/scroll modes) never touch CDP. The interaction tools (run_js,
click, type, press_key, hover, wait_for_function, save_pdf, scroll
gesture, screenshot's fullPage/selector modes, and capture_requests's
body-recording session) attach chrome.debugger only while they run — which
shows Chrome's "started debugging this browser" infobar during that call.
run_script's assertions are CDP-free too — they read the page with
chrome.scripting and the passive network buffer — so guarding a scripted
workflow adds no automation fingerprint of its own. A step only uses CDP if the
step's own tool does.
Every tool also takes an optional timeoutMs to override its default time
budget — raise it for a slow navigate or a long run_js, or lower it to fail
fast.
Scripting — replay a workflow, and know when it breaks
Once a workflow is known, an agent shouldn't re-derive it every time. run_script
replays an ordered list of tool-call steps in one call — no model turn per
step — and each step can carry an effect contract that says what it was
supposed to cause:
{ "version": 1, "steps": [
{ "tool": "navigate", "params": { "url": "https://example.com/login" } },
{ "tool": "type", "params": { "selector": "#user", "text": "${SECRET:user}" } },
{ "tool": "type", "params": { "selector": "#pass", "text": "${SECRET:pass}" } },
{ "tool": "click", "params": { "selector": "button[type=submit]" },
"expect": [
{ "assert": "network", "method": "POST", "url": "**/login", "status": 302 },
{ "assert": "url", "pathEquals": "/" },
{ "assert": "selector", "selector": "a[href='/logout']", "state": "present" }
] },
{ "tool": "extract", "params": { "container": ".row", "fields": { "title": "h3" } },
"expect": [ { "assert": "count", "selector": ".row", "min": 1 } ] }
] }Assertions work at five layers — network (method + URL glob + status),
url, selector, text, count — each with onFail: halt (default), warn,
or retry. Prefer network where a step hits a server: it's the contract
between client and server, where the state change actually happens, so it catches
the failures a DOM check can't — a page that renders "Success!" over a 402.
The result has two channels, joined but never blended: your data in data,
the verdicts in assertions, plus a per-step trace.
When reality diverges, it hands back the yoke. A failed halt contract stops
the run and returns a failure payload — the failing contract plus a page
snapshot at the point of divergence — so the agent can fix the step and call
run_script { from: <haltedAt> } to resume from the fault, not the top. A
mutating step at the resume boundary is never silently re-fired, so you don't
double-submit.
You don't have to write scripts by hand. record_start, do the workflow
normally, record_stop — you get a draft with candidate assertions inferred
from what actually changed (the real status a request returned, the URL it
landed on, how many rows came back), and password fields redacted to
${SECRET:…}. Curate and save it.
Scripts are deliberately linear — no loops, no branching, no data flowing between steps. A script does the deterministic part; the moment something needs a decision, that's the agent's job again.
Credentials go in secrets at call time and are never stored in the script.
Multiple tabs
The daemon owns one shared browser, but tabs are isolated per connection: each
plk mcp process is a lane with its own default tab. Separate agents that
each run their own plk mcp share the one warm, logged-in browser without
colliding — no setup, existing single-tab code just works.
Agents: work only in tabs you opened. Subagents typically inherit their parent session's MCP connection — so they share a lane and its default tab, and will navigate it out from under each other. The collision is silent: you get the other agent's page back under your own request, with no error. Pluckor can't detect this (one connection carries no per-caller identity), so the discipline is the control:
open_tabbefore your first navigation, and pass that handle on every call.- Keep using it — one tab for the session, not one per step.
- Never drive a tab you didn't open — not another agent's, and not one the human has open.
list_tabsshowing a tab is not permission to use it.- Open as many as you need, and
close_tabeach one you opened.- On any
NO_TAByour handle is dead (tab t2 was closed …if someone closed it,unknown tab handle: t2if you closed it or it was never yours) — open a new tab, never fall back to whatever tab happens to be there.
open_tab returns a tab handle, and every tool accepts an optional tab
to target it (omit it for your lane's default tab).
open_tab { url: "https://example.com/list" } → { tab: "t2" }
extract { container: ".row", fields: {…}, tab: "t2" }
navigate { url: "https://example.com/item/1", tab: "t3" }
list_tabs → your tabs
close_tab { tab: "t2" }Handles and default tabs are lane-scoped: a connection only sees and drives its own tabs.
Multiple browsers (separate instances)
When agents are doing completely unrelated work and shouldn't share a session
at all, run separate instances — each its own browser, profile, logins,
daemon, and ports. Set PLUCKOR_INSTANCE (a name or number):
// two MCP servers → two independent browsers
{
"mcpServers": {
"pluckor": { "command": "pluckor", "args": ["mcp"] },
"pluckor-work": { "command": "pluckor", "args": ["mcp"],
"env": { "PLUCKOR_INSTANCE": "work" } }
}
}PLUCKOR_INSTANCE=work gives that daemon its own home
(~/.pluckor-bridge-work), profile, ports, and auth token — derived
deterministically, so plk/plk mcp agree with no discovery. Unset = the
default instance, unchanged.
- Ports: default
9234/9235; integerN→+2N; a name → a stable hash. Override withPLUCKOR_WS_PORT/PLUCKOR_CONTROL_PORT. - The Chrome download cache is shared (no re-download); profiles/logins are
per-instance.
plk status/plk stoptarget the instance in the env.
Rule of thumb: one browser, many lanes/tabs (above) for related work that can share logins; separate instances for unrelated work that shouldn't.
Running in a container / headless Linux
Pluckor runs a real, headful Chrome — never --headless (headless has a
detectable fingerprint, which defeats the point). On a server or container with
no physical display, you keep Chrome headful and give it a virtual display
(Xvfb):
- Provide a display:
Xvfb :99 & export DISPLAY=:99(or wrap the command). - Set
PLUCKOR_CHROME_FLAGS="--no-sandbox --disable-dev-shm-usage"— containers need--no-sandbox(no user namespaces as root) and--disable-dev-shm-usage(tiny/dev/shm). - Build for
linux/amd64— on Linux, Chrome for Testing is amd64-only (there's no arm64 build), so on Apple Silicon / arm hosts build/run the image with--platform linux/amd64. - Install Chrome's shared libraries (
libnss3,libgbm1,libatk-bridge2.0, fonts, …) andunzip.
A ready-to-adapt Dockerfile and entrypoint live in
examples/docker/. This exact setup is verified end-to-end
in CI-style testing (headful Chrome under Xvfb drives a page inside the
container).
Driving it from outside the container
By default the daemon+browser and the plk mcp proxy must share one loopback
(the control channel is 127.0.0.1-only). To keep the browser+daemon in the
container and drive it from an agent on the host (or another machine),
expose the control port with a token:
- In the container:
PLUCKOR_CONTROL_BIND=0.0.0.0andPLUCKOR_CONTROL_TOKEN=…(the daemon refuses to start exposed without a token), and publish the port —docker run -p 127.0.0.1:9235:9235 …. Start the daemon inside (plk start). - On the host, point the proxy at it:
PLUCKOR_CONTROL_HOST=127.0.0.1(the published port), the samePLUCKOR_CONTROL_TOKEN, andPLUCKOR_ATTACH=1(don't spawn a local daemon). For a daemon on another machine, setPLUCKOR_CONTROL_HOSTto its host/IP (and put the port on a trusted network or behind a tunnel — the token is the gate).
The agent always drives the page from outside it (tool calls, never code in the page). This just lets the whole Pluckor unit live in a container while the agent runs elsewhere.
Two extra management tools let an agent recover on its own instead of getting stuck:
| Tool | What it does | Returns |
|------|--------------|---------|
| status | Daemon health: proxy vs daemon version, whether the daemon is outdated, browser connected, control reachable | { proxyVersion, controlReachable, daemon, outdated } |
| restart | Stop the daemon — even a stale-pid or outdated one — start a fresh one, and reconnect | { restarted, daemon } |
If a browser tool fails with NO_BROWSER / NOT_CONNECTED / CONNECTION_LOST /
a timeout, the error tells the agent to call restart and retry. A dropped
control socket reconnects on its own; restart is for a daemon that is stale,
wedged, or an older version than the one you just installed. (Because the
browser is shared, restart bounces it for every session.)
How it works
agent ── MCP/stdio ──▶ plk mcp (proxy) ─┐
program ── NDJSON/argv ─▶ plk call | pipe ─┤ WebSocket (control)
▼
plk daemon ──▶ Chrome for Testing + Pluckor extension
│ │
│ WebSocket │ content script (reads, no CDP)
└────────────────────▶ │ chrome.debugger (run_js/click/type)
▼
the live pageThe caller invokes a tool → its front end (the MCP proxy, or plk call/plk
pipe) forwards it to the daemon → the daemon drives the extension → the
extension acts on the page and sends the result back the same way. The two front
ends share one tool registry and one validation path, so they can't drift.
Everything binds to 127.0.0.1 only; the browser is never exposed off-box.
Neither front end queues: the daemon handles each control call independently and
the extension dispatches per command, so many tabs can be working at once —
whether that's several agents on their own plk mcp, or one plk pipe session
driving ten tabs in parallel.
Example: scrape a Cloudflare-protected listing
navigate { "url": "https://www.g2.com/categories/mortgage-crm" }
wait_for_selector { "selector": ".product-card" }
run_js { "expression": "Array.from(document.querySelectorAll('.product-card')).map(c => c.querySelector('a[href*=\"/products/\"]')?.textContent.trim())" }→ the agent gets back a JSON array of product names. (The repo's
examples/g2-extract.ts shows a full list → detail-page pipeline pulling
ratings + review counts across multiple pages in one browser session.)
Configuration
| Env var | Default | Purpose |
|---------|---------|---------|
| PLUCKOR_HOME | ~/.pluckor-bridge | Persistent Chrome profile + Chrome for Testing download |
| PLUCKOR_EXTENSION_DIST | (bundled) | Override the extension directory (advanced) |
| PLUCKOR_NO_FEEDBACK | (unset) | Set to 1 to remove the optional agent-feedback request (below) |
The extension ships inside this package (it's not a separate install), and the persistent profile keeps cookies/logins across runs.
Agent feedback (optional, on by default)
Pluckor is driven by agents, so the ones best placed to notice that a tool is buggy, confusingly named, or missing something are the agents using it — and they have no way to tell us. At startup the MCP server therefore invites the agent to open an issue at github.com/pluckor/pluckor-feedbacks when it hits a bug, an awkward edge, a docs gap, or wishes a capability existed.
It's framed as a request, never a requirement, and explicitly ranked below the
user's actual task; it also tells the agent not to put credentials, personal data,
or scraped page content into an issue. Set PLUCKOR_NO_FEEDBACK=1 and the
request is omitted entirely — it's sent as MCP server instructions, so the
opt-out is real rather than advisory.
Notes & limits
- First run downloads Chrome for Testing; later runs reuse the cache.
- The browser is real and visible — a window opens. Reads leave no automation
fingerprint; the
chrome.debuggerescalation is only for JS eval and trusted input. - One browser per machine. The daemon runs a single browser on a fixed local port; concurrent agent sessions share it (they don't each launch their own).
- Interactive Cloudflare Turnstile (the checkbox) is a known boundary — a CDP-dispatched click can be detected, so treat it as needing a human.
License
MIT
