Skip to content
SingularityAI-DevPublic

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

txtWright: a headless browser agent that reads pages as text, not pixels, and a way to stop paying a model for the same click twice. 140 tests passing, 10 of 10 on the live smoke gate, MIT licensed, Python 3.11 and up.

txtwrght

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.

Tests Smoke gate License: MIT Python

One repo: the engine (src/txtwrght) plus the Claude Code binding (claude/). See ARCHITECTURE.md for the split.

txtwrght sits between a real browser and whatever is driving it (a model, an outer agent, or nothing at all) as the missing text-only interface layer.


The problem

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.


What it is

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").


The loop

Animated loop: observe the indexed page, think one action, act on it through Playwright, then observe again. Indices are only valid for the snapshot that produced them.

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.


Three ways to spend a model's attention

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/login

Driven. 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 end

claude/ 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 --verify

A recorded trace is distilled into a plain Playwright script, verified by replay, and staged for review, no model left in the runtime path.

Selectors 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.


What it does not do

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.


How this differs from browser-use

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.


Install

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.


Status

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

Architecture

txtwrght architecture: Claude Code drives the session CLI, the agent loop asks an LLM endpoint for one action, both act through Browser + tools, which calls the three in-page payloads extractor.js, actions.js and settle.js in Chromium; the serializer turns the flat tree into indexed text; runs record to trace.jsonl, which txtwrght distill turns into a staged Playwright script that replays against Chromium.

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.


Where the injected JavaScript lives

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.


Tests

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 endpoint

Two 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.

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages