Cortex
Cortex, a Browser Automation Agent with Self-Healing Site Structure
I needed a way to let an AI agent operate real websites, log in, fill forms, extract listings, without either hand-writing brittle selectors or handing the agent raw HTML on every page. I built Cortex as a standalone browser service exposed over MCP: a real stealth-configured Chrome session, a compact structural view an agent can reason over, and a DuckDB graph that remembers how a site's controls behave and heals itself when the site changes. Secrets never reach the agent's context. It has been in continuous development since September 2025 and now exposes 41 tools backed by 227 tests and 38 written architecture decisions.
The problem
Any agent that has to operate a real website runs into the same wall twice. Hand-written selectors break the moment a site renames a class or reshuffles a layout. Handing the raw page to an LLM on every visit works, but it is slow, expensive, non-deterministic, and wastes the model's context on markup it cannot act on directly. Neither approach touches the harder cases at all: a Cloudflare or hCaptcha wall, a react-select widget that silently eats its own input on a stray backspace, a login flow that must never expose a password to the model doing the clicking. I needed a service an agent could drive by intent, that survives a redesign instead of breaking on one, and that keeps every credential out of the model's context by construction rather than by convention.
Constraints
- ·The consumer is an LLM agent over MCP, so every tool has to accept and return compact JSON, never a raw DOM dump the model has to parse itself.
- ·Real anti-bot exposure. Targets include LinkedIn, Cloudflare and hCaptcha protected job boards, and application flows with non-standard widgets, not a synthetic test page.
- ·Secrets cannot reach the model. A password or an OTP code can never appear in a tool argument or a tool result.
- ·The recorded graph doubles as a future training set, so a silently wrong recorded action is a permanent, invisible error, not a cache miss to be evicted.
- ·One engineer, continuous build, no team to split browser-stealth work from the agent-facing surface, so every hard call needed to be written down rather than discussed in a hallway.
Architecture
An MCP server (FastMCP) exposes 41 tools over streamable-HTTP or stdio, gated by a bearer token on every request except a separate token-gated /secret route. The tools are thin wrappers over Hands, the orchestrator that drives a real, stealth-configured Chrome session through Playwright: view and read return a compact structural document, never the DOM, and act tools such as fill_form, click, and combo_select drive the page by ref and verify every stateful action against live DOM state afterward. Site knowledge, structure fingerprints, learned widget recipes, and the mapped state graph, lives in an embedded DuckDB store where Hands is the sole writer. Two other producers, a batch scraper with per-site specialists and a structure-only mapper called genesis, feed that same graph one-directionally through a single ingest path, so an interactive agent visit and a bulk scrape both enrich one brain instead of three disconnected stores.
- ›MCP server (mcp_server.py): 41 tools over streamable-HTTP or stdio, a bearer-token gate on every request, a separate token-gated /secret UI mounted on the same app
- ›Hands (hands.py, orchestrator): view, read, mark, crawl_marked, fill_form, click, select_option, combo_select, traverse, scan, extract, login, request_secret and more
- ›Browser engine: Playwright driving a real installed Chrome (not bundled Chromium), fingerprint and hardware-emulation spoofing, a tab pool, human-like mouse and typing
- ›DuckDB store, the brain: sites, maps, nodes, edges, manifests, sessions, field_health, widget_patterns, embedded and single-writer, Hands holds the lock
- ›Structure fingerprinting: a Merkle tree over tag, role, and child fingerprints only, never id, class, or text, so drift-heal descends only into subtrees that actually changed
- ›Secret broker: a token-gated /secret route where a human types a password or OTP, the server injects it server-side and never returns the value to the agent
- ›Batch scraper (H1, main.py run): per-site specialists for Google Maps, LinkedIn jobs, and scholarships, feeding the brain through ingest_blueprint
- ›Mapper (H2, main.py map): genesis structure extraction, a pure function of the current DOM with no browser of its own, also feeding the brain through ingest_blueprint
How one request flows
- 01The agent opens a session. open_session launches a real installed Chrome with stealth fingerprinting and a persistent profile, and returns a session id. Nothing about the page has been read yet.
- 02The agent reads before it acts. view or read returns a compact structural document: interactive controls with a short ref, headings, reading-order text. The full DOM stays in process.
- 03The agent acts by ref. click, type, select_option, combo_select, and fill_form take refs from the last read. Stateless fields batch together, stateful ones run one at a time, and every field returns a verified outcome, never a silent success.
- 04Structure gets mapped when it matters. scan runs structure extraction into a blueprint, or the agent marks leaves with mark and follows them in one pass with crawl_marked. A known site can instead be flown over with graph_view and traverse, replaying stored actions with no re-scanning.
- 05A login or a secret routes to a human. login or request_secret opens the token-gated /secret form. A human types the value there, the server injects it into the page, and only a status string returns to the agent.
- 06Every verified action is written back. A recipe or a fingerprint is persisted to the DuckDB graph only after it is checked against the live DOM, because an unverified write would be a permanent, invisible error in what doubles as training data.
- 07The session closes with state kept for reuse. close_session tears the browser down, but the encrypted storage state persists, so the next session reuses cookies instead of logging in cold, the single riskiest action the system can take.
Engineering decisions
The DOM never crosses the wire
What. Every tool accepts and returns compact JSON, never a raw page. view and read carry the whole burden of deciding what matters.
Why. A real DOM runs to megabytes, slow to ship and a waste of an agent's context window, since the model cannot act on raw markup directly anyway.
Tradeoff. Every observation tool has to summarize instead of dump, which is real design work each new tool has to repeat.
Real Chrome beat cleverer stealth
What. Playwright launches a real installed Chrome as the primary path, with the bundled Chromium kept only as a fallback.
Why. Bundled Chromium plus heavy stealth injection is exactly what detectors fingerprint. Switching to real Chrome took one Cloudflare-gated page from 0 elements read to 28, and lifted three other live targets from partial reads to full ones.
Tradeoff. Every host that runs this needs Chrome installed, a real deployment cost, and the bundled fallback stays knowingly weaker for hosts that lack it.
Secrets never enter the agent's context
What. login and request_secret open a token-gated /secret page. A human types the value, the server injects it into the page directly, and drops its own reference immediately after.
Why. Anything an LLM reads is effectively logged and possibly retained, so a credential in a tool argument is a credential in a transcript.
Tradeoff. A human has to be present at bootstrap and at every credential expiry, so the system cannot be fully unattended over long horizons.
Structure-only fingerprints, so drift can heal instead of break
What. A control's identity is built from its tag, its role, and its children's fingerprints, recursively, never from an id, class, or current text.
Why. That makes the fingerprint survive a redesign or rotating class names. Drift-heal compares the root fingerprint first, and only descends into subtrees whose hash actually changed.
Tradeoff. The fingerprint is text-blind by construction, so a footer paragraph and a body paragraph hash identically. Text recurrence is handled by a separate signal instead.
Recipes are declarative, not recorded keystrokes
What. A recipe records what a widget is, for example that a react-select commits by clicking an option rather than pressing Enter, and a universal handler regenerates the concrete steps every run.
Why. A macro of literal keystrokes snaps the moment refs renumber on a re-render, which happens on nearly every stateful action. One fix to a handler corrects every site using that widget class at once.
Tradeoff. Every widget class needs a hand-written universal handler generic enough to absorb real per-site variance, there is no shortcut of replaying the last successful run.
Tried, never trusted
What. Every recipe application, per-site or cross-site, is followed by a real check against post-action state before it counts as done. A mismatch falls back to deriving the action from scratch.
Why. A confident wrong action is worse than a failed one, because it returns cleanly while leaving the form silently wrong, a backspace that ate a chip and reported success, a select that looked set and was not.
Tradeoff. Roughly a doubled observation call per action, accepted because a silent corruption at scale is far more expensive to find later.
One brain, three heads, one writer
What. DuckDB is single-writer. All writes to the shared site-knowledge graph route through Hands alone. A batch scraper and a structure-only mapper enrich the same graph one-directionally through a single tested ingest path instead of writing to it directly.
Why. Three processes racing to write the same store would turn an unverified write into a permanent, invisible error once the graph doubles as training data.
Tradeoff. A batch run cannot update the brain while the agent process holds the database lock, a real operational constraint accepted for one auditable write path over three racing ones.
The submit guard defaults on
What. A detected final-submit click is refused unless the caller passes confirm=True explicitly.
Why. An irreversible action, submitting a real application to a real company, should not depend on an agent remembering not to fire it.
Tradeoff. This started defaulting off for backward compatibility and was flipped to on once the real cost of each side was written out plainly: one extra explicit argument against a real, public, unfixable submission from a confused agent.
What changed along the way
- →Structure extraction (genesis) originally launched its own browser and crawled sub-pages to decide where to look. That self-navigation was the most brittle part of the system, and it left entirely once an external agent took over navigation, leaving genesis a pure function of the current DOM.
- →The MCP transport was designed around a separate agent container reaching the service over a network, HTTP only, no stdio. That consumer never materialized. The real consumer became Claude Code spawning the server locally, so stdio transport was added afterward.
- →The plan called for stored recipes to be consulted before an action, a lookup that would save re-deriving a widget's behavior on a second visit. Only the write-back half shipped so far, every verified action still gets recorded, but nothing yet reads a recipe before acting, recorded openly as an unbuilt piece rather than left to be discovered.
- →Drift-heal shipped in two passes. The first compared only a flat set of leaf blocks. A later pass made the comparison hierarchical over the actual outline tree, so a change localizes to the section that moved instead of a scattered list of changed blocks.
Security and reliability
- ·Every MCP request needs a bearer token once the server binds anywhere but loopback. The server refuses to start on a public bind with no token configured, rather than starting unauthenticated.
- ·Passwords and OTP codes never pass through a tool argument or a tool result. The /secret UI is its own token-gated route with a constant-time comparison, and it fails closed with no token set.
- ·Stored browser session state is encrypted at rest with Fernet, keyed from an environment secret. A wrong or missing key returns nothing, and there is no silent plaintext fallback.
- ·The raw Playwright-eval debug tool is disabled unless explicitly enabled, so there is no anonymous code execution path by default.
- ·A detected final-submit click is refused without an explicit confirmation flag, enforced by the tool itself, not by agent discipline.
- ·Every stateful form action is checked against the live DOM after it runs, and a mismatch is recorded as a failure rather than reported as a success.
- ·Session reuse is preferred over cold automated login, the single most flagged, most ToS-risky action available, so re-authentication happens interactively only on expiry.
Metrics
What shipped
- ✓Drives a real, stealth-configured Chrome session by intent, over MCP, with the DOM never leaving the process.
- ✓Reads, fills, and verifies web forms, including react-select and chip widgets, with a per-field, non-silent outcome for every action.
- ✓Heals its own structural map of a site through Merkle-fingerprint diffing instead of re-scanning from zero on every visit.
- ✓Keeps every credential out of the driving agent's context through a separate, token-gated human channel.
- ✓Unifies three separate producers, an interactive agent, a batch scraper, and a structure-only mapper, into one DuckDB graph with a single, auditable writer.
Lessons learned
- ·Being an actual browser beat every layer of stealth injection tried on top of a fake one. The simplest fix was also the correct one.
- ·A macro breaks the moment the DOM re-renders. A declarative description of what a widget is survives it, and that distinction was worth rebuilding the recipe system around.
- ·Writing down not just what shipped, but what was planned and never wired in, made the system easier to trust, because the gaps were visible instead of implied.
- ·Text-blind structural fingerprints and text-based recurrence signals are two different jobs. Trying to make one signal do both was the wrong instinct, and splitting them was the right one.
- ·A guard that defaults to inactive protects nobody. Naming the real cost on both sides of a safety default, in writing, is what actually gets it flipped on.