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

Status

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

<div class="_1f3a" data-v-8b>
<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

dom# Fleet health
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.

01 settle

Settle

Navigate, then wait for network idle, web fonts loaded, and no layout shift for a quiet period.

a page that stopped moving
02 extract

Extract

Walk the accessibility tree and the DOM together. Each block keeps text, role, box, selector, href and field info.

DomBlock[]
03 cover

Cover

Screenshot, then find only the painted regions the DOM cannot explain. Merge overlaps, drop anything under the floor.

gap regions, often zero
04 ocr

OCR

Crop each gap and read it through one engine interface. Boxes come back translated into page coordinates.

OcrLine[]
05 fuse

Fuse

Merge both into reading order: columns by x-overlap, top to bottom inside a column. Markdown by default, JSON on request.

one document
the stage worth understanding

03Cover, 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.

illustration, not a recorded run

troy read ./fixtures/article.html markdown, the default

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.

macOS, first choice

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 mac
everywhere else

Tesseractthrough 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 fallback
opt in, with --deep

VisionLlma 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 automatic

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

ActionWhat it does afterWhat 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 prompt can be talked out of a rule.
a library cannot.

06A correctness rule

Text a human cannot see is not page content

Extraction marks a block invisible when it is display:none, zero sized, visibility:hidden, fully transparent, or clipped out of view. Those blocks are still extracted, because knowing they exist is useful, and they are kept out of the rendered document. Hidden text is a known way to poison a scraper, and printing it as page content would be a bug with real consequences the moment an agent acts on what it read.

In the page

<p style="clip-path:inset(50%)">
  Settled in full, no balance due.
</p>

Painted area zero, visible to nobody, sitting in the DOM directly under a statement that says otherwise.

In the document

Balance due 4,932.40          ocr

1 block excluded: visually hidden

The claim never reaches the agent, and the count of what was dropped still does. Silence about the exclusion would be its own kind of dishonesty.

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.

the default

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 profile
opt in

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

your login, not your main browser
opt in

Attachover CDP

Connect to a Chromium that Troy did not start, for the case where something else owns the browser lifecycle.

bring your own browser

08State 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 Cdp port 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 read works 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, ycombinator and generic, 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.