# Introduction One call that gets your agent through any site, on any model. Frankensurf is an open-source web layer for AI agents. Ask for a URL or a search, and it picks the right browsing tool for that page, climbs to a stronger one when the site pushes back, and returns clean page data with a receipt of how it got there. - **Every site.** Plain HTTP, real browsers, stealth browsers, unblockers and, as a last resort, you. Six rungs, one call. - **Every agent.** The same tools and the same results on Claude, GPT, Gemini or an open-weight model, through MCP, Python or the command line. - **Shows its work.** Every result says which tools ran, how long each took and what it cost. ## Get started ## Read your first page No account or key needed. ```python from frankensurf import Runtime async with Runtime("state") as web: page = await web.read("https://developers.cloudflare.com/fundamentals/reference/markdown-for-agents/", policy_overrides={"prefer_markdown": True}) print(page["title"], page["receipt"]["method"]) ``` ```bash frankensurf read https://developers.cloudflare.com/fundamentals/reference/markdown-for-agents/ --markdown ``` ```json { "tool": "read", "arguments": { "url": "https://developers.cloudflare.com/fundamentals/reference/markdown-for-agents/" } } ``` ```json title="Response" { "url": "https://developers.cloudflare.com/fundamentals/reference/markdown-for-agents/", "title": "Markdown for Agents", "content_type": "text/markdown; charset=utf-8", "text": "---\ndescription: Cloudflare's Markdown for Agents converts HTML to Markdown at the edge…", "structured": { "format": "markdown" }, "receipt": { "status": "observed", "method": "http", "latency_ms": 261, "cost_usd": 0.0, "attempts": [{ "provider": "http", "status": "observed", "latency_ms": 261 }] } } ``` ## What you can do ## Why Frankensurf Every browsing tool breaks somewhere. A model's built-in fetch skips JavaScript. A browser gets challenged. A stealth browser gets flagged. An unblocker costs money on pages that never needed it. Frankensurf stitches [30-plus tools](/docs/integrations/) together and routes each page to the cheapest one that works, so your agent stops dead-ending. It returns raw page data, not answers. Your agent has a model; it decides what the page means. --- # Act Fill, click and assert in your own signed-in browser, under explicit grants and with every step on record. `do` runs a typed list of browser steps in your Chrome, through a registered [identity](/docs/read/signed-in/). Reading never grants acting: every action needs its own grants, and nothing runs automatically. - Steps are `fill`, `click`, `wait_for`, `assert_text` and `assert_value` on CSS selectors. - Every call has an idempotency key. A completed key returns its saved result instead of running again. - Values and selectors stay out of receipts; before and after HTML and screenshots are kept privately as evidence. ## Usage Write the steps as an intent: ```json title="intent.json" { "url": "https://www.example-shop.com/account/address", "contract": "browser.raw_control.v1", "action_class": "WRITE_EXTERNAL", "idempotency_key": "update-address-2026-10-06", "description": "Update the delivery address", "actions": [ { "tool": "fill", "selector": "#postcode", "value": "2000" }, { "tool": "click", "selector": "button[type=submit]" }, { "tool": "assert_text", "selector": ".notice", "value": "Saved" } ] } ``` ```python from frankensurf import WebPolicy policy = WebPolicy( identity="shop-me", action_classes=("WRITE_REVERSIBLE", "WRITE_EXTERNAL", "PURCHASE/FINANCIAL", "ACCOUNT_SECURITY"), browser_do_allowed_contracts=("browser.raw_control.v1",), browser_do_allowed_origins=("https://www.example-shop.com",), browser_do_allowed_tools=("fill", "click", "assert_text"), ) result = await web.do(json.load(open("intent.json")), policy, provider="browser_use_local_cdp_do") ``` ```bash frankensurf do --intent-file intent.json --identity shop-me \ --provider browser_use_local_cdp_do \ --action-contract browser.raw_control.v1 \ --action-origin https://www.example-shop.com \ --action-tool fill --action-tool click --action-tool assert_text \ --grant-action WRITE_REVERSIBLE --grant-action WRITE_EXTERNAL \ --grant-action PURCHASE/FINANCIAL --grant-action ACCOUNT_SECURITY ``` ## Grants `browser.raw_control.v1` can do anything a click can do, so it needs the full set of grants, in both the call and the identity: | Grant | What it must name | | --- | --- | | Contract | `browser.raw_control.v1` | | Provider | `browser_use_local_cdp_do` | | Origins | Every origin the steps may touch | | Tools | Exactly the tools the intent uses | | Action classes | `WRITE_REVERSIBLE`, `WRITE_EXTERNAL`, `PURCHASE/FINANCIAL`, `ACCOUNT_SECURITY` | Register the identity with matching `--allow-action` grants. ## What a result means A completed action means the steps ran as written and the assertions passed. The result always says `semantic_result: "unknown"`: Frankensurf doesn't claim the address was saved or the order placed. Check that with a read. If a run fails part-way, its key becomes `EXECUTION_OUTCOME_UNKNOWN` and is never replayed, because the site may already have changed. Password, file and hidden inputs, downloads, popups, WebSockets and your own JavaScript are blocked. Requests outside the allowed origins are blocked too. --- # Agent mode A built-in agent, on any model LiteLLM supports, that searches and reads with Frankensurf and answers with sources. Plus LLM extraction and summaries. Frankensurf is built for agents, but you don't need one to use it. In agent mode a model you choose does the searching and reading for you: ask a question, get an answer with the pages it came from, and a plain list of anything it couldn't read. ```bash pip install "frankensurf[agent]" export FRANKENSURF_AGENT_MODEL=openrouter/google/gemini-2.5-flash # any LiteLLM model, or pass model= export OPENROUTER_API_KEY=... # the provider's key, as LiteLLM reads it ``` ## Ask ```python answer = fs.ask("What revenue did NVIDIA report for its most recent quarter?") print(answer["answer"]) # cites [1], [2] ... with a source list print(answer["sources"]) # the pages it read: url, title, trace_id print(answer["couldnt_read"]) # pages no tool could read, and why print(answer["llm_cost_usd"]) # what the model calls cost (None if the provider doesn't say) ``` ```bash frankensurf ask "What revenue did NVIDIA report for its most recent quarter?" ``` The model gets two tools, Frankensurf's `search` and `read` (pages come back as markdown), and is told to read before it answers. If it tries to answer from search snippets alone, it's sent back to read the pages first. A page no tool could read reaches it as "Could not read this page (CODE)", with the instruction to say so instead of filling it in. Each answer lists the pages it read, so you can check them. ## Extract and summarize ```python data = fs.extract(["https://example.com/pricing"], prompt="the plans and their monthly prices", schema={"type": "object", "properties": {"plans": {"type": "array"}}}) data["data"] # checked against the schema; one retry with the errors if it doesn't fit summary = fs.summarize("https://example.com/post")["summary"] ``` `frankensurf serve --model ...` gives Firecrawl's `summary` and `json` scrape formats and its `/v1/extract` and `/v2/extract` jobs the same engine ([Firecrawl-compatible API](/docs/firecrawl-compatible/)). ## Options `model=` (or `FRANKENSURF_AGENT_MODEL`), `max_steps=` (8 tool rounds, then a final answer), `paid=` and `max_cost=` for Frankensurf's own paid tools. Anything else goes to `litellm.acompletion` (`temperature`, `api_base` for a local model, and so on). Page text reaches the model inside `` tags with the instruction never to follow what's in them, but a model can still be steered by a page written to do it. Treat answers about pages you don't trust accordingly. Your provider key stays with LiteLLM; Frankensurf never sees or logs it. --- # Add to your agent Connect Frankensurf to Claude Code, Claude Desktop, Cursor, Codex or any MCP client. `frankensurf-mcp` is a local MCP server. Your agent starts it and gets [its tools](/docs/reference/mcp/): `read`, `search`, `extract`, `batch` and the rest. [Install Frankensurf](/docs/install/) first, then point your client at it. In the examples, replace `/path/to/frankensurf` with your checkout, for example `/home/you/frankensurf` on Linux or `/Users/you/frankensurf` on macOS. On Windows the server runs inside WSL; see [Windows](#windows) below. ```bash claude mcp add frankensurf \ -e FRANKENSURF_STATE=/path/to/frankensurf/state \ -- /path/to/frankensurf/.venv/bin/frankensurf-mcp ``` Run `/mcp` inside Claude Code to check that `frankensurf` is connected. Open Settings → Developer → Edit Config, add this to `claude_desktop_config.json`, then restart Claude Desktop. ```json { "mcpServers": { "frankensurf": { "command": "/path/to/frankensurf/.venv/bin/frankensurf-mcp", "env": { "FRANKENSURF_STATE": "/path/to/frankensurf/state" } } } } ``` Add this to `~/.cursor/mcp.json`: ```json { "mcpServers": { "frankensurf": { "command": "/path/to/frankensurf/.venv/bin/frankensurf-mcp", "env": { "FRANKENSURF_STATE": "/path/to/frankensurf/state" } } } } ``` Add this to `~/.codex/config.toml`: ```toml [mcp_servers.frankensurf] command = "/path/to/frankensurf/.venv/bin/frankensurf-mcp" env = { FRANKENSURF_STATE = "/path/to/frankensurf/state" } ``` Any client that launches stdio MCP servers works. Use: | Field | Value | | --- | --- | | Command | `/path/to/frankensurf/.venv/bin/frankensurf-mcp` | | Environment | `FRANKENSURF_STATE=/path/to/frankensurf/state` | | Transport | stdio | ## Teach your agent the tools FrankenSurf ships a skill: a short guide to which tool and option fits, how to read a receipt, and what to do when a page isn't right. The MCP server already sends it to every client as its instructions; for agents that load skills from a folder (Claude Code), install it too: ```bash frankensurf skill install # ~/.claude/skills/frankensurf/SKILL.md frankensurf skill install --skill-dir ./my-agent/skills frankensurf skill show frankensurf skill uninstall # remove it again (--skill-dir for another folder) ``` `frankensurf setup` asks first: in a terminal it names the path and waits for a yes (the default is no). Scripts and CI are never asked and nothing is written; setup's JSON says where the skill would go. Pass `frankensurf setup --install-skill` to install it, or `--no-install-skill` to skip it and the question. An installed skill that differs from the bundled one is only overwritten if you say yes or pass `--install-skill`. ## When a page isn't what you needed Frankensurf guarantees the page is the one a real browser sees: not a block page, a sign-in redirect served to bots, an empty JavaScript frame or a fake 404. Whether it holds the data you wanted is your agent's call. When it doesn't, your agent can ask Frankensurf to try harder: - Every observed read's receipt carries `if_not_right`: its `trace_id`, the strong tools not yet tried, and how to ask. - Read again with `retry_of=` (MCP: `try_harder_than`, CLI: `--try-harder-than`). Frankensurf skips every tool that read used, including earlier rounds, and starts from the strongest remaining one. - When nothing is left, the read fails with "every allowed tool was already tried". The next steps are allowing paid tools, a [profile](/docs/read/profiles/), or [handoff](/docs/blocked/handoff/). - The MCP `trace` tool returns the full record of any read: every tool tried, failures, timings and completeness steps. ## Where each config file lives | Client | macOS and Linux | Windows | | --- | --- | --- | | Claude Code | set by `claude mcp add` | set by `claude mcp add` | | Claude Desktop | `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS; there is no Linux app) | `%APPDATA%\Claude\claude_desktop_config.json` | | Cursor | `~/.cursor/mcp.json` | `%USERPROFILE%\.cursor\mcp.json` | | Codex | `~/.codex/config.toml` | `%USERPROFILE%\.codex\config.toml` | ## Windows Frankensurf runs inside WSL, so a Windows client starts it through `wsl`. Use `wsl` as the command and pass the rest as arguments, with Linux paths inside WSL: ```json { "mcpServers": { "frankensurf": { "command": "wsl", "args": ["-d", "Ubuntu", "--", "env", "FRANKENSURF_STATE=/home/you/frankensurf/state", "/home/you/frankensurf/.venv/bin/frankensurf-mcp"] } } } ``` For Claude Code running inside WSL itself, the Linux command above works as is. ## Optional settings Add these to `env` if you run the optional services: | Variable | When | | --- | --- | | `FRANKENSURF_SEARCH_URL` | You run [SearXNG](/docs/integrations/searxng/) somewhere other than the default `http://127.0.0.1:8088` | | `FRANKENSURF_STEEL_URL` | You run [Steel](/docs/integrations/steel/), for example `http://127.0.0.1:3100` | Paid service keys are read from your environment or `~/.config/frankensurf/.env`, never from tool arguments. See [Paid tools and budgets](/docs/blocked/paid-tools/). Ask your agent: *"Read https://news.ycombinator.com and list the top five stories."* --- # More than one connection When a site blocks your address, let a walled read try your other connections. Some walls block an address, not a browser. Once a site has seen enough reads from one connection, every tool on that machine meets the wall, however stealthy. If you have other connections, FrankenSurf can use them: a read that your direct connection walled gets one more pass through each of them. ## Set it up List your connections in `FRANKENSURF_EGRESS`, as `name=url`, separated by commas (newlines work in the environment, but not in `~/.config/frankensurf/.env`, which reads one line per setting): ```bash # A VPS you can SSH to is a free second connection: ssh -N -D 1080 you@your-vps & export FRANKENSURF_EGRESS="vps=socks5://127.0.0.1:1080, phone=http://192.168.1.20:8080" ``` Any HTTP or SOCKS5 proxy works: an SSH tunnel to a VPS, a proxy app on your phone's hotspot, a residential pool. SOCKS needs the socks extra: `pip install "frankensurf[socks]"`, or `uv tool install "frankensurf[mcp,socks]"` for a tool install. The pool comes from your configuration only: receipts, traces and hints name a connection (`vps`), never its address or credentials. Check them: `frankensurf egress check` reads one small page through your direct connection and each connection in the pool, and says whether it works and where it comes out (address and country), never the connection's URL. ## A free second connection - **An SSH tunnel to any server you can log in to** (a VPS, a friend's machine, a cloud shell): `ssh -N -D 1080 you@host`, then `vps=socks5://127.0.0.1:1080`. - **Cloudflare WARP**, free: register once with [wgcf](https://github.com/ViRb3/wgcf) (`wgcf register`, then `wgcf generate`), and run [wireproxy](https://github.com/pufferffish/wireproxy) with that profile and a `[Socks5]` section on `127.0.0.1:40000`; then `warp=socks5://127.0.0.1:40000`. `egress check` shows `warp: on` when it works. Walls that refuse data-centre addresses may refuse WARP's too, so check it on the sites you need. - **Your phone's hotspot** with a proxy app on the phone. ## What happens 1. A read starts on your direct connection, as always. 2. If it ends at a wall or a rate limit (`egress_failures`: `BLOCKED`, `CAPTCHA`, `RATE_LIMITED`), each connection in the pool gets one pass with `egress_tools` (Camoufox, Scrapling and plain HTTP by default). Tools that got past this [wall vendor](/docs/blocked/escalation/#skipping-ahead) more often than not go first. Only tools that take a connection per read count: plain HTTP and the isolated stealth browsers (Camoufox, Scrapling, Patchright, nodriver without a password). The local browser and Steel take their proxy when they start, so they are left out. If the page then needs a stronger read, that climb goes through the same connection, with the same tools. 3. The first connection that gets the page wins, and becomes the site's hint for a day (`origin_route_hint_ttl_seconds`): the next read of that site goes through it first, and falls back to the usual route if it stops working. A site cooling down after a wall ([pacing](/docs/blocked/escalation/#pacing)) still gets its known connection, and only that. `receipt.egress` appears whenever the pool was tried: `used` (the connection's name, or `null` when none got through), `tried`, `after` (the failure that started it) and `direct_trace_id` (the walled direct read, whose cost stays on the bill), or `from_hint: true` when the site's hint chose the connection. The read's trace records it too. `FRANKENSURF_PROXY` is different: it sends every read from your machine through one proxy. The pool is a fallback, used only after a wall; a pass through a pool connection uses that connection instead, and its receipt has no `via_proxy`. Set `use_egress_pool: false` on a read to keep it on the direct connection. ## Options | Option | Default | Description | | --- | --- | --- | | `FRANKENSURF_EGRESS` | unset | Your other connections, `name=url`, comma- or newline-separated. | | `use_egress_pool` | `true` | Let a walled read try the pool. | | `egress_failures` | `BLOCKED`, `CAPTCHA`, `RATE_LIMITED` | Failures that start a pass through the pool. | | `egress_tools` | `camoufox`, `scrapling`, `http` | Tools a pass uses (only those that take a connection per read); the wall vendor's best go first. | --- # How escalation works Frankensurf starts with the cheapest tool and climbs a six-rung ladder only when a site pushes back. Every way to fetch a page is a rung. Low rungs are fast and free; high rungs get through more. Each read starts low and climbs only as far as it has to. | Rung | What it does | Tools | | --- | --- | --- | | **T0** Markdown | Asks the site for `text/markdown` | [HTTP](/docs/integrations/http/) | | **T1** Light fetch | Plain HTTP or a reader, no browser | [HTTP](/docs/integrations/http/), [Scrapling](/docs/integrations/scrapling/), [Jina Reader](/docs/integrations/jina-reader/) | | **T2** Browser | A real browser renders the page | [Chromium](/docs/integrations/local-chromium/), [Steel](/docs/integrations/steel/), [Crawl4AI](/docs/integrations/crawl4ai/), [Browserbase](/docs/integrations/browserbase/), [Hyperbrowser](/docs/integrations/hyperbrowser/), [Browserless](/docs/integrations/browserless/), [Kernel](/docs/integrations/kernel/), [Anchor](/docs/integrations/anchor/), [Cloudflare](/docs/integrations/cloudflare-browser-run/) | | **T3** Stealth and signed | Looks like a person, or signs as a verified agent | [Camoufox](/docs/integrations/camoufox/), [Scrapling](/docs/integrations/scrapling/), [Patchright](/docs/integrations/patchright/), [nodriver](/docs/integrations/nodriver/), [signed requests](/docs/blocked/signed-requests/) | | **T4** Unblocker | Paid services built for the hardest walls | [Firecrawl](/docs/integrations/firecrawl/), [ZenRows](/docs/integrations/zenrows/), [fastCRW](/docs/integrations/fastcrw/), [Scrapfly](/docs/integrations/scrapfly/), [Bright Data](/docs/integrations/bright-data/), [Zyte](/docs/integrations/zyte/), [Apify](/docs/integrations/apify/) | | **T5** You | A person clears the wall | [Human handoff](/docs/blocked/handoff/) | ## What counts as a failure A tool "succeeding" isn't enough. Frankensurf checks what came back and climbs when it sees: - an app shell or unrendered template (`VISUAL_REQUIRED`); - a rendered page with almost no text (`EMPTY_PAGE`); - a challenge or "Just a moment…" page (`CAPTCHA`, `BLOCKED`); - a sign-in page where content should be (`AUTH_REQUIRED`). Sites also fake walls for bots: a sign-in redirect, or a 404, that a real browser never sees. So on a public read: - a `NOT_FOUND` from a plain fetch, or after another wall, gets one more try; - a sign-in wall stops the climb only after `auth_wall_confirmations` (default 5) tools in a row hit one; - after a suspected fake wall, the tools that look most different from the one that was fooled go next (`fake_wall_confirmers`: Jina Reader, Camoufox, then the paid unblockers). A page that is plainly a site's bot page (a final URL such as `/help/bots.html` or `/captcha`, or text like "thinks you are a bot") counts as `CAPTCHA`, and a short page that only asks you to sign in, in English, Spanish, Portuguese, French, German, Italian or Dutch, counts as `AUTH_REQUIRED`. An "access denied" title counts as a wall in German, French, Spanish, Italian, Dutch, the Nordic languages, Polish, Czech, Hungarian, Russian, Japanese and Chinese as well as English. A browser's own error page (DNS or connection failure) or a site's maintenance page fails as `PROVIDER_DOWN`, so it is never kept as the page. Browsers read content inside open shadow roots too, so pages built from web components keep their text. Signed-in reads (identities and profiles) keep the strict rule: a sign-in wall there means the session needs renewing, and the climb stops. ### Second opinion A plain HTTP page can pass every check and still lack its content: a site that loads results with JavaScript serves only its header and footer as text. So when an automatic read lands on plain HTTP with scripts and under 5,000 characters of text (`second_opinion_text_chars`), Frankensurf also asks a browser and keeps whichever page has clearly more content (at least 1.5 times as much, and 1,000 characters more). `receipt.second_opinion` shows both. If the render fails or isn't better, the HTTP page stands; the only cost is time. ### Completeness Then every automatic read gets a structural check (`completeness.py`). It looks at the page's shape: - **Search and category pages** (a query parameter, or a path such as `/search`) need at least 10 distinct same-site item links (product, listing or detail URLs; never assets or service and legal pages) or 4 prices. Results that don't look like product links still count when they look like results: 8 or more links under one path prefix (`/packages/`, `/project/`), at least a third of which mention the query. A menu shares a prefix too, but not the query. Links to the page's own subdomains count as its results (posts on `name.substack.com`, albums on `artist.bandcamp.com`), but never service subdomains (`help.`, `blog.`, `careers.` and the like) or other sites on a shared platform. - **Item pages** need a price or 1,500 characters of text. - **Other pages** need 1,500 characters, or 400 without scripts. An incomplete page is re-read along `completeness_ladder`: Jina Reader, Scrapling, Camoufox, then Firecrawl, Zyte, ZenRows and Scrapfly when paid tools are allowed, then Patchright and Bright Data. Each step is an ordinary read pinned to one tool, with the usual checks and receipt. Frankensurf stops at the first complete page and keeps the most complete one it saw. A search page that passes only narrowly (fewer than `completeness_borderline_items`, 20, item links and few prices) also gets one read from the strongest allowed tool, a paid one when allowed. `completeness_max_extra_reads` (default 5) and `completeness_deadline_seconds` (default 150) bound the extra work; each extra tool gets `completeness_step_timeout_seconds` (default 45) before it is cut off and the best page so far stands (`receipt.completeness.cut_off`). Measured on the held-out sets, only Zyte ever ran past 45 seconds, mostly to time out. `receipt.completeness` lists every step. Climbing also stops when two browsers read the same page as the first read (item links within one, text within 20%): that is the page, not an unfinished one, and `completeness.agreed` says so (`completeness_stop_on_agreement`, on by default). Free tools on the ladder race in pairs (`completeness_parallel`, default 2, up to 4). The first complete page wins and the other read is cancelled. Starts are half a second apart, so a site sees at most two extra reads at once. Paid tools always run one at a time, so racing never pays twice. On the 139-site held-out set this cut the free tier's p90 from 50 s to 37 s and its median from 5.6 s to 4.5 s. ### Placeholders and menus Two more shapes of a page that came back without its content are caught on every kind of page, not only search results: - **Placeholders.** Prices of `$0` with no real price, or template values that leaked into the text (`{{ price }}`, `NaN`, `undefined`), mean the page had not loaded. `completeness.placeholder` is set and the read escalates. - **Menus only.** When more than 80% of the text is link text and there is no price, the read got the site's navigation, not the page. `completeness.link_text_share` says how much. Some pages are complete but hide their prices behind a choice ("select guests to see prices"). No stronger tool would show more, so these don't escalate; `completeness.needs_interaction` quotes the page so your agent knows why there is no price. ### Pages a stronger tool can't fix Some wrong pages look right to every tool, so Frankensurf names them instead of climbing: - **Off-query results.** A site that ignores an unknown search parameter shows its default feed, which passes the structure check. When the query is known (from parameters such as `q`, `kw` or `keywords`, or from your `expect_terms`), a results page whose items and text never mention it comes back observed but marked `completeness.off_query`. `receipt.next_step` says the search URL is probably wrong. A page still loading its results escalates as before. Once you know the right URL, a [site module](/docs/read/site-modules/) template keeps it. - **Pages a site module vouches for.** When a saved module's assertions pass, the page is complete even if it has few links, because the module knows its results live in JSON. A module's `invalid` markers turn the site's own error or empty-feed page into `NOT_FOUND`. - **Site error pages.** A redirect to an `error` or `404` path, an error title such as "Page not found", or a short page saying the page doesn't exist fails as `NOT_FOUND`. A plain fetch's error page gets one confirming read from a different tool, since some sites serve fake 404s to bots; then the read stops. ### When the URL is wrong A URL can simply be wrong: a guessed search path, a product that moved, a typo, a search address the site no longer honours. When a public read ends `NOT_FOUND`, or lands on search results that ignore the query (`off_query`), Frankensurf searches the site itself for what the URL asked for: its query (`?q=air+fryer` is "air fryer", in any of the usual parameter names), the words of its last path segment (`/p/brass-desk-lamp-123` is "brass desk lamp"), or the one query value that is plain words. It reads the site's home page, finds its search (schema.org `SearchAction`, the search form, or the search box itself), and reads the results. They stand only when they are complete and mention those words: - `receipt.rescued` says it happened: `from` (the URL you asked for), `query`, `via` and `search_url`; the quality grade carries a `rescued` flag and `receipt.next_step` says these are search results, not the page itself. - When the search finds nothing that fits, the read stays `NOT_FOUND` and `receipt.rescue` says what was tried. - `rescue_not_found: false` turns it off. ## One small action Some pages are one action away from their content: a cookie banner over the article, a "load more" button under the first results, or a price that appears only after you pick an option. When a page says it needs a choice (`completeness.needs_interaction`) or is only a consent notice, the first escalation step is `interact`: the local browser with three safe actions. - **dismiss_consent**: inside a cookie or consent banner, press reject or "necessary only". It never accepts; with no reject button it closes the banner. - **reveal**: give a required dropdown that has no value its first real option. - **load_more**: press "load more" or "show more" up to three times while the page keeps growing. Never "next page", which leaves the page. Nothing is bought, booked, signed in to, submitted or typed: a guard refuses any button whose words say buy, checkout, add to cart, subscribe, sign in, pay, book or accept. `receipt.interactions` lists every press. Ask for the actions on any browser read with `interactions` (for example `["dismiss_consent", "load_more"]`). ## Walled deep links A public read that ends at a wall (`BLOCKED`, `CAPTCHA`) gets one more try: a stealth browser that opens the site's home page first and then follows to the page, as a person arrives, instead of landing cold on a deep link. `receipt.warm_up` says what happened (`warm_up_on_wall`, on by default). In two runs on the 139-site set, at home and on a server, it recovered the same walled sites (Bloomingdale's, OzBargain); it adds time to reads that stay walled. The local browser also keeps each site's cookies for a day (`site_session_hours`), so a challenge it passed or a banner it rejected stays that way on the next read. Only anonymous state: never a profile's or an identity's. ## Racing a slow read (smart lightning) A blocked or script-heavy site can take several tools in turn before one gets through. Once an automatic read has run for 3 seconds without a page (`hedge_after_seconds`, 0 turns it off), two more reads start beside it (`hedge_racers`), pinned to the first free tools on the completeness ladder: Jina Reader, which fetches from its own network, and Scrapling, a stealth browser. The first complete page wins and the rest are cancelled. Most pages are read well inside 3 seconds and never race, so a site sees extra requests only for the reads that need them. When nothing is complete, the more complete page stands, and when the main read fails, the racers' best page does (`receipt.hedge.after_failure` names the failure and `walled` the tools it met). Racers are free tools only. A racer that wins becomes the site's route hint, so the next read starts there. `receipt.hedge` records the race. [Lightning mode](/docs/read/lightning/) starts the racers at once instead of after 3 seconds. ## Skipping ahead Four things reorder the ladder so repeat visits are fast: | | What it does | Setting | | --- | --- | --- | | **Wall vendors** | A wall's response names the bot-defence service behind it (`receipt.wall_vendor`: Cloudflare, Akamai, DataDome, PerimeterX, Imperva, Kasada, AWS WAF, Vercel or Sucuri). Frankensurf counts which tools got past each vendor on this machine; once a tool has at least 3 tries and even odds, later reads that meet the same vendor on any site try it next, and a tool that has never got past it in 8 tries goes last (`receipt.routing.learned_for`). A fresh install starts from measured priors (`bundled_wall_priors.json`, worth up to 8 tries per tool), which count less with each of your own reads and not at all after 8 (`use_wall_priors`). Vendors, never site names. | `state_dir/wall-stats.json` | | **Escalation** | After 4 failed tries in one read, free tools that fetch from their own network (Jina Reader) move ahead: a wall that blocks by address stops every tool on this machine alike, and some pages none of them render. When paid tools are allowed, they move ahead after the first wall (`paid_after_walls`; after two when a free tool usually beats that wall vendor), cheapest likely success first, behind [your real browser](/docs/blocked/real-browser/) when it is allowed; without paid tools, the real browser moves ahead after 4 walls. | `escalate_after_walls`, `paid_after_walls` | | **Site hints** | When a read succeeds only after earlier tools failed, the tool that got through goes first on that site for 24 hours. | `origin_route_hint_ttl_seconds` | | **Route memory** | After 3 clean reads in a row on the same path pattern, that tool goes first for that path. | `route_memory_min_samples` | Measured on g2.com: 63.5 s before hints, 8.5 s on the first read with escalation, 2.4 s with the hint. A few sites need a particular tool from the first visit. Those live as data in `bundled_route_seeds.json`, never as site code, and your own settings always win. ## Pacing Frankensurf is polite by default. - Reads to one site are at least 2 seconds apart, across processes (`origin_min_interval_seconds`). - A `BLOCKED`, `CAPTCHA` or `RATE_LIMITED` answer pauses direct reads of that site for 15 minutes (`origin_cooldown_seconds`). - Paid unblockers, handoff and [your real browser](/docs/blocked/real-browser/) aren't held by the pause: a different service, a person or a real browser is handling the wall. ## Choosing yourself | You want | Set | | --- | --- | | One specific tool, no fallback | `provider="zenrows"` | | An exact order | `provider_candidates=["http", "camoufox"]` | | Paid tools allowed, within a budget | `allow_paid_fallbacks=True`, `max_cost_usd=0.05` | | Nothing on your machine | `allow_local_browser=False` | `receipt.attempts` shows every tool tried and why each stopped, and `receipt.routing.provider_plan.ordered` shows the order that was planned. --- # Human handoff When no tool can clear a wall, open the page for a person and resume the read in the same tab. Some walls need a person: a CAPTCHA no tool clears, a 2FA code, a consent page, a sign-in. Handoff is the last rung (T5). Frankensurf opens the page in a visible browser, tells you, and waits. Once the page shows real content, the read resumes in that same tab. - Frankensurf only watches the tab. It never clicks or types. - What you clear is kept in a persistent profile, so the next visit is usually automatic. - Runs only when a call asks for it. ## Running on a server Sites refuse data-centre addresses more often than home connections. Measured on the same 139 fresh sites with free tools: 80.6% read from a home connection, 70.5% from a cloud VM, 74.8% with Steel running beside it. Three things close the gap: - **Run Steel next to FrankenSurf.** `docker compose up -d` in the repository starts the self-hosted Steel browser; set `FRANKENSURF_STEEL_URL`. - **Bring a proxy.** Set `FRANKENSURF_PROXY` (for example `http://user:pass@proxy.example:8080` or `socks5://host:1080`, a residential or mobile pool) and plain HTTP, the local browser, Steel and the stealth browsers all go through it. Hosted readers already use their own networks. It comes from the environment only: it never appears in policies, receipts or traces, and a read that used it says `receipt.via_proxy` (a pass through one of [your other connections](/docs/blocked/egress/) uses that connection instead). - **Give it other connections.** With `FRANKENSURF_EGRESS`, a read the server's address got walled on tries your other connections ([More than one connection](/docs/blocked/egress/)). - **Keep paid fallbacks for the hardest walls.** ## Servers: refuse internal addresses If a server reads URLs it did not choose (from search results or from users), set `FRANKENSURF_BLOCK_PRIVATE_NETWORK=1`, or `block_private_network` on a read. Reads then refuse loopback, private, link-local and local-only targets (`localhost`, `*.internal`, `*.local`), names that resolve to them, and redirects into them, with `POLICY_DENIED`. It is off by default so agents can still read your own local development servers. ## Or an older copy When a page is walled and an older copy will do (an article, a spec sheet, documentation), allow the archive instead. Frankensurf asks the Internet Archive for its latest stored copy, as the very last rung: ```python page = await web.read(url, policy_overrides={"allow_archive": True}) page["receipt"]["archived"] # {"source", "archived_at", "snapshot_url"} ``` It is never on by default: an archived price or stock level is not today's. An archived read says so in `receipt.archived`, sets `receipt.source_freshness` to `archived`, and never becomes the site's route hint. A walled read's `receipt.next_step` mentions both options. ## Usage ```python # After every automatic tool has failed page = await web.read(url, policy_overrides={"allow_handoff": True}) # Straight to a person page = await web.read(url, provider="handoff") ``` ```bash frankensurf read https://example.com/account --handoff ``` ```json { "tool": "read", "arguments": { "url": "https://example.com/account", "allow_handoff": true } } ``` You get a message on stderr and, where `notify-send` exists, a desktop notification. ## Offering it from an agent A read that stops at a wall a person could clear carries a suggestion: ```json title="receipt.next_step" { "provider": "handoff", "reason": "CAPTCHA" } ``` Your agent can show that to its user and retry with `allow_handoff: true`. ## Options | Option | Default | Description | | --- | --- | --- | | `allow_handoff` | `false` | Try handoff after every automatic tool fails. | | `handoff_timeout_seconds` | `300` | How long to wait for a person. | | `FRANKENSURF_HANDOFF_PROFILE` | `~/.local/share/frankensurf/handoff-profile` | Browser profile to use. | | `FRANKENSURF_HANDOFF_CDP_URL` | none | Open the page as a tab in a browser you already run. | Handoff needs a display, or `FRANKENSURF_HANDOFF_CDP_URL`. Without either it reports itself unavailable and is skipped. --- # Paid tools and budgets Turn on paid browsers, unblockers and search, and cap what a read can spend. The free stack needs no keys. Paid tools join a read only when two things are true: the key is set, and the call allows it. - Paid tools are tried after the free ones, until a wall: then they move ahead ([escalation](/docs/blocked/escalation/), `paid_after_walls`, after the first wall by default, or the second when a free tool usually gets past that wall vendor). - Cheapest likely success first: each paid tool's measured cost per read over its chance of getting past this wall vendor (bundled measurements: Firecrawl passed 88 to 100% of the labelled Cloudflare, Akamai, DataDome and PerimeterX sites at $0.004 a read; ZenRows 29 to 50% at $0.025). Your own reads update the chances. - They are never retried automatically. - A tool without a key is skipped, not failed. ## Usage ```python page = await web.read(url, policy_overrides={ "allow_paid_fallbacks": True, "max_cost_usd": 0.05, }) ``` ```json { "tool": "read", "arguments": { "url": "https://www.g2.com/", "acquisition_policy": { "allow_paid_fallbacks": true, "max_cost_usd": 0.05 } } } ``` `receipt.cost_usd` is the total measured cost. If any tool's cost is unknown, the total is `null`, never a guess. ## Add keys Frankensurf looks for each key in this order: 1. the environment, as `FRANKENSURF_`; 2. `~/.config/frankensurf/.env`; 3. the `api_keys` object in `~/.config/frankensurf/services.json`. ```bash title="~/.config/frankensurf/.env" FRANKENSURF_ZENROWS_API_KEY=... FRANKENSURF_FIRECRAWL_API_KEY=... FRANKENSURF_EXA_API_KEY=... ``` Keep the file readable only by you (`chmod 600`). With a secrets manager such as 1Password, Doppler or Infisical, store the keys under these names and run through it, for example `op run -- frankensurf read …`. Keys never appear in results, receipts or errors. | Tool | Keys | | --- | --- | | [Jina Reader](/docs/integrations/jina-reader/) | `JINA_API_KEY` (optional) | | [Firecrawl](/docs/integrations/firecrawl/) | `FIRECRAWL_API_KEY` | | [ZenRows](/docs/integrations/zenrows/) | `ZENROWS_API_KEY` | | [Scrapfly](/docs/integrations/scrapfly/) | `SCRAPFLY_API_KEY` | | [Bright Data](/docs/integrations/bright-data/) | `BRIGHTDATA_API_KEY`, `BRIGHTDATA_ZONE` | | [Browserbase](/docs/integrations/browserbase/) | `BROWSERBASE_API_KEY` | | [Steel Cloud](/docs/integrations/steel/) | `STEEL_API_KEY` | | [Hyperbrowser](/docs/integrations/hyperbrowser/) | `HYPERBROWSER_API_KEY` | | [Browserless](/docs/integrations/browserless/) | `BROWSERLESS_API_KEY` | | [Kernel](/docs/integrations/kernel/) | `KERNEL_API_KEY` | | [Anchor](/docs/integrations/anchor/) | `ANCHOR_API_KEY` | | [Cloudflare Browser Run](/docs/integrations/cloudflare-browser-run/) | `CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_ACCOUNT_ID` | | [Zyte](/docs/integrations/zyte/) | `ZYTE_API_KEY` | | [Apify](/docs/integrations/apify/) | `APIFY_API_TOKEN` | | [fastCRW](/docs/integrations/fastcrw/) | `FASTCRW_API_KEY`, or `FASTCRW_URL` for self-hosted | | [Skyvern](/docs/integrations/skyvern/) | `SKYVERN_API_KEY` | | [Exa](/docs/integrations/exa/) | `EXA_API_KEY` | | [Brave](/docs/integrations/brave/) | `BRAVE_API_KEY` | | [Tavily](/docs/integrations/tavily/) | `TAVILY_API_KEY` | | [Parallel](/docs/integrations/parallel/) | `PARALLEL_API_KEY` | | [Serper](/docs/integrations/serper/) | `SERPER_API_KEY` | | [SerpAPI](/docs/integrations/serpapi/) | `SERPAPI_API_KEY` | | [Google Programmable Search](/docs/integrations/google-cse/) | `GOOGLE_CSE_API_KEY`, and `FRANKENSURF_GOOGLE_CSE_CX` | All prefixed with `FRANKENSURF_`. ## Budgets `max_cost_usd` caps the whole read, fallbacks included. Tools that can bound their worst case (ZenRows, Firecrawl and the hosted browsers) refuse a call that could pass what's left, with `BUDGET_EXHAUSTED`. Tools that can't report cost don't run under a cap. A bound is an estimate. For Firecrawl, a PDF adds a credit per parsed page, so under a cap a URL that looks like a PDF (`.pdf`, arXiv's `/pdf/`, `?format=pdf`) is priced for the pages the policy would read, and a page nothing has fetched yet gets one `HEAD` request first (free, from your machine) to learn whether it is a PDF and how big. When a paid call still bills more than the cap (a PDF with more pages than expected), the page you paid for is returned, not thrown away: `receipt.budget` is `{"cap_usd": 0.02, "spent_usd": 0.0266, "exceeded": true}`, `cost_budget_satisfied` is `false`, and no further paid call runs. Set `strict_cost_cap=True` to fail with `BUDGET_EXHAUSTED` instead. A call whose cost is unknown is always refused. Hosted browsers bill by session time. The default rates are list prices; set yours with `FRANKENSURF__USD_PER_HOUR`, for example `FRANKENSURF_BROWSERBASE_USD_PER_HOUR=0.10`. --- # Your real browser When walls keep stopping automated browsers, let your installed Chrome or Edge try the page. Some walls recognise automated browsers, however stealthy. A real browser, the Chrome or Edge you browse with, looks like what it is. Turn this on and a read that keeps meeting walls tries the page in your real browser (a real, non-headless window, kept off your screen) before giving up. - **Opt-in.** Off unless a call allows it (`allow_real_browser`) or you set `FRANKENSURF_REAL_BROWSER=1` for every read. - **Never your profile.** It uses FrankenSurf's own browser profile, so your cookies, history and sign-ins stay yours. Cookies that profile earns (a challenge it passed) are kept, as in a person's browser, so later visits get through more easily. - **Never clicks.** A challenge that clears by itself ("Just a moment...") is waited out, up to `real_browser_wait_seconds` (15). One that needs a person still fails as a wall, and a [handoff](/docs/blocked/handoff/) can follow. - **Out of your way.** On Linux and WSL it runs on a private virtual display (Xvfb: `sudo apt install xvfb`): a real, non-headless browser that never appears on your screen or takes your cursor or focus, and that works on a server with no display too. Without Xvfb, and on macOS and Windows, the window opens off-screen on your display: out of sight, but it may still take focus there. Set `FRANKENSURF_REAL_BROWSER_WINDOW=visible` to watch it on your screen (on a virtual display it draws without your GPU, which a few walls notice). A browser you attach with `FRANKENSURF_REAL_BROWSER_CDP` is yours to place: its windows show wherever that browser runs. - **One page at a time** per profile, in a window that opens and closes. A read that finds the profile busy (another read, or another FrankenSurf process) skips the real browser rather than waiting. ## When it runs It joins the end of an automatic read's route (before an archived copy, when that is allowed too). Once the read has met walls (`escalate_after_walls`, 4), it moves to the front of what is left, ahead of any [paid tools](/docs/blocked/paid-tools/): it is free, and walls rarely tell it from a person. The receipt says so: `receipt.method` is `real_browser`, and `receipt.routing.escalated_to` names it when walls moved it ahead. A walled site pauses other reads for 15 minutes ([pacing](/docs/blocked/escalation/#pacing)), but not this one: a read that allows the real browser while the site is cooling down after a wall goes straight to it (a rate limit's pause still holds). So an agent can retry a walled page with `allow_real_browser`, or `try_harder_than` and `allow_real_browser` together, and get a real attempt. A handoff, when the call allows one, still comes last and uses its own profile: a sign-in you complete there never reaches the real browser's anonymous reads. ## Usage ```python # Try your real browser when walls keep blocking page = await web.read(url, policy_overrides={"allow_real_browser": True}) # Straight to your real browser page = await web.read(url, provider="real_browser") ``` ```bash frankensurf read https://example.com/item/123 --real-browser ``` ```json { "tool": "read", "arguments": { "url": "https://example.com/item/123", "allow_real_browser": true } } ``` ## Which browser FrankenSurf looks for Google Chrome, then Microsoft Edge, where they install themselves on Linux, macOS and Windows. Point it elsewhere with `FRANKENSURF_REAL_BROWSER_PATH` (any Chromium-based browser, such as Brave). To use a browser that is already running, start it with remote debugging and a profile of its own, and set `FRANKENSURF_REAL_BROWSER_CDP`: ```bash google-chrome --remote-debugging-port=9222 --user-data-dir="$HOME/.frankensurf-chrome" export FRANKENSURF_REAL_BROWSER_CDP=http://127.0.0.1:9222 ``` Each read then opens a fresh private window there and closes it afterwards: no cookies go in or out, so the browser's own sign-ins never reach a read. Chrome refuses remote debugging on your everyday profile, which is why it needs its own. It needs Xvfb or a display (on Linux), or `FRANKENSURF_REAL_BROWSER_CDP`. Without any of them it reports itself unavailable and is skipped. In WSL, a Linux Chrome inside WSL works (hidden on Xvfb, or through WSLg when visible); a Windows browser is reachable only through `FRANKENSURF_REAL_BROWSER_CDP`, and only when WSL can reach Windows' localhost (mirrored networking). ## Options | Option | Default | Description | | --- | --- | --- | | `allow_real_browser` | `false` | Let your real browser join automatic reads. | | `real_browser_wait_seconds` | `15` | How long a challenge gets to clear by itself. | | `FRANKENSURF_REAL_BROWSER` | unset | `1` turns it on for every read (an MCP server, say). | | `FRANKENSURF_REAL_BROWSER_PATH` | Chrome, then Edge | The browser to launch. | | `FRANKENSURF_REAL_BROWSER_PROFILE` | `~/.local/share/frankensurf/real-browser-profile` | FrankenSurf's own profile for it. | | `FRANKENSURF_REAL_BROWSER_CDP` | none | Open tabs in a real browser you already run. | | `FRANKENSURF_REAL_BROWSER_WINDOW` | hidden | `visible` shows the window on your screen. | --- # Signed requests Arrive as a verified agent with Web Bot Auth, instead of an anonymous bot. [Web Bot Auth](https://datatracker.ietf.org/doc/draft-meunier-web-bot-auth-architecture/) signs each request with your key using HTTP Message Signatures (RFC 9421). Cloudflare, AWS, Akamai and Vercel can verify the signature and let a known agent through. - Every plain HTTP read is signed once you have a key, including each redirect. - The private key never leaves your machine. - Without a key, nothing is signed. ## Set it up 1. Create a key and print its public directory. ```bash frankensurf bot-auth-init frankensurf bot-auth-directory > directory.json ``` 2. Serve `directory.json` at `/.well-known/http-message-signatures-directory` on an HTTPS origin you control. 3. Point Frankensurf at that origin. ```bash export FRANKENSURF_SIGNATURE_AGENT=https://agent.example.com ``` 4. Register the origin with the networks that verify signed agents, such as [Cloudflare's signed agents](https://blog.cloudflare.com/signed-agents/). ## What gets sent Each request carries `Signature-Agent`, `Signature-Input` and `Signature` headers covering `@authority` and `signature-agent`, with a five-minute lifetime and a fresh nonce. ## Options | Option | Default | Description | | --- | --- | --- | | `sign_requests` | `true` | Set `false` to skip signing for one call. | | `FRANKENSURF_BOT_AUTH_KEY_FILE` | `~/.config/frankensurf/web-bot-auth.jwk` | Where the private key lives. | Checked against Cloudflare's public verifier with the RFC 9421 test key: the request is reported as authenticated, and a wrong key is rejected. --- # Map and crawl List a site's URLs without reading them, or read a whole section of a site as markdown. **Map** lists what a site has: the sitemaps robots.txt names (or `/sitemap.xml`, `/sitemap_index.xml`, `/sitemap-index.xml`), walked through any sitemap index, plus the links on the start page. It reads no pages, so it's quick. `search` keeps the URLs that mention its words, best match first. **Crawl** reads a section of a site: the start page, the sitemap and the links they lead to, breadth first, up to `max_depth` link hops and `limit` pages. Like Firecrawl, it only reads pages under the start URL's path (`/docs/` reads `/docs/...`) unless you pass `entire_domain`. Every page is a normal read: paced per site, checked, climbing to a stronger tool when walled, with a receipt. Each comes back as markdown. robots.txt is honoured unless you turn it off. ## Usage ```python urls = fs.map("https://frankensurf.dev/", search="cost") pages = fs.crawl("https://frankensurf.dev/docs/", limit=50, max_depth=2, exclude=[r"^/docs/reference/"]) for page in pages: print(page["status"], page["url"], len(page["markdown"])) ``` ```bash frankensurf map https://frankensurf.dev/ --query cost frankensurf crawl https://frankensurf.dev/docs/ --limit 50 --max-depth 2 --exclude-path '^/docs/reference/' ``` ```json { "tool": "crawl", "arguments": { "url": "https://frankensurf.dev/docs/", "limit": 20 } } ``` ## Options | Option | Default | What it does | | --- | --- | --- | | `limit` | 100 (map: 5,000) | Most pages to read (map: URLs to return). | | `max_depth` | `2` | Link hops from the start page; sitemap pages count as one hop. | | `include`, `exclude` | none | Regular expressions on the URL path; only matching, or never matching. | | `entire_domain` | `false` | Read the whole site, not only under the start path. | | `include_subdomains` | `false` | Follow links to the site's subdomains. | | `respect_robots` | `true` | Skip what robots.txt disallows (`--ignore-robots` turns it off). | Each page: `url`, `final_url`, `depth`, `status`, `title`, `markdown`, `failure`, `trace_id`, `cost_usd`. The crawl also reports `read`, `failed`, `left_in_queue` (pages found past the limit) and `skipped` (by robots.txt and by your filters). `frankensurf serve` answers Firecrawl's `/v1/map`, `/v2/map`, `/v1/crawl` and `/v2/crawl` with the same engine ([Firecrawl-compatible API](/docs/firecrawl-compatible/)). --- # Docker One Frankensurf for every machine on your network, as a Firecrawl-compatible API or an MCP server. Run Frankensurf once, in a container, and point every machine at it. The image has Chromium and the free stealth browsers installed, and keeps its route memory and jobs on a volume. ```bash docker run -d --name frankensurf -p 3002:3002 --shm-size=1g \ -e FRANKENSURF_API_KEY=change-me -v frankensurf:/data \ ghcr.io/yail259/frankensurf ``` That's the [Firecrawl-compatible API](/docs/firecrawl-compatible/) on port 3002. Anything that takes a Firecrawl URL uses it with the key as its API key: ```python from firecrawl import Firecrawl app = Firecrawl(api_key="change-me", api_url="http://frankensurf.local:3002") page = app.scrape("https://example.com/pricing", formats=["markdown"]) ``` ## As an MCP server ```bash docker run -d --name frankensurf-mcp -p 8000:8000 --shm-size=1g \ -e FRANKENSURF_API_KEY=change-me -v frankensurf:/data \ ghcr.io/yail259/frankensurf frankensurf-mcp --http --host 0.0.0.0 ``` Then add it to a client as a streamable HTTP server at `http://frankensurf.local:8000/mcp` with the header `Authorization: Bearer change-me`. In Claude Code: ```bash claude mcp add --transport http frankensurf http://frankensurf.local:8000/mcp \ --header "Authorization: Bearer change-me" ``` ## Compose ```yaml services: frankensurf: image: ghcr.io/yail259/frankensurf restart: unless-stopped shm_size: 1gb ports: ["3002:3002"] environment: FRANKENSURF_API_KEY: change-me # Paid tools stay off unless you add their keys and start with --paid: # FRANKENSURF_FIRECRAWL_API_KEY: ... volumes: ["frankensurf:/data"] volumes: frankensurf: ``` On a network address, `frankensurf serve` and `frankensurf-mcp --http` refuse to start without `FRANKENSURF_API_KEY`: anyone who can reach the port could read pages through it, and spend on your paid tools if they're on. `--allow-unauthenticated` runs it open, for a network you trust. ## Notes - **Images:** `ghcr.io/yail259/frankensurf:` and `:latest`, for `linux/amd64` and `linux/arm64`. Where a stealth browser isn't built for a platform, that image still reads with plain HTTP, Jina Reader and Chromium. - **Size:** about 6 GB unpacked. It carries four browsers (Chromium twice, for Frankensurf and the stealth tools' own Playwright, plus Camoufox's Firefox), which is what gets it past walls. - **`--shm-size=1g`:** browsers need shared memory; Docker's default 64 MB makes them crash. - **Chrome's sandbox:** Docker blocks the user namespaces it needs, so inside the container the stealth Chromium runs without it, the same as Playwright's own Chromium does by default; the container is the isolation. Outside a container it stays on wherever the kernel allows it. - **Memory:** about 1 GB for each browser read running at once. - **Agent mode:** add `-e FRANKENSURF_AGENT_MODEL=...` and the provider's key to get the summary, json and extract endpoints ([Agent mode](/docs/agent-mode/)). - The container runs as an unprivileged user, with `tini` reaping browser processes. --- # Firecrawl-compatible API Run Frankensurf as a local Firecrawl-compatible endpoint, so GPT Researcher, deep-research and anything else that takes a Firecrawl URL reads through it with no code change. A lot of agent projects read the web through Firecrawl, and most of them let you point at your own Firecrawl server. `frankensurf serve` is one: it answers Firecrawl's `scrape` and `search` calls, and reads every page through Frankensurf instead. ```bash frankensurf serve # http://127.0.0.1:3002, Firecrawl's own port, this machine only ``` Then set the project's Firecrawl URL to `http://127.0.0.1:3002`. The API key can be anything; it isn't checked. Free tools only unless you start it with `--paid`, and `--max-cost` caps a request. ## What you get back - **Scrape** (`POST /v1/scrape`, `/v2/scrape`): the page as markdown (headings, lists, links, tables, converted from its HTML), plus `html` and `links` if you ask for them. `metadata.frankensurf` carries the trace ID, so `frankensurf trace ` shows every tool it tried. `onlyMainContent` is accepted and ignored: Frankensurf's [main-content cut](/docs/read/main-content/) is built for articles, and on search and listing pages it lost the results. Menus cost a few tokens; missing results cost the answer. - **Search** (`POST /v1/search`, `/v2/search`): Frankensurf's checked search results. With `scrapeOptions.formats`, each result is read too. - **A page no tool could read is never passed off as one.** Scrape answers `success: false` with a plain reason, status 500 like Firecrawl's own failures (408 when the request's `timeout` runs out), so the SDKs raise it as an error: ```text Could not read this page (CAPTCHA: Provider returned a challenge or sign-in page). Tried: http, steel, camoufox, local, scrapling_http, patchright, nodriver, crawl4ai. Don't fill in its contents from search snippets or memory. ``` In search, that result keeps its URL, title and description, but has no markdown; the reason is in `metadata.error`. A research agent that uses the markdown simply doesn't get a page that wasn't there. - **Map** (`/v1/map`, `/v2/map`) and **crawl** (`/v1/crawl`, `/v2/crawl`, then `GET /v1/crawl/{id}` until `completed`; `DELETE` cancels): the same engine as [Map and crawl](/docs/crawl/). Crawl jobs live in the server's memory, so they end with it. Pages no tool could read are listed under `frankensurf.failed`. - **With a model** (`frankensurf serve --model `, or `FRANKENSURF_AGENT_MODEL`): the `summary` and `json` scrape formats (v1 `jsonOptions`, v2 `{"type": "json", "schema", "prompt"}`), and `/v1/extract`, `/v2/extract` jobs (`GET /v1/extract/{id}`). See [Agent mode](/docs/agent-mode/). - **Batch scrape** (`/v1/batch/scrape`, `/v2/batch/scrape`, then `GET .../batch/scrape/{id}`): each URL scraped with the same options, paced per site; failures under `frankensurf.failed`. ### Scrape options | Firecrawl option | In Frankensurf | | --- | --- | | `actions` (`wait`, `click`, `write`, `press`, `scroll`, `screenshot`, `scrape`, `executeJavascript`) | Run in order in Frankensurf's own local browser before the page is captured; results under `actions` (`screenshots`, `scrapes`, `javascriptReturns`). Public reads only: never with a stored profile or identity, where a click could act as you. A failed action stops the rest and is named in `metadata.frankensurf.action_failed`. | | `screenshot`, `screenshot@fullPage`, `{"type": "screenshot", "fullPage": true}` | A browser read; the image comes back as a `data:image/png;base64,...` URL rather than a hosted link. | | `changeTracking` (`modes: ["git-diff"]`, `tag`) | Compared with the last scrape of the same URL and tag on this machine: `new`, `same` or `changed`, with `previousScrapeAt` and a unified diff. Kept in the state directory, so it survives restarts. | | `location.languages` | Sent as Accept-Language and used as the browser's locale. `location.country` isn't applied: Frankensurf has no proxies of its own in other countries ([egress](/docs/blocked/egress/) can add some). | | `proxy: "stealth"` | Starts from the stealth browsers (Scrapling, Camoufox, Patchright, nodriver) instead of plain HTTP. `basic` and `auto` are the usual cheapest-first route. | | `webhook` (crawl, batch scrape) | `started`, `page`, `completed` and `failed` events, POSTed as `{success, type, id, data, metadata}`, filtered by `events`, with your `headers`. | Crawl, batch scrape and extract jobs are kept in the state directory: their results outlive the server, and a job that was running when it stopped comes back as `failed`, saying so. ## GPT Researcher [GPT Researcher](https://github.com/assafelovic/gpt-researcher) can scrape through Firecrawl. Point it at Frankensurf: ```bash frankensurf serve & export SCRAPER=firecrawl export FIRECRAWL_SERVER_URL=http://127.0.0.1:3002 export FIRECRAWL_API_KEY=local export FIRECRAWL_CONCURRENCY=4 # GPT Researcher's default of 2 suits Firecrawl's free tier pip install firecrawl-py # GPT Researcher installs it on first use otherwise ``` That changes the page reads. For search too, install the retriever plugin, which ships the way GPT Researcher asks search providers to ship: ```bash pip install "gptr-frankensurf @ git+https://github.com/yail259/frankensurf#subdirectory=contrib/gptr-frankensurf" export RETRIEVER=frankensurf # or frankensurf,duckduckgo ``` Its results are Frankensurf's checked search results, free sources only unless `FRANKENSURF_GPTR_PAID=1`. ### What it changed, on 150 sites We read the 150 sites of our held-out set 7 (picked before any was read, never tuned on) with GPT Researcher's own scraper code, and graded every page with the benchmark's check: does the text hold what the page should, at least three times. | GPT Researcher scraping with | Right page | Wrong page passed on | No page | | --- | --- | --- | --- | | its default scraper (`SCRAPER=bs`) | 58 | 15 | 77 | | `frankensurf serve`, free tools only | 134 | 3 | 13, each with the reason | Through Frankensurf it read 76 sites the default scraper didn't, and none the other way. It is slower: a median of 9.0 s a page against 1.7 s, and 40 s for the slowest tenth, because a walled page climbs through several tools before it gets in or gives up. We also had it write full reports for 8 questions whose best sources sit behind walls. Through `frankensurf serve` it read 89 sources against 65, and a model grading the reports blind scored them 7.0 against 5.9 out of 10, better on 6 questions and worse on 1. That's a consistent direction, but 8 questions are too few to call it significant. One run on 16 October 2026; the rows and scripts are in [`benchmarks/2026-10-16-gpt-researcher`](https://github.com/yail259/frankensurf/tree/main/benchmarks/2026-10-16-gpt-researcher). ## deep-research [deep-research](https://github.com/dzhng/deep-research) searches and reads through Firecrawl. In its `.env.local`: ```bash FIRECRAWL_KEY=local FIRECRAWL_BASE_URL=http://127.0.0.1:3002 FIRECRAWL_CONCURRENCY=4 ``` Its searches ask for each result's markdown with a 15 second timeout. Frankensurf answers 3 seconds inside it, so a slow page comes back as a missing page instead of a failed search. With its exact call, four searches got all 20 pages in time, twice, in 4 to 13 seconds a search. (Before v0.44.1 it was 17 and 14 of 20: news searches returned Google News redirect links, which are slow to follow. Search now swaps them for the publisher's own URL.) ## Anything else that takes a Firecrawl URL We've checked Firecrawl's own SDKs against it: `@mendable/firecrawl-js` 1.16 (v1 API) and `firecrawl-py` 4.50 (v2 API), for scrape and search. If a tool lets you set Firecrawl's URL, it should work; tell us if one doesn't. `serve` listens on 127.0.0.1 only. `--host 0.0.0.0` opens it to your network, with no authentication: anyone who can reach it can read pages through your machine. --- # Agent frameworks Use Frankensurf's read and search as tools in LangChain, the OpenAI Agents SDK, CrewAI, or as a Claude Code plugin. Any MCP client can start `frankensurf-mcp` ([Add to your agent](/docs/agents/)). A project that already reads through Firecrawl, like GPT Researcher or deep-research, can point its Firecrawl URL at `frankensurf serve` instead ([Firecrawl-compatible API](/docs/firecrawl-compatible/)). For frameworks that take Python tools, `frankensurf.agent_tools` gives you two ready-made ones: - **frankensurf_read**: a page's text (or the article, with `main`), its title, a quality grade, which tool got it, and a `trace_id` to pass back as `try_harder_than` when the page wasn't right. With a JSON Schema, the fields filled from the page's own data ([Structured fields](/docs/read/structured/)). - **frankensurf_search**: checked results (url, title, snippet, published), with `recency`, `site` and `vertical`. Policy settings pass straight through, for example `langchain_tools(allow_paid_fallbacks=True, max_cost_usd=0.02)`. Text is capped at 20,000 characters (`max_text`). ```bash pip install "frankensurf[langchain]" ``` ```python from frankensurf.agent_tools import langchain_tools from langgraph.prebuilt import create_react_agent agent = create_react_agent(model, tools=langchain_tools()) ``` Each tool works sync (`invoke`) and async (`ainvoke`). ```bash pip install "frankensurf[openai-agents]" ``` ```python from agents import Agent, Runner from frankensurf.agent_tools import openai_agents_tools agent = Agent(name="researcher", instructions="Use the web tools to answer.", tools=openai_agents_tools()) result = await Runner.run(agent, "What changed in the latest ferry timetable?") ``` The SDK's strict tool schemas can't describe a free-form object, so here the schema is passed as JSON text: `extract_schema_json`. ```bash pip install "frankensurf[crewai]" ``` ```python from crewai import Agent from frankensurf.agent_tools import crewai_tools researcher = Agent(role="Researcher", goal="Find and read sources", backstory="...", tools=crewai_tools()) ``` CrewAI supports Python up to 3.13. Install the package (`pip install "frankensurf[mcp]"`, then `frankensurf install`), then: ``` /plugin marketplace add yail259/frankensurf /plugin install frankensurf@frankensurf ``` The plugin starts the MCP server and adds the skill that teaches Claude when to read, search, try harder, or hand a wall to you. --- # Install Install Frankensurf on Linux, macOS or Windows and read your first page. Frankensurf is a Python package and needs Python 3.12 or newer. Pick your system: `frankensurf install` fetches every free tool in one go, the way `playwright install` fetches browsers. Each lands in its own folder, never in your Python environment, and one that fails leaves the others working: | Tool | What it adds | | --- | --- | | `chromium` | Chromium for JavaScript pages | | `stealth` | [Camoufox](/docs/integrations/camoufox/), [Scrapling](/docs/integrations/scrapling/) and [Patchright](/docs/integrations/patchright/) | | `search` | A local [SearXNG](/docs/integrations/searxng/) on `127.0.0.1:8088`, no Docker | | `crawl4ai` | [Crawl4AI](/docs/integrations/crawl4ai/) and its own browsers (Linux and WSL; not on macOS yet) | | `nodriver` | [nodriver](/docs/integrations/nodriver/), only when you name it (AGPL-3.0) | `frankensurf install search chromium` installs just those, `--with-deps` adds the browsers' system libraries on Linux (it asks for sudo, as Playwright does), `frankensurf install --list` shows what is installed where, and `frankensurf uninstall all` (or `uninstall search`, ...) removes it again. 1. Install the package with the MCP extra. ```bash python3 -m venv ~/.venvs/frankensurf && source ~/.venvs/frankensurf/bin/activate pip install "frankensurf[mcp]" ``` 2. Install every free tool and the system libraries the browsers need (it asks for sudo). This takes a few minutes. ```bash frankensurf install --with-deps ``` 3. Read a page, and see each tool it tried. ```bash frankensurf read "https://www.walmart.com/search?q=air+fryer" --explain ``` 1. Install Python 3.12 or newer if you don't have it, for example with [Homebrew](https://brew.sh/): `brew install python@3.12`. 2. Install the package, then every free tool. ```bash python3 -m venv ~/.venvs/frankensurf && source ~/.venvs/frankensurf/bin/activate pip install "frankensurf[mcp]" frankensurf install ``` 3. Read a page, and see each tool it tried. ```bash frankensurf read "https://www.walmart.com/search?q=air+fryer" --explain ``` Each release is installed from PyPI on a fresh macOS machine (Apple silicon) and checked: install, every browser, a read and the MCP server. `frankensurf install` ends with `works_here`, which browsers started and read a page on your machine; please [open an issue](https://github.com/yail259/frankensurf/issues) if one doesn't. Frankensurf runs inside WSL (Windows Subsystem for Linux). 1. Install WSL with Ubuntu from an administrator PowerShell, then restart. ```powershell wsl --install -d Ubuntu ``` 2. Open **Ubuntu** from the Start menu and follow the Linux steps there: ```bash sudo apt install -y python3-venv python3 -m venv ~/.venvs/frankensurf && source ~/.venvs/frankensurf/bin/activate pip install "frankensurf[mcp]" frankensurf install --with-deps frankensurf read "https://www.walmart.com/search?q=air+fryer" --explain ``` Your agent on Windows starts the server through WSL; see [Add to your agent](/docs/agents/). That is the free stack: plain HTTP, markdown negotiation, Jina Reader, a local Chromium, the stealth browsers, Crawl4AI and your own search engine. No account, key or Docker. On the newest [benchmark](/docs/more/benchmarks/) (150 fresh sites, read once) it reads 88.0% of sites, as many as the best paid tool on its own; adding the [paid tools](/docs/blocked/paid-tools/) takes it to 96.0%. `frankensurf setup` is the guided install: Chromium and the stealth browsers (`--no-stealth` skips them), then it asks before installing local search and before adding the [agent skill](/docs/agents/) to `~/.claude/skills` (both default to no, and scripts and CI are never asked). `--search` and `--install-skill` say yes ahead of time; `--no-search` and `--no-install-skill` skip the questions. ## As a command-line tool The package installs a `frankensurf` command (and `frankensurf-mcp` for agents). To have it on your PATH without managing a virtual environment, install it as a tool with [uv](https://docs.astral.sh/uv/) or [pipx](https://pipx.pypa.io/): ```bash uv tool install "frankensurf[mcp]" # or: pipx install "frankensurf[mcp]" frankensurf install frankensurf read "https://news.ycombinator.com" --explain ``` `uv tool upgrade frankensurf` (or `pipx upgrade frankensurf`) updates it. Working on Frankensurf itself? Install from a clone instead: `git clone https://github.com/yail259/frankensurf.git`, then `pip install -e '.[test,mcp]'` and `frankensurf install`. ## Optional extras Each one adds tools to the [escalation ladder](/docs/blocked/escalation/). Everything free is in `frankensurf install` above; these are the rest. | Add | Command | | --- | --- | | [nodriver](/docs/integrations/nodriver/) (AGPL-3.0, drives your own Chrome) | `frankensurf install nodriver` | | Self-hosted browser ([Steel](/docs/integrations/steel/), needs [Docker](https://docs.docker.com/get-docker/)) | `bash scripts/start-steel.sh` (from a clone) | | SOCKS connections for [the connection pool](/docs/blocked/egress/) | `pip install "frankensurf[socks]"` (tool install: `uv tool install "frankensurf[mcp,socks]"`) | | Paid services | [Add keys](/docs/blocked/paid-tools/) | Next, [add Frankensurf to your agent](/docs/agents/). --- # Integrations Every tool Frankensurf stitches together, what it costs, and how far it has been tested. Frankensurf builds none of its tools. It stitches the best ones together and routes each page to the cheapest one that works. Free tools join by themselves once installed; paid ones join when their key is set and the call allows paid tools. **Tested** means: **Live**, a real read through Frankensurf against a real site or the real service; **Mocks**, checked against the service's published API format only. A mock isn't done: add the key and run one read to promote it. A key also works under the service's own name (`SCRAPFLY_API_KEY`, `ZYTE_API_KEY` and so on); the `FRANKENSURF_` name wins when both are set. ## Fetch and render | Tool | Rung | Cost | Tested | | --- | --- | --- | --- | | [HTTP](/docs/integrations/http/) | T0–T1 | Free, built in | Live | | [Jina Reader](/docs/integrations/jina-reader/) | T1 | Free, built in | Live | | [Chromium](/docs/integrations/local-chromium/) | T2 | Free | Live | | [Steel](/docs/integrations/steel/) | T2 | Free self-hosted, or paid cloud | Live | | [Crawl4AI](/docs/integrations/crawl4ai/) | T2 | Free | Live health check | | [Browserbase](/docs/integrations/browserbase/) | T2 | Paid | Mocks | | [Hyperbrowser](/docs/integrations/hyperbrowser/) | T2 | Paid | Mocks | | [Browserless](/docs/integrations/browserless/) | T2 | Paid | Mocks | | [Kernel](/docs/integrations/kernel/) | T2 | Paid | Mocks | | [Anchor](/docs/integrations/anchor/) | T2 | Paid | Mocks | | [Cloudflare Browser Run](/docs/integrations/cloudflare-browser-run/) | T2 | Paid | Mocks | ## Stealth | Tool | Rung | Cost | Tested | | --- | --- | --- | --- | | [Camoufox](/docs/integrations/camoufox/) | T3 | Free | Live | | [Scrapling](/docs/integrations/scrapling/) | T1–T3 | Free | Live | | [Patchright](/docs/integrations/patchright/) | T3 | Free | Live (smoke) | | [nodriver](/docs/integrations/nodriver/) | T3 | Free | Live (smoke) | | [ShardX](/docs/integrations/shardx/) (optional plugin, closed source) | after the bundled tools | Free | Live (throwaway machine) | | [Signed requests](/docs/blocked/signed-requests/) (Web Bot Auth) | T3 | Free | Live (Cloudflare verifier) | ## Scrapers and unblockers | Tool | Rung | Cost | Tested | | --- | --- | --- | --- | | [Firecrawl](/docs/integrations/firecrawl/) | T4 | Paid | Live | | [ZenRows](/docs/integrations/zenrows/) | T4 | Paid | Live | | [fastCRW](/docs/integrations/fastcrw/) | T4 | Paid, or free self-hosted | Live (self-hosted) | | [Scrapfly](/docs/integrations/scrapfly/) | T4 | Paid | Live | | [Bright Data](/docs/integrations/bright-data/) | T4 | Paid | Mocks | | [Zyte](/docs/integrations/zyte/) | T4 | Paid | Live | | [Apify](/docs/integrations/apify/) | T4 | Paid | Mocks | ## Agents and people | Tool | What it does | Tested | | --- | --- | --- | | [Browser Use](/docs/integrations/browser-use/) | An AI agent explores the page with your model | Live (agent harness) | | [Skyvern](/docs/integrations/skyvern/) | A cloud agent returns what it found; only when named | Mocks | | [Human handoff](/docs/blocked/handoff/) | You clear the wall in a visible browser | Live (headed test) | | [Your Chrome](/docs/read/signed-in/) | Reads signed in as you | Live (identity canary) | ## Search | Source | Cost | Tested | | --- | --- | --- | | [SearXNG](/docs/integrations/searxng/) | Free, self-hosted | Live | | [DuckDuckGo](/docs/integrations/duckduckgo/) | Free | Fallback only | | [Bing](/docs/integrations/bing/) | Free | Live | | [Google News](/docs/integrations/google-news/) | Free | Live | | [Exa](/docs/integrations/exa/) | Paid | Live | | [Brave Search](/docs/integrations/brave/) | Paid | Mocks | | [Tavily](/docs/integrations/tavily/) | Paid | Mocks | | [Parallel](/docs/integrations/parallel/) | Paid | Mocks | | [Serper](/docs/integrations/serper/) | Paid | Mocks | | [SerpAPI](/docs/integrations/serpapi/) | Paid | Mocks | | [Google Programmable Search](/docs/integrations/google-cse/) | Paid (100 a day free) | Mocks | [Stagehand](https://github.com/browserbase/stagehand) and [Chrome DevTools MCP](https://github.com/ChromeDevTools/chrome-devtools-mcp) drive a browser step by step rather than fetch pages, so they aren't providers. An agent can use either alongside Frankensurf's `read` for pages that need clicking through. Want another tool? See [Writing a plugin](/docs/more/plugins/). --- # Anchor Anchor Browser sessions: managed auth and a hardened Chromium. [Anchor Browser](https://anchorbrowser.io/) runs hardened Chromium sessions in the cloud. Each read opens one session, renders the page and releases it. - Billed by session time, rounded up to one minute. - Refuses a session that could pass your `max_cost_usd`. - Your key travels only in the connection URL, never in errors. ## Set up Set `FRANKENSURF_ANCHOR_API_KEY`. Default rate $0.10/hour; set yours with `FRANKENSURF_ANCHOR_USD_PER_HOUR`. ## Use it Once its key is set, it joins any read that allows paid tools. To use it on its own: ```python page = await web.read( "https://example.com/products/2231", provider="anchor", policy_overrides={"allow_paid_fallbacks": True}, ) ``` ```json { "tool": "read", "arguments": { "url": "https://example.com/products/2231", "provider": "anchor", "acquisition_policy": { "allow_paid_fallbacks": true } } } ``` ## Details | Property | Value | | --- | --- | | Provider ID | `anchor` | | Rung | T2 | | Cost | Paid | | Tested | Mocks only | | Docs | [docs.anchorbrowser.io](https://docs.anchorbrowser.io/) | --- # Apify Apify's RAG Web Browser actor: one page as HTML or markdown. [Apify](https://apify.com/)'s RAG Web Browser actor fetches a page on Apify's platform, in standby mode so each read is a single fast call. - One generic actor, no per-site scrapers: per-site knowledge stays in route seeds. - Returns the page for your agent to interpret, like every other provider. - Cost depends on your Apify plan and isn't reported per request. ## Set up Set `FRANKENSURF_APIFY_API_TOKEN`. ## Use it Once its key is set, it joins any read that allows paid tools. To use it on its own: ```python page = await web.read( "https://example.com/products/2231", provider="apify", policy_overrides={"allow_paid_fallbacks": True}, ) ``` ```json { "tool": "read", "arguments": { "url": "https://example.com/products/2231", "provider": "apify", "acquisition_policy": { "allow_paid_fallbacks": true } } } ``` ## Details | Property | Value | | --- | --- | | Provider ID | `apify` | | Rung | T4 | | Cost | Paid | | Tested | Mocks only | | Docs | [apify.com/apify/rag-web-browser](https://apify.com/apify/rag-web-browser) | --- # Bing Bing's RSS results as a free fallback. Reads Bing's RSS search results. It's the last web source an automatic search asks, and it never races another source. If every result in an answer shares nothing with the query, the source counts as failed (`SEARCH_UNAVAILABLE`, "results unrelated to the query") rather than as an answer. - No key needed. - Fetched through the `http` provider, so pacing applies. - Results are indexed candidates; read them to confirm. ## Set up Built in. ## Use it Ask for this source by name. Named sources never fall back to another. ```python results = await web.search("playwright python tutorial", source="bing_rss") ``` ```bash frankensurf search playwright python tutorial --source bing_rss ``` ```json { "tool": "search", "arguments": { "query": "playwright python tutorial", "source": "bing_rss" } } ``` ## Details | Property | Value | | --- | --- | | Source ID | `bing_rss` | | Cost | Free | | Tested | Live | | Docs | [www.bing.com](https://www.bing.com/) | --- # Brave Search The Brave Search API, from an independent index. [Brave Search API](https://brave.com/search/api/) queries Brave's own web index. - Cost is $0.005 per query by default; set yours with `FRANKENSURF_BRAVE_COST_PER_QUERY`. - Joins after the free sources when paid tools are allowed. - Skipped until the key is set. ## Set up Set `FRANKENSURF_BRAVE_API_KEY`. ## Use it Ask for this source by name. Named sources never fall back to another. ```python from frankensurf import WebPolicy results = await web.search("playwright python tutorial", source="brave", policy=WebPolicy(allow_paid_fallbacks=True)) ``` ```json { "tool": "search", "arguments": { "query": "playwright python tutorial", "source": "brave", "acquisition_policy": { "allow_paid_fallbacks": true } } } ``` ## Details | Property | Value | | --- | --- | | Source ID | `brave` | | Cost | Paid | | Tested | Mocks only | | Docs | [api-dashboard.search.brave.com/app/documentation](https://api-dashboard.search.brave.com/app/documentation) | --- # Bright Data Bright Data's Web Unlocker for the hardest walls. [Bright Data Web Unlocker](https://brightdata.com/products/web-unlocker) handles proxies, fingerprints and challenges on Bright Data's network. - Needs a Web Unlocker zone as well as a key. - Bright Data doesn't report per-request cost, so `cost_usd` is unknown and it can't run under a `max_cost_usd` cap. - Counts as working only after a live run with a real key. ## Set up Set `FRANKENSURF_BRIGHTDATA_API_KEY` and `FRANKENSURF_BRIGHTDATA_ZONE`. ## Use it Once its key is set, it joins any read that allows paid tools. To use it on its own: ```python page = await web.read( "https://example.com/products/2231", provider="brightdata_unlocker", policy_overrides={"allow_paid_fallbacks": True}, ) ``` ```json { "tool": "read", "arguments": { "url": "https://example.com/products/2231", "provider": "brightdata_unlocker", "acquisition_policy": { "allow_paid_fallbacks": true } } } ``` ## Details | Property | Value | | --- | --- | | Provider ID | `brightdata_unlocker` | | Rung | T4 | | Cost | Paid | | Tested | Mocks only | | Docs | [docs.brightdata.com/scraping-automation/web-unlocker/introduction](https://docs.brightdata.com/scraping-automation/web-unlocker/introduction) | --- # Browser Use An AI browser agent that explores a page with your model. [Browser Use](https://github.com/browser-use/browser-use) 0.13.10 drives a fresh browser with a model you supply. Give it a task and it can click, use search boxes and finish on any page inside its allowed origins. - Uses your model, through a factory function you own. - Runs in its own environment and a throwaway browser profile. - Bounded by steps, model calls, actions and time. A paid model also needs `allow_paid_fallbacks=True`. ## Set up 1. Install the pinned SDK in its own environment: ```bash python3 -m venv ~/.local/share/frankensurf/agent-venv ~/.local/share/frankensurf/agent-venv/bin/python -m pip install -r requirements-browser-use.txt ``` 2. Write a model factory, for example `/home/you/models/agent.py`: ```python from browser_use import ChatOpenAI from frankensurf.browser_use_binding import ModelBinding def create_model(): return ModelBinding(ChatOpenAI(model="gpt-4.1-mini", max_retries=0), billing="paid") ``` 3. Point Frankensurf at it: ```bash export FRANKENSURF_BROWSER_USE_MODEL_FACTORY=/home/you/models/agent.py:create_model export FRANKENSURF_BROWSER_USE_BILLING=paid ``` ## Use it It runs when a call names it, never as a step of ordinary reads: a model-driven agent takes tens of seconds a page (about 40 s for a simple page on a local 9B model), where FrankenSurf's own browsers take a few. Each attempt gets an agent's time (`agent_provider_timeout_seconds`, 300 s). To use it: ```python page = await web.read( "https://example.com/products/2231", provider="browser_use", ) ``` ```bash frankensurf read https://example.com/products/2231 --provider browser_use ``` ```json { "tool": "read", "arguments": { "url": "https://example.com/products/2231", "provider": "browser_use" } } ``` Give it a task with `browser_agent_task`: ```python page = await web.read("https://example.com", provider="browser_use", policy_overrides={"browser_agent_task": "find the pricing page and open the Pro plan"}) ``` ## Details | Property | Value | | --- | --- | | Provider ID | `browser_use` | | Rung | Agent | | Cost | Free | | Tested | Live (agent harness) | | Docs | [docs.browser-use.com](https://docs.browser-use.com/) | --- # Browserbase Hosted Chromium sessions from Browserbase. [Browserbase](https://www.browserbase.com/) runs Chromium in the cloud. Each read opens one session, renders the page and releases the session. - Billed by session time, rounded up to one minute. - Refuses a session that could pass your `max_cost_usd`. - Your key travels only in the connection URL, never in errors. ## Set up Set `FRANKENSURF_BROWSERBASE_API_KEY`, and optionally `FRANKENSURF_BROWSERBASE_PROJECT_ID`. Default rate $0.12/hour; set yours with `FRANKENSURF_BROWSERBASE_USD_PER_HOUR`. ## Use it Once its key is set, it joins any read that allows paid tools. To use it on its own: ```python page = await web.read( "https://example.com/products/2231", provider="browserbase", policy_overrides={"allow_paid_fallbacks": True}, ) ``` ```json { "tool": "read", "arguments": { "url": "https://example.com/products/2231", "provider": "browserbase", "acquisition_policy": { "allow_paid_fallbacks": true } } } ``` ## Details | Property | Value | | --- | --- | | Provider ID | `browserbase` | | Rung | T2 | | Cost | Paid | | Tested | Mocks only | | Docs | [docs.browserbase.com](https://docs.browserbase.com/) | --- # Browserless Hosted Chromium from Browserless. [Browserless](https://www.browserless.io/) serves Chromium over a websocket. Each read connects, renders the page and disconnects. - Billed in 30-second units. - Refuses a session that could pass your `max_cost_usd`. - Point it at another region or a self-hosted Browserless with `FRANKENSURF_BROWSERLESS_URL`. ## Set up Set `FRANKENSURF_BROWSERLESS_API_KEY`. Default rate $0.24/hour; set yours with `FRANKENSURF_BROWSERLESS_USD_PER_HOUR`. ## Use it Once its key is set, it joins any read that allows paid tools. To use it on its own: ```python page = await web.read( "https://example.com/products/2231", provider="browserless", policy_overrides={"allow_paid_fallbacks": True}, ) ``` ```json { "tool": "read", "arguments": { "url": "https://example.com/products/2231", "provider": "browserless", "acquisition_policy": { "allow_paid_fallbacks": true } } } ``` ## Details | Property | Value | | --- | --- | | Provider ID | `browserless` | | Rung | T2 | | Cost | Paid | | Tested | Mocks only | | Docs | [docs.browserless.io](https://docs.browserless.io/) | --- # Camoufox A Firefox fork that spoofs its fingerprint below JavaScript. [Camoufox](https://github.com/daijro/camoufox) looks like an ordinary Firefox to sites that fingerprint browsers. It runs in its own isolated worker process. - Free and local. - `camoufox_entry` is a variant that enters through a site's public entry page first; it runs only through a route seed or when named. - Pacing and cool-downs still apply: stealth is for looking ordinary, not for ignoring a site that said no. ## Set up ```bash frankensurf install stealth ``` This installs Camoufox and Scrapling in a separate environment (`FRANKENSURF_PROVIDER_PYTHON`). ## Use it It joins the default route by itself once installed. To use it on its own: ```python page = await web.read( "https://example.com/products/2231", provider="camoufox", ) ``` ```bash frankensurf read https://example.com/products/2231 --provider camoufox ``` ```json { "tool": "read", "arguments": { "url": "https://example.com/products/2231", "provider": "camoufox" } } ``` ## Details | Property | Value | | --- | --- | | Provider ID | `camoufox` | | Rung | T3 | | Cost | Free | | Tested | Live | | Docs | [camoufox.com](https://camoufox.com/) | --- # Cloudflare Browser Run Cloudflare's hosted browser renders a page at the edge and returns its HTML. [Cloudflare Browser Run](https://developers.cloudflare.com/browser-rendering/) (Browser Rendering) renders a page on Cloudflare's network. Frankensurf calls its `/content` endpoint for the rendered HTML. - No browser to run yourself. - Needs an API token and your account ID. - Billed by your Cloudflare plan; cost isn't reported per request. ## Set up Set `FRANKENSURF_CLOUDFLARE_API_TOKEN` and `FRANKENSURF_CLOUDFLARE_ACCOUNT_ID`. ## Use it Once its key is set, it joins any read that allows paid tools. To use it on its own: ```python page = await web.read( "https://example.com/products/2231", provider="cloudflare_browser_run", policy_overrides={"allow_paid_fallbacks": True}, ) ``` ```json { "tool": "read", "arguments": { "url": "https://example.com/products/2231", "provider": "cloudflare_browser_run", "acquisition_policy": { "allow_paid_fallbacks": true } } } ``` ## Details | Property | Value | | --- | --- | | Provider ID | `cloudflare_browser_run` | | Rung | T2 | | Cost | Paid | | Tested | Mocks only | | Docs | [developers.cloudflare.com/browser-rendering](https://developers.cloudflare.com/browser-rendering/) | --- # Crawl4AI The open-source Crawl4AI renderer, pinned and isolated in its own environment. [Crawl4AI](https://github.com/unclecode/crawl4ai) 0.9.4 runs in a separate Python environment with its own browsers, so its dependencies never touch Frankensurf's. - Pinned to an exact package lock; every dependency is hash-checked before each run. - Uses its own browser cache, never the shared Playwright one. - Any change to the install makes the provider unavailable instead of running unchecked code. ## Set up ```bash frankensurf install crawl4ai # part of plain `frankensurf install` too ``` On Linux (on Windows, inside WSL); not on macOS yet, where its environment checks do not pass. The installer creates `~/.local/share/frankensurf/crawl4ai-venv` and ends with a live render check. Move it with `FRANKENSURF_CRAWL4AI_DIR`, `FRANKENSURF_CRAWL4AI_PYTHON` and `FRANKENSURF_CRAWL4AI_BROWSERS_PATH`. ## Use it It joins the default route by itself once set up. To use it on its own: ```python page = await web.read( "https://example.com/products/2231", provider="crawl4ai", ) ``` ```bash frankensurf read https://example.com/products/2231 --provider crawl4ai ``` ```json { "tool": "read", "arguments": { "url": "https://example.com/products/2231", "provider": "crawl4ai" } } ``` ## Details | Property | Value | | --- | --- | | Provider ID | `crawl4ai` | | Rung | T2 | | Cost | Free | | Tested | Live health check | | Docs | [docs.crawl4ai.com](https://docs.crawl4ai.com/) | --- # DuckDuckGo DuckDuckGo's HTML results as a free fallback. Reads DuckDuckGo's HTML results page. It's the second free source, used when SearXNG isn't available. - No key needed. - Fetched through the `http` provider, so pacing applies. - May be blocked; the next source is tried. ## Set up Built in. ## Use it Ask for this source by name. Named sources never fall back to another. ```python results = await web.search("playwright python tutorial", source="duckduckgo_html") ``` ```bash frankensurf search playwright python tutorial --source duckduckgo_html ``` ```json { "tool": "search", "arguments": { "query": "playwright python tutorial", "source": "duckduckgo_html" } } ``` ## Details | Property | Value | | --- | --- | | Source ID | `duckduckgo_html` | | Cost | Free | | Tested | Live smoke failed; fallback only | | Docs | [duckduckgo.com](https://duckduckgo.com/) | --- # Exa Exa's neural search API. [Exa](https://exa.ai/) is a search API built for AI agents. - Cost comes from Exa's own `costDollars`. - Joins after the free sources when paid tools are allowed. - Skipped until the key is set. ## Set up Set `FRANKENSURF_EXA_API_KEY`. ## Use it Ask for this source by name. Named sources never fall back to another. ```python from frankensurf import WebPolicy results = await web.search("playwright python tutorial", source="exa", policy=WebPolicy(allow_paid_fallbacks=True)) ``` ```json { "tool": "search", "arguments": { "query": "playwright python tutorial", "source": "exa", "acquisition_policy": { "allow_paid_fallbacks": true } } } ``` ## Details | Property | Value | | --- | --- | | Source ID | `exa` | | Cost | Paid | | Tested | Live | | Docs | [docs.exa.ai](https://docs.exa.ai/) | --- # fastCRW A Firecrawl-compatible scrape API, hosted or self-hosted. [fastCRW](https://fastcrw.com/) exposes a Firecrawl-compatible `/v1/scrape`. Use the hosted API with a key, or point it at your own CRW server. - Self-hosted CRW needs no key: set `FRANKENSURF_FASTCRW_URL`. - Returns clean page content. - Cost isn't reported per request. ## Set up Hosted: set `FRANKENSURF_FASTCRW_API_KEY`. Self-hosted: set `FRANKENSURF_FASTCRW_URL` to your CRW server. ## Use it Once its key is set, it joins any read that allows paid tools. To use it on its own: ```python page = await web.read( "https://example.com/products/2231", provider="fastcrw", policy_overrides={"allow_paid_fallbacks": True}, ) ``` ```json { "tool": "read", "arguments": { "url": "https://example.com/products/2231", "provider": "fastcrw", "acquisition_policy": { "allow_paid_fallbacks": true } } } ``` ## Details | Property | Value | | --- | --- | | Provider ID | `fastcrw` | | Rung | T4 | | Cost | Paid | | Tested | Live (self-hosted CRW, 6 Oct 2026) | | Docs | [fastcrw.com](https://fastcrw.com/) | --- # Firecrawl Firecrawl's scrape API as a paid rung for hard pages. [Firecrawl](https://www.firecrawl.dev/) scrapes a page on its own infrastructure and returns clean content. - Cost comes from the credits each call uses. - Refuses a call that could pass your `max_cost_usd` (one credit; a PDF costs one credit per page). - Gets the longer unblocker timeout (90 s). ## Set up Set `FRANKENSURF_FIRECRAWL_API_KEY`. Default rate $0.0038/credit; set yours with `FRANKENSURF_FIRECRAWL_USD_PER_CREDIT`. ## Use it Once its key is set, it joins any read that allows paid tools. To use it on its own: ```python page = await web.read( "https://example.com/products/2231", provider="firecrawl", policy_overrides={"allow_paid_fallbacks": True}, ) ``` ```json { "tool": "read", "arguments": { "url": "https://example.com/products/2231", "provider": "firecrawl", "acquisition_policy": { "allow_paid_fallbacks": true } } } ``` ## Details | Property | Value | | --- | --- | | Provider ID | `firecrawl` | | Rung | T4 | | Cost | Paid | | Tested | Live | | Docs | [docs.firecrawl.dev](https://docs.firecrawl.dev/) | --- # Google Programmable Search Google's Custom Search JSON API: 100 free queries a day. [Google Programmable Search](https://developers.google.com/custom-search/v1/overview) (the Custom Search JSON API) searches Google with your own key and search engine. - The first 100 queries a day are free, then $5 per 1,000. Frankensurf can't see the day's count, so it prices every query at $0.005; set `FRANKENSURF_GOOGLE_CSE_COST_PER_QUERY=0` if you stay inside the free tier. - Up to 10 results per query; window (`dateRestrict`), `site` (`siteSearch`) and region are applied natively. - Joins after the free sources when paid tools are allowed. Skipped until both settings are set. ## Set up Create a search engine set to search the entire web, then set `GOOGLE_CSE_API_KEY` (or `FRANKENSURF_GOOGLE_CSE_API_KEY`) and `FRANKENSURF_GOOGLE_CSE_CX` (the search engine ID). ## Use it Ask for this source by name. Named sources never fall back to another. ```python from frankensurf import WebPolicy results = await web.search("rio tinto acquisition", source="google_cse", policy=WebPolicy(allow_paid_fallbacks=True)) ``` ```json { "tool": "search", "arguments": { "query": "rio tinto acquisition", "source": "google_cse", "acquisition_policy": { "allow_paid_fallbacks": true } } } ``` ## Details | Property | Value | | --- | --- | | Source ID | `google_cse` | | Cost | Paid (100 a day free) | | Tested | Mocks only (request and response shapes from Google's documentation) | | Docs | [developers.google.com/custom-search](https://developers.google.com/custom-search/v1/overview) | --- # Google News Google News search as RSS: free, no key. [Google News](https://news.google.com/) answers news searches as an RSS feed, free and without a key. - In the news vertical, after Bing News; quoted names, `OR` and the date window (`when:7d`) work. - Each result names its publisher (`extra.publisher`, `extra.publisher_url`) and date. - Results link to the publisher's own article. Google's feed links go through a redirect that only a browser can follow, so search swaps each one for the URL it leads to (two small requests to Google, run in parallel) and keeps Google's link in `extra.google_news_url`. A link that doesn't swap stays as Google's. ## Set up Nothing to set up. ## Use it Ask for this source by name. Named sources never fall back to another. ```python results = await web.search("\"Rio Tinto\" acquisition", source="google_news_rss") ``` ```json { "tool": "search", "arguments": { "query": "\"Rio Tinto\" acquisition", "source": "google_news_rss" } } ``` ## Details | Property | Value | | --- | --- | | Source ID | `google_news_rss` | | Cost | Free | | Tested | Live | | Docs | [news.google.com](https://news.google.com/) | --- # HTTP Plain HTTP with markdown negotiation and request signing. Always first. The built-in HTTP provider is the first rung of every read. Most pages never need anything else. - Asks for `text/markdown` when `prefer_markdown` is on (T0). - Signs every request, redirects included, once you have a [Web Bot Auth key](/docs/blocked/signed-requests/) (T3). - Reports `VISUAL_REQUIRED` on app shells, so the read moves on to a browser. ## Set up Built in. Nothing to install. ## Use it It joins the default route by itself once set up. To use it on its own: ```python page = await web.read( "https://example.com/products/2231", provider="http", ) ``` ```bash frankensurf read https://example.com/products/2231 --provider http ``` ```json { "tool": "read", "arguments": { "url": "https://example.com/products/2231", "provider": "http" } } ``` ## Details | Property | Value | | --- | --- | | Provider ID | `http` | | Rung | T0–T1 | | Cost | Free | | Tested | Live | --- # Hyperbrowser Hosted Chromium sessions from Hyperbrowser. [Hyperbrowser](https://www.hyperbrowser.ai/) runs browser fleets in the cloud. Each read opens one session, renders the page and releases the session. - Billed by session time, rounded up to one minute. - Refuses a session that could pass your `max_cost_usd`. - Your key travels only in the connection URL, never in errors. ## Set up Set `FRANKENSURF_HYPERBROWSER_API_KEY`. Default rate $0.10/hour; set yours with `FRANKENSURF_HYPERBROWSER_USD_PER_HOUR`. ## Use it Once its key is set, it joins any read that allows paid tools. To use it on its own: ```python page = await web.read( "https://example.com/products/2231", provider="hyperbrowser", policy_overrides={"allow_paid_fallbacks": True}, ) ``` ```json { "tool": "read", "arguments": { "url": "https://example.com/products/2231", "provider": "hyperbrowser", "acquisition_policy": { "allow_paid_fallbacks": true } } } ``` ## Details | Property | Value | | --- | --- | | Provider ID | `hyperbrowser` | | Rung | T2 | | Cost | Paid | | Tested | Mocks only | | Docs | [docs.hyperbrowser.ai](https://docs.hyperbrowser.ai/) | --- # Jina Reader Clean markdown from any URL through r.jina.ai. Free, no key needed. [Jina Reader](https://jina.ai/reader/) turns a URL into markdown on Jina's servers. It is the last free tool in the default route. - Works without a key; a key raises the rate limit. - Returns markdown, with the page title as a heading. - A challenge seen through Jina is reported as `CAPTCHA`, so the read keeps climbing. ## Set up Built in. Optionally set `FRANKENSURF_JINA_API_KEY`. ## Use it It joins the default route by itself once set up. To use it on its own: ```python page = await web.read( "https://example.com/products/2231", provider="jina_reader", ) ``` ```bash frankensurf read https://example.com/products/2231 --provider jina_reader ``` ```json { "tool": "read", "arguments": { "url": "https://example.com/products/2231", "provider": "jina_reader" } } ``` ## Details | Property | Value | | --- | --- | | Provider ID | `jina_reader` | | Rung | T1 | | Cost | Free | | Tested | Live | | Docs | [jina.ai/reader](https://jina.ai/reader/) | --- # Kernel Kernel's hosted browsers, with fast cold starts and an optional stealth mode. [Kernel](https://www.kernel.sh/) runs browsers in the cloud. Each read opens one session, renders the page and releases it. - Billed by session time, rounded up to one minute. - Refuses a session that could pass your `max_cost_usd`. - Your key travels only in the connection URL, never in errors. ## Set up Set `FRANKENSURF_KERNEL_API_KEY`. Default rate $0.10/hour; set yours with `FRANKENSURF_KERNEL_USD_PER_HOUR`. ## Use it Once its key is set, it joins any read that allows paid tools. To use it on its own: ```python page = await web.read( "https://example.com/products/2231", provider="kernel", policy_overrides={"allow_paid_fallbacks": True}, ) ``` ```json { "tool": "read", "arguments": { "url": "https://example.com/products/2231", "provider": "kernel", "acquisition_policy": { "allow_paid_fallbacks": true } } } ``` ## Details | Property | Value | | --- | --- | | Provider ID | `kernel` | | Rung | T2 | | Cost | Paid | | Tested | Mocks only | | Docs | [docs.onkernel.com](https://docs.onkernel.com/) | --- # Chromium Headless Chromium on your machine, through Playwright. The `local` provider renders pages in a headless Chromium driven by [Playwright](https://playwright.dev/python/). It is the first browser in the default route. - Free and private: nothing leaves your machine. - Supports `wait_selector`, `settle_ms` and `capture_json_responses`. - Off when `allow_local_browser` is `false`. ## Set up ```bash .venv/bin/playwright install chromium ``` ## Use it It joins the default route by itself once set up. To use it on its own: ```python page = await web.read( "https://example.com/products/2231", provider="local", ) ``` ```bash frankensurf read https://example.com/products/2231 --provider local ``` ```json { "tool": "read", "arguments": { "url": "https://example.com/products/2231", "provider": "local" } } ``` ## Details | Property | Value | | --- | --- | | Provider ID | `local` | | Rung | T2 | | Cost | Free | | Tested | Live | | Docs | [playwright.dev/python](https://playwright.dev/python/) | --- # nodriver Chrome driven directly over CDP, without WebDriver. [nodriver](https://github.com/ultrafunkamsterdam/nodriver) drives Chrome over the DevTools protocol directly, with no WebDriver layer to detect. It runs in the same isolated environment as Camoufox. - Free and local. - nodriver doesn't report the page's HTTP status, so its reads count as 200 and rely on Frankensurf's content checks. - Pin 0.47.0: later versions ship a file Python 3.14 refuses to import. ## Set up ```bash ~/.local/share/frankensurf/provider-venv/bin/pip install "nodriver==0.47.0" ``` ## Use it It is optional: `frankensurf install` installs it only when you name it (`frankensurf install nodriver`), and reports it as `not installed (optional)` otherwise. Until you install it, the default route skips it, and pinning it fails with `PROVIDER_UNAVAILABLE`. Once installed it joins the default route by itself, and `setup` checks it in `works_here`. To use it on its own: ```python page = await web.read( "https://example.com/products/2231", provider="nodriver", ) ``` ```bash frankensurf read https://example.com/products/2231 --provider nodriver ``` ```json { "tool": "read", "arguments": { "url": "https://example.com/products/2231", "provider": "nodriver" } } ``` ## Details | Property | Value | | --- | --- | | Provider ID | `nodriver` | | Rung | T3 | | Cost | Free | | Tested | Live (smoke, 6 Oct 2026) | | Docs | [ultrafunkamsterdam.github.io/nodriver](https://ultrafunkamsterdam.github.io/nodriver/) | --- # Parallel Parallel's web search API. [Parallel](https://parallel.ai/) offers a search API tuned for accurate answers. - Cost is $0.005 per query by default; set yours with `FRANKENSURF_PARALLEL_COST_PER_QUERY`. - Joins after the free sources when paid tools are allowed. - Skipped until the key is set. ## Set up Set `FRANKENSURF_PARALLEL_API_KEY`. ## Use it Ask for this source by name. Named sources never fall back to another. ```python from frankensurf import WebPolicy results = await web.search("playwright python tutorial", source="parallel", policy=WebPolicy(allow_paid_fallbacks=True)) ``` ```json { "tool": "search", "arguments": { "query": "playwright python tutorial", "source": "parallel", "acquisition_policy": { "allow_paid_fallbacks": true } } } ``` ## Details | Property | Value | | --- | --- | | Source ID | `parallel` | | Cost | Paid | | Tested | Mocks only | | Docs | [docs.parallel.ai](https://docs.parallel.ai/) | --- # Patchright Playwright patched to avoid the usual automation tells. [Patchright](https://github.com/Kaliiiiiiiiii-Vinyzu/patchright-python) is a drop-in Playwright that removes the signals sites use to spot automation. It runs in the same isolated environment as Camoufox. - Free and local. - Runs in its own worker process. - Pacing and cool-downs still apply. ## Set up Install it into the stealth environment: ```bash ~/.local/share/frankensurf/provider-venv/bin/pip install patchright ``` ## Use it It joins the default route by itself once set up. To use it on its own: ```python page = await web.read( "https://example.com/products/2231", provider="patchright", ) ``` ```bash frankensurf read https://example.com/products/2231 --provider patchright ``` ```json { "tool": "read", "arguments": { "url": "https://example.com/products/2231", "provider": "patchright" } } ``` ## Details | Property | Value | | --- | --- | | Provider ID | `patchright` | | Rung | T3 | | Cost | Free | | Tested | Live (smoke, 6 Oct 2026) | | Docs | [github.com/Kaliiiiiiiiii-Vinyzu/patchright-python](https://github.com/Kaliiiiiiiiii-Vinyzu/patchright-python) | --- # Scrapfly Scrapfly's scraping API as an unblocker rung. [Scrapfly](https://scrapfly.io/) renders pages with anti-bot bypass on its own infrastructure. - Cost is credits used times your rate; unknown until you set it. - Gets the longer unblocker timeout (90 s). ## Set up Set `FRANKENSURF_SCRAPFLY_API_KEY` and `FRANKENSURF_SCRAPFLY_USD_PER_CREDIT`. ## Use it Once its key is set, it joins any read that allows paid tools. To use it on its own: ```python page = await web.read( "https://example.com/products/2231", provider="scrapfly", policy_overrides={"allow_paid_fallbacks": True}, ) ``` ```json { "tool": "read", "arguments": { "url": "https://example.com/products/2231", "provider": "scrapfly", "acquisition_policy": { "allow_paid_fallbacks": true } } } ``` ## Details | Property | Value | | --- | --- | | Provider ID | `scrapfly` | | Rung | T4 | | Cost | Paid | | Tested | Live (6 Oct 2026) | | Docs | [scrapfly.io/docs](https://scrapfly.io/docs) | --- # Scrapling Scrapling's stealth browser and its browser-like HTTP fetcher. [Scrapling](https://github.com/D4Vinci/Scrapling) provides three providers, all running in an isolated worker process. - `scrapling`: the stealth browser fetcher (T3). - `scrapling_http`: an HTTP fetcher with a browser-like TLS fingerprint (T1). - `scrapling_ready`: the stealth browser waiting for a site's ready signal; runs only through a route seed or when named. ## Set up ```bash frankensurf install stealth ``` ## Use it It joins the default route by itself once set up. To use it on its own: ```python page = await web.read( "https://example.com/products/2231", provider="scrapling", ) ``` ```bash frankensurf read https://example.com/products/2231 --provider scrapling ``` ```json { "tool": "read", "arguments": { "url": "https://example.com/products/2231", "provider": "scrapling" } } ``` ## Details | Property | Value | | --- | --- | | Provider ID | `scrapling` | | Rung | T1–T3 | | Cost | Free | | Tested | Live | | Docs | [scrapling.readthedocs.io](https://scrapling.readthedocs.io/) | --- # SearXNG Your own metasearch engine. The default search source. [SearXNG](https://github.com/searxng/searxng) runs on your machine and fans each query out to many engines. It needs no account, key or Docker. - First in the default search order. - Upstream engine failures are shown in `upstream_failures`, not hidden. - Bound to `127.0.0.1:8088`. Search works without it (DuckDuckGo, Bing and the topic sources answer), but is stronger with it: it answered about a third of the queries in our blind benchmark. ## Set up ```bash frankensurf install search # install and start it (about 140 MB, a few seconds) frankensurf search-engine stop # any time; also: start, status frankensurf uninstall search # remove it ``` `install search` downloads SearXNG's source at one pinned commit, refuses it unless its SHA-256 matches, installs its Python packages in an environment of its own (`~/.local/share/frankensurf/searxng-native`), and runs it in the background on `127.0.0.1:8088` only, with JSON output on. Settings are copied to `~/.local/share/frankensurf/searxng/` with a random secret key; edit them if you like, they are never overwritten. After a reboot, the next search starts it again (set `FRANKENSURF_SEARCH_AUTOSTART=0` to stop that). If a SearXNG with JSON already answers on that port, nothing is run. ## At volume SearXNG asks Google, Brave, DuckDuckGo and the other public engines from your own address. At a few dozen searches in a few minutes they start refusing: CAPTCHAs, "access denied", "too many requests". In one test, 39 of the first 100 searches met a refusal, with 54 refusal messages in 5 minutes. SearXNG then rests each refusing engine and answers from the rest, so results get thinner rather than stopping. Frankensurf's settings spread the load: Bing, Google, Yahoo, Qwant and Mojeek are switched on beside SearXNG's defaults (DuckDuckGo and Brave), and a refusing engine rests 15 minutes instead of 3. An unedited settings file from an earlier release is upgraded to these the next time the engine starts; one you edited is left alone (delete it to get the new defaults). For steady volume, add a paid source such as [Serper](/docs/integrations/serper/) and allow paid tools. Prefer Docker? Without the native install, `frankensurf search-engine start` runs a pinned SearXNG image as a container named `frankensurf-search` on the same port, with the same settings. Nothing needs an environment variable: Frankensurf looks at `http://127.0.0.1:8088` by default. Set `FRANKENSURF_SEARCH_URL` (MCP server) or pass `--search-url` (CLI) only to use a SearXNG somewhere else on loopback. From a clone, `bash scripts/start-search.sh` starts the same image. When a search ran without it because it was not reachable, `receipt.search_routing.local_search` says so, with the command to install or start it. ## Use it Ask for this source by name. Named sources never fall back to another. ```python results = await web.search("playwright python tutorial", source="searxng") ``` ```bash frankensurf search playwright python tutorial --source searxng ``` ```json { "tool": "search", "arguments": { "query": "playwright python tutorial", "source": "searxng" } } ``` ## Details | Property | Value | | --- | --- | | Source ID | `searxng` | | Cost | Free | | Tested | Live | | Docs | [docs.searxng.org](https://docs.searxng.org/) | --- # SerpAPI Google results as JSON through SerpAPI. [SerpAPI](https://serpapi.com/) returns Google's results as JSON. - Cost is $0.015 per search by default (Developer plan, $75 for 5,000); set yours with `FRANKENSURF_SERPAPI_COST_PER_QUERY`. - Window (`tbs=qdr:`), region (`gl`, `hl`) and news (`tbm=nws`) are applied natively. - Joins after the free sources when paid tools are allowed. Skipped until the key is set. ## Set up Set `SERPAPI_API_KEY` (or `FRANKENSURF_SERPAPI_API_KEY`). ## Use it Ask for this source by name. Named sources never fall back to another. ```python from frankensurf import WebPolicy results = await web.search("rio tinto acquisition", source="serpapi", policy=WebPolicy(allow_paid_fallbacks=True)) ``` ```json { "tool": "search", "arguments": { "query": "rio tinto acquisition", "source": "serpapi", "acquisition_policy": { "allow_paid_fallbacks": true } } } ``` ## Details | Property | Value | | --- | --- | | Source ID | `serpapi` | | Cost | Paid | | Tested | Mocks only (request and response shapes from SerpAPI's documentation) | | Docs | [serpapi.com/search-api](https://serpapi.com/search-api) | --- # Serper Google results as JSON through serper.dev. [Serper](https://serper.dev/) returns Google's results as JSON: the web through `/search`, news through `/news`. - Cost is $0.001 per credit by default ($1.00 per 1,000 searches on the Starter pack); a search is 1 credit and Serper reports the credits used. Set yours with `FRANKENSURF_SERPER_USD_PER_CREDIT`. - Window (`tbs=qdr:`), region (`gl`, `hl`) and `site:` operators are applied natively; news-vertical searches use `/news`. - Joins after the free sources when paid tools are allowed. Skipped until the key is set. ## Set up Set `SERPER_API_KEY` (or `FRANKENSURF_SERPER_API_KEY`). ## Use it Ask for this source by name. Named sources never fall back to another. ```python from frankensurf import WebPolicy results = await web.search("rio tinto acquisition", source="serper", policy=WebPolicy(allow_paid_fallbacks=True)) ``` ```json { "tool": "search", "arguments": { "query": "rio tinto acquisition", "source": "serper", "acquisition_policy": { "allow_paid_fallbacks": true } } } ``` ## Details | Property | Value | | --- | --- | | Source ID | `serper` | | Cost | Paid | | Tested | Mocks only (request and response shapes from Serper's documentation) | | Docs | [serper.dev](https://serper.dev/) | --- # ShardX (optional plugin) ProxyShard's anti-detect browser, as a plugin you install and trust yourself. Fast on Akamai sites; closed source. [ShardX](https://github.com/ProxyShard/ShardBrowser) is ProxyShard's anti-detect browser. On Akamai-protected sites it was the strongest single browser we tested, and several times faster than Frankensurf climbing its own ladder to the same pages. It isn't part of Frankensurf: it comes as a separate plugin, [`contrib/frankensurf-shardx`](https://github.com/yail259/frankensurf/tree/main/contrib/frankensurf-shardx), that you install and trust yourself. ShardX's engine is a prebuilt Chromium that the `shardx` SDK downloads from the vendor the first time it starts. Its source isn't published, so nobody outside ProxyShard can check what it does. Like any plugin it runs with your permissions, and it can see every page it reads. We only ever ran it on a throwaway machine with no keys or logins. Decide for yourself before trusting it, and consider a separate user or a VM. ## Set up 1. Install the plugin. It pins `shardx==2.0.3`, the version we benchmarked, and installs Playwright to drive it: ```bash pip install "frankensurf-shardx @ git+https://github.com/yail259/frankensurf#subdirectory=contrib/frankensurf-shardx" ``` 2. Trust it. Until you do, Frankensurf loads it but never runs it: ```bash frankensurf plugins trust provider shardx frankensurf-shardx frankensurf plugins # shardx should be under "loaded" ``` 3. Read with it. The first read downloads ShardX's browser and starts it; later reads in the same session reuse it. To stop using it: `frankensurf plugins untrust provider shardx`, then `pip uninstall frankensurf-shardx`. ## Use it Once trusted, it joins automatic reads after the bundled tools, and Frankensurf learns from your reads which walls it gets past. To try it first on a site you know is behind Akamai, name it: ```python page = await web.read("https://www.example.com/search?q=lamp", provider="shardx") ``` ```bash frankensurf read "https://www.example.com/search?q=lamp" --provider shardx ``` ```json { "tool": "read", "arguments": { "url": "https://www.example.com/search?q=lamp", "provider": "shardx" } } ``` Or put it early on the completeness ladder, so it's tried as soon as a cheap read comes back incomplete: `completeness_ladder=("jina_reader", "shardx", "scrapling", ...)`. It opens a window by default. On Linux with no display it runs headless; set `FRANKENSURF_SHARDX_HEADLESS=1` to force that, or `FRANKENSURF_SHARDX_HEADED=1` to always show the window. The benchmarks below ran with a window, under a virtual display. ## How it did One read per tool per site, same pass/fail check as every other benchmark, on a throwaway GitHub machine. Raw rows: [benchmarks/2026-10-14-shardx](https://github.com/yail259/frankensurf/tree/main/benchmarks/2026-10-14-shardx). **18 Akamai-protected sites** (46 retail search pages listed first, kept only where Akamai answered): | Tool | Read right | Median | | --- | --- | --- | | Frankensurf, free tools | 15/18 | 25.0 s | | ShardX | 11/18 | 5.4 s | | Camoufox | 6/18 | 6.2 s | | Scrapling | 5/18 | 4.2 s | **150 sites picked blind:** ShardX 69.3%, Scrapling 64.0%, Camoufox 62.7%. Frankensurf with free tools read 81.3% of the same set the day before: site by site, ShardX read 3 that Frankensurf missed, and Frankensurf read 21 that ShardX missed. So it doesn't add many sites Frankensurf can't already read. What it adds is speed on Akamai: about 5 s a page where Frankensurf's own climb took 25. ## Details | Property | Value | | --- | --- | | Provider ID | `shardx` | | Package | `frankensurf-shardx` (not on PyPI; install from the repository) | | Cost | Free | | Source | Plugin open source; ShardX engine closed | | Tested | Live on a throwaway machine (14 Oct 2026); plugin tests with ShardX faked | | Docs | [github.com/ProxyShard/ShardBrowser](https://github.com/ProxyShard/ShardBrowser) | --- # Skyvern Skyvern's cloud browser agent reads the page and returns what it found. [Skyvern](https://www.skyvern.com/) runs a vision-based browser agent in the cloud. Frankensurf sends it a task and returns the agent's findings as JSON, not the page. - Runs only when you name it; never part of the default route. - Gets a longer timeout (`agent_provider_timeout_seconds`, 300 s by default). - Point it at a self-hosted Skyvern with `FRANKENSURF_SKYVERN_URL`. ## Set up Set `FRANKENSURF_SKYVERN_API_KEY`. ## Use it It runs only when you ask for it by name: ```python page = await web.read( "https://example.com/products/2231", provider="skyvern", policy_overrides={"allow_paid_fallbacks": True}, ) ``` ```json { "tool": "read", "arguments": { "url": "https://example.com/products/2231", "provider": "skyvern", "acquisition_policy": { "allow_paid_fallbacks": true } } } ``` ## Details | Property | Value | | --- | --- | | Provider ID | `skyvern` | | Rung | Agent | | Cost | Paid | | Tested | Mocks only | | Docs | [docs.skyvern.com](https://docs.skyvern.com/) | --- # Steel A self-hosted Steel browser, or Steel Cloud. [Steel](https://github.com/steel-dev/steel-browser) is an open-source browser API. Run it yourself for free as `steel`, or use Steel Cloud as `steel_cloud`. - `steel` drives your own Steel container. - `steel_cloud` opens a hosted session per read and releases it, even on failure. It is paid. - Point `steel_cloud` at any Steel API with `FRANKENSURF_STEEL_CLOUD_URL`. ## Set up Self-hosted: ```bash bash scripts/start-steel.sh export FRANKENSURF_STEEL_URL=http://127.0.0.1:3100 ``` Steel Cloud: set `FRANKENSURF_STEEL_API_KEY`. ## Use it It joins the default route by itself once set up. To use it on its own: ```python page = await web.read( "https://example.com/products/2231", provider="steel", ) ``` ```bash frankensurf read https://example.com/products/2231 --provider steel ``` ```json { "tool": "read", "arguments": { "url": "https://example.com/products/2231", "provider": "steel" } } ``` ## Details | Property | Value | | --- | --- | | Provider ID | `steel` | | Rung | T2 | | Cost | Free | | Tested | Live (self-hosted and through `steel_cloud`) | | Docs | [steel.dev](https://steel.dev/) | --- # Tavily Tavily's search API for AI agents. [Tavily](https://tavily.com/) is a search API built for agents. - Cost is $0.008 per query by default (one credit, pay-as-you-go); set yours with `FRANKENSURF_TAVILY_COST_PER_QUERY`. - Joins after the free sources when paid tools are allowed. - Skipped until the key is set. ## Set up Set `FRANKENSURF_TAVILY_API_KEY`. ## Use it Ask for this source by name. Named sources never fall back to another. ```python from frankensurf import WebPolicy results = await web.search("playwright python tutorial", source="tavily", policy=WebPolicy(allow_paid_fallbacks=True)) ``` ```json { "tool": "search", "arguments": { "query": "playwright python tutorial", "source": "tavily", "acquisition_policy": { "allow_paid_fallbacks": true } } } ``` ## Details | Property | Value | | --- | --- | | Source ID | `tavily` | | Cost | Paid | | Tested | Mocks only | | Docs | [docs.tavily.com](https://docs.tavily.com/) | --- # ZenRows ZenRows' web unblocker for heavily protected pages. [ZenRows](https://www.zenrows.com/) renders pages through premium proxies and anti-bot handling. - Cost comes from ZenRows' own response header. - Refuses a call whose worst case ($0.025 by default) could pass your `max_cost_usd`. - An explicit ZenRows read isn't held by a site's cool-down. ## Set up Set `FRANKENSURF_ZENROWS_API_KEY`. ## Use it Once its key is set, it joins any read that allows paid tools. To use it on its own: ```python page = await web.read( "https://example.com/products/2231", provider="zenrows", policy_overrides={"allow_paid_fallbacks": True}, ) ``` ```json { "tool": "read", "arguments": { "url": "https://example.com/products/2231", "provider": "zenrows", "acquisition_policy": { "allow_paid_fallbacks": true } } } ``` ## Details | Property | Value | | --- | --- | | Provider ID | `zenrows` | | Rung | T4 | | Cost | Paid | | Tested | Live | | Docs | [docs.zenrows.com](https://docs.zenrows.com/) | --- # Zyte Zyte API browser rendering through its anti-ban network. [Zyte API](https://www.zyte.com/zyte-api/) renders the page in a browser behind Zyte's anti-ban network, as an unblocker rung. - Returns the rendered HTML. - An explicit Zyte read isn't held by a site's cool-down. - Cost isn't reported per request, so `cost_usd` is unknown; it can't run under a `max_cost_usd` cap. ## Set up Set `FRANKENSURF_ZYTE_API_KEY`, or the service's own name `ZYTE_API_KEY`. ## Use it Once its key is set, it joins any read that allows paid tools. To use it on its own: ```python page = await web.read( "https://example.com/products/2231", provider="zyte", policy_overrides={"allow_paid_fallbacks": True}, ) ``` ```json { "tool": "read", "arguments": { "url": "https://example.com/products/2231", "provider": "zyte", "acquisition_policy": { "allow_paid_fallbacks": true } } } ``` ## Details | Property | Value | | --- | --- | | Provider ID | `zyte` | | Rung | T4 | | Cost | Paid | | Tested | Live (6 Oct 2026) | | Docs | [docs.zyte.com/zyte-api](https://docs.zyte.com/zyte-api/) | --- # Benchmarks Is stitching better than any one tool? What has been measured, and how to run it yourself. Two benchmarks back the numbers on the landing page. Both judge every read by its content, not its status code: a product page must have a price, a search page must have result links, an article must have real text. A challenge page or an empty app shell counts as a failure. ## Fresh sites The fairest test: lists of sites picked before any of their pages was read, used once. Nothing was tuned on them. **Newest: 150 more sites, 11 October 2026, read once (held-out set 7), every tool alone.** The candidates proposed for set 6 that set 6 did not take, never read before. Read from a home connection; Firecrawl, Zyte and Jina Reader fetch from their own networks. | Tool | Sites read | Median | p90 | Cost per 1k pages | | --- | --- | --- | --- | --- | | Patchright only | 38.7% | 4.1 s | 8.3 s | free | | Headless Chromium only | 40.7% | 2.7 s | 6.8 s | free | | Plain HTTP only | 48.7% | 1.2 s | 3.3 s | free | | Camoufox only | 68.7% | 10.9 s | 15.7 s | free | | Scrapling only | 68.7% | 8.5 s | 19.2 s | free | | Jina Reader only | 73.3% | 1.5 s | 10.2 s | free | | Firecrawl only | 84.7% | 4.6 s | 10.6 s | $4.28 | | Zyte only | 86.0% | 10.7 s | 31.3 s | not reported | | Frankensurf, free tools only | 88.0% | 3.6 s | 21.3 s | $0 | | **Frankensurf, with paid fallbacks** | **96.0%** | 2.9 s | 25.7 s | $0.98 | The free tools alone read as many sites as the best paid tool on its own: 16 sites only the free tools read against 11 only Firecrawl read, a gap within the noise of 150 sites (on set 6 it went the other way, 84.7% against 85.3%). With paid fallbacks, Frankensurf read 17 sites Firecrawl missed and Firecrawl none it missed; of the 146 sites any tool read, it got 98.6%. From a fresh cloud VM (v0.35): free tools 82.0%, [lightning mode](/docs/read/lightning/) 84.0% at a 1.2 s median. Rows: [`benchmarks/2026-10-11/`](https://github.com/yail259/frankensurf/tree/main/benchmarks/2026-10-11). **150 sites, 10 October 2026 (v0.33), every tool alone.** Held-out set 6: 10 sites in each of 15 categories, picked by agents working from memory with no web access, every site and brand from earlier sets left out. Read from a home connection; Firecrawl, Zyte and Jina Reader fetch from their own networks. | Tool | Sites read | Median | p90 | Cost per 1k pages | | --- | --- | --- | --- | --- | | Headless Chromium only | 45.3% | 2.6 s | 4.9 s | free | | Plain HTTP only | 51.3% | 1.0 s | 3.0 s | free | | Camoufox only | 66.0% | 8.5 s | 13.3 s | free | | Scrapling only | 72.7% | 6.8 s | 17.2 s | free | | Jina Reader only | 74.0% | 2.3 s | 10.4 s | free | | Firecrawl only | 85.3% | 4.8 s | 11.3 s | $4.25 | | Zyte only | 86.0% | 12.0 s | 42.3 s | not reported | | Frankensurf, free tools only | 84.7% | 3.3 s | 18.1 s | $0 | | **Frankensurf, with paid fallbacks** | **95.3%** | 3.0 s | 25.9 s | $1.06 | Times are over pages read; cost is per 1,000 pages read right. Frankensurf with paid fallbacks read 18 sites Firecrawl missed, and Firecrawl none that it missed; of the 146 sites any tool read, it got 97.9%. Its median read is the fastest of the tools that read most pages, but its slowest tenth is slower than Firecrawl's. ZenRows and Scrapfly are left out: their plans had run out of credits. The same set read from a fresh cloud VM (a data-centre address) with v0.33: free tools 82.7% (v0.32: 78.7%), [lightning mode](/docs/read/lightning/) 84.0% at a 1.6 s median. Re-run with v0.34 the next morning: with paid fallbacks 95.3% again (median 3.0 s, p90 25.9 s; the three reads that took 90 seconds or more now take 21 to 43); free tools 84.0%, median 4.4 s. The free median moves with Jina Reader's cache: its first read of a page took a median 5.8 s that morning, and the first run above came an hour after other runs had read the same pages through it. v0.33 against v0.32 on 40 sites of the v0.30 set below (10 October), one read at a time, both versions on every site: free tools read 37 against 35 (median 3.9 s against 6.5 s); with paid fallbacks 37 against 36 (median 3.6 s against 5.1 s, $1.32 against $0.83 per 1,000 pages: paid tools now come in at the first wall). Of 7 URLs that answered not found, 4 came back as the site's own search results for what they asked for ([When the URL is wrong](/docs/blocked/escalation/#when-the-url-is-wrong)). **152 sites, 6 October 2026 (v0.16):** | Tool | Sites read | | --- | --- | | Jina Reader only | 63.8% | | Zyte only | 73.7% | | Frankensurf, free tools only | 73.7% | | Firecrawl only | 80.3% | | **Frankensurf, with paid fallbacks** | **89.5%** | **139 more sites, 9 October 2026 (v0.26):** | Tool | Sites read | Median | p90 | | --- | --- | --- | --- | | Firecrawl only | 74.1% | 6.6 s | 14.7 s | | Frankensurf, free tools only | 81.3% | 5.6 s | 50.2 s | | Frankensurf, free, racing free tools | 82.0% | 4.5 s | 37.2 s | | **Frankensurf, with paid fallbacks** | **88.5%** | 9.2 s | 49.5 s | On the second set, Frankensurf read 22 sites Firecrawl missed and Firecrawl read 2 Frankensurf missed. Of the 127 sites at least one tool could read, Frankensurf got 96.9% and Firecrawl 81.1%. Firecrawl's plan rate-limited some pages at 12 workers; those were re-run one at a time until none were left. Every news, government, travel, jobs, media, property, food and car site passed. The misses are mostly US and Australian retail, plus Bluesky and Pinterest. Frankensurf is still slower in the tail, which is what racing the free tools goes after. Rows: [`benchmarks/2026-10-09/`](https://github.com/yail259/frankensurf/tree/main/benchmarks/2026-10-09). **150 more sites, 10 October 2026 (v0.30):** | Tool | Sites read | Median | p90 | | --- | --- | --- | --- | | Firecrawl only | 75.3% | 4.3 s | 8.5 s | | Frankensurf, free tools only, from a cloud VM | 76.7% | | | | Frankensurf, free tools only | 80.0% | 4.9 s | 24.1 s | | **Frankensurf, with paid fallbacks** | **86.7%** | 5.1 s | 59.6 s | Of the 131 sites at least one tool read, Frankensurf with paid fallbacks got 130 and Firecrawl 113. **The same 150 sites, 10 October 2026 (v0.32), read from a cloud VM:** picked by agents working from memory with no web access, every site and brand from the earlier sets left out. Frankensurf ran on a fresh Azure VM, a data-centre address that sites wall more often than a home one; Firecrawl fetches from its own network. | Tool | Sites read | Median | p90 | Cost | | --- | --- | --- | --- | --- | | Frankensurf, free tools only | 78.7% | 5.9 s | 25.1 s | $0 | | Frankensurf, free, [lightning mode](/docs/read/lightning/) | 82.7% | 1.7 s | 13.3 s | $0 | | Firecrawl only | 85.3% | 4.8 s | 11.3 s | $0.54 | Here Firecrawl read the most: most of the free tier's misses were walls that refuse a data-centre address, which [another connection](/docs/blocked/egress/) or the paid fallbacks (no keys on the VM) are for. Lightning's median read took 1.7 seconds to Firecrawl's 4.8, at no cost. Rows: [`benchmarks/2026-10-10/`](https://github.com/yail259/frankensurf/tree/main/benchmarks/2026-10-10). ## Search Search is judged blind. For each query, every tool's top five results are pooled, shuffled and stripped of which tool found them, then graded one by one on whether they actually answer the query: the right subject, the right kind of page (a story or press release, a tutorial, the project's own repository, a paper, a page with the fact) and inside the date window. The measure is useful results in the top five. An earlier automatic judge used the same rules as Frankensurf's own checks and overstated it; its claims were withdrawn. **Blind set 7, 120 queries, v0.40.0** (news, announcements, how-to, code, papers and facts; given to every tool as plain text): | Tool | Useful results (of 5) | Cost per 1k searches | | --- | --- | --- | | Frankensurf, paid allowed | 4.67 | $9.38 | | Exa Only | 4.66 | $9.50 | | Frankensurf, free, with the local search engine | 3.83 | free | | Frankensurf, free, no local search engine | 1.62 | free | With paid allowed, Frankensurf is level with Exa (better on 11 queries, worse on 9): it starts from Exa's results and replaces the ones its checks catch as wrong. **Blind set 6, against Firecrawl's search**, on the 69 queries Firecrawl answered before its plan ran out of credits: | Tool | Useful results (of 5) | | --- | --- | | Frankensurf, free, with the local search engine | 3.78 | | Firecrawl Search Only | 2.91 | Free Frankensurf was better on 35 of those queries and worse on 5 (p < 0.0001). The local search engine is SearXNG (run in Docker for these runs; `frankensurf install search` installs the same engine without Docker); without it, free search is much weaker. [Rows, every verdict and the scripts](https://github.com/yail259/frankensurf/tree/main/benchmarks/2026-10-13-search). **v0.41 against v0.40, blind set 8** (120 fresh queries, free sources and the same local engine for both): v0.41 3.42 useful results of 5, v0.40 2.71; better on 41 queries, worse on 12 (p = 0.0001). News, facts and how-to gained most. [Rows and verdicts](https://github.com/yail259/frankensurf/tree/main/benchmarks/2026-10-14-search-v041). ## Tool benchmark Every tool reads the same pages on its own, then the stitched router reads them too. **Tuned** is 78 pages from the site benchmark, which route seeds were built on. **Unseen** is 45 sites in two held-out lists that nothing was tuned on. Measured 6 October 2026. | Arm | Tuned (78) | Unseen (45) | $ per 1k valid pages, unseen | | --- | --- | --- | --- | | Headless Chromium | 32.1% | 15.6% | $0 | | Plain HTTP | 43.6% | 33.3% | $0 | | Jina Reader | 35.9% | 51.1% | $0 | | Camoufox | 60.3% | 48.9% | $0 | | Scrapling | 61.5% | 60.0% | $0 | | Firecrawl | 88.5% | 73.3% | $4.61 | | Frankensurf, free tools only | 89.7% | 53.3% | $0 | | **Frankensurf** | **98.7%** | **71.1%** | **$1.07** | - On tuned sites, stitching beats every single tool. - On unseen sites it is 2 points behind Firecrawl, at a quarter of the cost, and slower: median 6.6 s against 2.8 s. - Some single tool read 89% of the unseen pages. The gap is the router's choices: 11 of its 13 unseen misses were pages that looked fine but lacked their content. ZenRows is left out: the plan's rate limit rejected its first reads, so its numbers say more about the account than about ZenRows. ```bash PYTHONPATH=.:src python scripts/toolbench.py --state /tmp/tb \ --rows benchmarks/2026-10-06/sitebench-paid-v0.14.jsonl ``` Raw rows and the summary are in [`benchmarks/2026-10-06/`](https://github.com/yail259/frankensurf/tree/main/benchmarks/2026-10-06). ## Site benchmark `scripts/sitebench.py` reads real deep pages (product pages, search results, articles, PDFs, JSON) behind named walls, and follows search results to the pages they link to. | Version | Arm | Valid content | False successes | p50 | Paid cost | | --- | --- | --- | --- | --- | --- | | v0.11.0 | free | 61/72 (85%) | 4 | 3.0 s | $0 | | v0.11.0 | paid | 67/72 (93%) | 4 | 3.5 s | $0.07 | | v0.12.0 | paid | 77/82 | 4 | 3.5 s | $0.033 | | v0.13.0 | paid | 82/82 | 0 | 4.1 s | $0.029 | | v0.14.0 | paid | 82/82 | 0 | 4.4 s | $0.015 | ```bash PYTHONPATH=.:src python scripts/sitebench.py --state /tmp/sb-1 # free PYTHONPATH=.:src python scripts/sitebench.py --state /tmp/sb-2 --paid # paid fallbacks ``` Use a fresh `--state` for every run. One run's blocks would otherwise cool down sites for the next. ## Not measured yet - Completion on agent task suites such as Online-Mind2Web and WebVoyager. - Tokens per task, and the gap between models. - Browserbase, Hyperbrowser, Browserless, Scrapfly, Bright Data and Brave with real keys. --- # Code map and boundaries What each module does, and what belongs in Frankensurf versus a consumer. ## What belongs here Frankensurf is the web substrate. It owns access: routing, providers, pacing, policy, identities, receipts and evidence. It does not own meaning. - **No site-specific Python.** Per-site knowledge is data: route seeds in `bundled_route_seeds.json`, and route memory. `tests/test_substrate_boundary.py` enforces it. - **No hard-coded limits.** Every stop, budget or allowlist is a [WebPolicy](/docs/reference/settings/) field with a default. - **Everything is a plugin.** New tools go behind the plugin interfaces. - **Callers interpret.** What a page means (prices, states, rankings) belongs in the app or agent calling Frankensurf, which pins a tagged release. - **Claims need evidence.** A provider counts as working after a live run, not after mocked tests. ## Modules - src/frankensurf/ - runtime.py `Runtime` and `WebPolicy`: routing, acquisition, content checks, cache, evidence, traces - providers.py provider interface, registry and built-in registrations - hosted_providers.py Jina Reader, Firecrawl, the unblockers, Exa and Brave transports - managed_browsers.py Browserbase, Steel Cloud, Hyperbrowser, Browserless - bot_auth.py Web Bot Auth keys and request signing - handoff.py human handoff in a visible browser - experimental.py, provider_worker.py isolated Camoufox and Scrapling workers - scrapling_ready.py, public_entry.py seed-only Scrapling and Camoufox routes - crawl4ai_provider.py, crawl4ai_worker.py, crawl4ai_config.py Crawl4AI - browser_use_provider.py, browser_use_worker.py, browser_use_config.py, browser_use_binding.py the Browser Use agent - browser_use_action_provider.py, browser_use_action_worker.py, web_do.py, actions.py typed browser actions - search_plugins.py, search.py search sources and fallback - adapters.py `html`, `json`, `rss` - pagination.py `paginate` - routes.py, bundled_route_seeds.json, bundled_routes.json route seeds and operator recipes - route_memory.py exact-path route memory - plugin_catalog.py third-party plugin discovery and trust - identity.py, identity_snapshots.py identities, leases, secrets, visible snapshots - repair.py, recovery.py failed-extract diagnosis - cli.py, mcp_server.py the `frankensurf` command and MCP server - scripts/ installers, local services, benchmarks - tests/ ## Releases Work on short-lived branches from `main`, merge, and tag a release when a change should reach consumers. Consumers pin a tag, never a working checkout. --- # What it costs Cost per thousand correct pages, Frankensurf against Firecrawl's plans, from our benchmark runs and Firecrawl's published prices. A scraping API charges for every page it fetches, right or wrong. Frankensurf's free tools charge nothing: no per-page fee, no plan, no credits. They run on your own machine, and a cheap one is enough. You only pay a paid tool for the pages the free ones can't get, and only if you turn paid tools on. ## Per 1,000 correct pages | | Pages read correctly | Per 1,000 pages | Per 1,000 correct pages | | --- | --- | --- | --- | | Firecrawl, Hobby ($16/mo, 5k pages) | 84.7% | $3.20 | $3.78 | | Firecrawl, Standard ($83/mo, 100k) | 84.7% | $0.83 | $0.98 | | Firecrawl, Scale ($599/mo, 1M) | 84.7% | $0.60 | $0.71 | | **Frankensurf, free tools** | 87–88% | **$0** in fees; about $0.01 of a $5/month server | **about $0.01** | | **Frankensurf with paid fallbacks** (Firecrawl at the Standard rate) | 96.0% | $0.21 | **$0.22** | Free tools read more pages correctly than Firecrawl on its own, for close to nothing. With paid fallbacks, Frankensurf reads 96% of pages instead of 85% for about a third of Firecrawl's best rate, because it only pays on the quarter of pages that are walled. ## Where the numbers come from - **Pages read correctly:** held-out set 7, 150 sites picked before any was read, every tool alone and Frankensurf, graded by the same check ([rows](https://github.com/yail259/frankensurf/tree/main/benchmarks/2026-10-11)). - **Firecrawl:** its pricing page on 16 October 2026, billed yearly: 1 credit for a basic page scrape. A page that comes back 403 or 404 still costs a credit; JSON extraction adds 4 credits a page; monthly credits don't roll over below Scale. - **Frankensurf on a small server:** the same 150 sites in free mode, from a fresh state (no learned routes), pinned to 2 CPU cores, four reads at a time: 820 seconds, about 660 pages an hour, or roughly 480,000 a month, 131 of 150 read correctly. 107 of the 150 came back from plain HTTP or Jina Reader with no browser at all. A 2 vCPU server costs about $5 a month (Hetzner CX22 class), so about $0.01 per 1,000 pages. On a machine you already run, nothing. - **With paid fallbacks:** 37 of 150 reads spent anything, one Firecrawl credit each, on pages the free tools couldn't get. At Firecrawl's Standard rate that is $0.20 per 1,000 pages, plus the server. ## At a million pages a month | | Monthly cost | Correct pages | | --- | --- | --- | | Firecrawl Scale | $599 | about 847,000 | | Frankensurf, free tools | about $10 (two small servers) | about 875,000 | | Frankensurf with paid fallbacks | about $345 (two small servers, and Firecrawl Growth for about 247,000 fallback credits) | about 960,000 | Firecrawl runs the machines for you; Frankensurf runs on yours, so someone looks after them. Walled pages are slower through Frankensurf (a median of 3 to 10 seconds a page, depending on the machine, against Firecrawl's 4.6), so a big job runs more reads at a time. The 2-core run had more memory than a 4 GB server; browsers want about 1 GB each, so keep reads at a time near the memory you have. Six of its reads went through Steel. And these are basic page reads: Firecrawl's extraction and Frankensurf's structured fields aren't priced the same way. --- # Logging and tracing What each read tried and why each tool failed, for your agent, your logs, and OpenTelemetry. Every read keeps a receipt. Its `attempts` list says which tools were tried, in order, and why each one failed, so "it didn't work" turns into something you can fix. ```json [ {"provider": "http", "status": "failed", "failure": "BLOCKED", "message": "Provider returned a challenge page", "latency_ms": 307}, {"provider": "camoufox", "status": "failed", "failure": "CAPTCHA", "failure_stage": "navigation_readiness", "latency_ms": 4455}, {"provider": "firecrawl", "status": "observed", "latency_ms": 3120} ] ``` ## Where you see it - **Python:** `page.attempts` on `fs.read(...)`, or `frankensurf.runtime.attempt_summary(result["receipt"])`. - **MCP:** the `read` tool returns the whole receipt, attempts included. - **LangChain, OpenAI Agents SDK, CrewAI:** `frankensurf_read` returns `attempts` next to `failure`. - **`frankensurf serve`:** a page no tool could read answers with `frankensurf.attempts`. - **CLI:** `frankensurf read URL --explain` prints them; `frankensurf trace ` shows any earlier read's full receipt. ## OpenTelemetry ```bash pip install "frankensurf[otel]" ``` With the OpenTelemetry API installed, each read is a span, `frankensurf.read`, with one child span per tool tried, `frankensurf.attempt`. Configure a tracer provider and exporter as you would for anything else (or run under `opentelemetry-instrument`), and reads show up in Jaeger, Honeycomb, Datadog, Grafana Tempo or Langfuse. | On | Attributes | | --- | --- | | `frankensurf.read` | `server.address`, `url.path`, `frankensurf.status`, `.method`, `.failure.code`, `.failure.message`, `.cost_usd`, `.trace_id`, `.attempts` | | `frankensurf.attempt` | `frankensurf.provider`, `.status`, `.failure.code`, `.failure.message`, `.failure.stage`, `.latency_ms` | A failed read or attempt has an error status with its failure code. Spans carry the host and the path, never the query string. Signed-in reads (an identity or a profile) carry the host only. Without the package installed, nothing is recorded. Attempt spans are placed one after another from the receipt's timings; tools raced at once (lightning mode, hedges) overlapped in reality. --- # Writing a plugin Build your own provider, search source or adapter as a Python package, install it, and trust it. Every tool in Frankensurf is a plugin behind one of three interfaces, and yours can be too: your company's scraping API, a browser you run elsewhere, a search index. Core does the rest for it: routing, pacing, content checks, retries, cost caps, receipts and evidence. | Kind | Interface | Entry-point group | | --- | --- | --- | | Provider (gets a page) | `ProviderPlugin` in `providers.py` | `frankensurf.plugins.provider` | | Search source | `SearchPlugin` in `search_plugins.py` | `frankensurf.plugins.search` | | Adapter (shapes a result) | `AdapterPlugin` in `adapters.py` | `frankensurf.plugins.adapter` | A plugin is an ordinary Python package. Installing it never makes it run: it runs only after you trust that plugin, from that exact package, in a private trust file. ## Build one in five steps The repository has a working provider to copy: [`examples/frankensurf-plugin-example`](https://github.com/yail259/frankensurf/tree/main/examples/frankensurf-plugin-example). It reads pages through your own scraping service, an HTTP API that takes a URL and returns the page. 1. **Lay out the package.** ``` frankensurf-plugin-example/ pyproject.toml src/frankensurf_plugin_example/__init__.py ``` 2. **Write the provider.** A manifest and an `acquire` method that makes the call and returns the page: ```python import os import httpx from frankensurf.providers import ProviderManifest from frankensurf.runtime import WebFailure class ExampleFetch: manifest = ProviderManifest( "example_fetch", "0.1.0", # must match the entry point name rendering=True, # the service runs a browser paid=True, # only with allow_paid_fallbacks / --paid remote=True, # fetches from its own network ) def available(self, configured): # Unavailable means skipped, never failed. return bool(os.environ.get("EXAMPLE_API_KEY")) async def acquire(self, request, services): if not self.available(None): # named explicitly, it runs anyway raise WebFailure("PROVIDER_UNAVAILABLE", "set EXAMPLE_API_KEY first") async with httpx.AsyncClient(timeout=request.policy.unblocker_timeout_seconds) as client: response = await client.post(os.environ["EXAMPLE_API_URL"], json={"url": request.url}, headers={"Authorization": "Bearer " + os.environ["EXAMPLE_API_KEY"]}) body = response.json() if body["status"] == 403: raise WebFailure("BLOCKED", "page answered 403", 403) return {"url": body["url"], "content": body["html"], "content_type": "text/html", "http_status": body["status"], "cost_usd": 0.001} ``` The example in the repository also maps timeouts, the service's own errors (`402` is `BUDGET_EXHAUSTED`, `429` is `RATE_LIMITED`) and the page's status to [failure codes](/docs/reference/errors/). 3. **Declare the entry point** in `pyproject.toml`. Its name is the plugin ID: ```toml [project] name = "frankensurf-plugin-example" version = "0.1.0" dependencies = ["frankensurf>=0.34", "httpx>=0.28,<1"] [project.entry-points."frankensurf.plugins.provider"] example_fetch = "frankensurf_plugin_example:ExampleFetch" ``` 4. **Install it into Frankensurf's own environment.** That depends on how you installed Frankensurf: ```bash pip install -e ./frankensurf-plugin-example # pip or a venv uv tool install frankensurf --with-editable ./frankensurf-plugin-example # uv tool pipx inject frankensurf -e ./frankensurf-plugin-example # pipx ``` Then `frankensurf plugins` lists it as turned away, with the command that trusts it: ```json "rejected": [{"kind": "provider", "id": "example_fetch", "code": "PLUGIN_NOT_TRUSTED", "distribution": "frankensurf-plugin-example", "hint": "installed but not trusted yet: frankensurf plugins trust provider example_fetch frankensurf-plugin-example"}] ``` 5. **Trust it, then use it.** ```bash frankensurf plugins trust provider example_fetch frankensurf-plugin-example frankensurf plugins # now under "loaded" export EXAMPLE_API_URL=https://your-service.example/read EXAMPLE_API_KEY=... frankensurf read https://example.com --provider example_fetch --paid ``` From Python, name it the same way: `policy_overrides={"provider": "example_fetch", "allow_paid_fallbacks": True}`. ## Where it runs on its own A trusted provider joins every automatic read, after the bundled tools. To have it tried when a page comes back incomplete, put it on the completeness ladder, for example `completeness_ladder=("jina_reader", "example_fetch", "scrapling", ...)`. Paid tools are tried cheapest likely success first, from costs measured for the built-in ones; a paid plugin has none, so it comes after them (its `cost_usd` still goes on every bill), and Frankensurf learns which wall vendors it gets past from your reads. ## The rules a plugin follows - **Return the page, raise the failure.** Return what you got and let Core judge it: challenge pages, empty shells and results that never loaded are caught for you. Raise `WebFailure(code, message)` when the page or the tool fails, so Core can climb and cool down correctly. - **Never put a key in a message or a result.** Read keys from the environment. An error's text can carry a URL with a token in it: report its type instead. - **Keep state inside methods or the class.** Frankensurf copies a plugin for each session; it turns away one whose methods read mutable module-level values (`PLUGIN_BINDING_UNSAFE`). - **Report cost.** `cost_usd` per read, or `None` when you don't know, which stops a read that has a spending cap. - **Use Core's fetch when you fetch the site yourself.** `services.http(url, policy)` applies the user's proxy, pacing and private-network guard. The manifest flags decide where it can run: | Flag | Meaning | | --- | --- | | `rendering` | Can satisfy `render=True`. | | `requires_local_browser` | Off when `allow_local_browser=False`. | | `paid` | Needs `allow_paid_fallbacks=True` (`--paid`). | | `remote` | Fetches from its own network; tried when walls block this machine's address. | | `cost_bounded` | Refuses a call whose worst case could pass the remaining cap. | | `authentication` | Identity-only; never in the public route. | | `route_scope_required` | Runs only through a route seed or when named. | | `operations` | Which of `read`, `extract`, `do` it supports. | Plugins with heavy or clashing dependencies can run in their own process; see `provider_worker.py` and `experimental.py` for how the stealth browsers do it. ## The trust file `frankensurf plugins trust` and its siblings edit `~/.config/frankensurf/plugins.json`: ```bash frankensurf plugins # what loaded, what was turned away and why frankensurf plugins trust KIND ID DISTRIBUTION # KIND: provider, search or adapter frankensurf plugins untrust KIND ID frankensurf plugins disable KIND ID # also turns off a bundled plugin frankensurf plugins enable KIND ID ``` ```json { "schema": "frankensurf.plugin-policy/v1", "trusted": {"provider": {"example_fetch": "frankensurf-plugin-example"}, "search": {}, "adapter": {}}, "disabled": {"provider": ["zenrows"], "search": [], "adapter": []} } ``` Each trusted ID is bound to one exact distribution, so another package can't claim it. A plugin cannot replace a bundled one. The file must be yours and private (the command writes it `0600` in a `0700` directory); a loose file is refused, not half-read. The catalogue is read once when a `Runtime` starts, and nothing is installed or downloaded during an operation. `Runtime.inspect_plugins()` shows the same as `frankensurf plugins`. Plugin code is trusted local code, not a sandbox: it runs with your permissions. Only trust packages you would run yourself. Third-party plugins need Linux, macOS or WSL, where the trust file's owner and permissions can be checked. ## Contributing one upstream Bundled providers live in `src/frankensurf/` and register in `providers.py`; hosted services go in `hosted_providers.py` or `managed_browsers.py`. Add mocked-API tests, then run it live before calling it working. See [Code map and boundaries](/docs/more/code-map/). --- # Repairing failed extracts Let the Browser Use agent diagnose a failed extract, then promote a checked fix yourself. When a site changes its layout, an `extract` can start failing with `SCHEMA_CHANGED`. Repair lets the [Browser Use agent](/docs/integrations/browser-use/) look at the live page and propose a fix. Nothing changes until you promote it. - Works on public extracts that you ran with assertions (the fields you expect). - The agent proposes; Frankensurf checks the proposal against the saved failure and a fresh live read before you can use it. - You promote or disable a fix with one command, and can roll it back. ## How it works 1. **Run an extract with assertions.** When it fails, the result carries a `recovery` object with a trace ID, and Frankensurf keeps a private copy of the failure. 2. **Diagnose.** The agent inspects the current page and proposes exact CSS selectors for each asserted field. ```bash frankensurf repair TRACE_ID ``` 3. **Check.** Frankensurf tests the proposal against the saved failure and runs an independent live read through another provider. The result records both, with timing and cost. 4. **Promote.** Turn the checked fix on by its ID and hash. ```bash frankensurf repair-promote PROPOSAL_ID --proposal-sha256 SHA256 ``` A promoted fix only applies to the same URL, adapter version and assertions, and only after the original adapter fails. Turn it off at any time: ```bash frankensurf repair-disable PROPOSAL_ID --disable-reason "layout changed again" ``` ## Limits - Public reads only. Signed-in reads, actions and purchases are never repaired. - The agent never writes to a site, sees a secret or runs its own code. - Route proposals are recorded but can't be promoted yet. Python: `Runtime.repair(trace_id)`, `promote_repair(...)` and `disable_repair(...)`. MCP: the `repair` tool. --- # Shared site knowledge Share which tool reads which site, and start from what others learned. Opt in both ways, as data. Every read teaches Frankensurf something about a site: which tool got through, and which didn't. That stays on your machine (route memory). Sharing it is opt in, both ways, and it's only data: which tool reads a site reliably, never code and never anything signed in. ## Start from the community's ```bash frankensurf share sync # fetch community/routes.json from the repository frankensurf share remove # stop using it ``` `sync` downloads the community recipes, checks every one, and saves them beside your own recipes. A community recipe is a provisional seed: it orders the first tools tried on that site. It can't turn on paid tools, use an identity, or override your own recipes and route memory, and a site a bundled recipe already covers keeps that recipe. ## Share yours ```bash frankensurf share export --out my-recipes.json ``` The export lists sites where one tool read reliably when the cheapest tool didn't: at least 3 reads, at least 80% of them good. It includes only: - **the site's origin and a short path pattern**, cut to the first path segment, or the whole site when even that looks item-specific, so it says which sites need which tool, not which pages you read; - **public reads only**, so nothing read with a profile, identity or your own browser; - **how many reads back each recipe.** Nothing is sent anywhere. Read the file, delete anything you'd rather keep to yourself, then open a pull request adding it to [`community/recipes/`](https://github.com/yail259/frankensurf/tree/main/community). Accepted recipes are merged into the file `share sync` downloads. Every file in `community/` is checked by the same rules `share sync` applies: known recipe fields, a `community_` id, single pages, and no authority settings such as identities, paid tools or provider lists. --- # Weekly benchmark The same 150 sites, read every week by each tool on its own and by Frankensurf, with the raw rows. Every Monday a fresh GitHub machine reads the same 150 sites (held-out set 7, picked blind on 10 October 2026), once with each tool on its own and once with Frankensurf. Every read is judged by the same content checks as the [launch benchmark](/docs/more/benchmarks/). The sites stay the same so the trend means something. Sites change, tools change, and this shows whether the result holds. **[See it charted: every site, every tool, speed and trend →](/benchmarks/)** It runs from a data-centre address, which sites wall more often than a home connection, so every rate here is lower than the headline numbers. Compare the tools with each other, week by week. Paid tools appear when their keys are configured for the run. Raw rows for every week, and the script, are in the repository: [benchmarks/weekly/](https://github.com/yail259/frankensurf/tree/main/benchmarks/weekly) and [scripts/weekly_bench.py](https://github.com/yail259/frankensurf/blob/main/scripts/weekly_bench.py). --- # Paginate Follow a bounded run of pages and get every page with one combined receipt. `paginate` follows a sequence of pages through one adapter, such as a JSON API's `?page=` parameter, and returns each page with an aggregate receipt. - Bounded by `max_pages`, so it never runs away. - Each page is an ordinary read: paced, receipted and able to climb past walls. - Use `continuation_adapter` when later pages need a different adapter from the first. ## Usage ```python run = await web.paginate("https://shop.example.com/products.json?page=1", "json", policy_overrides={"max_pages": 5}) ``` ```bash frankensurf paginate 'https://shop.example.com/products.json?page=1' --adapter json --max-pages 5 ``` ```json { "tool": "paginate", "arguments": { "url": "https://shop.example.com/products.json?page=1", "adapter": "json", "acquisition_policy": { "max_pages": 5 } } } ``` ## Options | Option | Default | Description | | --- | --- | --- | | `adapter` | required | `html`, `json` or `rss`. | | `max_pages` | `2` | Most pages to follow. | | `continuation_adapter` | same as `adapter` | Adapter for pages after the first. | --- # Read a page Turn any URL into clean text or markdown, with its structured data and a receipt. `read` gets a page through whatever is in the way and hands over everything on it. - Tries the cheapest tool first and [climbs](/docs/blocked/escalation/) when a page pushes back: app shells, challenges and empty renders count as failures, not thin successes. - Handles JavaScript apps, PDFs, JSON APIs and signed-in pages. - Returns text or markdown, JSON-LD, the page's embedded JSON, image URLs and a receipt. ## Usage ```python page = fs.read("https://developer.mozilla.org/en-US/docs/Web/HTTP") if page.ok: print(page.title) print(page.text[:500]) else: print("couldn't read it:", page.failure) # the code and why, never a stand-in page ``` `fs.read` takes `paid=True`, `max_cost=`, `main=True`, `links=True`, `provider=` and `try_harder_than=`, and any other [setting](/docs/reference/settings/) as a keyword. `page` is the full result dict with shortcuts: `ok`, `failure`, `title`, `text`, `links`, `via`, `cost_usd`, `trace_id`. Inside async code use `await fs.aread(...)`; for many pages, `fs.batch(urls)`. A long-running service keeps one `Runtime` open instead: ```python from frankensurf import Runtime async with Runtime() as web: page = await web.read("https://developer.mozilla.org/en-US/docs/Web/HTTP") ``` ```bash frankensurf read https://developer.mozilla.org/en-US/docs/Web/HTTP ``` ```json { "tool": "read", "arguments": { "url": "https://developer.mozilla.org/en-US/docs/Web/HTTP" } } ``` ```json title="Response" { "url": "https://developer.mozilla.org/en-US/docs/Web/HTTP", "title": "HTTP: Hypertext Transfer Protocol | MDN", "text": "HTTP: Hypertext Transfer Protocol | MDN Skip to main content …", "content_type": "text/html", "structured": { "jsonld": [], "embedded_json": [] }, "image_urls": [], "receipt": { "trace_id": "7700792749c345eeb2060f3a4edfb6e2", "status": "observed", "method": "http", "http_status": 200, "latency_ms": 250, "cost_usd": 0.0, "attempts": [{ "provider": "http", "status": "observed", "latency_ms": 249 }] } } ``` ## Response | Field | Description | | --- | --- | | `url` | Final URL after redirects. | | `title` | Page title. | | `text` | Readable text, or markdown when the site sent markdown. | | `structured` | JSON-LD and embedded JSON for HTML; the parsed body for JSON; page count for PDFs. See [Page data](/docs/read/page-data/). | | `image_urls` | Images found on the page. | | `content` | The raw body. The CLI leaves it out unless you pass `--raw`; MCP always leaves it out. | | `receipt` | Which tools ran, how long each took, what it cost. See [Receipts](/docs/reference/receipts/). | ## Options Pass options as `policy_overrides` in Python, flags on the CLI, or `acquisition_policy` over MCP. | Option | Default | Description | | --- | --- | --- | | `prefer_markdown` | `false` (`true` over MCP) | Ask the site for [markdown](/docs/read/markdown/) first. | | `render` | `false` | Skip plain HTTP and use a [browser](/docs/read/javascript/). | | `freshness` | `"now"` | `hour`, `day` or `cached` may reuse a saved copy. | | `include_images` | `false` | Download and check [images](/docs/read/images/). | | `allow_paid_fallbacks` | `false` | Let [paid tools](/docs/blocked/paid-tools/) join when free ones fail. | | `allow_handoff` | `false` | Let a [person](/docs/blocked/handoff/) clear the last wall. | | `identity` | none | Read [signed in](/docs/read/signed-in/) as you. | | `timeout_seconds` | `25` | Time allowed for each tool. | Every option is listed in [Settings](/docs/reference/settings/). A failed read still returns a result: `receipt.status` is `"failed"` and `receipt.failure` says why. See [Errors](/docs/reference/errors/). --- # Batch read Read many pages in one call, politely. `batch` reads a list of URLs and returns one result per URL, in the order you gave them. - Runs up to four pages at once, one at a time per site. - The first URL for each site goes first. The rest of that site wait for it, then start from the tool it found, instead of each climbing the ladder. - Every page gets its own receipt; one failure never fails the batch. - Signed-in batches run one page at a time. ## Usage ```python pages = await web.batch([ "https://example.com/a", "https://example.com/b", "https://another.example/c", ]) ``` ```bash frankensurf batch https://example.com/a https://example.com/b https://another.example/c ``` ```json { "tool": "batch", "arguments": { "urls": ["https://example.com/a", "https://example.com/b"] } } ``` ## Concurrency Set it when you open the runtime: ```python Runtime("state", concurrency=4, per_domain=1, domain_delay=0.25) ``` Reads to one site are also paced across processes. See [Pacing](/docs/blocked/escalation/#pacing). --- # Images Download and check a page's images, not just their URLs. Every read returns `image_urls`. With `include_images`, Frankensurf also downloads them and checks each one. - Each image gets a SHA256 hash, its real format and size, and its own error if it failed. A file extension is not trusted. - One bad image never fails the read. - Download exact URLs on their own with `download_images`. ## Result cards On a search or listing page, `card_images` pairs each result with its own thumbnail in the same load, so checking results by eye or with a vision model doesn't cost a page load per item. Each card is `{url, title, image}`. - **Order:** entries from the page's JSON-LD `ItemList` come first, then linked cards that have an image, then the rest. Menu links never crowd out results. - **Which image:** a card's image is the one inside its link, or inside the nearest container that holds no other result. Neighbouring cards never share one, and logos and icons are skipped. - **Lazy images:** lazy-loaded images are found through `data-src` and `srcset`, and browsers scroll first so they load. - **Unlinked cards:** some sites render cards without links and list the URLs only in JSON-LD. Those cards come back with their URL but no image, because matching them up is site knowledge. ```python page = await web.read("https://shop.example.com/search?q=lamp", policy_overrides={"card_images": True}) for card in page["cards"]: print(card["title"], card["image"]) ``` ## Usage ```python page = await web.read(url, policy_overrides={"include_images": True, "max_images": 10}) images = await web.download_images(["https://example.com/a.jpg", "https://example.com/b.png"]) ``` ```bash frankensurf read https://example.com/p/2231 --images --max-images 10 frankensurf images https://example.com/a.jpg https://example.com/b.png ``` ```json { "tool": "images", "arguments": { "urls": ["https://example.com/a.jpg"], "max_images": 10 } } ``` ## Options | Option | Default | Description | | --- | --- | --- | | `include_images` | `false` | Download the page's images. | | `max_images` | `50` | Most images to download. It caps the count; it doesn't mean the gallery is complete. | | `card_images` | `false` | Add `cards`: each result link with its title and its own thumbnail URL. | | `scroll_screens` | `0` | Screens to scroll in a browser before capture so lazy images load. `card_images` alone scrolls 3. | | `max_image_bytes` | 20 MB | Largest image accepted. | --- # JavaScript pages Render single-page apps in a real browser, and wait for the content you need. You rarely need to ask for a browser. When plain HTTP returns an app shell, an unrendered template or almost no text, Frankensurf reports `VISUAL_REQUIRED` and moves on to a browser by itself. - Force a browser with `render=True` when you already know the page needs one. - Wait for a specific element before capture. - Browsers: local [Chromium](/docs/integrations/local-chromium/), [Steel](/docs/integrations/steel/), [Crawl4AI](/docs/integrations/crawl4ai/), stealth browsers and hosted ones. ## Usage ```python page = await web.read("https://app.example.com/dashboard", policy_overrides={ "render": True, "wait_selector": "[data-testid=results]", "settle_ms": 800, }) ``` ```bash frankensurf read https://app.example.com/dashboard --render --wait-selector '[data-testid=results]' ``` ```json { "tool": "read", "arguments": { "url": "https://app.example.com/dashboard", "render": true, "acquisition_policy": { "wait_selector": "[data-testid=results]", "settle_ms": 800 } } } ``` Here is what automatic escalation looks like on a page plain HTTP can't use: ```json title="receipt.attempts" [ { "provider": "http", "status": "failed", "failure": "VISUAL_REQUIRED", "latency_ms": 88 }, { "provider": "local", "status": "observed", "latency_ms": 1310 } ] ``` ## Options | Option | Default | Description | | --- | --- | --- | | `render` | `false` | Use a browser from the start. | | `wait_selector` | none | Wait for this CSS selector. | | `wait_state` | `"attached"` | `attached` or `visible`. | | `content_ready_selector` | none | Wait for this selector, but capture anyway when it times out. | | `settle_ms` | `400` | Extra wait after the page is ready. | | `allow_local_browser` | `true` | Set `false` to use only remote browsers. | --- # Lightning mode Start three tools at once on a page and keep the first complete one. An ordinary read starts with the cheapest tool and climbs only when it has to. Since v0.33 a read that has no page after 3 seconds starts two racers beside it ([smart lightning](/docs/blocked/escalation/#racing-a-slow-read-smart-lightning)), so slow reads are already raced by default. Lightning mode starts the read and the two racers at once, for reads where every second matters. - The racers are the first free tools on the [completeness ladder](/docs/blocked/escalation/#completeness): by default Jina Reader, which fetches from its own network, and Scrapling, a stealth browser. They start half a second apart, after the read itself, and the read leaves those two tools to them, so no tool reads the page twice. - The first [complete](/docs/blocked/escalation/#completeness) page wins; the other reads are cancelled. - When no page is complete, the more complete of the read's page and the racers' best stands; when the read failed, the racers' best does. - An incomplete page then escalates, racing up to four of the tools not tried yet instead of two. - Free tools only: lightning never starts a paid tool. - A read shaped by a [site module](/docs/read/site-modules/) is not raced: the module reads the page from its own kind of source. Measured on 40 sites from the 150-site held-out set, one read at a time, with the order of the two modes shuffled per site: the same 35 sites read either way, the median read fell from 5.0 to 1.9 seconds, and reads that took over 10 seconds normally came back in 2.7 (median). The slowest tenth stayed about the same (14.9 against 15.7 seconds): when the read's own page is incomplete and the racers are slow, lightning waits for them. With many reads running at once on one machine, the extra browsers compete for its CPU and the gain shrinks. On a fresh blind set of 150 sites, read from a cloud VM, lightning read 124 (82.7%) at a median of 1.7 seconds, against 118 (78.7%) and 5.9 seconds for ordinary reads four at a time ([benchmarks](/docs/more/benchmarks/)). A site sees up to three requests at once from you instead of one. Keep it for reads where time matters, and use [batch](/docs/read/batch/) rather than lightning for many pages on one site. ## Usage ```python page = await web.read(url, policy_overrides={"lightning": True}) ``` ```bash frankensurf read "https://www.bandcamp.com/search?q=jazz" --lightning ``` ```json { "tool": "read", "arguments": { "url": "https://www.bandcamp.com/search?q=jazz", "lightning": true } } ``` Set `FRANKENSURF_LIGHTNING=1` (in the environment or `~/.config/frankensurf/.env`) to turn it on for every read, for example on an MCP server where every answer is waited on. It is a default: a call that sets `lightning: false` reads normally. ## The receipt `receipt.hedge` describes the race: `lightning: true`, the racers in `providers`, the ones that finished in `tried`, and `won: true` with the winning racer in `provider` when a racer's page was kept. A racer that wins becomes the site's route hint, so the next read of that site starts with it. --- # Main content The article without menus, footers and cookie banners, so the first few thousand characters are the content. A page's text includes its menus, footer and banners. When your agent only reads the first few thousand characters, the article can be pushed out of view. Ask for the main content and the result also gets `main_text`: the article, starting with its headline. ```python page = await web.read(url, policy_overrides={"main_content": True}) page["main_text"] # the article page["receipt"]["main_content"] # {"method", "chars", "of_chars"} ``` ```bash frankensurf read https://news.example.com/story --main ``` ```json { "tool": "read", "arguments": { "url": "https://news.example.com/story", "main_content": true } } ``` Over MCP, `text` becomes the main content and `full_text_chars` says how long the whole page's text was. ## How it is cut - **HTML.** [trafilatura](https://trafilatura.readthedocs.io/) when it is installed (`pip install "frankensurf[main]"`, recommended). Otherwise a built-in extractor drops navigation, headers, footers, sidebars and banners by tag, ARIA role and class names, prefers `
` and `
`, and otherwise keeps the block with the most paragraph text. It is a basic fallback; trafilatura is better on unusual layouts. - **Markdown from a hosted reader** (Jina Reader). The link lists before the first real paragraph and after the last one are cut, and the title kept. A page whose text is only a cookie or consent notice counts as incomplete, so the read escalates instead of handing the notice back. --- # Markdown Ask sites for markdown first, so your agent reads clean text. ## Any page as markdown `markdown_output` converts the page Frankensurf got into markdown, whichever tool got it: headings, paragraphs, nested lists, links and images (made absolute), bold and italics, code blocks, tables and quotes. It comes back as `markdown`, next to the plain `text`. Scripts, styles and forms are dropped; menus stay (add `main_content` for the article alone). A page the site already served as markdown is passed through as it is. ```python page = fs.read("https://docs.python.org/3/library/asyncio-task.html", markdown=True) print(page.markdown[:500]) ``` `--markdown-output` on the CLI, `markdown` in the MCP `read` tool. `frankensurf serve` always returns it in Firecrawl's `markdown` field. ## Ask the site for markdown With `prefer_markdown`, the first request asks for `text/markdown`. Sites that support [Markdown for Agents](https://developers.cloudflare.com/fundamentals/reference/markdown-for-agents) answer with markdown directly; everyone else gets the usual HTML route. - No browser and no extra request when the site supports it. - On by default in the MCP `read` tool. - [Jina Reader](/docs/integrations/jina-reader/) returns markdown for sites that don't. ## Usage ```python page = await web.read("https://developers.cloudflare.com/fundamentals/reference/markdown-for-agents/", policy_overrides={"prefer_markdown": True}) ``` ```bash frankensurf read https://developers.cloudflare.com/fundamentals/reference/markdown-for-agents/ --markdown ``` ```json { "tool": "read", "arguments": { "url": "https://developers.cloudflare.com/fundamentals/reference/markdown-for-agents/" } } ``` ```json title="Response" { "title": "Markdown for Agents", "content_type": "text/markdown; charset=utf-8", "text": "---\ndescription: Cloudflare's Markdown for Agents converts HTML to Markdown at the edge…", "structured": { "format": "markdown" }, "receipt": { "method": "http", "latency_ms": 261 } } ``` ## When to turn it off Markdown keeps links and headings, so it is not always shorter than Frankensurf's extracted text. If you need the page's HTML structure, pass `prefer_markdown: false` (over MCP, in `acquisition_policy`). --- # Page data Get the structured data a page already carries, including the JSON its own frontend fetched. Most pages carry their data in machine-readable form before any text extraction. Frankensurf hands it over as-is, so your agent can read prices, dates or listings without scraping the layout. - `structured.jsonld`: every JSON-LD block the page publishes. - `structured.embedded_json`: JSON embedded for the page's own frontend, such as Next.js's `__NEXT_DATA__`. - `captured_json`: on rendered reads, the JSON responses the page fetched from its own API. - `links`, with `include_links`: every link on the page as `url` and anchor text, in page order and once each (up to `max_links`, 500), so a results page's item URLs come back with it. `--links` on the CLI, `links` in MCP. ## Usage ```python page = await web.read("https://shop.example.com/p/2231", policy_overrides={ "render": True, "capture_json_responses": True, }) offers = [item["data"] for item in page.get("captured_json", {}).get("items", [])] ``` ```bash frankensurf read https://shop.example.com/p/2231 --render --capture-json ``` ```json { "tool": "read", "arguments": { "url": "https://shop.example.com/p/2231", "render": true, "acquisition_policy": { "capture_json_responses": true } } } ``` ```json title="Response (trimmed)" { "structured": { "jsonld": [{ "@type": "Product", "name": "…", "offers": { "price": "…" } }], "embedded_json": [] }, "captured_json": { "items": [{ "url": "https://shop.example.com/api/product/2231", "http_status": 200, "content_type": "application/json", "format": "json", "data": { "title": "Widget", "price": 42 } }], "skipped": 0 } } ``` ## JSON responses When the URL itself returns JSON, `structured` is the parsed body. Newline-delimited JSON becomes `structured.records`. ## Options | Option | Default | Description | | --- | --- | --- | | `capture_json_responses` | `false` | Capture the page's own JSON responses (rendered and signed-in reads). | | `capture_json_max_items` | `20` | Most responses kept. | | `capture_json_max_bytes` | 2 MB | Largest response kept. | Frankensurf returns data, not answers. Deciding which price is current or whether an item is in stock is your agent's job. --- # PDFs Read PDFs as text, with no extra setup. When a URL returns a PDF, Frankensurf extracts its text with [pypdf](https://pypdf.readthedocs.io/). Your agent gets text, not bytes. ## Usage ```python page = await web.read("https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf") ``` ```bash frankensurf read https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf ``` ```json { "tool": "read", "arguments": { "url": "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf" } } ``` ```json title="Response" { "url": "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf", "text": "Dummy PDF file", "content_type": "application/pdf; qs=0.001", "structured": { "format": "pdf", "pages": 1 }, "receipt": { "method": "http", "latency_ms": 414 } } ``` ## Options | Option | Default | Description | | --- | --- | --- | | `pdf_max_pages` | `50` | Most pages to extract. Also the most pages a paid tool such as Firecrawl is asked to parse; it bills one credit per page, and a cost cap skips it when the estimate is over. | | `max_bytes` | 40 MB | Largest file accepted. | A PDF longer than `pdf_max_pages` (or than a cost cap pays a paid tool to parse) is cut short, and the result says so. `structured` carries `pages` (read), `pages_total` and `truncated`, and the receipt a short note: ```json "structured": { "format": "pdf", "pages": 10, "pages_total": 48, "truncated": true }, "receipt": { "pdf": { "pages_read": 10, "pages_total": 48, "truncated": true, "note": "PDF truncated: read 10 of 48 pages; raise pdf_max_pages" } } ``` A paid tool returns markdown or HTML, so there the same fields sit under `structured.pdf`. Firecrawl reports the true count as `totalPages`. When it does not, `pages_total` is `null` and a parse that stopped at the page limit counts as truncated. Scanned PDFs without a text layer return no text. OCR is not built in yet. --- # Profiles Sign in once in a Frankensurf browser, then read signed-in pages through any tool that can carry the session. A **profile** is a login that belongs to Frankensurf, not to one browser. You sign in once; after that, a read can carry the session through whichever tool gets the page, and signed-in reads climb the same [escalation ladder](/docs/blocked/escalation/) as everything else. - The session (cookies and local storage) is encrypted on disk. Your agent passes a profile name, never a cookie. - Each profile covers a list of sites. Its cookies go only to those sites, and a profile read of any other site is refused. - A profile pins one fingerprint (user agent, locale, timezone, window size), so the site sees the same browser every time. - Each read saves the refreshed cookies back, so sessions stay current. ## Create a profile 1. Sign in once. ```bash frankensurf profile-login shopping --site example-shop.com ``` A Frankensurf browser window opens. Sign in as you normally would (2FA included), then close the window. Frankensurf never types or clicks here. 2. Check it. ```bash frankensurf profile-list ``` ## Usage ```python page = await web.read("https://www.example-shop.com/orders", policy_overrides={"profile": "shopping"}) ``` ```bash frankensurf read https://www.example-shop.com/orders --profile shopping ``` ```json { "tool": "read", "arguments": { "url": "https://www.example-shop.com/orders", "profile": "shopping" } } ``` `receipt.profile` shows the profile, its version and the tool that carried it. ## Which tools carry a profile | Sharing | Tools | | --- | --- | | `local` (default) | [HTTP](/docs/integrations/http/) with the cookies, [Chromium](/docs/integrations/local-chromium/), [Patchright](/docs/integrations/patchright/) | | `hosted` | adds [Steel](/docs/integrations/steel/), [Browserbase](/docs/integrations/browserbase/), [Hyperbrowser](/docs/integrations/hyperbrowser/), [Browserless](/docs/integrations/browserless/), [Kernel](/docs/integrations/kernel/), [Anchor](/docs/integrations/anchor/) | Choose with `--sharing hosted` on `profile-login`. `hosted` puts the session on that service's servers while it reads. Camoufox is Firefox, so it would contradict a profile's Chromium fingerprint and doesn't carry profiles. Scrape APIs don't carry them either. ## When the session expires A read that lands on a sign-in page fails with `AUTH_REQUIRED`, and `receipt.next_step` names the command that fixes it: `frankensurf profile-login shopping --site example-shop.com`. Signing in again updates the same profile. ## Where it's stored Profiles are AES-GCM files in `~/.local/share/frankensurf/profiles`, readable only by you. The key is `FRANKENSURF_PROFILE_KEY` (32 random bytes, base64) if you set it, which lets you keep it in a secrets manager such as Infisical; otherwise a key file is created at `~/.config/frankensurf/profile.key` with mode 600. Without the key, the profile files are unreadable. Profiles and [identities](/docs/read/signed-in/) are separate. An identity reads through your own running Chrome and never moves its session. A profile is Frankensurf's own copy of a login, so any capable tool can use it. Use an identity when the site must only ever see your real browser. ## Commands | Command | Does | | --- | --- | | `profile-login NAME --site SITE [--site ...] [--sharing local\|hosted]` | Sign in and save, or refresh an existing profile | | `profile-list` | Names, sites, sharing level and cookie counts; never the session | | `profile-delete NAME` | Remove a profile | --- # Signed-in pages Read pages as you, through your own Chrome, without your agent ever seeing a password or cookie. Some pages only exist when you're signed in: orders, saved searches, messages. Frankensurf reads them through a Chrome you sign in to yourself. Your agent passes a name, never a password, cookie or profile. For most sites a [profile](/docs/read/profiles/) is simpler: sign in once in a Frankensurf browser and any capable tool can carry the session. Identities are for sites that must only ever see your real Chrome. - Each saved login is an **identity**, tied to one Chrome profile and a list of sites. - Pages run normally, scripts included, so single-page apps like dashboards and inboxes render. - If anything about the browser looks wrong, the read stops. It never falls back to a logged-out read. ## Set up an identity 1. Start a dedicated Chrome with remote debugging on a loopback port and its own profile. Sign in to the site yourself. 2. Register that Chrome. ```bash frankensurf executor-enroll my-chrome \ --cdp-url http://127.0.0.1:9222 \ --user-data-dir /home/you/.frankensurf/profiles/main \ --profile-directory Default ``` 3. Register the identity: which sites it covers, and how to tell you're signed in. ```bash frankensurf identity-enroll shop-me \ --executor-id my-chrome --domain www.example-shop.com \ --auth-url https://www.example-shop.com/account \ --authenticated-selector '#account-menu' ``` `scripts/prepare_local_identity.py` prints a reviewable plan for a dedicated profile, and with `--launch` opens it for you to sign in. ## Usage ```python page = await web.read("https://www.example-shop.com/orders", policy_overrides={"identity": "shop-me"}) ``` ```bash frankensurf read https://www.example-shop.com/orders --identity shop-me frankensurf identity-status shop-me ``` ```json { "tool": "read", "arguments": { "url": "https://www.example-shop.com/orders", "identity": "shop-me" } } ``` ## What it guarantees - Every top-level navigation, including redirects, must stay on the identity's sites. Popups are closed. - Frankensurf never clicks or types in a signed-in tab during a read. - One process uses an identity at a time; your own browsing in other tabs is unaffected. - Output URLs drop credential parameters such as `access_token`. Remove an identity with `frankensurf identity-revoke shop-me`. Identities run on your machine only (`LOCAL_ONLY`). Stored secrets are in private files (mode 600) but not yet encrypted at rest. --- # Site modules Save what your agent learns about a site, as data, and reuse it on every read. When your agent works out how a site really works, it can save that as a **site module** instead of writing a throwaway parser. Typical lessons: - the search URL only takes `q`; - results sit as JSON inside the page; - listing URLs live in JSON-LD and need pairing with the cards. Every later read of that site comes back shaped: `items` with the fields you named, the next page, and checks that tell you when the site changes. A module is **data, never code**, and lives in your own state folder. Frankensurf ships none; site knowledge stays with whoever learned it. ## Items with no module at all Pass `auto_items` (MCP: `items=True`) on any read. If the page carries a list of items in its own data, the result gets `items`, found the same way as below and with no extra read, plus `auto_module`: the module that found them. Save it (`site_modules` `put`) and every later read of that kind of page is shaped without asking. ```python page = await web.read(url, policy_overrides={"auto_items": True}) page["items"][:3]; page["receipt"]["auto_items"] # {"found", "count", "source"} ``` ## Let Frankensurf draft one Most listing pages carry their rows as data for their own frontend: JSON-LD, JSON in a script tag (`__NEXT_DATA__` and friends), or JSON the page fetches while it renders. When none of that holds the rows, they are usually repeated HTML cards. `module discover` reads a page, looks in all four places, and drafts a module from the best list of items it finds (name, link, price, image). Each draft is validated and run against that same page, so you see the item count and sample rows before you save anything. ```python found = await web.discover_module("https://www.example.com/search?q=lamp") best = found["drafts"][0] # module, count, fields, sample await web.discover_module("https://www.example.com/search?q=lamp", save=True) ``` ```bash frankensurf module discover "https://www.example.com/search?q=lamp" --save ``` ```json { "tool": "discover_site_module", "arguments": { "url": "https://www.example.com/search?q=lamp", "save": true } } ``` After saving, every read of a matching URL (another query, the same path) comes back with `items`. Check the sample first: a draft can pick the wrong list on a page with several, and nothing here knows a site. On 16 listing pages it had never seen, it drafted the right list on 8, the wrong one on 1, and nothing on 7 (mostly pages it could not read). Keep the drafts that are right, and edit or write the rest by hand. Start from a home page instead, with a query, when you don't know the site's search URL. Frankensurf finds the search itself: schema.org `SearchAction` first, then the page's search form, then, for a search box run by script, it types the query once into the visible box in a local browser, presses Enter and keeps the URL it lands on. Nothing else is typed or clicked. ```bash frankensurf module discover https://www.example.com/ --query "desk lamp" --save ``` On 14 home pages it had never seen, it found the search on 10 (three of the others refused the read) and drafted the right results on 6. When the page was a search, the draft also gets a `search` template built from its URL: the parameter that held the search words (`?q=lamp`), or the path segment after `/q/`, `/search/` or `/tag/`. Everything else in the URL stays as it was. So one saved search page becomes any search: ```python page = await web.read_template("auto-example-com", "search", {"query": "desk lamp"}) ``` ## How far to trust the items Every drafted module, every module read and every `auto_items` result carries an `items_quality` check against the page itself: - `linked`: items whose link stays on the site (or the site it redirected to) and looks like an item page, not this page, a help page or a filter of this listing; - `named`: items whose name appears in the page's text; - `priced`: items with a positive price; - `distinct`: items that are not all the same link. A `grade` (`good`, `partial`, `poor`) sums it up. Discovery drafts several candidates and ranks them by this check before size; poor lists are dropped. On 16 unseen listing pages it drafted the right items on 10 (8 before the check), the wrong ones on 1, and none on 5 that refused the read. When a saved module comes back `poor` on a page that read fine, the site probably changed. The receipt then carries `module.repair_draft`: a new version drafted from that same page, keeping your templates and pagination. Nothing is saved until you accept it with `site_modules` `put`. ## What a module holds ```json { "id": "example.search", "version": "1", "match": { "origin": "https://www.example.com", "path_pattern": "/search" }, "templates": { "search": { "url": "https://www.example.com/search?q={query}", "params": { "query": "required" } } }, "sources": { "hits": { "kind": "embedded_json", "decode": "next_flight", "marker": "\"hits\":" } }, "items": { "from": "hits", "fields": { "title": "ObjectTitle", "url": { "path": "ITEM_URL", "type": "url" }, "price": { "path": "ObjectPrice", "type": "number" } } }, "assertions": { "schema": "frankensurf.workload-assertions/v1", "checks": [ { "path": "items", "operator": "minimum_count", "value": 5 }, { "path": "matching_query", "operator": "minimum_count", "value": 3 } ] }, "notes": "Search takes q; searchTerm is silently ignored." } ``` | Part | What it does | | --- | --- | | `match` | `origin` and a `path_pattern` (a regular expression over the URL path), like a route recipe. Optional `query_keys`. | | `templates` | URLs with `{params}`, each `"required"` or `{default, encoding: "query" \| "path"}`. They always stay on the module's origin. | | `readiness` | `selector` to wait for (bounded), `settle_ms`. | | `sources` | Where rows live: `jsonld` (`type`, `path`), `embedded_json` (`selector`, `marker`, `path`, optional `decode: "next_flight"` for Next.js pages), `captured_json` (JSON the page fetched: `url_contains`, `path`), or `html` (`item_selector` and `fields`, each a `selector` and optional `attribute` or list of attributes tried in order). | | `items` | A field map over one source: `name: "path"` or `{path, type}` with `type` `auto`, `text`, `number` or `url`. `join` fills blanks from a second source, by `position` or by `{left, right}` key. `limit`, default 200. | | `pagination` | `{param, start, step, max_pages}` or `{next_selector}`. The read returns `next_url`. | | `invalid` | Markers of the site's error or empty-feed page: `final_url_contains`, `title`, `text`. Such a page fails as `NOT_FOUND` instead of climbing to stronger tools. | | `assertions` | A `frankensurf.workload-assertions/v1` set over `{items, count, first, matching_query}`. Operators are `nonempty`, `equals` and `minimum_count`. | | `policy_defaults` | Operational settings, such as `scroll_screens` or `settle_ms`. | **What a module can never do:** name an identity, pick or allow providers, allow paid tools, or reach another origin. Any setting you pass on the call wins over the module's. ## Using modules - **Saved modules:** an enabled one that matches the URL shapes the read by default. - **One read:** name a module with `module`, run an unsaved one with `module_override`, or turn modules off with `module=False` (`none` on the CLI and MCP). - **What comes back:** `items` and, with pagination, `next_url`. The receipt gains `module`: its `id`, `version`, `sha256`, how it was chosen, the item count and the assertion results (`status`: `passed`, `failed` or `invalid`). Raw page data is unchanged. - **Completeness:** a page whose module assertions pass counts as complete, so a site that keeps its results in JSON isn't escalated for having few links. ```python web.site_modules.put(module) # save or replace (bump version) page = await web.read_template("example.search", "search", {"query": "desk lamp"}) for item in page["items"]: print(item["title"], item["price"], item["url"]) print(page["receipt"]["module"]["status"]) # passed / failed / invalid await web.read(url, module=False) # without modules web.site_modules.enable("example.search", False) # or disable it ``` ```bash frankensurf module add example-search.json frankensurf module list frankensurf read-template example.search search --param query="desk lamp" frankensurf read https://www.example.com/search?q=lamp --module none frankensurf module disable example.search ``` ```json { "tool": "site_modules", "arguments": { "action": "put", "module": { "id": "example.search", "version": "1", "...": "..." } } } { "tool": "read_template", "arguments": { "module": "example.search", "template": "search", "params": { "query": "desk lamp" } } } ``` ## When a site changes When a module's assertions start failing, `receipt.module.status` says `failed` (or `invalid`), `receipt.module.repair` says what to do, and the raw page is still there. Your agent can either: - **Save a fix directly.** Put the module again under a new `version`. Fine for your own modules. - **Propose a fix through repair.** Use this when changes should be reviewed. The agent drafts the next version (same `id` and origin, new `version`) and proposes it with the failing read's `trace_id`. Frankensurf then: 1. checks it with the **old version's assertions**, so a fix can't pass by weakening the checks; 2. runs it over the page the failing read kept; 3. runs it over a fresh, independent read; 4. records the proposal and its validation. Nothing changes yet. The owner promotes it with `repair-promote`. Promotion re-checks the kept page, refuses if the module changed in the meantime, saves the new version and records it in the repair history. `repair-disable` rolls back to the version it replaced. ```python proposal = await web.propose_module_repair(failing_trace_id, next_version) # proposal["status"] == "proposal_ready" once the kept page and a fresh read pass web.promote_repair(proposal["proposal"]["id"], proposal["proposal"]["sha256"]) # owner web.disable_repair(proposal["proposal"]["id"], "rolled back after review") # undo ``` ```bash frankensurf module repair TRACE_ID example-search-v2.json frankensurf repair-promote PROPOSAL_ID --proposal-sha256 SHA256 frankensurf repair-disable PROPOSAL_ID --disable-reason "rolled back after review" ``` ```json { "tool": "repair_site_module", "arguments": { "trace_id": "…", "module": { "id": "example.search", "version": "2", "...": "..." } } } ``` Agents can propose, but only the owner promotes, from the CLI or Python. --- # Structured fields Pass a JSON Schema and get its fields filled from the data the page publishes, with a climb to a stronger tool while a required field is missing. Most of the time an agent wants a few fields from a page, not the page: a product's name and price, an article's headline and date, a job's company and location. Pass a JSON Schema and the result gets `extracted`, with each property filled from what the page publishes for machines. ```python schema = { "type": "object", "properties": { "name": {"type": "string"}, "price": {"type": "number"}, "currency": {"type": "string"}, "image": {"type": "string"}, }, "required": ["name", "price"], } page = await web.read(url, policy_overrides={"extract_schema": schema}) page["extracted"]["data"] # {"name": "Brass desk lamp", "price": 89.95, "currency": "AUD", ...} page["extracted"]["missing"] # required properties not found: [] page["extracted"]["sources"] # {"price": "jsonld:product.offers.price", ...} ``` ```bash frankensurf read https://shop.example.com/item/42 --schema product.json ``` ```json { "tool": "read", "arguments": { "url": "https://shop.example.com/item/42", "schema": { "type": "object", "properties": { "name": { "type": "string" }, "price": { "type": "number" } }, "required": ["name", "price"] } } } ``` ## Where the values come from No model is used. Values come from what pages publish for search engines and link previews: - **JSON-LD** (schema.org): `Product`, `Article`, `JobPosting`, `Event` and the rest, including `@graph` blocks. - **Microdata** (`itemprop`). - **Meta tags:** Open Graph (`og:title`, `og:image`), `article:published_time`, `product:price:amount` and the like. - **The page's own JSON**, such as a framework's embedded state, searched by key name. A property is matched by its name and common synonyms: `price` finds `offers.price`, `lowPrice` and `product:price:amount`; `published` finds `datePublished` and `article:published_time`; `name` finds `headline` and `og:title`. Values are converted to the property's type, so `"1,299.00"`, `"1.249,50 €"` and `"1 249"` all become numbers. Name your own paths with `x-paths`: ```json { "cheapest": { "type": "number", "x-paths": ["offers.lowPrice", "meta:product:price:amount"] } } ``` Give the schema a `title` (or `x-type`) such as `"Product"` and the matching JSON-LD object is preferred when a page has several. The filled object is validated against the schema; `valid` and `errors` say how it went. ## Missing fields make the read try harder A page that loaded but lacks a required property counts as incomplete, the same as a search page with no results. An automatic read then climbs to a stronger tool, and keeps the page that fills the most properties. `receipt.extracted` records `found`, `of` and `missing`. A field the page never publishes in its data stays missing. For those, read the page's text (`main_content` helps) and let your agent pick the value out. --- # Command line Every frankensurf command and option. ```bash frankensurf [targets] [options] ``` Output is JSON. The raw page body is left out unless you pass `--raw`. State goes to `--state` (default `./state`). ## Commands | Command | Targets | What it does | | --- | --- | --- | | `setup` | none | Download Chromium and the free stealth browsers (`--no-stealth` skips them), then check each one starts and reads a page: `works_here` in the result. | | `read` | one URL | Read a page. `--module ID` uses a site module; `--module none` turns them off. | | `read-template` | module ID, template | Read a URL built from a site module's template; `--param NAME=VALUE` per parameter. | | `module` | `list`, `show ID`, `add FILE`, `enable ID`, `disable ID`, `rm ID`, `repair TRACE_ID FILE`, `discover URL [--query TEXT] [--save]` | Manage [site modules](/docs/read/site-modules/); `repair` proposes a fixed version for owner promotion with `repair-promote`; `discover` drafts one from a page's own data. | | `watch-sites` | site URLs | New pages on those sites from their feeds and sitemaps; `--since DATE`, `--read-new`. | | `skill` | `install` or `uninstall` (`--skill-dir`), or `show` | Install, remove or print the agent skill (SKILL.md). | | `plugins` | `list`, `trust KIND ID DISTRIBUTION`, `untrust KIND ID`, `disable KIND ID`, `enable KIND ID` | Third-party [plugins](/docs/more/plugins/): what loaded, what was turned away and why, and the trust file. | | `egress` | `check` | Whether your direct connection and each [second connection](/docs/blocked/egress/) works, and where it comes out (address, country, WARP). | | `extract` | one URL | Read through `--adapter` (default `html`). | | `batch` | URLs | Read several pages. | | `search` | query words | Search. `--source` picks one source; `--limit` (default 10). | | `watch` | one URL | One poll for new links; `--link-pattern RE`. | | `paginate` | one URL | Follow pages; needs `--adapter`; `--max-pages`, `--continuation-adapter`. | | `images` | image URLs | Download and check images. | | `trace` | one trace ID | Show how a result was fetched. | | `import` | one URL | Store a capture you made: `--evidence-file`, `--observed-at`, `--content-type`. | | `do` | none | A typed browser action from `--intent-file`; needs `--identity`, `--grant-action`, `--action-origin`. | | `repair` | one trace ID | Diagnose a retained extract failure; `--repair-policy-file`. | | `repair-promote`, `repair-disable` | one proposal or overlay ID | Activate (`--proposal-sha256`) or disable (`--disable-reason`) a repair overlay. | | `executor-enroll` | executor ID | Register a Chrome: `--cdp-url`, `--user-data-dir`, `--profile-directory`, `--network-context`, `--geography`. | | `identity-enroll` | identity ID | Register a login: `--executor-id`, `--domain`, `--image-domain`, `--auth-url`, `--authenticated-selector`, `--login-selector`, `--allow-action`. | | `identity-status` | optional identity ID | Health of your identities. | | `identity-revoke` | identity ID | Remove an identity. | | `bot-auth-init` | none | Create a Web Bot Auth key. | | `bot-auth-directory` | none | Print the public key directory to serve. | ## Read options These map to [WebPolicy](/docs/reference/settings/) fields and work on `read`, `extract`, `batch`, `watch` and `paginate`. | Option | Field | | --- | --- | | `--provider ID` | `provider` | | `--provider-candidate ID` (repeat) | `provider_candidates` | | `--markdown` | `prefer_markdown` | | `--render` | `render` | | `--handoff` | `allow_handoff` | | `--lightning` | `lightning` | | `--real-browser` | `allow_real_browser` | | `--paid`, `--max-cost USD` | `allow_paid_fallbacks`, `max_cost_usd` | | `--capture-json` | `capture_json_responses` | | `--images`, `--max-images N` | `include_images`, `max_images` | | `--card-images`, `--scroll-screens N` | `card_images`, `scroll_screens` | | `--links`, `--max-links N` | `include_links`, `max_links` | | `--markdown-output` | `markdown_output` | `map URL` (`--query`, `--limit`) and `crawl URL` (`--limit`, `--max-depth`, `--include-path`, `--exclude-path`, `--entire-domain`, `--ignore-robots`): see [Map and crawl](/docs/crawl/). `ask "question"` (`--model`): agent mode, see [Agent mode](/docs/agent-mode/). `serve --model` turns on the summary, json and extract endpoints. | `--expect WORD` (repeat) | `expect_terms` | | `--freshness now\|hour\|day\|cached` | `freshness` | | `--timeout S` | `timeout_seconds` | | `--wait-selector CSS` | `wait_selector` | | `--content-ready-selector CSS`, `--content-ready-timeout S` | `content_ready_selector`, `content_ready_timeout_seconds` | | `--agent-task TEXT` | `browser_agent_task` | | `--identity ID` | `identity` | | `--max-pages N` | `max_pages` | | `--provider-max-attempts-per-candidate N`, `--provider-retry-delay S`, `--provider-retry-failure CODE` | retry fields | Search adds `--search-source-candidate`, `--search-source-allow`, `--search-source-prefer` (each repeatable), `--search-max-attempts`, `--search-source-timeout` (0 means the operation timeout) and `--search-url`. `--paid` allows paid tools whose keys are set (`allow_paid_fallbacks`) and `--max-cost USD` caps what one operation may spend (`max_cost_usd`). Fields without a flag are set from Python or MCP. ## Other options | Option | What it does | | --- | --- | | `--state DIR` | State directory (default `state`). | | `--steel-url URL` | A self-hosted Steel. | | `--identity-registry PATH` | Identity registry (default `FRANKENSURF_IDENTITIES` or `~/.frankensurf/identities.json`). | | `--raw` | Include the raw page body. | --- # Environment and files Every environment variable and file Frankensurf reads. ## Environment variables | Variable | What it sets | | --- | --- | | `FRANKENSURF_STATE` | State directory for the MCP server. | | `FRANKENSURF_PLUGINS` | The plugin trust file (default `~/.config/frankensurf/plugins.json`); see [Writing a plugin](/docs/more/plugins/#the-trust-file). | | `FRANKENSURF_STEEL_URL` | A self-hosted Steel for the `steel` provider. | | `FRANKENSURF_SEARCH_URL` | A SearXNG at another loopback address (MCP server). Not needed for the one `frankensurf install search` starts, at `http://127.0.0.1:8088`. | | `FRANKENSURF_GOOGLE_CSE_CX` | The search engine ID for [Google Programmable Search](/docs/integrations/google-cse/), beside `GOOGLE_CSE_API_KEY`. | | `FRANKENSURF_SEARCH_AUTOSTART` | `0`: search never starts the installed local SearXNG on its own (by default it does, once per process, when nothing answers on `127.0.0.1:8088`). | | `FRANKENSURF_IDENTITIES` | Identity registry (default `~/.frankensurf/identities.json`). | | `FRANKENSURF_PROVIDER_PYTHON` | Python for the isolated stealth browsers. | | `FRANKENSURF_CRAWL4AI_DIR`, `FRANKENSURF_CRAWL4AI_PYTHON`, `FRANKENSURF_CRAWL4AI_BROWSERS_PATH`, `FRANKENSURF_CRAWL4AI_HEALTH` | The isolated Crawl4AI install. | | `FRANKENSURF_BROWSER_USE_PYTHON`, `FRANKENSURF_BROWSER_USE_BROWSER`, `FRANKENSURF_BROWSER_USE_MODEL_FACTORY`, `FRANKENSURF_BROWSER_USE_BILLING`, `FRANKENSURF_BROWSER_USE_BINDING_REVISION` | The Browser Use agent. | | `FRANKENSURF_SIGNATURE_AGENT`, `FRANKENSURF_BOT_AUTH_KEY_FILE` | [Signed requests](/docs/blocked/signed-requests/). | | `FRANKENSURF_HANDOFF_PROFILE`, `FRANKENSURF_HANDOFF_CDP_URL` | [Handoff](/docs/blocked/handoff/). | | `FRANKENSURF_REAL_BROWSER`, `FRANKENSURF_REAL_BROWSER_PATH`, `FRANKENSURF_REAL_BROWSER_PROFILE`, `FRANKENSURF_REAL_BROWSER_CDP`, `FRANKENSURF_REAL_BROWSER_WINDOW` | [Your real browser](/docs/blocked/real-browser/): `1` turns it on for every read. | | `FRANKENSURF_EGRESS` | [Your other connections](/docs/blocked/egress/), `name=url`, for walled reads. | | `FRANKENSURF_LIGHTNING` | `1` turns [lightning mode](/docs/read/lightning/) on for every read. Both switches also work in `~/.config/frankensurf/.env`, and both are defaults: a call that sets the field itself wins. | | `FRANKENSURF__API_KEY` and rate settings | Paid services. See [Keys and costs](/docs/blocked/paid-tools/). | ## Files | Path | What it holds | | --- | --- | | `./state/` (or `--state`) | Cache, evidence, traces, route memory, `origin-pacing.json`, `watches/`. | | `~/.config/frankensurf/.env` | Keys and settings as `FRANKENSURF_…=…` lines. | | `~/.config/frankensurf/services.json` | Keys under `api_keys`. | | `~/.config/frankensurf/web-bot-auth.jwk` | Your Web Bot Auth private key. | | `~/.config/frankensurf/browser-agent.json` | Browser Use model configuration. | | `~/.config/frankensurf/plugins.json` | Which installed third-party plugins to trust. | | `~/.frankensurf/identities.json` | Registered executors and identities. | | `~/.local/share/frankensurf/handoff-profile/` | The handoff browser profile. | | `~/.local/share/frankensurf/crawl4ai-venv/` | The Crawl4AI environment. | Keep everything under `~/.config/frankensurf` readable only by you. --- # Errors What each failure code means and what to try next. A failed read has `receipt.status: "failed"` and `receipt.failure` with a code and a message. `receipt.attempts` shows what each provider hit on the way. ## Walls | Code | What happened | Next | | --- | --- | --- | | `BLOCKED` | The site refused (403, or an empty block response). | Climbs automatically; starts a cool-down. Allow paid fallbacks or handoff. | | `CAPTCHA` | A challenge page. | Same as `BLOCKED`. `receipt.next_step` may suggest handoff. | | `RATE_LIMITED` | Too many requests (429). | Wait; pacing and the cool-down handle it. | | `AUTH_REQUIRED`, `AUTH_EXPIRED` | The page needs a sign-in. Stops the climb. | Use an [identity](/docs/read/signed-in/) or handoff. | ## Page problems | Code | What happened | Next | | --- | --- | --- | | `VISUAL_REQUIRED` | Plain HTTP got an app shell or an unrendered template. | Climbs to a browser automatically. | | `EMPTY_PAGE` | A browser rendered almost no text, or any tool, named or not, got a page with nothing on it (no text, page data, links or forms). | Climbs automatically. | | `NOT_FOUND` | 404 or 410. Stops the climb unless an earlier attempt hit a wall. | A missing page does not prove a sale. | | `CONTENT_MISMATCH` | The page is not what was asked for, for example a search that was redirected to the site's home page. | Climbs automatically; check the URL. | | `SCHEMA_CHANGED` | The response did not have the expected shape. | Check the adapter, or the service changed. | | `TIMEOUT` | Too slow. | Raise `timeout_seconds`. | | `LIMIT_EXCEEDED` | Too big. | Raise `max_bytes`. | | `INVALID_URL` | Not a valid URL. | Fix it. | | `INVALID_IMAGE` | One image did not decode. | Only that image fails. | ## Providers, policy and cost | Code | What happened | | --- | --- | | `PROVIDER_DOWN` | The provider or site had a server error. Free providers retry once. | | `PROVIDER_UNAVAILABLE` | Not installed or not configured (no key, no display), including a browser build Playwright expects but can't find: run `frankensurf install chromium`. | | `PLUGIN_DISABLED` | The provider is disabled in the catalogue. | | `POLICY_DENIED` | Your settings allow no provider for this. | | `BUDGET_EXHAUSTED` | The next call could pass `max_cost_usd`. | | `EXPLICIT_POLICY_CONFLICT` | A whole `WebPolicy` conflicts with the site's route seed; pass `policy_overrides` instead. | | `ROUTE_SCOPE_REQUIRED` | That provider only runs through a route seed for one site. | | `SEARCH_UNAVAILABLE` | Every search source failed. | | `DISCOVERY_UNAVAILABLE`, `PLUGIN_NOT_TRUSTED`, `PLUGIN_LOAD_REJECTED` | A third-party plugin could not be discovered, is not trusted in `plugins.json`, or failed to load. Shown in `inspect_plugins()`. | | `EXECUTION_OUTCOME_UNKNOWN` | A browser action failed part-way; its idempotency key is fenced from replay. | | `UNKNOWN` | Anything else. Look at the trace. | ## Identities | Code | What happened | | --- | --- | | `IDENTITY_REQUIRED` | Your Chrome was asked for without naming an identity. | | `IDENTITY_UNKNOWN`, `IDENTITY_REVOKED` | Not registered, or removed. | | `IDENTITY_EXECUTOR_OFFLINE`, `IDENTITY_EXECUTOR_UNKNOWN`, `IDENTITY_EXECUTOR_DISABLED` | The registered Chrome is not available. | | `IDENTITY_PROFILE_MISMATCH`, `IDENTITY_CHANGED` | Chrome is not running the registered profile. | | `IDENTITY_IN_USE` | Another process holds the identity. | | `IDENTITY_REAUTH_REQUIRED` | You have been signed out; sign in again. | | `IDENTITY_OPERATOR_HANDOFF_REQUIRED` | A visible snapshot needs you to open exactly one matching page, or its region is missing. | | `IDENTITY_DOMAIN_DENIED`, `IDENTITY_PROVIDER_DENIED`, `IDENTITY_ACTION_DENIED`, `IDENTITY_POLICY_DENIED` | Not allowed for that site, provider or action. | | `IDENTITY_AUTHORITY_UNSUPPORTED` | Only `LOCAL_ONLY` runs today. | | `IDENTITY_CONFIG_INVALID`, `IDENTITY_REGISTRY_INVALID`, `IDENTITY_STORE_UNSAFE` | Bad settings or file permissions. | | `VAULT_REFERENCE_INVALID`, `VAULT_REFERENCE_MISSING`, `VAULT_VALUE_INVALID` | A stored secret is missing or unreadable. | --- # MCP tools The tools frankensurf-mcp gives your agent, and their arguments. `frankensurf-mcp` speaks MCP over stdio. To connect it, see [Add to your agent](/docs/agents/). | Tool | Arguments | Returns | | --- | --- | --- | | `read` | `url`, `provider`, `render`, `include_images`, `freshness`, `identity`, `allow_handoff`, `profile`, `try_harder_than`, `expect_terms`, `card_images`, `links`, `markdown`, `allow_archive`, `items`, `main_content`, `lightning`, `allow_real_browser`, `acquisition_policy` | The page and its receipt. `lightning` starts the read and two other tools at once ([Lightning mode](/docs/read/lightning/)); `allow_real_browser` lets your Chrome or Edge try a walled page ([Your real browser](/docs/blocked/real-browser/)). `main_content` returns the article without menus ([Main content](/docs/read/main-content/)). Asks for markdown by default. `try_harder_than` takes an earlier read's `trace_id` and skips every tool it used. `expect_terms` are words results should mention; a results page that mentions none is flagged `off_query`. `card_images` adds `cards` (link, title, thumbnail); `links` adds every link on the page (url, anchor text); `markdown` adds the page as markdown. `module` names a [site module](/docs/read/site-modules/) (`none` turns them off); `module_override` runs an unsaved one. | | `read_template` | `module`, `template`, `params`, `expect_terms`, `card_images`, `acquisition_policy` | A read of a URL built from a saved module's template, shaped by that module: `items`, `next_url`, `receipt.module`. | | `site_modules` | `action` (`list`, `get`, `put`, `enable`, `disable`, `delete`), `module_id`, `module` | Save and manage [site modules](/docs/read/site-modules/). | | `watch_sites` | `sites`, `since`, `read_new`, `max_new_per_site`, `acquisition_policy` | New pages on many sites from their feeds and sitemaps, polled with conditional requests; `read_new` reads them too. See [Watch](/docs/watch/). | | `batch_template` | `module`, `template`, `params_list`, `acquisition_policy` | One saved template (say, a search) for many parameter sets as one paced batch: each result's `params`, `items`, `next_url` and receipt status, without page text. | | `discover_site_module` | `url`, `save`, `module_id`, `query` | Up to three validated module drafts from a listing page's JSON-LD, script JSON, fetched JSON or repeated HTML cards, each with its item count and sample rows. `save` saves the best. | | `repair_site_module` | `trace_id`, `module` | Propose the next version of a module that failed on a read. Validated against the kept page and a fresh read with the old version's assertions; only the owner promotes it. | | `map_site` | `url`, `search`, `limit`, `include_subdomains` | A site's URLs from its sitemaps and links, without reading them ([Map and crawl](/docs/crawl/)). | | `crawl` | `url`, `limit`, `max_depth`, `include_paths`, `exclude_paths`, `entire_domain`, `acquisition_policy` | Read a section of a site as markdown, breadth first, paced per site. | | `trace` | `trace_id` | The full record of an earlier read: tools tried, failures, timings, completeness steps. No page content. | | `extract` | `url`, `adapter`, `include_images`, `identity`, `provider`, `freshness`, `acquisition_policy` | The page through an adapter: `html` (default), `json` or `rss`. | | `batch` | `urls`, `provider`, `adapter`, `identity`, `include_images`, `freshness` | One result per URL. | | `search` | `query`, `source`, `limit`, `site`, `exclude_domains`, `recency`, `region`, `vertical`, `mode`, `identity`, `provider`, `acquisition_policy` | Attributed results. Options are applied natively and filtered after; `vertical` picks news, reference, discussions, qa, code, papers or books (inferred from the query when omitted); `mode="merge"` fuses every eligible source. See [Search](/docs/search/). | | `paginate` | `url`, `adapter`, `continuation_adapter`, `acquisition_policy` | Every page with an aggregate receipt. | | `images` | `urls`, `identity`, `max_images`, `provider` | Checked images. | | `identity_status` | `identity` | Health of your identities. | | `import_evidence` | `content`, `url`, `observed_at`, `content_type`, `image_urls` | A stored capture. | | `import_image_evidence` | `path`, `url`, `observed_at` | A stored image capture. | | `do` | `intent`, `identity`, `action_classes`, … | A typed browser action. See [Act](/docs/act/). | | `repair` | `trace_id`, `repair_policy` | A diagnosis of a failed extract. | `acquisition_policy` takes any [setting](/docs/reference/settings/), for example `{"allow_paid_fallbacks": true, "max_cost_usd": 0.05}`. JSON `null`, `false` and `0` count as set. MCP results leave out the raw page body. Pass `acquisition_policy: {"prefer_markdown": false}` to `read` when you need the HTML structure instead of markdown. Identities and keys are set up from the command line, never through MCP. `watch` is available from Python and the CLI. --- # Python The Runtime class, its methods and what they return. ```python from frankensurf import Runtime, WebPolicy ``` ## Runtime Open it with `async with`. It owns the state directory: cache, evidence, traces, route memory and pacing. ```python Runtime( state_dir="state", # cache, evidence, traces, route memory steel_api_url=None, # a self-hosted Steel, or FRANKENSURF_STEEL_URL local_cdp_url=None, # used only through a registered identity concurrency=4, # pages at once in a batch per_domain=1, # pages at once per site in a batch domain_delay=0.25, # seconds between batch requests to one site identity_registry=None, # or FRANKENSURF_IDENTITIES plugin_config_path=None, # or ~/.config/frankensurf/plugins.json ) ``` ## Methods Every read-like method takes `policy` (a whole `WebPolicy`) or, preferably, `policy_overrides` (a dict of only the fields you mean). See [WebPolicy](/docs/reference/settings/). | Method | Returns | What it does | | --- | --- | --- | | `await read(url, policy=None, provider=None, adapter=None, *, policy_overrides=None)` | result | Read a page. | | `await extract(url, adapter="html", policy=None, provider=None, *, policy_overrides=None)` | result | Read through an explicit adapter: `html`, `json` or `rss`. | | `await batch(urls, policy=None, adapter=None)` | list of results | Several pages, in order. Identity batches run one at a time. | | `await search(query, source=None, limit=10, engine_config=None, policy=None)` | search result | Search with fallback; a named `source` never falls back. | | `await watch(url, *, link_pattern=None, policy_overrides=None, state_key=None, max_seen=5000)` | `{"new": [...], "first_poll": bool, "receipt": ...}` | One poll for links not seen before. | | `await paginate(url, adapter, policy=None, *, continuation_adapter=None, policy_overrides=None)` | pages and aggregate receipt | Follow a bounded run of pages. | | `await download_images(urls, policy=None)` | list of images | Download and check images. | | `await do(intent, policy=None, provider=None)` | action result | A typed browser action. See [Browser actions](/docs/act/). | | `trace(trace_id)` | dict | The full record of one request. | | `capabilities(domain=None)` | list | Success and speed per site and provider, from traces. | | `identity_status(identity_id=None)` | dict | Health of your identities, without secrets or paths. | | `import_evidence(content, url, observed_at, …)` | result | Store something you captured yourself, with its real time. | | `inspect_plugins()` | dict | The frozen plugin catalogue. | | `await repair(trace_id, policy=None)` | dict | Diagnose one retained public extract failure. | ## Results ```python { "url": "https://example.com/", "title": "Example Domain", "text": "…", "content": "…", # raw body "content_type": "text/html", "headers": {…}, # never cookies "structured": {"jsonld": [], "embedded_json": []}, "image_urls": […], "images": […], # with include_images=True "field_status": {"availability": "unknown", "transaction_price": "unknown", "content": "observed"}, "receipt": {…}, } ``` See [Reading pages](/docs/read/) for every field and [Receipts](/docs/reference/receipts/) for the receipt. ## Failures A failed operation returns a result whose `receipt.status` is `"failed"` and whose `receipt.failure` holds `{"code", "message"}`. Invalid arguments and invalid policies raise `ValueError` straight away. See [Error codes](/docs/reference/errors/). --- # Receipts How every result shows how it was fetched, when, at what cost, and what is still unknown. Every result carries a receipt. It says how the page was fetched, when, what it cost and what Frankensurf could not confirm. Raw content stays on your machine, linked to the receipt by hash. ## A receipt ```json { "trace_id": "6f1c…", "status": "observed", "operation": "read", "method": "local", "observed_at": "2026-10-06T03:14:07Z", "freshness_seconds": 0, "cache_hit": false, "requested_url": "https://example.com", "final_url": "https://example.com/", "http_status": 200, "latency_ms": 2267, "cost_usd": 0, "evidence": [{"path": "state/evidence/2a8c….html", "sha256": "2a8c…", "bytes": 23}], "attempts": [ {"provider": "http", "status": "failed", "failure": "VISUAL_REQUIRED", "latency_ms": 90}, {"provider": "local", "status": "observed", "latency_ms": 2176} ], "routing": {"provider_plan": { "ordered": ["http", "local", "camoufox", "scrapling", "scrapling_http", "crawl4ai", "jina_reader"], "basis": "registration order; insufficient exact-scope evidence" }} } ``` Trimmed from a real read. `routing.provider_plan.ordered` is the route this read planned, after skipping anything unavailable. - **`attempts`** lists every provider tried, in order, with its outcome, time and cost. - **`evidence`** points to saved copies, named by their SHA256 hash, under the state directory. - **`requested_url`** and **`final_url`** stay separate, so redirects show. - **`cost_usd`** is the sum of measured provider costs. If any attempt's cost is unknown, the total is `null`, never a guess. - **`next_step`** appears when a read stopped at a wall a person could clear (it names `handoff`), or when a results page doesn't mention the query (`reason: "off_query"`: the search URL is probably wrong). - **`module`** names the [site module](/docs/read/site-modules/) that shaped the read: `id`, `version`, `sha256`, `source` (`matched`, `named` or `override`), `items`, `next_url`, and `assertions` with `status` (`passed`, `failed`, or `invalid` when its markers matched the site's error page). - **`completeness`** records the structure check: page kind, item links, prices, every escalation step, and for searches with a known query, `query` (terms, matching items, text mentions) and `off_query`. `agreed` means the climb stopped because two browsers read the same page as the first read. - **`wall_vendor`** names the bot-defence service behind a wall the read met (`cloudflare`, `akamai`, `datadome`, `perimeterx`, `imperva`, `kasada`, `aws_waf`, `vercel`, `sucuri`), from the response's own headers and challenge page. `routing.learned_for` shows when that vendor's past results on this machine, or the bundled priors (`priors: true` when they changed the order), [reordered the ladder](/docs/blocked/escalation/#skipping-ahead), and `routing.escalated_to` names the tool that failed tries moved ahead (one that fetches from its own network) or walls moved ahead (your [real browser](/docs/blocked/real-browser/) when allowed, else a paid one). - **`rescued`** appears when the URL answered not found, or showed results that ignored its query, and the site's own search found what it asked for: `from`, `after` (`NOT_FOUND` or `off_query`), `query`, `via`, `search_url` ([When the URL is wrong](/docs/blocked/escalation/#when-the-url-is-wrong)). `rescue` (on the original receipt) says what was tried when nothing fit. - **`egress`** appears whenever a walled read tried [your other connections](/docs/blocked/egress/): `used` (the name of the one that got through, or `null`), `tried`, `after` and `direct_trace_id`, or `from_hint` when the site's hint chose it. - **`hedge`** appears when a read with no page after 3 seconds was raced by other free tools ([smart lightning](/docs/blocked/escalation/#racing-a-slow-read-smart-lightning)), or a [lightning](/docs/read/lightning/) read raced from the start (`lightning`): `providers`, `tried`, `after_seconds`, `provider`, `won`, and when its page stood in for a failed main read, `after_failure` and the `walled` tools. `routing.left_to_racers` lists the tools the main read left to its racers rather than read the page twice. - **`completeness.cut_off`** is true when a tool trying to complete a page ran past `completeness_step_timeout_seconds` (or the climb's deadline) and was stopped; the best page so far stands. - Cookies, keys and credential URL parameters never appear. ## Quality Every observed page read carries `receipt.quality`: a grade your agent can route on. ```json { "grade": "poor", "kind": "article", "flags": ["paywall"], "reason": "paywalled: only 433 characters of the article are free", "article": { "chars": 433, "paragraphs": 2, "method": "trafilatura", "paywall": true } } ``` - `good`: the content is there. - `partial`: it is there, with a warning (`needs_interaction`, `archived`). - `poor`: a placeholder (`$0` prices, template values), menus only, a cookie notice, results that ignore the query (`off_query`), or a paywall teaser. Article pages (schema.org `Article`/`NewsArticle`, Open Graph `article`, or publisher-style paths) are judged on their extracted article: 600+ characters and two or more prose paragraphs, however long the menus around them. A thin article escalates to a stronger tool first. On 108 fresh news and company articles the grade matched whether the article was there on 107. ## Freshness | `freshness` | What happens | | --- | --- | | `now` (default) | Always fetches. | | `hour`, `day`, `cached` | May reuse a saved copy. The receipt keeps the original time and reports its age. | An old copy is never passed off as new. ## Unknown stays unknown A page loading doesn't prove what's on it is current, and a 404 doesn't prove why it's gone. `field_status` keeps availability and price `unknown`; the caller decides what the page means. ## Images With `include_images=True`, images are downloaded and checked, not just linked. Each one has a hash, its real format and size, and its own error if it failed. `max_images` (default 50) caps the count; it does not mean the gallery is complete. ## Traces Every read saves a trace. `frankensurf trace ` or `Runtime.trace(trace_id)` shows one. `Runtime.capabilities()` adds traces up into success and speed per site and provider, with sample counts. Evidence lives under the state directory (`--state`, default `state`; the MCP server uses `FRANKENSURF_STATE`). It is never sent anywhere. --- # Settings Every option a read, search or action accepts, with its default. Every option is a field on `WebPolicy`, with a default. Nothing is hard-coded: if a read stops, waits or skips something, an option here decides it. - **Python**: pass `policy_overrides={...}` with only the options you mean. - **CLI**: common options have flags; see [Command line](/docs/reference/cli/). - **MCP**: pass them in `acquisition_policy`. ```python await web.read(url, policy_overrides={"allow_paid_fallbacks": True, "max_cost_usd": 0.05}) ``` Passing a whole `WebPolicy` instead fixes every option, which overrides a site's route seed and returns `EXPLICIT_POLICY_CONFLICT` where they disagree. These tables are generated from the code with `scripts/web_policy_doc.py`. ## Fetching | Field | Default | What it does | | --- | --- | --- | | `freshness` | `"now"` | `now` always fetches; `hour`, `day` and `cached` may reuse a saved copy. | | `render` | `False` | Require a browser; skips providers that cannot render. | | `prefer_markdown` | `False` | Ask servers for `text/markdown` first (T0). | | `sign_requests` | `True` | Sign plain HTTP reads with Web Bot Auth when a key exists (T3). | | `timeout_seconds` | `25` | Time allowed for each provider attempt. | | `unblocker_timeout_seconds` | `90.0` | Time allowed for an unblocker attempt. | | `agent_provider_timeout_seconds` | `300.0` | Time allowed for an agent provider such as Skyvern. | | `max_bytes` | 40 MB | Largest response accepted. | | `pdf_max_pages` | `50` | Most PDF pages to extract text from. Also the most pages a paid tool such as Firecrawl is asked to parse, since it bills per page. A longer PDF is cut short and says so: `structured.truncated` and `receipt.pdf`. | | `capture_json_responses` | `False` | On rendered reads, return the JSON the page fetched for itself as `captured_json`. | | `capture_json_max_items` | `20` | Most captured JSON responses kept. | | `capture_json_max_bytes` | 2 MB | Largest captured JSON response kept. | | `include_images` | `False` | Download and check the page's images. | | `max_images` | `50` | Most images to download. | | `card_images` | `False` | Add `cards`: each result link with its title and own thumbnail URL. | | `include_links` | `False` | Add `links`: every link on the page (`url`, `text`), in page order, once each. | | `max_links` | `500` | Most links `include_links` returns. | | `accept_language` | `None` | Accept-Language for plain HTTP reads, and the browser's locale (`de-DE,de;q=0.9`). | | `browser_actions` | `()` | Actions in the local browser before capture: wait, click, write, press, scroll, screenshot, scrape, executeJavascript ([Firecrawl-compatible API](/docs/firecrawl-compatible/#scrape-options)). Public reads only; use with `provider="local"`. | | `screenshot_full_page` | `False` | The browser screenshot covers the whole page. | | `markdown_output` | `False` | Add `markdown`: the page converted to markdown (headings, lists, links, tables). | | `scroll_screens` | `0` | Screens a browser scrolls before capture so lazy content loads (3 with `card_images`). | | `expect_terms` | `()` | Words search results should mention besides the URL query; results that mention none are flagged `off_query`. | | `main_content` | `False` | Also return `main_text`: the article without menus, footers and banners. | | `interactions` | `()` | Safe browser actions on browser reads: `dismiss_consent` (reject, never accept), `load_more`, `reveal`. | | `auto_items` | `False` | Items from any listing page with no saved module, found in the page's own data; the draft module comes back too. | | `max_image_bytes` | 20 MB | Largest image accepted. | | `image_max_attempts` | `2` | Attempts per image. | | `image_retry_delay_seconds` | `0.25` | Delay between image attempts. | | `image_retry_failures` | `PROVIDER_DOWN`, `TIMEOUT` | Image failures worth another attempt. | | `retain_public_failure_evidence` | `True` | Keep evidence of failed public reads. | ## Routing and cost | Field | Default | What it does | | --- | --- | --- | | `provider` | `None` | Force one provider. Never falls back. | | `provider_candidates` | `None` | An exact ordered list of providers. | | `allow_local_browser` | `True` | Allow providers that run a browser on your machine. | | `allow_paid_fallbacks` | `False` | Allow paid providers to join the route. | | `max_cost_usd` | `None` | Cap on measured provider cost for the whole operation. | | `strict_cost_cap` | `False` | A paid call that bills more than `max_cost_usd` still returns its valid content, and `receipt.budget` says the cap was exceeded. `True` discards it and fails with `BUDGET_EXHAUSTED`. | | `allow_handoff` | `False` | Try `handoff` after every automatic provider fails. | | `handoff_timeout_seconds` | `300.0` | How long a handoff waits for a person. | | `allow_real_browser` | `False` | Let your real Chrome or Edge join automatic reads; it moves ahead once walls do. | | `real_browser_wait_seconds` | `15.0` | How long the real browser lets a challenge clear by itself (it never clicks). | | `use_egress_pool` | `True` | Let a walled read try your other connections (FRANKENSURF_EGRESS). | | `egress_failures` | `BLOCKED`, `CAPTCHA`, `RATE_LIMITED` | Failures that start a pass through your other connections. | | `egress_tools` | `camoufox`, `scrapling`, `http` | Tools a pass through another connection uses; the wall vendor's best goes first. | | `allow_archive` | `False` | A stored copy (Internet Archive) as the very last resort; the result says when it was taken. | | `block_private_network` | `False` | Refuse loopback, private and link-local addresses, names that resolve to them, and redirects into them. | | `exclude_providers` | `()` | Skip these providers (the agent's try-harder lever). | | `try_harder` | `False` | Order the remaining providers strongest first, paid ones included when allowed. | | `retry_of_trace` | `None` | The trace this read retries, for the receipt. | | `terminal_failures` | `AUTH_REQUIRED`, `AUTH_EXPIRED`, `NOT_FOUND` | Failures that stop the climb. | | `context_stop_failures` | `AUTH_REQUIRED`, `AUTH_EXPIRED` | Failures that stop later reads of the same domain in this Runtime. | | `auth_wall_confirmations` | `5` | Providers in a row that must hit a sign-in wall before a public read stops. | | `fake_wall_confirmers` | `jina_reader`, `camoufox`, `firecrawl`, `zyte`, `scrapling`, `zenrows` | Providers that go next after a suspected fake wall, in this order. | | `escalate_after_walls` | `4` | Walls in one read before paid providers move ahead, and failed tries of any kind before free tools that fetch from their own network do; 0 turns both off. | | `paid_after_walls` | `1` | Walls before allowed paid tools move ahead, cheapest likely success first (one more when a free tool usually beats that wall vendor); 0 leaves them to escalate_after_walls. | | `escalation_failures` | `BLOCKED`, `CAPTCHA` | Failures that count as walls. | | `use_route_memory` | `True` | Let route memory reorder providers. | | `use_wall_priors` | `True` | Start wall-vendor routing from measured priors on a fresh install; your own reads outweigh them. | | `route_memory_ttl_seconds` | `3600` | How far back route memory looks. | | `route_memory_min_samples` | `3` | Clean reads in a row before a provider is promoted. | | `origin_route_hint_ttl_seconds` | `86400.0` | How long a site hint lasts. | | `provider_max_attempts_per_candidate` | `2` | Attempts per provider. Paid and capped reads are never retried. | | `provider_retry_delay_seconds` | `1.0` | Delay between attempts. | | `provider_retry_failures` | `PROVIDER_DOWN` | Failures worth another attempt. | | `provider_deadline_grace_seconds` | `10` | Extra time an isolated worker gets past `timeout_seconds`. | | `provider_cleanup_grace_seconds` | `5` | Time allowed to close a provider's process tree. | | `provider_composition_max_depth` | `8` | Deepest nesting when one provider calls another. | | `provider_composition_max_attempts` | `64` | Most nested provider attempts in one operation. | | `compound_source_candidates` | `http`, `camoufox`, `scrapling` | Validated provider list reserved for compound routes; not used by the default route. | ## Completeness and walls | Field | Default | What it does | | --- | --- | --- | | `completeness_escalation` | `True` | Check each automatic read's structure and re-read an incomplete page along the ladder. | | `completeness_ladder` | `jina_reader`, `scrapling`, `camoufox`, `firecrawl`, `zyte`, `zenrows`, `scrapfly`, `patchright`, `brightdata_unlocker` | Providers an incomplete page is re-read with, in order (paid ones only when allowed). | | `completeness_max_extra_reads` | `5` | Most extra reads one escalation makes. | | `completeness_parallel` | `2` | Free ladder tools raced at once (up to 4); paid tools always run alone. | | `completeness_stop_on_agreement` | `True` | Stop climbing once two browsers read the same page as the first read. | | `completeness_borderline_items` | `20` | A search page that passes with fewer item links (and few prices) gets one more read. | | `completeness_settle_ms` | `5000` | Minimum settle time for escalation reads. | | `completeness_deadline_seconds` | `150.0` | Time allowed for the whole escalation; a tool still running then is cut off. | | `completeness_step_timeout_seconds` | `45.0` | Once a read holds a page, time each extra tool gets to complete it before it is cut off. | | `hedge_after_seconds` | `3.0` | After this long without a page, a read is raced by hedge_racers free tools (smart lightning); 0 turns it off. | | `hedge_racers` | `2` | Free tools that race a read with no page after hedge_after_seconds (smart lightning), 1 to 3. | | `lightning` | `False` | Start the read and two other free tools at once and keep the first complete page; escalation races four. | | `rescue_not_found` | `True` | After NOT_FOUND, search the site itself for what the URL asked for and return the results when they mention it (receipt.rescued). | | `interact_on_escalation` | `True` | Pages that need a choice or show only a consent notice get one local-browser read with the safe actions first. | | `warm_up_on_wall` | `True` | A public read that ends at a wall gets one more try through the site's home page. | | `site_session_hours` | `24.0` | Hours the local browser keeps each site's cookies; 0 turns it off. | ## Pacing | Field | Default | What it does | | --- | --- | --- | | `origin_min_interval_seconds` | `2.0` | Minimum gap between reads of one origin. | | `origin_cooldown_seconds` | `900.0` | Pause after a wall. | | `origin_cooldown_failures` | `BLOCKED`, `CAPTCHA`, `RATE_LIMITED` | Failures that start a cool-down. | ## Rendering and content checks | Field | Default | What it does | | --- | --- | --- | | `wait_selector` | `None` | Wait for this CSS selector before capture. | | `wait_state` | `"attached"` | `attached` or `visible`, for `wait_selector`. | | `content_ready_selector` | `None` | Preferred ready selector; on timeout the current page is captured. | | `content_ready_timeout_seconds` | `10` | How long to wait for `content_ready_selector`. | | `settle_ms` | `400` | Extra wait after the page is ready. | | `public_browser_headless` | `True` | Run public browsers headless. | | `html_shell_min_text_chars` | `200` | A plain-HTTP page with scripts and less text than this is treated as an app shell. | | `html_shell_large_bytes` | `250000` | A page at least this large ... | | `html_shell_large_min_text_chars` | `1000` | ... with less text than this is also an app shell. | | `rendered_min_text_chars` | `50` | A rendered page with less text than this is `EMPTY_PAGE`. | | `second_opinion_text_chars` | `5000` | Below this much text, a plain HTTP page with scripts is also rendered and the fuller page kept; 0 turns it off. | | `navigation_page` | `1` | Page 1 to 3 through a site's own Next navigation (seeded routes only). | | `public_entry_url` | `None` | Entry page a `camoufox_entry` route visits first. | | `public_entry_continue_failures` | `BLOCKED` | Entry-page failures that still continue to the target. | | `scrapling_navigation_wait_until` | `None` | Navigation wait condition for Scrapling and entry-page routes. | | `scrapling_solve_cloudflare` | `False` | Let Scrapling attempt Cloudflare's interstitial. | | `scrapling_load_dom` | `True` | Wait for Scrapling's DOM load. | | `scrapling_google_search` | `False` | Scrapling's Google referrer option. | ## Search and paging | Field | Default | What it does | | --- | --- | --- | | `search_source_candidates` | `None` | An exact ordered list of search sources. | | `search_source_allow` | `None` | Only these sources may run. | | `search_source_prefer` | `()` | Move these sources to the front. | | `search_max_attempts` | `None` | Most sources to try. | | `search_source_timeout_seconds` | `8` | Time per source; `None` uses the operation timeout. | | `search_terminal_failures` | `POLICY_DENIED`, `BUDGET_EXHAUSTED` | Failures that stop search fallback. | | `search_merge_sources` | `4` | Sources asked at once by `search(mode="merge")`. | | `search_ladder` | `True` | An automatic search climbs sources until a result set passes the checks (on the query, inside the date window, real pages, deduplicated), carrying passing results up; false keeps the first answer. | | `search_good_results` | `3` | Passing results that make a set good (or the limit, if lower). | | `search_date_reads` | `6` | A windowed automatic search (`recency`) reads up to this many undated results' pages per source's result set (twice as many per search) over plain http, with no paid tool, to find their publication date; results provably outside the window drop out, and ones still undated count for at most half of `search_good_results`. 0 turns it off. | | `search_date_read_timeout_seconds` | `5.0` | Time one such date read gets, in seconds. | | `search_hold_undated` | `True` | In a windowed automatic search, results whose publication date could not be verified are left out of `results` whenever any verified in-window results exist, and returned apart as `unverified_results`. False mixes them in, ranked after the verified ones. | | `search_fill_rungs` | `2` | In a windowed automatic search, once the answer is good, up to this many more free sources are asked while it holds fewer verified in-window results than the limit, and what passes is merged in. Without a window, one more free source is asked when local SearXNG is reachable and the answer holds fewer passing results than the limit. Never a paid or last-resort source. 0 turns it off. | | `search_hedge_after_seconds` | `3.0` | After this long without an answer, the next free search source starts beside the current one; 0 turns it off. | | `search_hedge_racers` | `2` | Search sources running at once while racing, 1 to 3. Paid sources never race. | | `search_max_seconds` | `30.0` | Ceiling for one whole search, in seconds. | | `search_max_sources` | `6` | Ceiling on search sources started in one search. | | `search_paid_after_weak` | `2` | Weak or empty free sources before allowed paid search sources join, cheapest likely success first; 0 leaves them until every free source has been tried. | | `search_paid_parallel` | `True` | With `allow_paid_fallbacks`, an automatic search starts the cheapest likely-success paid search source beside the first free rung instead of after the free ones fail, whatever the kind of query. Everything is merged, checked and ranked, and a paid result that fails the checks gives way to a passing free one. The cost cap applies. False keeps paid sources behind the free rungs. | | `search_paid_wait_seconds` | `10.0` | When the free sources make the answer good while the parallel paid source is still running, wait this many seconds for it, so the answer can be anchored on it. If it does not answer in time, the free answer stands, `search_ladder.anchor` is null with the note `paid_timeout`, and the cancelled call is costed at its bundled price per search (marked estimated). 0 does not wait. | | `search_learned_routing` | `True` | Try first the source that most often passed the checks for this kind of query (vertical, recency, site filter). | | `search_exclude_operators` | `3` | Most -site: operators sent to an engine; the whole exclude list is enforced on the results either way. | | `max_pages` | `2` | Most pages `paginate` follows. | ## Browser agent | Field | Default | What it does | | --- | --- | --- | | `browser_agent_max_steps` | `40` | Most agent steps. | | `browser_agent_max_model_calls` | `40` | Most model calls. | | `browser_agent_max_actions` | `80` | Most actions overall. | | `browser_agent_max_actions_per_step` | `3` | Most actions per step. | | `browser_agent_max_failures` | `3` | Failures before the agent stops. | | `browser_agent_allowed_actions` | `navigate_public`, `inspect_page`, `follow_link`, `click_element`, `search_site`, `wait_readiness`, `scroll_page`, `done` | Actions the agent may take. | | `browser_agent_allowed_origins` | `None` | Origins the agent may visit; defaults to the target's. | | `browser_agent_entry_url` | `None` | Where the agent starts, if not the target URL. | | `browser_agent_task` | `None` | A task for the Browser Use agent. | | `browser_agent_llm_timeout_seconds` | `20` | Time per model call. | | `browser_agent_step_timeout_seconds` | `30` | Time per step. | | `browser_agent_action_timeout_seconds` | `10` | Time per action. | | `browser_agent_readiness_poll_ms` | `100` | Readiness polling interval. | | `browser_agent_packet_max_bytes` | 48 MB | Largest agent result packet. | | `browser_agent_use_vision` | `False` | Send screenshots to the model. | ## Identity and actions | Field | Default | What it does | | --- | --- | --- | | `identity` | `None` | Read as a registered identity. | | `profile` | `None` | Read as a stored FrankenSurf profile; exclusive with `identity`. | | `action_classes` | `READ_PUBLIC`, `READ_AUTHENTICATED` | Action classes this operation grants. | | `browser_do_allowed_tools` | `fill`, `click`, `wait_for`, `assert_text`, `assert_value` | Browser tools `do` may use. | | `browser_do_allowed_contracts` | `local_fixture.reversible_draft.v1` | Action contracts `do` may run. | | `browser_do_allowed_origins` | `None` | Exact origins `do` may act on. | | `browser_do_max_actions` | `20` | Most actions in one intent. | | `browser_do_action_timeout_seconds` | `10` | Time per action. | | `browser_do_settle_ms` | `200` | Wait after each action. | | `browser_do_packet_max_bytes` | 48 MB | Largest action intent. | | `browser_do_journal_max_bytes` | 16 MB | Largest action journal. | | `browser_do_selector_max_bytes` | `4096` | Longest selector. | | `browser_do_value_max_bytes` | 1 MB | Largest field value. | | `browser_do_description_max_bytes` | `8192` | Longest action description. | | `browser_do_idempotency_key_max_bytes` | `256` | Longest idempotency key. | | `browser_do_require_auth_check` | `True` | Require the identity's signed-in check before acting. | ## Repair | Field | Default | What it does | | --- | --- | --- | | `workload_assertion_max_count` | `32` | Most assertions on one extract (for repair). | | `workload_assertion_max_bytes` | `65536` | Largest assertion set. | | `repair_overlay_registry_max_bytes` | 4 MB | Largest repair overlay registry. | --- # Search Search the web with attributed results and automatic fallback across sources. `search` returns ranked results with their URL, title and snippet. Every result says which source found it, and every result is checked: inside the date window you asked for, about what you asked for, and an actual item rather than a homepage, listing or challenge page. - Free by default: your own [SearXNG](/docs/integrations/searxng/) (optional, no Docker: `frankensurf install search`, and search is stronger with it), then [DuckDuckGo](/docs/integrations/duckduckgo/) and [Bing](/docs/integrations/bing/), plus [Google News](/docs/integrations/google-news/), Wikipedia, Hacker News, Stack Exchange, GitHub, arXiv, Open Library, and `site_watch`, which reads a site's own feeds and sitemaps. - When a source's results fail the checks, Frankensurf climbs to the next one and merges what passed ([the search ladder](#the-search-ladder)). - Paid sources [Exa](/docs/integrations/exa/), Firecrawl search, [Brave](/docs/integrations/brave/), [Tavily](/docs/integrations/tavily/), [Parallel](/docs/integrations/parallel/), [Serper](/docs/integrations/serper/), [SerpAPI](/docs/integrations/serpapi/) and [Google Programmable Search](/docs/integrations/google-cse/) join when their keys are set and you allow paid tools, cheapest likely success first. - Name a source and Frankensurf uses only that one, never a substitute. ## Usage ```python results = await web.search("playwright python tutorial", limit=10) for hit in results["results"]: print(hit["title"], hit["url"]) ``` ```bash frankensurf search playwright python tutorial --limit 10 ``` ```json { "tool": "search", "arguments": { "query": "playwright python tutorial", "limit": 10 } } ``` ```json title="Response (trimmed)" { "query": "playwright python tutorial", "source": "searxng", "coverage": "partial", "results": [ { "url": "https://www.browserstack.com/guide/playwright-python-tutorial", "title": "The ultimate Playwright Python tutorial | BrowserStack", "snippet": "Learn end-to-end web testing with Playwright Python…", "engines": ["duckduckgo"], "verification": "indexed_discovery" }, { "url": "https://playwright.dev/python/docs/library", "title": "Getting started - Library | Playwright Python", "snippet": "These commands download the Playwright package and install browser binaries…", "engines": ["duckduckgo"], "verification": "indexed_discovery" } ], "upstream_failures": [["brave", "too many requests"], ["google cse", "too many requests"]] } ``` `coverage: "partial"` and `upstream_failures` show that some of SearXNG's own engines were rate-limited. Failures stay visible instead of looking like "no results". ## Narrow it down ```python results = await web.search("film camera repair", recency="week", region="en-AU", exclude_domains=["pinterest.com", "youtube.com"]) ``` ```bash frankensurf search film camera repair --recency week --region en-AU \ --exclude-domain pinterest.com --exclude-domain youtube.com ``` - `site`: only results from one domain (its subdomains too). - `exclude_domains`: up to 200 domains that never come back. Subdomains match too (excluding `example.com` drops `news.example.com`). Engines get only the first few as `-site:` operators (`search_exclude_operators`, default 3); the full list is enforced on the results, and the search asks for extra results so the limit is still met. - `recency`: `day`, `week`, `month` or `year`. - `region`: language and country, like `en-AU` or `de`. Each source applies what it can natively: SearXNG `time_range`, DuckDuckGo `df` and `kl`, Bing's date filters and `cc`, Exa and Tavily domain and date filters, Brave `freshness`, and `site:`/`-site:` operators where an engine reads them. Then every result list is filtered again, so an excluded domain never comes back from any source, and dated results older than the window are dropped. `receipt.search_options.filtered` counts what went, including how many results had no date to check. ## Boolean queries Monitoring tools write queries like `"Rio Tinto" OR "Rio Tinto Group" (acquisition OR layoffs OR "data breach")`. Each source gets the query as written. The checks judge results by what the query names: a story that names Rio Tinto (or Rio Tinto Group) is on the query without also naming an event word, which only narrows what the engines return. Event words (acquisition, layoffs, lawsuit, funding, earnings ...) beside a name make it a news search. Free sources often mishandle long OR-lists, returning homepages or nothing. When a boolean query's answer is not good, Frankensurf searches once more for the main name alone (`"Rio Tinto"`), in the same vertical and window, and keeps whichever answer passed more results. `receipt.query_simplified` says what was asked, what was kept and what the first try cost: ```json "query_simplified": {"from": "\"Rio Tinto\" OR ...", "to": "\"Rio Tinto\"", "kept": "simpler", "why": "the query as written got a weak answer (2 results passed the checks)"} ``` A named source is never retried. On 12 companies with free sources only and no SearXNG, a monitoring-style query found every company, against 7 of 12 before. ## Kinds of search `vertical` picks the kind of source. Automatic fallback stays inside it. Leave it out and FrankenSurf infers it from the query, so `fastify github repo` gets the code treatment and `who wrote Hamlet` the reference one. An explicit `vertical` always wins. | `vertical` | Source | Notes | | --- | --- | --- | | `web` (the fallback) | SearXNG, DuckDuckGo, Bing, plus paid sources when allowed | | | `news` | Bing News RSS | Publisher in `extra.publisher`, dates on every item | | `reference` | Wikipedia API | In the `region` language (`de` searches de.wikipedia.org). Question words are dropped (`who wrote War and Peace` searches for *War and Peace*), the article titled for the topic comes first, disambiguation pages are dropped, and titles with a qualifier such as `(film)` or `(1956 film)` go last unless the query asks for that kind of thing | | `discussions` | Hacker News (Algolia API) | `extra.points`, `extra.comments`, `extra.discussion_url` | | `qa` | Stack Exchange API | Stack Overflow by default; any Stack Exchange site by name via `engine_config={"engines": "superuser"}` | | `code` | GitHub API | Repositories; `recency` means pushed recently. `extra.stars`. Platform words (*github*, *repo*, *library*, *package*, *crate*, *source code*) and filler are not sent as search terms. A project name (`fastify github repo`) searches repository names; a need (`python library for image processing`) searches names and descriptions and turns a language word into a `language:` filter. Results are sorted by stars | | `papers` | arXiv API | | | `books` | Open Library | | All of these are official, keyless public APIs. ### Inferred verticals When no `vertical` is given, the query's wording picks one. The rules are deterministic and use common words and sentence shape only, never lists of sites or companies. A query that matches nothing is `web`. Rules are checked in this order and the first match wins. | Inferred | Signals | Example | | --- | --- | --- | | `qa` | An error or how-to (*how to*, *how do I*, *error*, *exception*, *fix*, *fails with*) together with code-shaped tokens (`snake_case`, `call()`, file extensions, error codes) or a language name | `how to reverse a list in python` | | `papers` | *papers on*, *paper on*, *research on*, *arxiv*, *preprint*, *survey of* | `papers on retrieval augmented generation` | | `code` | Platform words (*github*, *gitlab*, *repo*, *repository*, *crate*, *npm*, *pypi*, *source code*); *library for*, *framework for*, *sdk for*; a language beside *library*, *package*, *crate*, *module* or *framework* | `python library for image processing` | | `web` | Announcement phrasing (*announces*, *announcement*, *press release*, *launch*) stays windowed web, not news | `Anthropic announces new model` | | `news` | *news*, *headlines*, *latest on*; an entity with *today* or *this week* | `openai news` | | `reference` | Fact questions: *who*, *what is*, *what year*, *when was*, *how tall*, *how many*, *capital of*, *population of*, *currency of*, *wrote*, *invented*, *founded*, *painted*, *composed*, *discovered*; not when the query asks for *best*, *vs*, *review* or buying advice | `who wrote Hamlet` | A how-to with no code in it (`how to boil an egg`) stays `web`, as do words that are only sometimes about software (`Python the snake`, `Go to market strategy`, `package delivery tracking`). An explicit `source` is never re-routed. The receipt says what was picked and why: `receipt.search_routing.vertical_inferred` is `{"vertical": "code", "reason": "platform word: github"}`. It is absent when you passed a `vertical` yourself. `mode="merge"` infers the same way. A vertical with its own specialist source (`code`, `reference`, `papers`, `news`) does not wait for it to fail. When the local SearXNG is reachable, the specialist and SearXNG start together as the first rung, and their answers are merged, checked and ranked as one. If one fails or is slow it is simply left out (the slower one gets a few seconds after the first answer). Turning off `search_hedge_after_seconds` turns this racing off too. ## Local search Search works with nothing installed. A local SearXNG makes it stronger: `frankensurf install search` installs and starts one, no Docker needed, and search starts it again after a reboot. `frankensurf search-engine start`, `stop` and `status` manage it. See [SearXNG](/docs/integrations/searxng/). Expect it to thin out at volume. SearXNG asks the public engines from your own address, and Google, Brave and DuckDuckGo start refusing (CAPTCHAs, blocks) after a few dozen searches in a few minutes; in one test, 39 of the first 100 searches met a refusal. Frankensurf's settings switch on more engines (Bing, Yahoo, Qwant, Mojeek beside them) and rest a refusing engine for 15 minutes, so some keep answering, but for steady volume (hundreds of searches an hour) add a paid source such as [Serper](/docs/integrations/serper/) ($1 per 1,000) and allow paid tools. When an automatic search ran without it because nothing answered on `127.0.0.1:8088`, the receipt says why results may be thinner: ```json "search_routing": { "local_search": { "status": "not_running", "note": "Local search (SearXNG) was not running, so this search used other sources and may be thinner. Start it with: frankensurf search-engine start", "start_with": "frankensurf search-engine start" } } ``` The field is absent when SearXNG answered, when you named a source, and when you pointed at your own search URL. ## The search ladder An automatic search (no `source`) does not stop at the first source that answers. It checks every result set the way a read checks a page, and climbs to the next source until a set is good: at least `min(3, limit)` results (`search_good_results`) that pass. A result fails when it: - is provably outside the `recency` window (its own date, a date the snippet leads with, "3 days ago", or a `/2026/10/12/` URL path; an unknown date is not a failure, it only ranks below dated in-window results); - shares no key term with the query (stopwords and words like *news*, *latest*, *this week* don't count); - is a homepage, search page, tag listing or challenge page, when the query asks for specific items (a path that ends in an index word such as `/newsroom/`, `/changelog/`, `/press-releases/` or `/c/announcements/22`, or an aggregator's `/news/`, is a listing; a path with a title slug is not); - is about another thing with the same name: when the query's entity is a single word (*Block*, *Unity*, *Arm*), a result whose domain is not the entity's and whose title has the word inside a longer proper-noun phrase, led by a capitalised *The* (*The Block*) or following another capitalised word (*Cinder Block*), is `wrong_entity`. Possessives (*Dorsey's Block*), corporate suffixes (*Block, Inc.*) and freshness words don't count as a longer name; - repeats another result's page; - for an announcement query in a window (*announce*, *announcement*, *press release*, *launch*), is not an article: it passes only with a verified in-window date or an article-like path (a date in the path; a `news`, `press`, `blog`, `stories`, `announcements`, `releases`, `articles`, `updates`, `insights` or `media` segment followed by a slug of three or more hyphenated words or an id; or any slug of four or more words). Product, pricing, careers, platform, sign-in, investor overview, filings, results and category pages, listings (`?l=`, `?page=`), profiles and homepages fail as `not_article`, so the ladder climbs instead of returning them; - for a code query, is a fork or an archived repository, is in a language the query does not name (when it names one and GitHub knows the repository's), or has too few stars: under 500 for a need (*library for X*, *X framework*), under 100 for a name lookup unless the repository carries the name. With a `recency` window, results that carry no date are not left to guess at: the pages of up to `search_date_reads` (default 6) undated results that would make the top are read at once over plain http (`search_date_read_timeout_seconds`, default 5; no paid tools, the normal per-origin pacing), and the publication date in the page's own metadata (`article:published_time`, JSON-LD `datePublished`, `