Configuration and glue · MIT · Docker Compose

Web search your agent can call, running entirely on your own machine.

Six layers, two containers by default, no API key and no per-query bill. SearXNG resolves a query into candidate URLs; fastCRW turns those URLs into clean markdown; an MCP endpoint on 127.0.0.1:3002 makes both available to any agent that speaks the protocol. Built to replace Tavily, Exa, Serper and Firecrawl for personal use.

The diagram below is the site map. Every slab is a layer of the running system and a section of this page; each section links to the layer's own page. Nothing here is a metaphor — the boundaries drawn between slabs are real process, container and network boundaries, and the annotations are the values that appear in docker-compose.yml and config/crw.toml.

The localbrowser stack, from the calling agent down to the Docker host Six stacked slabs: agent, MCP surface, crw, SearXNG, renderer and host. Between each pair of slabs an interface plane is labelled with the protocol that crosses it: MCP JSON-RPC, HTTP POST /v1/search, HTTP to searxng:8080, URLs to bytes, and Docker loopback binds. A quarantined summariser hangs off the crw layer as an optional sidecar. MCP · JSON-RPC HTTP · POST /v1/search HTTP · searxng:8080 URLs → bytes docker · loopback binds agent the privileged model — the session that holds your tools holds the tools Claude Code, Cline, opencode, your own MCP surface crw_search · crw_scrape · crw_crawl · crw_map · crw_extract · search_answer 6 tools http://127.0.0.1:3002/mcp or stdio: websearch-mcp.py crw fastCRW — search, scrape, crawl, map, extract, MCP endpoint container · :3002 → 3000 quarantined summariser [extraction.llm] · optional no tools · cannot act SearXNG metasearch — a query becomes a list of candidate URLs ~250 engines · no API key image pinned, never :latest 127.0.0.1:8888 → 8080 renderer escalation ladder — a URL becomes clean markdown mode = "auto" plain HTTP → JS → stealth camofox sidecar :9377, opt-in host Docker Compose — loopback binds, resource limits, health checks docker compose ~2 GB base · ~4 GB stealth no GPU unless summarising DOWN: REQUEST UP: RESULT
Figure 1 — the stack Each slab is clickable and scrolls to its section below. Dashed lines are interface planes, labelled with what actually crosses them. SearXNG and the renderer are drawn as separate layers because they answer different questions — which pages? and what is on this page? — but both are driven from the crw layer above them, not by each other.

PremiseBuying search per query, or hosting it

Every hosted search API for agents — Tavily, Exa, Serper, Firecrawl — sells the same two primitives: find me pages about X, and give me the readable text of this URL. Both have mature open-source implementations. SearXNG has been aggregating around 250 search engines since long before agents existed, and fastCRW does HTML-to-markdown extraction with a browser-rendering fallback. localbrowser is the compose file, configuration and glue that puts them behind one MCP endpoint on loopback.

What that buys you: no key to rotate, no per-query bill, no third party keeping a log of every question your agent asks. What it costs you: you now operate a scraper, which means you inherit its failure mode. Search engines rate-limit and CAPTCHA the IP doing the scraping, and that IP is now yours. That failure is documented in detail rather than papered over, because it will happen.

This is configuration and glue. The hard parts are other people's work.

README.md — Credits

layer 0agentThe model that holds the tools

The top layer is not something this repository ships — it is your agent session, and the stack's security design is mostly about what that session is allowed to see. An agent with shell access, your credentials in its environment, and the ability to read arbitrary web pages is the combination Simon Willison calls the lethal trifecta: private data, untrusted content, and a way to send data out.

localbrowser's answer is not to make page content safe. It is to offer a second path in which the privileged model never reads raw page text at all — a small summariser model reads it instead, and that model has no tools and cannot act. The repository is explicit that this is defence in depth and not a guarantee.

You supply
Any MCP client — Claude Code, Cline, opencode, or your own
Registered with
claude mcp add --transport http --scope user crw http://127.0.0.1:3002/mcp
Risk model
Page content is attacker-controlled input to a model that holds tools
Trust boundaries: the dual-LLM pattern, the trifecta, and container containment

layer 1MCP surfaceSix tools, two shapes of answer

crw publishes its own MCP endpoint over HTTP with five tools: crw_search, crw_scrape, crw_crawl, crw_map and crw_extract. These return raw page markdown — fast, lossless, and expensive: 50–100k tokens of prefill for a single search, all of it text a stranger wrote.

The repository adds a sixth tool from a different direction. mcp/websearch-mcp.py is a 173-line stdio MCP server with no dependencies outside the standard library. It exposes search_answer, which posts to crw's /v1/search with "answer": true and returns the synthesised answer plus source URLs — never the page bodies. Both sets stay registered on purpose: summaries drop exact syntax, code blocks and tables, so the raw tools remain the right call when you need those.

Raw path
crw's built-in MCP server, HTTP transport, /mcp
Synthesised path
websearch-mcp.py, stdio transport, one tool
Protocol version
2024-11-05, hand-rolled JSON-RPC over stdin/stdout
The MCP surface: every tool, and when a summary is the wrong answer

layer 2crwThe engine that fans out and joins back

fastCRW is the load-bearing component: it owns the MCP endpoint, calls SearXNG for candidate URLs, drives the renderer ladder over each result, converts HTML to markdown, and — if a summariser is configured — performs answer synthesis. One POST /v1/search becomes one SearXNG query plus n page fetches plus one LLM call.

Its configuration in this repository is deliberately small: a SearXNG URL, multi-query expansion turned on, renderer mode auto, and two commented blocks for the optional tiers. The container itself is where most of the hardening lives — read_only, cap_drop: ALL, no-new-privileges, 2 GB of memory and 512 PIDs — because this is the process that parses bytes written by strangers.

Image
ghcr.io/us/crw:latest, overridable via CRW_IMAGE
Config
./config/crw.toml mounted read-only at /app/config.docker.toml
Query expansion
query_expand = true — unions an entity-rewrite's results with the original
crw: one search request, traced across four containers

layer 3SearXNGQuery in, candidate URLs out

SearXNG queries every enabled engine in parallel and merges the results. There is no sequential fallback, which has a consequence people usually discover from a bill: a second paid API key is not a backup, it is a second charge on every search.

The instance here runs with the JSON output format explicitly enabled — without it, SearXNG answers ?format=json with a 403 and every downstream call fails silently — and with six independent crawlers switched on alongside the defaults: mojeek, marginalia, stract, mwmbl, right dao and yep. They have their own indexes, so they do not share the big providers' rate limits. That is the difference between fewer results and zero results.

Image
searxng/searxng:2026.5.9-0cba32c15 — pinned, never :latest
Formats
html for the debug UI, json for everything else
Limiter
false — it needs Valkey/Redis, and this instance is private
SearXNG: the fan-out, the zero-result failure, and three silent gotchas

layer 4rendererURL in, clean markdown out

Reading a page is a ladder, not a single strategy. mode = "auto" starts with a plain HTTP fetch — cheap, and already doing proper TLS fingerprinting — and escalates only when that fails, ending, if you have built it in, at a Camoufox stealth browser running as a REST sidecar.

The repository's own measurements of that stealth tier are unflattering and published as such: it lost or tied on every site tested. The reason is that hard bot protection blocks on IP and request rate, not browser fingerprint. What it is still worth is honest failure — bot challenge detected instead of a CAPTCHA page handed to your model and scored as a successful fetch.

Tier 1
Plain HTTP with TLS fingerprinting — handles most of the web
Tier 2
JS rendering for pages that need a DOM
Tier 3
Camoufox, opt-in at build time, include_in_auto puts it last
Renderers: the escalation ladder and an honest negative result

layer 5hostThree containers, all on loopback

The substrate is one docker-compose.yml. crw depends on SearXNG being healthy, not merely started; the health check is wget -qO- /healthz every ten seconds. Every published port binds 127.0.0.1, including the optional Camoufox sidecar, whose own upstream make up publishes on 0.0.0.0.

That is not a stylistic preference. crw fetches arbitrary URLs on request and has no authentication; anything that can reach it has a server-side request forgery primitive against your internal network. Reach it from another machine with an SSH tunnel, not by editing the bind address.

Bring-up
./scripts/setup.sh — generates .env, starts the stack, verifies each layer
Verification
./scripts/health.sh — four independent checks, exits non-zero on failure
Requirements
Docker and Compose; ~2 GB RAM base, ~4 GB with the stealth profile
Operating the stack: bring-up, health checks, and the Camoufox build

Network shapeWhat actually leaves the machine

Self-hosting search changes who sees your queries, but it does not make the traffic disappear. Exactly two kinds of request cross the boundary: SearXNG asking search engines a question, and the renderer fetching a page. Both leave from your IP address. Nothing crosses inbound, because nothing is published anywhere but loopback.

Network egress: what crosses the host boundary, and what never does A dashed boundary marks the local machine. Inside it: the agent, the stdio MCP server, a compose network containing crw and searxng, and two optional sidecars reached through host.docker.internal. Two arrows leave the boundary — engine queries and page fetches. No arrow enters it. YOUR MACHINE — EVERY PUBLISHED PORT BINDS 127.0.0.1 agent privileged model, holds tools websearch-mcp.py stdio · the quarantined path DOCKER COMPOSE NETWORK crw fastCRW engine + MCP 127.0.0.1:3002 → 3000 searxng metasearch, ~250 engines 127.0.0.1:8888 → 8080 crw reaches searxng by service name: http://searxng:8080 EXTRA_HOSTS: HOST.DOCKER.INTERNAL → HOST-GATEWAY camofox Camoufox stealth sidecar 127.0.0.1:9377 profiles: [stealth] — off by default summariser [extraction.llm] — optional :8002/v1 · OpenAI-compatible no tools · cannot act ~250 search engines google, brave, ddg, startpage, mojeek, marginalia, stract, yep… target web pages arbitrary URLs, fetched on demand untrusted bytes engine queries, from your IP renderer page fetches, from your IP NOTHING CROSSES INBOUND crw has no auth and fetches any URL it is given. Changing CRW_BIND_ADDRESS hands that primitive to your LAN.
Figure 2 — egress Two outbound flows, no inbound path. Note that the crw container reaches the optional Camoufox and summariser sidecars through host.docker.internal rather than the compose network — they are loopback services on the host, and extra_hosts is what makes them addressable from inside the container.
Where to start reading

If you want the shortest path to a working system, go to Operating the stack — two scripts and one claude mcp add line. If you want to know whether this is safe to point at your agent, go to Trust boundaries first.