docker-stealthy-auto-browse: The Browser That Doesn’t Know It’s Being Automated

I’ve been automating browsers for years. Selenium, Puppeteer, Playwright — used them all, watched them all get caught. The arms race between bot detection and browser automation has been going on since the dawn of web scraping, and guess who’s been losing? Every single Chromium-based automation tool on the fucking planet.
The problem isn’t the tools themselves. Playwright is great software. Puppeteer works fine. The problem is Chrome DevTools Protocol — the mechanism they all use to talk to the browser. CDP is how your automation framework says “click this button” or “type in this field.” It’s also how Cloudflare, DataDome, PerimeterX, and every other bot detection service on Earth knows you’re not a real human. You can install stealth plugins, patch navigator.webdriver, spoof fingerprints until your eyes bleed — CDP is still there, and they will find it.
So I built docker-stealthy-auto-browse. Let me be clear — I didn’t invent any of this stealth shit. The heavy lifting is done by Camoufox, Playwright, PyAutoGUI, and browserforge. These are brilliant projects built by people smarter than me. What I did is take all of this shit, wire it together inside a Docker container, and slap an HTTP API on top so you can control the whole thing remotely with curl commands. One container, one endpoint, zero bullshit setup.

The Fundamental Problem With Every Other Approach

Here’s what every Chromium-based automation tool does: it opens Chrome, connects to it via CDP, and sends commands through this protocol. The browser knows. JavaScript running on the page knows. The bot detection service’s script that loaded before your page content? It definitely knows.
You can try to hide it:

  • Patch navigator.webdriver to return false — detectors check if it was patched
  • Install stealth plugins — detectors check for the side effects of those plugins
  • Spoof fingerprints — detectors compare your main context fingerprint against web worker fingerprints and find inconsistencies
  • Use headless mode — detectors check for headless signals

It’s a cat-and-mouse game where the cat has every advantage. CDP leaves traces everywhere — in the JavaScript runtime, in the way events are dispatched, in timing patterns, in the browser’s internal state. You’re trying to pretend a puppet isn’t a puppet while the strings are clearly visible.

The Approach: No Strings At All

docker-stealthy-auto-browse doesn’t hide automation signals. It eliminates them entirely.
Camoufox instead of Chromium. A custom Firefox fork. There is no Chrome DevTools Protocol because Firefox doesn’t use it. Bot detectors looking for CDP signals find nothing — not because we hid them, but because they don’t exist. navigator.webdriver is false — not patched to return false, genuinely false because Camoufox doesn’t set it.
Playwright for browser control. Handles the DOM-level stuff — navigation, element selection, page inspection. The convenient but detectable input mode goes through Playwright. Combined with Camoufox, it doesn’t leak the usual CDP automation signals that Chromium-based setups do.
PyAutoGUI instead of DOM events. When you need stealth, the mouse physically moves across the virtual screen with human-like curves, random jitter, and eased acceleration. When you type, real OS-level keystrokes are generated with randomized delays between characters. The browser receives these as genuine user input. No JavaScript in the world can tell the difference between PyAutoGUI input and a real human sitting at a keyboard.
Real fingerprints via browserforge. The fingerprint is generated once and applied consistently across the main context and web workers. No spoofing means no inconsistencies — a common detection vector that catches most fingerprint-spoofing tools.
Xvfb for a real display. The browser runs with a full graphical display inside the container via a virtual framebuffer. No headless mode, no headless signals. As far as the browser and any detection script is concerned, it’s running on a normal desktop.
My contribution is the glue — a Python HTTP API that wires all of these together, the Docker container that packages everything into a single docker run command, the page loader system for URL-triggered automation, the dual input mode abstraction (system vs playwright), and the noVNC integration for live viewing. The stealth tech is other people’s genius. The packaging and API is mine.

How It Works

You run the container, it exposes an HTTP API on port 8080. Send JSON commands, get JSON responses. That’s the entire interface.

docker run -d --name browser 
  -p 8080:8080 
  -p 5900:5900 
  psyb0t/stealthy-auto-browse

Port 8080 is the API. Port 5900 is a noVNC viewer so you can watch the browser in real-time from your own browser — open http://localhost:5900/ and you see exactly what the automated browser sees.
Navigate somewhere:

curl -X POST https://ciprian.51k.eu80 
  -H "Content-Type: application/json" 
  -d '{"action": "goto", "url": "https://example.com"}'

Read the page:

curl -X POST https://ciprian.51k.eu80 
  -H "Content-Type: application/json" 
  -d '{"action": "get_text"}'

Find every clickable thing on the page:

curl -X POST https://ciprian.51k.eu80 
  -H "Content-Type: application/json" 
  -d '{"action": "get_interactive_elements"}'

This returns every button, link, and input with their viewport coordinates, text, and CSS selectors. Now click one with a real mouse movement:

curl -X POST https://ciprian.51k.eu80 
  -H "Content-Type: application/json" 
  -d '{"action": "system_click", "x": 500, "y": 300}'

Type with real keystrokes:

curl -X POST https://ciprian.51k.eu80 
  -H "Content-Type: application/json" 
  -d '{"action": "system_type", "text": "hello world"}'

Take a screenshot:

curl https://ciprian.51k.eu80/screenshot/browser?whLargest=512 -o screenshot.png

Run multi-step scripts in a single request with run_script — no need to send one curl per action:

curl -X POST https://ciprian.51k.eu80 
  -H "Content-Type: application/json" 
  -d '{
    "action": "run_script",
    "steps": [
      {"action": "goto", "url": "https://example.com", "wait_until": "domcontentloaded"},
      {"action": "sleep", "duration": 2},
      {"action": "get_text", "output_id": "text"},
      {"action": "eval", "expression": "document.title", "output_id": "title"}
    ]
  }'

Also accepts "yaml": "..." with the same format used in script mode. In single-instance mode, requests are serialized automatically — send multiple scripts in parallel and they queue up instead of colliding.

Two Input Modes — This Matters

The container gives you two ways to interact with pages, and picking the right one is the difference between getting through and getting blocked.

System Input — Undetectable

system_click, mouse_move, system_type, send_key, scroll — these all use PyAutoGUI to generate real OS-level events. The mouse moves with human-like curves. Keystrokes have randomized timing. The browser has zero way to know these aren’t from a real person.
You work with viewport coordinates — get them from get_interactive_elements.

Playwright Input — Detectable But Convenient

click, fill, type — these use Playwright’s DOM automation with CSS selectors or XPath. Faster, easier, no coordinate math. But the event injection patterns are theoretically detectable by sophisticated behavioral analysis.
The rule is simple: site has bot detection? Use system input. Always. Just scraping something that doesn’t fight back? Playwright input is fine.

A Real Login Flow

Here’s what an undetectable login looks like — every interaction uses OS-level input:

API=https://ciprian.51k.eu80
# Navigate to login
curl -X POST $API -H 'Content-Type: application/json' 
  -d '{"action": "goto", "url": "https://example.com/login"}'
# Find all interactive elements
curl -X POST $API -H 'Content-Type: application/json' 
  -d '{"action": "get_interactive_elements"}'
# Click the email field (coordinates from above)
curl -X POST $API -H 'Content-Type: application/json' 
  -d '{"action": "system_click", "x": 400, "y": 200}'
# Type email with human-like keystrokes
curl -X POST $API -H 'Content-Type: application/json' 
  -d '{"action": "system_type", "text": "[email protected]"}'
# Tab to password field
curl -X POST $API -H 'Content-Type: application/json' 
  -d '{"action": "send_key", "key": "tab"}'
# Type password
curl -X POST $API -H 'Content-Type: application/json' 
  -d '{"action": "system_type", "text": "secretpassword"}'
# Submit
curl -X POST $API -H 'Content-Type: application/json' 
  -d '{"action": "send_key", "key": "enter"}'
# Wait for redirect
curl -X POST $API -H 'Content-Type: application/json' 
  -d '{"action": "wait_for_url", "url": "**/dashboard", "timeout": 15}'

The site sees a real human typing at natural speed with randomized delays. No CDP signals. No automation fingerprints. Nothing.

Page Loaders: Automation on Autopilot

Page loaders are like Greasemonkey userscripts but for the HTTP API. You write a YAML file that says “whenever the browser visits this domain, run these steps automatically.” Mount them into the container and forget about it.

# loaders/news_site.yaml
name: News Site Cleanup
match:
  domain: news-site.com
steps:
  - action: goto
    url: "${url}"
    wait_until: networkidle
  - action: wait_for_element
    selector: "article"
    timeout: 10
  - action: eval
    expression: "document.querySelector('.cookie-consent')?.remove()"
  - action: eval
    expression: "document.querySelector('.newsletter-overlay')?.remove()"
  - action: scroll_to_bottom
    delay: 0.3

Now every goto to news-site.com automatically waits for content, kills the cookie popup, kills the newsletter modal, and scrolls to trigger lazy-loaded images. No more sending 5 commands after every navigation.

The Full API

The HTTP API covers everything you’d need:

  • Navigation: goto, refresh with configurable wait conditions
  • System input: system_click, mouse_move, system_type, send_key, scroll — all OS-level, all undetectable
  • Page inspection: get_interactive_elements, get_text, get_html, eval
  • Wait conditions: wait_for_element, wait_for_text, wait_for_url, wait_for_network_idle — because sleep is for amateurs
  • Tab management: list_tabs, new_tab, switch_tab, close_tab
  • Cookies & storage: full CRUD for cookies, localStorage, sessionStorage
  • Downloads & uploads: handle file downloads and programmatic file inputs
  • Network logging: record all HTTP requests the page makes — find API endpoints, debug, verify
  • Screenshots: browser viewport or full desktop, with resize parameters
  • Dialog handling: auto-accept or configure responses to alert/confirm/prompt dialogs
  • Screen recording: start_recording, stop_recording, recording_status — MP4 of the actual rendered pixels

Two screenshot endpoints give you the browser viewport (what the page looks like) or the full virtual desktop (including browser chrome). Both support resize params so you’re not downloading 1920×1080 PNGs every time:

# Resize longest side to 512px
curl https://ciprian.51k.eu80/screenshot/browser?whLargest=512 -o shot.png
# Full desktop including browser chrome
curl https://ciprian.51k.eu80/screenshot/desktop?whLargest=512 -o desktop.png

Virtual Camera and Microphone

Point a video and/or audio file at a mounted /media directory and pages get camera and microphone tracks from those files through navigator.mediaDevices.getUserMedia(). Paths are validated at startup, symlinks resolved, to stay inside the configured media directory. Ask for a kind that isn’t configured and the request fails — it does not quietly fall back to a real device, which is the behavior you want when the whole point is that there is no real device.
Static files were the first version. Set VIRTUAL_MEDIA_DYNAMIC=true and you can swap the source at runtime: set_virtual_media_source picks an existing contained file, upload_virtual_media takes a bounded base64 payload. Both preserve the camera and microphone track identities a page has already acquired, so a page can change what it’s seeing without re-requesting getUserMedia() and noticing something happened.
Uploads are not a hole: generated collision-safe filenames rather than overwriting a named source, strict base64 decoding, a configurable VIRTUAL_MEDIA_UPLOAD_MAX_BYTES ceiling (50 MiB default), and an ffprobe check for the requested stream before anything is stored or activated. Source selection accepts only regular files contained in VIRTUAL_MEDIA_DIR — no remote URLs, no WebSocket feeds, no arbitrary host paths, no other live ingress. get_virtual_media_state reports dynamic-mode state, the active source basename and a revision counter, without leaking source paths or uploaded bytes.

Scraping Without Writing JavaScript

Four actions that cover what you’d otherwise hand-roll in an evaluate call every single time: get_page_info, get_element, get_elements, and get_computed_style. Page and CSS data straight out, no custom JS to write, no quoting nightmare to get it through JSON. get_elements defaults to 20 results — consistently, across the HTTP API, the MCP documentation and the test fixture, which it did not always do.

Screen Recording

Screenshots tell you what a page looked like. They don’t tell you what happened. So the browser records itself now — ffmpeg x11grab against the Xvfb display, writing MP4 to a mounted /recordings volume. Actual rendered pixels, including the OS-level mouse cursor moving around, because the cursor is real in this thing.
Three modes: window grabs the full Camoufox window, viewport crops the browser chrome off using the calibrated mozInnerScreenX/Y offsets (not hardcoded guesses), and desktop takes the entire Xvfb screen.

mkdir -p ./recordings
docker run -d -p 8080:8080 -v ./recordings:/recordings psyb0t/stealthy-auto-browse
# start
curl -X POST https://ciprian.51k.eu80/action 
  -d '{"action": "start_recording", "mode": "viewport", "fps": 20}'
# ... drive the browser ...
# stop — you name the file at stop time, after you know how it went
curl -X POST https://ciprian.51k.eu80/action 
  -d '{"action": "stop_recording", "slug": "my-flow"}'
# → ./recordings/my-flow.mp4

The slug is supplied at stop time on purpose — you name the recording after the run completes, when you actually know whether it’s login-success or login-broke-again. Slugs are sanitized against path traversal and collisions auto-rename. One active recording per container. Crash-safe: clean SIGINT shutdown plus a startup sweep for orphaned temp files.
show_cursor defaults to true, but flip it off when you’re doing visual-regression captures and cursor pixels would poison the diff. The response descriptor echoes the flag back so callers can confirm what they got.
Works over the HTTP API and through MCP. In cluster mode, start and stop must live inside the same run_script call so both hit the same instance — otherwise you’re telling one container to stop a recording another container started.
Resolution. Recording used to break above 1920×1080 because the entrypoint started Xvfb at that size and xrandr can’t grow the root framebuffer after the fact — ffmpeg would happily capture outside the real screen. The framebuffer is now allocated at XVFB_RESOLUTION up front and the resize is gone. Square and tall resolutions work; the 1920×1080 ceiling is gone.

When Camoufox Dies, It Comes Back

A user hit “Connection closed while reading from the driver” after a handful of n8n runs against Facebook. The root cause was ugly: the browser cached a dead Page object once Camoufox itself had died, so every subsequent request tried to talk to a corpse.
Now there’s a real health check. is_healthy() round-trips to the driver rather than trusting cached state, and ensure_healthy() relaunches the persistent context if the round-trip fails — the profile survives, so cookies and fingerprint carry over. Both the internal page getter and the active-page accessor heal first and use second. The request that trips the recovery eats about 4-5 seconds; everything after it runs at full speed.
Every recovery also dumps a postmortem at WARNING level: dmesg OOM lines, meminfo, loadavg, and the surviving camoufox-bin process list. So when it dies you get the actual cause in the JSON log instead of a mystery container stop.

Stealth Configuration That Matters

A few environment variables that actually affect whether you get caught:
Timezone matching. Bot detectors compare your browser’s timezone against your IP’s geolocation. If your IP says Romania but your timezone says UTC, that’s a red flag. Set TZ=Europe/Bucharest (or whatever matches your IP) and this vector disappears.
Proxy support. Route all traffic through any exit with PROXY_URLhttp://user:pass@host:port or socks5://host:port, whatever you have. Combined with timezone matching, you look like a real user from that exit’s location, which is why the docs are blunt that it should be an authorized exit whose location matches the fingerprint you’re testing. The repo now documents a setup where the exit is one you own rather than one you rent: a private pr0xteus WireGuard/SOCKS5 cell. It keeps its control API on host loopback and creates the SOCKS5 cell only on a private pr0xteus-egress Docker network — you allocate the exit, then start the browser on that same network, and nothing about the arrangement is reachable from outside the host. The full walkthrough is in docs/configuration.md.
Persistent profiles. Mount a directory to /userdata and your cookies, localStorage, sessions, and fingerprint survive container restarts. Without this, every restart is a fresh identity — which is sometimes what you want, and sometimes suspicious as fuck.

docker run -d 
  -e TZ=Europe/Bucharest 
  -e PROXY_URL=http://user:pass@proxy:8888 
  -v ./my-profile:/userdata 
  -p 8080:8080 
  -p 5900:5900 
  psyb0t/stealthy-auto-browse

Pre-installed Extensions

Every container ships with privacy extensions already configured:

  • uBlock Origin — blocks ads, trackers, and annoyances. Less noise, less tracking scripts running
  • LocalCDN — intercepts CDN requests and serves resources locally. Google and Cloudflare can’t track you across sites
  • ClearURLs — strips tracking parameters (utm_source, fbclid, gclid) from URLs
  • Consent-O-Matic — auto-rejects cookie consent popups so you don’t have to deal with that shit

Want more? Mount a persistent profile, open VNC, navigate to about:addons, and install whatever you want. They’ll persist across restarts.

Bot Detection Test Results

Tested against everything that matters and passed them all:

  • CreepJS — canvas/WebGL fingerprint consistency, lies detection, worker comparison: pass
  • BrowserScan — WebDriver flag, CDP signals, navigator properties: pass
  • Pixelscan — fingerprint coherence, timezone/IP match, WebRTC leaks: pass
  • Cloudflare — challenge pages, Turnstile, bot management: pass
  • SannySoft — Intoli + fingerprint scanner tests: pass
  • Incolumitas — modern detection techniques: pass
  • Rebrowser — CDP leak detection, webdriver, viewport analysis: pass
  • BrowserLeaks WebRTC — WebRTC IP leak detection: pass
  • DeviceAndBrowserInfo — 19 checks, all green, “You are human!”: pass
  • IpHey — “Trustworthy” rating: pass
  • Fingerprint.com — identified as normal Firefox, no bot flags: pass

It passes because there’s nothing to detect. No CDP to find because Firefox doesn’t have it. No spoofed fingerprints because the fingerprint is real and consistent. No automation flags because navigator.webdriver is genuinely false. No fake input events because PyAutoGUI generates real ones at the OS level.

Telling You There’s a CAPTCHA — And Nothing Else

detect_challenge reports bounded, privacy-minimised evidence for documented Turnstile, reCAPTCHA, hCaptcha, Friendly Captcha, ALTCHA, Arkose, AWS WAF and GeeTest integrations, plus conservative cues for visible generic ones. Available over HTTP, script mode, run_script, and MCP.
Read the verbs carefully, because the omissions are the design: it never clicks, never solves, never enters the challenge frame, and never exposes query strings, site keys, or response tokens. Arkose resource evidence redacts key-bearing path segments before any API, script or MCP response can return it. This tells you a challenge is there. It does not get you past it, and it is not trying to.
The companion is scroll_into_view: true, which brings the first rendered detected frame or widget into the viewport — without clicking, focusing, solving, submitting or entering it. That’s for handoff: your automation hits a wall, scrolls the wall into view, and a human picks it up over the noVNC session that’s been sitting there this whole time. The browser was always watchable; now it can tell you when to watch.

MCP Server

AI agents can control the browser over the Model Context Protocol via Streamable HTTP at /mcp on port 8080. All browser actions are exposed as MCP tools — navigation, screenshots, clicking, typing, JavaScript evaluation, cookies, everything.
Connect any MCP-compatible client — Claude Desktop, Claude Code, custom agents — to https://ciprian.51k.eu80/mcp/ and start browsing. Works in both standalone and cluster mode — HAProxy routes MCP traffic with the same sticky sessions as the HTTP API.
In cluster mode, the MCP server exposes only run_script (plus ping and sleep) as tools. Individual actions like goto, get_text, screenshot, etc. are not available as separate MCP tools when running behind a cluster. This is by design — see the Cluster Mode section below for why.
This is separate from the .agents/.skills/ directory approach mentioned below. Skills teach the AI how to use the HTTP API with curl. MCP gives the AI native tool access — no curl, no HTTP, the browser actions show up directly as callable tools. Use whichever fits your setup.

Authentication

Set AUTH_TOKEN to require a Bearer token on all requests (except /health):

docker run -d -p 8080:8080 -e AUTH_TOKEN=mysecretkey psyb0t/stealthy-auto-browse

Pass the token in the Authorization header:

curl -H "Authorization: Bearer mysecretkey" https://ciprian.51k.eu80 ...

v2.0.0 killed the query-param form. This used to accept ?auth_token=mysecretkey as well, which was convenient for MCP clients that couldn’t set headers and awful for everything else — tokens in that position leak into access logs, browser history, and Referer headers. The header is now the only accepted form, and the mere presence of an auth_token query param is an instant 401.
Worth knowing when you migrate: that query check runs before the header check, so a client sending a perfectly good Authorization header plus a leftover ?auth_token= still eats a 401. Strip the query param — don’t just add the header and assume you’re done. The comparison is constant-time now too (hmac.compare_digest) rather than a plain !=, so you can’t bisect a token by timing the responses.
Auth stays opt-in, though: leave AUTH_TOKEN unset and every endpoint except /health is open to anything that can reach the port.

Built For AI Agents

Here’s the thing nobody talks about with browser automation: the best use case in 2026 isn’t some Python script running a scraping loop. It’s AI agents that need to interact with the web like a human.
I use Claude Code constantly, and half the shit I need it to do involves web pages — filling out forms, checking dashboards, grabbing data from sites that don’t have APIs, interacting with admin panels. The problem with giving an LLM a browser has always been the interface. Selenium? Too complex. Playwright’s API? Too many moving parts. The LLM ends up writing 50 lines of setup code before it can click a single button.
docker-stealthy-auto-browse was designed from the ground up to be AI-friendly. The entire interface is curl commands with JSON. That’s it. An LLM doesn’t need to import libraries, manage browser instances, handle async contexts, or deal with any of that garbage. It just sends HTTP requests.
Think about what an AI agent needs to browse the web:

  1. Navigate somewhere — one curl to goto
  2. Understand what’s on the page — one curl to get_text. The AI reads the text and knows what it’s looking at. If text isn’t enough, get_interactive_elements returns every clickable thing with coordinates and labels. If it’s still confused, take a screenshot — Claude can read images
  3. Interact with elements — one curl to system_click with x,y coordinates, one curl to system_type for text input
  4. Wait for results — one curl to wait_for_text or wait_for_element
  5. Verify the outcome — one curl to get_text again

No SDK. No driver setup. No browser lifecycle management. The container handles all of that. The AI just talks to an HTTP endpoint.
I’ve had Claude Code do things like:

  • Log into web dashboards, navigate to specific pages, extract data, and summarize it
  • Fill out multi-step forms on sites that require JavaScript rendering
  • Monitor pages for changes and alert me when something updates
  • Interact with admin panels that have no API — clicking buttons, changing settings, downloading exports
  • Research shit on sites that block regular HTTP requests behind Cloudflare

The repo ships with an .agents/.skills/ directory containing a full skill definition for AI coding agents. Clone the repo (or just the .agents/ directory) into your project and Claude Code automatically discovers it. Set STEALTHY_AUTO_BROWSE_URL=https://ciprian.51k.eu80 and the agent has the full API reference, both input modes, typical workflows, and examples — everything it needs to browse the web out of the box.
It’s also available on ClawHub. Install it with clawhub install psyb0t/stealthy-auto-browse and any OpenClaw-compatible AI agent can use the browser on demand.
The combination of a dead-simple HTTP API, full stealth against bot detection, and built-in AI agent instructions makes this the best browser automation tool for LLMs that I’ve found. And I looked, believe me. Everything else either requires complex SDK setup that confuses the AI, or gets caught by Cloudflare on the first request, or both.

Script Mode: Run and Exit

Run a YAML script at container startup — execute the steps, get results as JSON on stdout, and the container exits. No HTTP server, no long-running process. Good for CI, cron jobs, one-shot scraping, or anything where you want to automate a sequence and get the output.

# Pipe a script in, get JSON results out
cat my-script.yaml | docker run --rm -i 
  psyb0t/stealthy-auto-browse --script > results.json
# Parameterize with environment variables
cat my-script.yaml | docker run --rm -i 
  -e TARGET_URL=https://example.com 
  psyb0t/stealthy-auto-browse --script

The script format is the same actions as the HTTP API, but in YAML:

name: Scrape Example
on_error: stop  # "stop" (default) or "continue"
steps:
  - action: goto
    url: ${env.TARGET_URL}
    wait_until: networkidle
  - action: save_screenshot
    output_id: page_screenshot
    whLargest: 1024
  - action: get_text
    output_id: page_text
  - action: eval
    expression: "document.title"
    output_id: title

Steps with an output_id get collected into the output JSON. Screenshots come out as base64-encoded PNGs. ${env.VAR_NAME} gets replaced with environment variables. Logs go to stderr, so redirecting stdout gives you clean JSON. Exit code 0 if all steps succeed, 1 if any fail. Page loaders still fire on goto if configured.

Scripts Can Branch and Loop Now (Within Limits)

A flat list of steps runs out of road fast. “Click accept if the cookie banner is there.” “Keep scrolling until the next-page button disappears.” Script mode and run_script handle both now: nested if branches plus repeat and while loops.
Conditions cover CSS element state, visible text, URL globs, boolean JavaScript results, and the named outputs of earlier steps — so a later branch can react to what an earlier step actually found instead of you guessing at submit time.
The word doing the work in that heading is bounded. Loop iteration count, total loop work, condition timeout and nesting depth are all capped. A submitted script has to be finite, because this thing accepts scripts over HTTP and a while loop with no ceiling is a denial-of-service primitive you handed out on purpose.

Cluster Mode

Need to handle concurrent requests? Run multiple browser instances behind HAProxy with a request queue and Redis cookie sync. Each browser handles one request at a time — the proxy queues the rest until a slot opens.

curl -LO https://raw.githubusercontent.com/psyb0t/docker-stealthy-auto-browse/main/docker-compose.cluster.yml
curl -LO https://raw.githubusercontent.com/psyb0t/docker-stealthy-auto-browse/main/haproxy.cfg.template
docker compose -f docker-compose.cluster.yml up -d

This starts Redis, 5 browser containers (configurable via NUM_REPLICAS), and the HAProxy queue-proxy. The entry point is https://ciprian.51k.eu80 — same API as single-container mode. MCP at /mcp/ works through the proxy too.
In cluster mode, only run_script is accepted. Sending individual actions like goto, get_text, click, or screenshot directly will return an error. This is intentional: each request in a multi-step sequence can land on a different browser instance unless the client carefully manages session stickiness — and when it doesn’t, you get stale content bugs that are subtle and maddening. run_script is atomic. Every step in the script runs on the same browser instance in the same request. No stickiness to manage. No cross-instance state bleed.
In standalone mode (single container, no cluster), individual actions still work fine — requests are serialized automatically so there’s no concurrency issue.
The syntax is identical to what you’d use in standalone mode. Full login sequence, navigation, extraction — all in one shot:

curl -X POST https://ciprian.51k.eu80 
  -H "Content-Type: application/json" 
  -d '{
    "action": "run_script",
    "steps": [
      {"action": "goto", "url": "https://example.com/login", "wait_until": "domcontentloaded"},
      {"action": "system_click", "x": 400, "y": 200},
      {"action": "system_type", "text": "[email protected]"},
      {"action": "send_key", "key": "tab"},
      {"action": "system_type", "text": "secretpassword"},
      {"action": "send_key", "key": "enter"},
      {"action": "wait_for_url", "url": "**/dashboard", "timeout": 15},
      {"action": "get_text", "output_id": "page"}
    ]
  }'

HAProxy handles routing internally — it assigns a free browser instance and keeps the entire script on that instance. You never touch INSTANCEID cookies. You don’t think about routing at all.
Redis cookie sync is the killer feature. Cookies set on any instance propagate to all others instantly via Redis PubSub. Log in on browser1, and browser2 through browser10 are immediately authenticated. Run a single login script on any instance and the whole fleet is logged in — no need to repeat the login on each browser.
HAProxy exposes a stats dashboard on port 8081 — live traffic, queue depth, server health, request rates per instance.

Skill and Plugin

The repo ships an agent skill and an OpenClaw plugin under .agents/, both published to ClawHub by CI on tag pushes. Point an agent at the plugin and it drives a running instance’s MCP endpoint directly — no glue code, no explaining the action list every session.

The Bottom Line

Every other browser automation tool is playing defense — hiding CDP signals, patching detection vectors, hoping the next Cloudflare update doesn’t break their stealth plugin. docker-stealthy-auto-browse doesn’t play that game. There’s no CDP to hide. There are no automation signals to patch. The browser genuinely doesn’t know it’s being automated.
One Docker container. One HTTP API. Passes every bot detector we’ve thrown at it.
Go grab it: github.com/psyb0t/docker-stealthy-auto-browse
Licensed under WTFPL — Do What The Fuck You Want To Public License. Because obviously.

Installing It Into Your Agent

Given the whole point is agents driving browsers, the install path matters more here than most. Everything under .agents/ is catalogued in one marketplace, so it is two commands:

claude plugin marketplace add psyb0t/agents
claude plugin install stealthy-auto-browse@psyb0t

Codex uses the same marketplace with a different verb — codex plugin add stealthy-auto-browse@psyb0t, because there is no codex plugin install. It also finds the skill on its own in a checkout of the repo, since it scans .agents/skills/ natively with nothing installed at all. It is also listed on the official MCP Registry now, so a client that resolves servers from there can find it without being handed a URL.