a headless browser / built to be read by an agent
Read The Whole Page.
Browser automation gives an agent two bad options: dump the raw HTML and drown in it, or take a screenshot and lose every link, selector and form field. Troy returns one clean document per page, taking structure from the DOM and pixels from OCR, and working out by itself which parts of the page need which.
macOS Apple silicon macOS Intel Windows installer Windows portable
unsigned build · first launch needs control-click then open
canvas
ocr: p95 318 ms
nothing in the DOM to explain it
dom block
h1 · main > header h1
text, role, box, selector
hidden text
"settled in full"
planted, so never reported
verified fill
wrote, blurred, read back
fails loudly on a mismatch
The browser is real. The read pipeline is not, yet. Troy opens as a proper Chromium window with its own tabs, omnibox and agent panel, and there are signed-in-code refusals, failure pages and a battle-test suite behind it. You can install it today from the buttons above. What is still in build is the part this page is mostly about: cover, OCR and fuse. Until that lands, the agent panel reads a page from the DOM alone, and every troy read block below illustrates designed behaviour rather than a recorded run. Public and MIT at github.com/anishfyi/troy.
01The problem
Two bad options, and the one nobody ships
A page is rendered for eyes. An agent gets handed either the source it was built from or a picture of the result, and neither is the thing it needs. The gap between those two columns is the entire product.
Option one: the source
<span aria-hidden="true">
<script>window.__NUXT__=
<canvas id="chart" width="740">
<div style="clip-path:inset(50%)">
Everything, including the scripts, the styles and the nodes nobody painted. Which of it was on screen?
Option two: the pixels
You can see the page. Every link, selector, field and value is gone, and nothing is addressable.
Troy: one document
ocrP95 318 ms
dom[Export CSV](button)
dominput#po_number ""
Every line carries its box and its source, so the agent can act on the thing it just read.
02The read pipeline
Five stages, one document out
A page read halfway through rendering is the most common source of garbage output, so settling is explicit rather than a fixed sleep. After that the pipeline is a funnel: take everything the DOM can prove, work out what is left over, and pay for pixels only there. Cover is the stage that makes the rest cheap.
Settle
Navigate, then wait for network idle, web fonts loaded, and no layout shift for a quiet period.
a page that stopped movingExtract
Walk the accessibility tree and the DOM together. Each block keeps text, role, box, selector, href and field info.
DomBlock[]Cover
Screenshot, then find only the painted regions the DOM cannot explain. Merge overlaps, drop anything under the floor.
gap regions, often zeroOCR
Crop each gap and read it through one engine interface. Boxes come back translated into page coordinates.
OcrLine[]Fuse
Merge both into reading order: columns by x-overlap, top to bottom inside a column. Markdown by default, JSON on request.
one document03Cover, the stage that decides
OCR only what the DOM cannot explain
Running OCR over a whole page is slow, and worse than slow: an OCR guess would overwrite text the DOM already knows exactly. So Troy screenshots the settled page and asks one question of every painted region: can the DOM account for this? A region becomes a gap when it is painted, big enough to matter, and is a canvas, an image or video with no accessible text, an embedded PDF, a cross-origin iframe, or an element with real painted area whose computed text is empty and whose crop has high edge density. That last test is what keeps photographs and flat panels out of the OCR queue. Pick a page below and step through it.
Reading the frame: greeked bars are DOM text, already known exactly and never OCR'd. Typed text is text only the pixels have. Nothing renders at all for a block that is in the DOM but never painted, which is the whole point of the third page.
04The engines
One interface, three backends, no choice to make
Every backend answers the same two questions: are you available, and what lines do you see in this PNG. Troy picks the best one present on the host, so a script written on a Mac runs unchanged in Linux CI. The one exception is --deep, and that is a request for comprehension rather than a request for a particular vendor.
AppleVisiona small bundled Swift helper over the Vision framework
Local, free, and materially better than Tesseract on the small antialiased text the web is actually made of. No key, no upload, no per-page cost.
local · free · default on macTesseractthrough the tesseract binary
Checked for at runtime, with a clear install hint when it is missing rather than a stack trace. This is the path Linux CI exercises on every push.
local · free · the fallbackVisionLlma vision model, asked for meaning
Sends the crop to a vision model to comprehend a chart, a table or a layout rather than to transcribe characters. Needs an API key and is never used unless it is asked for.
costs money · never automaticThe whole contract
interface OcrEngine {
name: string
available(): Promise<boolean>
recognize(png: Buffer): Promise<OcrLine[]>
}
An OcrLine is text, a box and a confidence. Crop coordinates are translated back into page coordinates before anything else sees them, which is what lets a pixel-read line sit in reading order next to a DOM line.
What the footer always says
illustration of the designed footer line, not a transcript
read in 1.34 s · 3 regions OCR'd · AppleVision
Every read reports how many regions went to pixels, which engine ran, and how long it took. Silent OCR would hide both the cost and the uncertainty, so the report is not optional.
05The action layer
Nothing is assumed to have worked
Reading makes acting better: a block's selector comes out of the same extraction pass, so troy read --json and troy click talk about the same page in the same terms. Every action then checks itself, because "the call returned" and "the page changed" are different claims.
| Action | What it does after | What it refuses |
|---|---|---|
| fill | Writes, blurs, then reads the value back off the element and fails on any mismatch. | password fields, values over maxlength |
| click | Scoped to a container, so a repeated label resolves inside the row you meant. | ambiguous matches, unscoped repeats, submit controls |
| state | Snapshot, diff and expect over custom-control state, not only native checked. |
an expect that does not match the diff |
| goto | Re-checks the host after redirects, not only before the navigation. | any host outside the allowlist |
Verification, not optimism
illustration of the designed output, not a transcript
troy fill "#po_number" "PO-4471"
wrote · blurred · read back "PO-4471" ok
troy fill "#po_number" "PO-4471-EXTENDED-REFERENCE"
refused: 26 chars over maxlength 24
Refusals live in code
illustration of the designed output, not a transcript
troy click "button[type=submit]"
refused: submit control
troy fill "#password" "..."
refused: password field
a library cannot.
07Sessions
Zero setup by default, logged in when you need it
One Session interface, three ways to get one. Everything downstream is mode agnostic, so reading and acting behave identically whether Troy launched the browser or merely joined it.
HeadlessPlaywright's own bundled Chromium
Nothing to install past the package itself, and a read works on a machine that finished installing Troy a second ago.
no flags, no profilePersistent profile~/.troy/profiles/<name>
A real Chromium on a dedicated profile, deliberately separate from your daily browser. Log in once by hand, then read authed and paywalled pages with --session.
Attachover CDP
Connect to a Chromium that Troy did not start, for the case where something else owns the browser lifecycle.
bring your own browser08State of the build
Two milestones down, six to go
Listing the plan with honest markers is more useful than a progress bar that starts at forty percent, so here is the order the work lands in and where it has actually got to.
- M1Scaffold. Repo, TypeScript project, the
Cdpport headless plus attach, CI on macos, windows and ubuntu.done - B1The browser. A real Chromium window under Troy's own chrome: tabs, an omnibox that refuses
javascript:and friends in code, failure pages that keep the address you asked for, crash recovery, a new tab page, and twenty-seven cases driving the actual app on macOS, Windows and Linux. Packaged as a DMG and an EXE.done - M2Extract and render.
troy readworks end to end on DOM-only pages.next - M3Engines. The OCR interface, the Apple Vision helper, the Tesseract backend.not started
- M4Cover and fuse. The differentiator lands and the fixture suite passes.not started
- M5Actions. The existing action layer ported to TypeScript, behaviour unchanged, its tests with it.not started
- M6Polish.
--deep, docs, the Claude Code plugin, npm publish.not started - B2The bridge. The read pipeline wired into the live tab, so an agent drives the window you are already signed into.not started
Out of scope for v1, deliberately
- File upload. Reading and driving first; handing files to a page is a v1.1 candidate.
- Session recording and replay, and resuming a multi-step flow after a crash.
- More site targets. Two exist,
ycombinatorandgeneric, and neither grows for v1. - Any browser other than Chromium. One engine, done properly.
09Where it lands
What you can do about it today
Download the browser and it will open, browse and refuse the things it says it refuses. The reading is the half still in build.
The repo is public and MIT at github.com/anishfyi/troy. The builds above are unsigned, so macOS wants control-click then Open on first launch and Windows wants More info then Run anyway; that is what an unnotarised app costs, and it is stated here rather than discovered by you.
Success is measured against four claims. None of them can be checked until the pipeline lands: a plain article page triggers zero OCR calls, a canvas page and an image-with-text page both return their text attributed to pixels, visually hidden DOM text never appears in the output, and the selectors that come out of troy read --json are accepted by troy click and troy fill without translation.
The name is older than the tool and it is not a Trojan horse. That reading points at malware, which is exactly the wrong association for something whose entire job is to be honest about what a page contains. Hence a lens, between two crop marks.