A headless, text-only browser agent, and a way to stop paying a model for the same click twice.
Playwright drives a real Chromium, a DOM extractor serializes the live page to indexed text, and either a model or an outer agent picks one action per step. No screenshots. No vision model. And once a flow is proven, txtwrght distill turns the recorded run into a plain Playwright script with no model in it at all: pay once, replay for free.
One repo: the engine (src/txtwrght) plus the Claude Code binding (claude/). See ARCHITECTURE.md for the split.
Browser agents that reason over screenshots pay for pixels they don't need. A login form is nine interactive elements and a button; a vision model has to re-derive that from an image on every single step, at image-token prices, before it can even start reasoning about the task.
Browser agents that reason over raw HTML pay a different tax: a real page is thousands of nodes deep, most of it invisible, non-interactive, or noise. Sending it all to an LLM burns context on <div> soup the model will never act on.
And almost every agentic browser run repeats work it already solved. The same login flow, the same "click accept on the cookie banner," the same three-step checkout, driven by a model from scratch every time, at full agent-loop cost, because nothing captured what happened well enough to replay it without a model in the loop.
txtwrght's answer to all three: extract only what's interactive and visible, serialize it to a flat indexed text view a model can read cheaply and act on precisely, and record every run well enough that a proven one never needs a model again.
A real browser (Playwright/Chromium), a DOM extractor that walks the live page and assigns a stable [n] index to every interactive element, and a serializer that turns that into one line of text per element. That indexed text is the entire prompt: no pixels, no DOM dump, no framework noise.
[Start of page]
Registration
*[0]<label for=username>Username />
*[1]<input type=text name=username placeholder=Your username />
*[2]<label for=password>Password />
*[3]<input type=password placeholder=Your password />
*[5]<select name=country>Choose one
South Africa
United Kingdom />
*[9]<button type=submit id=submit-btn>Register />
*[n] marks anything new since the last snapshot. Indices are only valid for the snapshot that produced them; every action invalidates them, and the next snapshot regenerates from zero. Whatever is driving the loop, a model or an outer agent, reads this and picks exactly one action: click(9), input_text(1, "geez"), select_dropdown_option(5, "South Africa").
Observe. The extractor re-walks the live DOM and reindexes.
Think. One reflection, one tool call. click, input_text, select_dropdown_option, scroll, scroll_horizontally, wait, ask_user, done.
Act. Playwright dispatches the real event, settles (load state plus a DOM-quiet wait), adopts popups and new tabs, dismisses dialogs per policy.
A step budget warning lands as a <sys> note at 5 and 2 steps remaining. A URL change lands as its own <sys> note mid-loop. The run ends on done, fail, or the step ceiling, whichever comes first, not the step ceiling alone.
Autonomous. The engine's own loop calls an LLM over any OpenAI-compatible endpoint.
txtwrght run "log in with username tomsmith and password SuperSecretPassword!" \
--url https://the-internet.herokuapp.com/loginDriven. An outer agent (Claude Code, or anything with a shell) is the loop. No second model. The browser stays alive between commands over a debugging port.
txtwrght session start --url https://example.com
txtwrght session snapshot
txtwrght session act click 5
txtwrght session endclaude/ ships the SKILL.md for this with Claude Code. It is thin glue over the session CLI, on purpose: any logic a binding needs belongs in the engine, not the binding.
Distilled. Once a flow is proven, freeze it. No model, no agent loop, no retry logic at replay time, just a script.
txtwrght distill traces/run-<id>.jsonl --verifySelectors are rebuilt from element identity recorded at action time (id, name, aria-label, css path), never from the step-local indices, because indices die the moment the run that produced them ends. Passwords are scrubbed at trace time and come back as os.environ lookups in the generated script, never literals. Scripts stage for review and are never auto-registered; --verify replays before you trust it.
Cross-origin iframes are out of scope. The extractor descends into same-origin contentDocument (tested, works) but doesn't bridge cross-origin frame boundaries, which browsers block by design without a cooperating protocol on both sides. A task needing to act inside a cross-origin frame won't see into it.
No vision, ever, by design. If a task genuinely needs to read pixels (a canvas chart with no accessible data, a CAPTCHA), txtwrght isn't the tool. It trades that capability for cheap, precise, auditable text-only reasoning on the far larger set of tasks that don't need it.
browser-use popularized the pattern txtwrght builds on: index interactive elements, serialize to text, one action per LLM step. txtwrght owes it and page-agent (which ported and extended the pattern) direct credit; the extractor and serializer here are a Playwright-native port, not a from-scratch reinvention. Full attribution chain in LICENSE.
The difference is what happens after a run succeeds. browser-use's unit of value is the agent step; txtwrght's is the distilled script. A login flow driven by browser-use costs an LLM call every single time it runs. The same flow through txtwrght distill costs an LLM call exactly once, and every replay after that is a Playwright script with a --verify gate and zero token spend. txtwrght is also runtime-agnostic in a specific sense browser-use isn't: the same engine serves an autonomous LLM loop, a driven outer-agent loop (Claude Code), and a distilled zero-model script from one shared extractor and tool set, rather than one agent-loop product.
cd txtwrght
python -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
playwright install chromium
cp .env.example .env # fill in an LLM endpoint (see below).env takes an ordered failover chain of OpenAI-compatible endpoints (LLM_1_*, LLM_2_*, LLM_3_*). No cloud API key is required if you're routing through a local proxy that already holds a subscription session; that's exactly how the Phase 1 exit gate below was run.
All build phases closed. The Phase 1 exit gate, a 10-task live smoke suite with recorded traces and a pass rate rather than a demo anecdote, passed 10 of 10 on 2026-08-17 (bar was 8 of 10, 181,851 tokens, 173 seconds). 94 Python tests and 46 Node tests green. Full history in CHANGELOG.md.
| Engine core | Extractor, serializer, tools, agent loop, tracing: done |
| Hardening | Popups, dialogs, settle, same-origin iframes, structured logs: done |
claude binding |
Claude Code drives the session CLI, proven on a real login: done |
| Distillation | Trace → script, replay-verified, two live proofs (a login, a redirect chain): done |
| Second-model gate | Same smoke suite on a non-Claude model, to isolate contract from prompt: deferred, no credential wired up yet |
Every node in the interactive version links to the source line it stands for, with guided views for the driven, autonomous and distilled modes. Download it and open it locally; GitHub shows HTML as source. It is generated with archify from txtwrght.architecture.json, so regenerate it rather than editing the HTML.
Everything txtwrght runs inside the page is a file under src/txtwrght/dom/, never a string constant in Python.
| File | Installs | Called by |
|---|---|---|
extractor.js |
window.__txtwrght_extract |
every snapshot |
actions.js |
window.__txtwrght_actions |
the action verbs in tools.py, the trace scrubber in agent.py |
settle.js |
window.__txtwrght_settle |
the DOM-quiet wait in browser.py |
Evaluating one of these files assigns a registry onto window; Python then calls into that registry by name and passes arguments, rather than shipping a function body per call. Assignment is idempotent, so a caller re-installs instead of tracking whether a navigation wiped the previous document.
Two things fall out of that shape. A Chrome MV3 extension may not evaluate source text it received over a wire, so a payload that is ever going to be shared with an extension driver has to already exist as a file a content script can load. That is the constraint driving the layout. And JavaScript in a .js file can be loaded straight into Node, which is what the second test suite below does.
pytest # 94 tests against a real Chromium: extractor, serializer, tools, distill
npm ci && npm test # 46 tests against happy-dom: the injected payloads, no browser
python smoke/run_smoke.py # the live 10-task exit gate, needs a working LLM endpointTwo suites, because they can prove different things.
Python, against real Chromium. The only place layout, hit-testing, popups and cross-frame behaviour are real. Extractor and serializer behaviour is pinned here by golden tests over local fixtures; the serialized-page format is the prompt contract, and changing it is a breaking change that has to be deliberate.
Node, against happy-dom. Loads the same dom/*.js files a browser would and calls into them directly: click-delivery verification, the css-path builder that distillation rebuilds selectors from, scroll arithmetic, and the mutation-quiet wait. Roughly two seconds, no browser to launch, so it is cheap enough to run on every edit. happy-dom has no layout engine and the extractor decides what is interactive from geometry, so extractor.js is deliberately only load-tested there and keeps its real coverage in the Python suite.