browser-codemode
v1.0.0
Published
Headless browser server for AI agents — scrape, extract, screenshot, and execute Puppeteer code over HTTP or MCP.
Readme
browser-codemode
Headless browser server for AI agents — scrape, extract, screenshot, and execute Puppeteer code over HTTP or MCP.
Built with Bun, Puppeteer (with stealth plugin), and Hono. Ships as a Docker container with Chrome included.
Features
- Web search — multi-provider search (Google → Brave → Bing → DuckDuckGo) with automatic fallback, no API keys needed
- Stealth browsing — puppeteer-extra stealth plugin bypasses bot detection (local launch mode only; not applied when connecting to a remote browser via
BROWSER_CDP_URL) - LLM extraction — send a page + prompt/schema → get structured JSON back (via OpenRouter)
- MCP server — SSE-based Model Context Protocol for AI agent integration
- Screenshot hosting — auto-uploads to S3-compatible storage (RustFS), returns shareable URLs
- Markdown conversion — HTML → clean markdown with link extraction and content cleanup
- YouTube transcripts — extract captions/subtitles from YouTube videos with timestamps, no API key needed
- llms.txt — fetch a website's LLM-friendly content index, parse links, and optionally fetch all subpages
- Eval — run arbitrary Puppeteer code with full
browser,page,puppeteeraccess - Tab hygiene — automatic orphaned tab cleanup, configurable max pages limit
Quick Start
# Clone and configure
cp .env.example .env # Set BROWSER_API_TOKEN at minimum
# Run with Docker
docker compose up -d
# Test
curl -s http://localhost:16050/health | jqEnvironment Variables
| Variable | Required | Description |
|----------|----------|-------------|
| BROWSER_API_TOKEN | No | Bearer token for API + MCP auth. If unset, the service runs with no auth — only run it on a trusted/private network, never exposed to the internet. |
| BROWSER_CDP_URL | No | Remote Chrome DevTools Protocol URL (uses local Chrome if unset). Stealth plugin is not applied in this mode. |
| OPENROUTER_API_KEY | No | Required for /api/extract (LLM-powered extraction) |
| RUSTFS_ENDPOINT_URL | No | S3-compatible endpoint for screenshot uploads |
| RUSTFS_ACCESS_KEY | No | S3 access key |
| RUSTFS_SECRET_KEY | No | S3 secret key |
| MAX_PAGES | No | Maximum concurrent browser tabs (default: 5) |
| PORT | No | HTTP port (default: 8787) |
| MAX_BODY_BYTES | No | Max request body size in bytes (default: 10485760 = 10 MiB) |
Control UI
A minimal, build-free control panel is served at the root path:
GET / → HTML control UIOpen http://localhost:16050/ in a browser. It lets you:
- Manage persistent tabs (sessions): open a URL → the server keeps the tab alive; list and close them. Sessions auto-expire after 10 min idle (kept alive while being viewed).
- Live-stream a tab via CDP JPEG screencast over WebSocket — stable, works everywhere, ~8–15fps (Chrome-capped).
- Control the tab interactively — click, scroll, and type on the canvas are forwarded to the page via CDP input.
- Code mode — run arbitrary Puppeteer against the selected live tab (
page+puppeteerin scope). - Feature tester — simple forms to exercise every HTTP endpoint (search, markdown, scrape, fetch, extract, screenshot, transcript, llms, eval).
Auth: when BROWSER_API_TOKEN is set, the UI page itself is gated by HTTP Basic auth — the browser prompts for credentials (any username, password = the token). After authenticating, the server injects the token into the page so API and WebSocket calls authenticate automatically; no manual entry. API/MCP/WS endpoints accept the token via Authorization: Bearer, Authorization: Basic, or ?token=. The UI shares the owned-tab safety model — its sessions are pinned, tagged tabs and never touch the target Chrome's own tabs.
Session endpoints
| Method | Path | Description |
|--------|------|-------------|
| GET | /api/sessions | List persistent sessions (id, url, title, casting, viewers) |
| POST | /api/sessions | Create a session. Body: { "url"?: string } |
| POST | /api/sessions/:id/navigate | Navigate a session. Body: { "url": string } |
| POST | /api/sessions/:id/eval | Run Puppeteer code against the session's page. Body: { "code": string } |
| DELETE | /api/sessions/:id | Close a session and its tab |
| WS | /ws/screencast?id=<id>&token=<token> | Live JPEG frames; send { type: "input", event } to control the tab |
Embedding (@yaelg/browser-codemode-embed)
Mount a live, interactive browser tab into any web app — the same streaming + control experience as the built-in UI, but in your page. The embed module is a thin, dependency-free wrapper over the session API + screencast WebSocket.
Import it straight from the server (served at /embed/browser-codemode-embed.js)
or install the published package:
import { mountBrowser } from '@yaelg/browser-codemode-embed';
const handle = await mountBrowser({
host: 'https://browser.yael.dev',
token: 'YOUR_API_TOKEN',
container: document.getElementById('browser'),
url: 'https://github.com', // create + open a session…
// sessionId: 'abc-…', // …or attach to an existing one
onState: (s) => console.log(s), // 'connecting' | 'open' | 'closed' | 'error'
onFps: (fps) => {},
});
await handle.navigate('https://news.ycombinator.com');
const title = await handle.eval('return document.title');
const png = await handle.snapshot(); // Promise<Blob>
handle.setInteractive(false); // view-only
await handle.destroy(); // closes the session if the embed created itThe canvas forwards mouse (click + drag), wheel, and keyboard to the tab. Frames
are JPEG over WebSocket; input is { type: "input", event } on the same socket.
Cross-origin: set EMBED_ALLOW_ORIGIN to the consuming app's origin (or *)
so the API/embed routes send CORS headers. The token still gates every request.
A self-contained demo is served at /embed/ (see embed/embed-example.html).
Authentication
When BROWSER_API_TOKEN is set, all /api/*, /mcp, the control UI (/), and the screencast WebSocket require authentication. Accepted schemes:
- Header:
Authorization: Bearer <BROWSER_API_TOKEN> - Header:
Authorization: Basic <base64(any-username:BROWSER_API_TOKEN)>(used by the UI's browser prompt) - Query param:
?token=<BROWSER_API_TOKEN>(used by the WebSocket)
The control UI page is served only after Basic-auth (or ?token=) succeeds; an unauthenticated GET / returns 401 with a WWW-Authenticate: Basic challenge. GET /health requires no auth.
HTTP API
GET /health
Health check — no auth required.
{ "status": "ok", "service": "browser-codemode", "browser": { "mode": "local", "target": "local" } }GET/POST /api/search
Search the web using the browser. Automatically falls back through providers until results are found.
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| query / q | string | required | Search query |
| count | number | 10 | Number of results (1–20) |
| provider | "google" \| "brave" \| "bing" \| "duckduckgo" | — | Force a specific engine (default: auto-fallback) |
# GET
curl "http://localhost:16050/api/search?q=best+rust+web+frameworks&count=5&token=$TOKEN"
# POST
curl -X POST http://localhost:16050/api/search \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"query": "best rust web frameworks", "count": 5}'Response:
{
"query": "best rust web frameworks",
"provider": "google",
"results": [
{ "title": "Top 10 Rust Web Frameworks in 2026", "url": "https://...", "snippet": "..." },
{ "title": "Actix vs Axum comparison", "url": "https://...", "snippet": "..." }
]
}Fallback chain: Google → Brave → Bing → DuckDuckGo. If a provider returns a CAPTCHA, is blocked, or returns no results, the next provider is tried automatically. Cookie consent popups are dismissed automatically.
GET /api/tabs
Debug endpoint — returns current browser tab count and URLs.
curl "http://localhost:16050/api/tabs?token=$TOKEN"Response:
{ "count": 5, "owned": 1, "active": 1, "pages": ["https://example.com"] }count is the total tab count of the underlying browser (includes a human's tabs in remote mode); owned and pages cover only service-created tabs.
POST /api/navigate
Navigate to a URL and extract content.
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| url | string | required | URL to navigate to |
| waitFor | string | "networkidle2" | Puppeteer wait condition |
| extract | "text" \| "html" \| "markdown" | "text" | Content extraction format |
| eval | string | — | JavaScript to run after page load, before extraction |
curl -X POST http://localhost:16050/api/navigate \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com", "extract": "markdown"}'Response:
{ "content": "# Example Domain\n\n...", "title": "Example Domain", "url": "https://example.com" }POST /api/scrape
Scrape a URL: returns markdown + categorized links + optional CSS selector data.
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| url | string | required | URL to scrape |
| selectors | Record<string, string> | — | Map of key → CSS selector to extract |
| html | boolean | false | Include raw HTML in response |
| eval | string | — | JavaScript to run after page load |
curl -X POST http://localhost:16050/api/scrape \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"url": "https://news.ycombinator.com", "selectors": {"titles": ".titleline > a"}}'Response:
{
"title": "Hacker News",
"url": "https://news.ycombinator.com",
"markdown": "...",
"links": { "internal": [...], "external": [...] },
"data": { "titles": ["Article 1", "Article 2", ...] }
}POST /api/extract
Extract structured data from a URL using an LLM. Navigates to the page, converts to markdown, sends to OpenRouter with your prompt and/or JSON schema.
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| url | string | required | URL to extract data from |
| prompt | string | — | Natural language extraction instruction |
| schema | object | — | JSON Schema for structured output |
| model | string | "minimax/minimax-m2.5" | OpenRouter model identifier |
| eval | string | — | JavaScript to run after page load |
At least one of
promptorschemais required.
curl -X POST http://localhost:16050/api/extract \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"url": "https://finviz.com/screener.ashx?v=111&s=ta_topgainers",
"prompt": "Extract the top 10 stock gainers with ticker, company, change%, and volume",
"schema": {
"type": "object",
"properties": {
"gainers": {
"type": "array",
"items": {
"type": "object",
"properties": {
"ticker": { "type": "string" },
"company": { "type": "string" },
"change_pct": { "type": "number" },
"volume": { "type": "number" }
}
}
}
}
}
}'Response:
{
"title": "Stock Screener",
"url": "https://finviz.com/screener.ashx?v=111&s=ta_topgainers",
"result": { "gainers": [{ "ticker": "AAPL", "company": "Apple Inc.", "change_pct": 5.2, "volume": 1234567 }, ...] },
"model": "minimax/minimax-m2.5",
"usage": { "prompt_tokens": 1234, "completion_tokens": 567, "total_tokens": 1801 }
}POST /api/screenshot
Take a screenshot of a URL. Uploads to S3-compatible storage if configured, returns shareable URL.
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| url | string | required | URL to screenshot |
| fullPage | boolean | false | Capture full scrollable page |
| eval | string | — | JavaScript to run before capture |
| base64 | boolean | true | Include base64 image data in response |
curl -X POST http://localhost:16050/api/screenshot \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com", "fullPage": true}'Response:
{ "url": "https://static.yael.dev/screenshots/bcm-1710000000000.png", "image": "iVBOR..." }GET/POST /api/markdown
Fetch a URL and return clean markdown. Supports content negotiation.
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| url | string | required | URL to convert (query param for GET, body for POST) |
| eval | string | — | JavaScript to run after page load |
| format | "html" | — | Query param: return rendered HTML instead of raw markdown |
# Raw markdown
curl "http://localhost:16050/api/markdown?url=https://example.com&token=$TOKEN"
# Rendered HTML (browser-viewable)
curl "http://localhost:16050/api/markdown?url=https://example.com&format=html&token=$TOKEN"Content negotiation: Accept: text/html returns rendered GFM-styled HTML page. Default returns raw markdown with Content-Type: text/markdown.
GET/POST /api/fetch
Fetch a URL through a real browser (renders JS) and return readable content. Faster than /api/navigate — uses domcontentloaded and caps output length.
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| url | string | required | URL to fetch (query param for GET, body for POST) |
| extract | "markdown" \| "text" | "markdown" | Output format |
| maxChars | number | 50000 | Truncate content to this many characters |
# GET
curl "http://localhost:16050/api/fetch?url=https://example.com&extract=text&token=$TOKEN"
# POST
curl -X POST http://localhost:16050/api/fetch \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com", "extract": "markdown", "maxChars": 20000}'Response:
{ "url": "https://example.com", "extract": "markdown", "content": "# Example Domain\n\n...", "tookMs": 412 }GET/POST /api/llms
Fetch a website's llms.txt or llms-full.txt — an LLM-friendly content index. Parses markdown links and optionally fetches linked subpages.
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| url / domain | string | required | Website URL or domain (e.g. docs.anthropic.com) |
| subpages | boolean \| string[] | — | true to fetch all linked pages, or array of URL/title patterns to filter |
# GET — just the llms.txt
curl "http://localhost:16050/api/llms?url=docs.anthropic.com&token=$TOKEN"
# GET — with all subpages fetched
curl "http://localhost:16050/api/llms?url=docs.anthropic.com&subpages=true&token=$TOKEN"
# POST — filter subpages by pattern
curl -X POST http://localhost:16050/api/llms \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"url": "docs.anthropic.com", "subpages": ["api", "getting-started"]}'Response:
{
"url": "https://docs.anthropic.com/llms.txt",
"exists": true,
"content": "# Anthropic Docs\n\n- [API Reference](/api): Full API docs\n...",
"links": [
{ "title": "API Reference", "url": "https://docs.anthropic.com/api", "description": "Full API docs" }
],
"pages": [
{ "url": "https://docs.anthropic.com/api", "title": "API Reference", "content": "..." }
]
}Tries llms-full.txt first, falls back to llms.txt. Subpages capped at 20 pages, 50k chars each. No browser needed — pure HTTP fetch.
GET/POST /api/transcript
Extract captions/subtitles from a YouTube video. Uses the page's embedded player data to fetch timed text — no YouTube API key required.
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| url | string | required | YouTube URL or video ID (query param for GET, body for POST) |
| lang | string | — | Preferred language code (e.g. "en", "fr"). Falls back to English → first available |
# GET
curl "http://localhost:16050/api/transcript?url=https://youtube.com/watch?v=dQw4w9WgXcQ&token=$TOKEN"
# POST
curl -X POST http://localhost:16050/api/transcript \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"url": "https://youtube.com/watch?v=dQw4w9WgXcQ", "lang": "en"}'Response:
{
"videoId": "dQw4w9WgXcQ",
"title": "Rick Astley - Never Gonna Give You Up",
"channel": "Rick Astley",
"duration": 212,
"language": "en",
"segments": [
{ "text": "We're no strangers to love", "start": 18.0, "duration": 2.5 },
{ "text": "You know the rules and so do I", "start": 20.5, "duration": 3.0 }
],
"fullText": "We're no strangers to love You know the rules and so do I ..."
}Supports full URLs (youtube.com/watch?v=, youtu.be/) and bare video IDs.
POST /api/eval
Execute arbitrary Puppeteer code. The function receives browser, page, and puppeteer in scope.
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| code | string | required | JavaScript/TypeScript code to execute |
curl -X POST http://localhost:16050/api/eval \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"code": "await page.goto(\"https://example.com\"); return await page.title();"}'Response:
{ "result": "Example Domain" }MCP Server
browser-codemode exposes an MCP (Model Context Protocol) server over SSE for AI agent integration.
Connection
GET /mcp → SSE stream (requires Bearer auth)
POST /mcp/messages?sessionId=... → Send messages to the serverTools
eval
Execute arbitrary Puppeteer code with browser, page, and puppeteer in scope.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| code | string | Yes | JavaScript/TypeScript code to execute |
navigate
Open a URL and return page content.
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| url | string | Yes | — | URL to navigate to |
| waitFor | string | No | "networkidle2" | Wait condition |
| extract | "text" \| "html" \| "markdown" | No | "text" | Extraction format |
| eval | string | No | — | JavaScript to run after load, before extraction |
screenshot
Take a screenshot. Returns a shareable URL (token-efficient — no base64 over MCP).
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| url | string | Yes | — | URL to screenshot |
| fullPage | boolean | No | false | Capture full page |
| eval | string | No | — | JavaScript to run before capture |
MCP screenshots return URLs only (base64 suppressed). Falls back to inline image if upload fails.
markdown
Fetch a URL and return clean markdown content.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| url | string | Yes | URL to fetch |
| eval | string | No | JavaScript to run after load |
scrape
Scrape a URL: returns markdown + categorized links + optional CSS selector data.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| url | string | Yes | URL to scrape |
| selectors | Record<string, string> | No | Map of key → CSS selector |
| eval | string | No | JavaScript to run after load |
fetch
Fetch a URL through a real browser and return readable content as markdown or text.
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| url | string | Yes | — | URL to fetch |
| extract | "markdown" \| "text" | No | "markdown" | Output format |
| maxChars | number | No | 50000 | Truncate content to this many characters |
search
Search the web. Tries Google → Brave → Bing → DuckDuckGo with automatic fallback.
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| query | string | Yes | — | Search query |
| count | number | No | 10 | Number of results (1–20) |
| provider | "google" \| "brave" \| "bing" \| "duckduckgo" | No | — | Force a specific engine |
llms_txt
Fetch a website's llms.txt LLM-friendly content index.
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| url | string | Yes | — | Website URL or domain |
| subpages | boolean \| string[] | No | — | Fetch linked pages (true=all, array=filter by pattern) |
transcript
Extract captions/subtitles from a YouTube video. No API key needed.
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| url | string | Yes | — | YouTube URL or video ID |
| lang | string | No | — | Preferred language code (falls back to English → first available) |
extract
Extract structured data from a URL using LLM.
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| url | string | Yes | — | URL to extract from |
| prompt | string | No* | — | Natural language extraction instruction |
| schema | object | No* | — | JSON Schema for structured output |
| model | string | No | "minimax/minimax-m2.5" | OpenRouter model |
| eval | string | No | — | JavaScript to run after load |
*At least one of
promptorschemais required.
Architecture
┌─────────────────────────────────────────────────┐
│ browser-codemode │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │
│ │ Hono API │ │ MCP SSE │ │ Puppeteer │ │
│ │ /api/* │ │ /mcp │ │ + Stealth │ │
│ └────┬─────┘ └────┬─────┘ └──────┬───────┘ │
│ │ │ │ │
│ └──────────────┴─────────────────┘ │
│ │ │
│ ┌───────┴───────┐ │
│ │ tools.ts │ │
│ │ eval,navigate │ │
│ │ scrape,extract│ │
│ │ screenshot,md │ │
│ │ search, │ │
│ │ transcript, │ │
│ │ llms-txt │ │
│ └───────┬───────┘ │
│ │ │
│ ┌────────────┼────────────┐ │
│ │ │ │ │
│ ┌────┴────┐ ┌────┴────┐ ┌────┴─────┐ │
│ │ llm.ts │ │storage │ │markdown │ │
│ │OpenRouter│ │ .ts │ │ .ts │ │
│ │extraction│ │ RustFS │ │ turndown │ │
│ └─────────┘ └─────────┘ └──────────┘ │
│ │ │
│ ┌────┴────┐ │
│ │search.ts│ │
│ │ Google │ │
│ │ Brave │ │
│ │ Bing │ │
│ │ DDG │ │
│ └─────────┘ │
└─────────────────────────────────────────────────┘Browser Modes
- Local (default): Launches headless Chrome from the Docker container with stealth plugins
- Remote: Connects to an existing Chrome via
BROWSER_CDP_URL(e.g., a persistent Chrome instance)
Each request gets a fresh page (1920×1080 viewport), auto-closed after use. 3-minute timeout per request.
Tab Hygiene
The service only ever touches pages it created — it tags every page it opens and never closes, counts, or inspects tabs belonging to the underlying browser. This is essential in remote/CDP mode, where the Chrome instance is shared with a human user.
- Owned-only: cleanup, eviction, and
/api/tabsURL listing apply solely to service-created pages. The human's tabs on a shared remote Chrome are never closed or leaked. - Max pages limit: configurable via
MAX_PAGES(default: 5), counting only service-owned pages. When reached, the service's own orphaned tabs are evicted. - Background cleanup: every 60 seconds, service-owned pages not tied to an active request are closed.
- Remote safety: in remote mode the service does not resize the shared browser window; it sets only a per-tab render viewport (via emulation), which doesn't move the window the human sees.
- Chrome flags (local mode):
--disable-background-timer-throttlingand--disable-backgrounding-occluded-windowsprevent background tab throttling.
Markdown Pipeline
HTML → markdown conversion includes:
- Pre-cleaning: strips nav, footer, forms, buttons, cookie/consent modals, ads
- Removes tracking pixels, empty images, data URI images
- Turndown with custom rules: skips decorative images, strips non-http links
- Post-processing: strips leaked HTML, decodes entities, removes UI noise
- Link extraction: deduped, categorized (internal/external), generic text filtered
Docker
services:
browser-codemode:
build: .
ports:
- "16050:8787"
environment:
- BROWSER_API_TOKEN=your-secret-token
- OPENROUTER_API_KEY=sk-or-... # For /api/extract
- RUSTFS_ENDPOINT_URL=... # For screenshot uploads
- RUSTFS_ACCESS_KEY=...
- RUSTFS_SECRET_KEY=...
restart: unless-stopped
shm_size: "1gb" # Required for ChromeBase image: ghcr.io/puppeteer/puppeteer:25.1.0 (Debian + Chrome + Node), pinned to match puppeteer-core. Chrome is discovered at runtime, so bumping the base image doesn't require code changes.
Deployment
Two browser backends:
- Self-contained (local mode) — leave
BROWSER_CDP_URLunset. The container launches its own headless Chrome. Simplest; good for pure scraping. - Remote Chrome (remote mode) — set
BROWSER_CDP_URLto a Chrome DevTools endpoint. Use this for a persistent, logged-in profile. Seehost/for the macOS setup (Launch Agent +socatover Tailscale).
Reference deployment (Coolify + Tailscale)
This is the setup this repo runs in production:
Mac host runs Chrome on
127.0.0.1:9222, exposed on the Tailscale IP viasocat, kept alive by a Launch Agent. Install perhost/README.md.browser-codemode runs as a Coolify application on a separate server that is on the same tailnet. It connects out to the Mac's Chrome.
Set these env vars on the Coolify app (runtime):
| Var | Example | |-----|---------| |
BROWSER_API_TOKEN| a long random secret | |BROWSER_CDP_URL|http://<mac-tailscale-ip>:9222| |OPENROUTER_API_KEY|sk-or-...(for/api/extract) | |RUSTFS_ENDPOINT_URL/RUSTFS_ACCESS_KEY/RUSTFS_SECRET_KEY| screenshot hosting |In Coolify, mark
BROWSER_CDP_URLas runtime (not just buildtime) or the container won't see it and will silently fall back to local mode.Open the control UI at
http://<app-host>:16050/and authenticate with the token (Basic-auth prompt).
Operational gotcha: the Mac's Tailscale IP can change. If
BROWSER_CDP_URLpoints at a stale IP, the service stays healthy (/healthdoesn't touch Chrome) but every page open hangs/times out. Verify withcurl http://<ip>:9222/json/versionfrom the app server, and checkGET /healthreports the expectedbrowser.target.
License
MIT
