Introduction #
FullSpectrum is a browser runtime for agents. The premise is that most browser automation should not call a model at all.
A session is recorded once into a compiled artifact — semantic locators plus pre- and post-conditions for every step — and every run after that is replayed deterministically against stock Chrome over CDP. There is no model in the loop, which means no token bill, no non-determinism, and no prompt-injection surface where page content could become control flow.
When a locator drifts, replay does not quietly substitute a different element. It stops and emits a reviewable repair: a diff, a confidence score, and the signals behind it, waiting for a human to approve or reject.
Two front doors, one runtime. The same package also renders and captures pages as semantic JSON — see Pages as JSON. Automation and rendering share one locator implementation, which is why a click in the mirror refuses in exactly the places a replay does.
Install #
Node.js 22 or newer, pnpm, and a Chrome binary. The runtime resolves a pinned Chrome for Testing build ahead of any system browser — see the version pin for why.
$ pnpm install
$ pnpm build
# install the pinned browser
$ npx @puppeteer/browsers install chrome@151.0.7922.71 --path ./.cache/chrome
# what browser will be used, and is it the pinned one?
$ pnpm fs doctor
fs doctor is the first command to run and the first one to check when
something behaves differently on two machines. It reports which binary was resolved, from
where, and whether it matches PINNED_CHROME_VERSION.
Quickstart #
Three commands: record a real session, replay it, then look at what the replay cost. Recording opens a headful browser and watches you work — press Enter in the terminal to stop.
$ pnpm fs record shopping --url https://example.com/
recording… use the browser, then press Enter
compiled 7 steps · 0 warnings
$ pnpm fs replay shopping
✔ shopping — 7/7 steps · 1180ms · 0 model calls
$ pnpm fs stats shopping
Nothing left the machine and nothing called a model. From here, the two things worth reading next are what replay guarantees and what happens when a page changes.
The version pin #
CDP has no meaningful stability guarantee — the “stable 1.3” tag is frozen at Chrome 64 while Chrome ships 151.x — and Chrome ships a new milestone every four weeks with weekly stable refreshes, a two-week major cycle from September 2026. A fleet that follows whatever browser the host happens to have changes behaviour without a commit.
So the pinned Chrome for Testing build is declared in the repo, resolved ahead of any system browser, and reported in every run. Upgrading it is a code change, gated by the regression suite.
$ pnpm regression # 20 scenarios, offline, against local fixture pages
$ pnpm fs verify # replay every stored workflow — the upgrade gate
This has already earned its keep. --disable-features=OptimizationHints,
a common flag in browser-automation setups, segfaults Chrome for Testing 151 on macOS
arm64 within milliseconds of the first Page.enable — while working fine on
Chrome 150.
How replay works #
Replay is a program, not a transcript being re-interpreted. Before a run touches the
browser, the plan is statically validated: whatever step i+1 requires, step
i must guarantee — post_i ⊆ pre_{i+1}. A workflow that cannot
possibly hold together fails before Chrome launches.
During the run, every step does the same four things in the same order: verify its pre-condition, resolve its target by similarity, act, then poll until its post-condition holds. It fails closed at every point where it is not sure.
| Situation | What happens |
|---|---|
| Locator scores below threshold | Step fails. A repair is proposed if one can be found. |
| Two candidates tie, ordinal gone | Refuses to guess — a wrong cached click is worse than a slow one. |
| Recorded target had no accessible name | Refuses. “The Nth unnamed button” reorders whenever the page does. |
| Page is not where the workflow expects | Stops before acting. |
| Page is on a different origin than declared | Refuses — including for extract, since reading data off an unexpected origin is a provenance failure. A step may declare its own host, so SSO redirects still replay. |
| A popup opens | Followed only if the recorded conditions hold there. |
| A ref from an older snapshot is reused | Refused outright. Stale refs are a safety property, not an inconvenience. |
Every stop produces an escalation carrying the reason, the page it happened on, and the repair a human could approve.
Recording #
fs record opens a real, headful browser and watches you work. Clicks, typing,
key presses and dropdown choices become steps; a hundred keystrokes become one
type step carrying the final value; a click into a text field is dropped,
because type focuses its own target.
The semantic identity of each target — role, accessible name, ordinal among identical
controls, neighbouring labels, any data-testid — is computed in the page,
in the event handler, while the element is still the element that was clicked.
Resolving it afterwards against a snapshot loses a race it cannot win: by then the click
has navigated, closed the menu, or re-rendered the row.
Variables
Bind a typed literal to a variable so replay can vary it:
$ pnpm fs record login --url https://example.com/login --var email=me@example.com
$ pnpm fs replay login --var email=someone@else.test
There is also a programmatic Recorder for when you already know the steps —
see the programmatic API.
Replaying #
$ pnpm fs replay shopping
$ pnpm fs replay shopping --var email=someone@else.test
$ pnpm fs replay shopping --json # machine-readable result
$ pnpm fs replay shopping --allow-healing # opt in to relocation, off by default
Healing is off by default during replay. A workflow that heals on every run is a
workflow nobody has fixed, so the default is to stop and tell you. --allow-healing
is for the cases where you have decided a drifting page is acceptable and you would rather
the run complete.
Replay everything before a browser upgrade. fs verify replays every
stored workflow and is the gate for changing PINNED_CHROME_VERSION.
Repairs #
Deterministic replay with repair-on-break is the incumbent enterprise RPA architecture and has been generally available for over a year. What nobody ships is a heal you can review: UiPath states plainly that “healing information from a previous run is not retained in the next run,” and Microsoft concedes its portal “doesn't currently surface additional self-healing details in the action logs.”
Here, a repair is a build-time artifact — something you read, diff, and approve.
$ pnpm fs repairs shopping
1 pending
#4 click "Add to cart" → "Add to bag"
confidence 0.91 · role button · same ordinal · same ancestors
$ pnpm fs repair apply shopping 1 # shows the diff, validates, then asks
$ pnpm fs repair reject shopping 1 # the history is append-only
Relocation uses similarity, not a model. HybridSimilo relocates 98.8% of elements with broken locators with no model at all, and similarity is free, deterministic, and not a prompt-injection surface. Heal history is retained across runs, so a repair you rejected last month does not come back next month wearing a new id.
Extraction #
Schema-constrained, in-page, deterministic:
{
"kind": "extract",
"into": "products",
"extract": {
"rows": "tr.item",
"fields": [
{ "name": "name", "selector": ".name" },
{ "name": "price", "selector": ".price", "type": "number" },
{ "name": "url", "selector": "a.link", "attribute": "href" }
],
"limit": 200
}
}
Extracted text returns data, never control flow. A value can fill a declared hole in a later step; it can never become a step, a URL, a tool name or a file path. That boundary is what makes model-free replay injection-proof rather than merely injection-resistant.
Rows, per-field matches and string lengths are all bounded. An extractor pointed at a hostile page is otherwise an unbounded read.
Audit log #
Every run writes an append-only, hash-chained log with per-step page provenance — which origin's content was on screen when each action was taken. That is the question an incident actually asks.
$ pnpm fs audit shopping run_01HQ…
chain verified · 7 entries · no gaps
1 goto https://example.com/ origin example.com
2 click "Search" origin example.com
3 type "wireless keyboard" origin example.com
…
The chain is tamper-evident, not signed: each entry commits to the previous one, so a line cannot be altered or removed without breaking every hash after it. Signing needs a key the writer holds and the verifier trusts, which is a deployment decision rather than a library one.
Measurement #
Every cache hit-rate claim in this space is self-reported and unaudited. No independent production hit-rate measurement exists for any workflow cache, which makes instrumenting it both the only way to know the bet is paying off and an asset in itself.
shopping
runs 27 (26 ok)
steps 189
clean replay 98.4% (no heal, no model)
healed 0.5%
failed 1
mean / p95 run 3120ms / 4980ms
model calls 0
heals 2 proposed, 1 applied, 1 pending
Those are illustrative numbers, not measured ones. The point of the command is that yours will be real. Nothing on this page reports a benchmark we ran on your behalf.
PageDocument #
FullSpectrum also renders sites from a semantic JSON document instead of HTML. HTML tells
you here is a div. PageDocument tells you here is the pricing
section:
{ "kind": "section", "region": "pricing",
"blocks": [ { "kind": "table", "columns": […], "rows": […] } ] }
Three rules the format enforces
-
Blocks are a closed union of 38 kinds. An unknown kind is a validation error, not
a blank rectangle. Every builder that starts with an open
{type, props}ends up rendering nothing for content it does not understand — which is indistinguishable from content that is genuinely empty. -
No styling in the document. Blocks carry intent (
tone: "danger"), never appearance (color: "#b91c1c"). That is what makes a redesign a change to a token block rather than a find-and-replace across every page ever authored. -
There is no
htmlfield anywhere. Inline formatting is a typed tree, so captured content — untrusted by definition — cannot carry markup or script into the renderer.
Render #
$ pnpm fs render examples/site.json --out site.html
$ pnpm fs validate page.json # what is wrong, and where
examples/site.json is a full landing page — nav, hero, metrics, cards,
callout, tabs, pricing table, quote, FAQ accordion and a form — with no HTML written to
produce it. The component kit is server-rendered, one function per block kind, with design
tokens, light and dark themes, and nothing external.
Capture #
$ pnpm fs capture https://example.com --out page.json
Capture recovers structure, not intent. Sections, headings, tables, forms,
navigation, lists and links survive; the rich authoring blocks do not, because once a
cardGrid has been rendered to HTML there is no signal that a div of cards ever
was one. Round-tripping examples/site.json through render → capture returns
sections, headings, tables, a quote, an accordion and the form — with metricRow,
cardGrid, callout and tabs arriving as their
structural equivalents.
It is honest about the cost, too. Hacker News captures to ~17k tokens with all 197 links
and hrefs intact, against ~3.3k for the compacted accessibility tree, which keeps none of
them. The document format is richer and renderable, not cheaper — the
accessibility tree remains the right channel for perception, and PageDocument
the right one for rendering and editing.
Mirror #
fs mirror <url> serves a real page rendered from its own JSON. A click
posts back the locator the document carries — never a selector, never coordinates —
and the server resolves it against a fresh snapshot through the same
findByLocator path replay uses.
click through the JSON → {"ok": true, "confidence": 0.9} page really moved
ambiguous target → refused: no accessible name and 31 candidates
are indistinguishable
The rendered page contains no script, style or markup from the origin, so nothing on the site can overdraw the UI or reach the automation.
Browse #
fs browse is the mirror plus the things that make it a browser: an address
bar, a history to move through, and links that go somewhere.
$ pnpm dev # reloads the view when the server restarts
$ pnpm fs browse news.ycombinator.com
$ pnpm fs browse --port 7373 --headful # watch the real Chrome alongside it
Everything the shell forwards back is typed:
| You do | It sends | Resolved by |
|---|---|---|
| Click a link | its raw href | new URL(href, currentPage) — server-side |
| Click a control | the locator the document carries | findByLocator, which refuses when ambiguous |
| Type in a field | the locator and the value | findByLocator → Input.insertText |
| Submit a form | the submit control, or Enter on a field | findByLocator → click, or focus + Enter |
| Back / forward | a delta | Page.getNavigationHistory — Chrome's own history |
It refuses in the same places replay does, and for the same reasons:
javascript: / data: / file: in the address bar → refused by scheme, never
silently searched for
mailto: / javascript: link → refused, not followed
locator matching nothing, or two things equally → refused: "best candidate
scored 0.42 < 0.75"
a locator missing fields, arriving over HTTP → refused, not a 500
DNS or connection failure → the browser's own error page
Not in this version: tabs, downloads, cookie and profile management, and per-keystroke typing — a value goes to the page when a field is left, not on every key, so fields with key-level validation will not see individual keystrokes.
Region labels #
Capture gets structure; a labeler puts the meaning back.
region: "pricing" is the difference between a model inferring what part of a
page it is looking at and simply knowing.
$ pnpm label-eval # rules only — free, instant
$ pnpm label-eval --claude # adds the model, and scores it against the rules
capture-only (no labeling) 0.0% ← the floor, by construction
heuristic 94.7% (18/19 sections, 1ms, $0)
The model has to beat free. A deterministic labeler ships alongside the Claude one
precisely so the comparison is forced, and --claude refuses to declare a
winner if the model failed to run. The model's authority is deliberately tiny: it receives
a flattened summary of sections and returns a region label per index. It cannot add,
remove, reorder or rewrite a block.
Honest limits on that 94.7%: those pages were authored for this benchmark and the rules were written knowing them. It is a floor and a regression check, not evidence of generalization. A real eval needs captured pages nobody here wrote, labelled by someone who did not write the rules.
CLI commands #
| Command | What it does | Flags |
|---|---|---|
record <name> | Watch a real session and compile it | --url --var k=v |
replay <name> | Replay deterministically | --var --allow-healing --json |
verify [names…] | Replay everything — the Chrome-upgrade gate | |
list | Stored workflows | |
show <name> | Steps, variables, validation | --json |
runs <name> | Run history | --limit N |
stats [name] | Clean-replay rate, heal rate, timings | |
audit <wf> <runId> | Page provenance, hash-chain verified | |
repairs <name> | Heals awaiting review | |
repair apply <name> <id> | Apply, with a diff and a confirmation | --all --yes |
repair reject <name> <id> | Record the decision | |
doctor | Browser resolution and the version pin | |
mcp | Expose the runtime over stdio MCP | |
capture <url> | A live page as JSON | --out --html --targets |
render <doc.json> | JSON as a page | --out --wide --no-js |
validate <doc.json> | Schema problems, with paths | |
mirror <url> | Serve a live, clickable JSON rendering | --port |
browse [url] | The browsing shell | --port --search --headful --dev |
Global flags: --root <dir> (store location, default
$FULLSPECTRUM_HOME or ./.fullspectrum),
--chrome <path>, --chrome-version <v>,
--require-pinned, --headful.
That table is the daily set, not the whole surface — fs help lists the full
command set, including app, export, schedule,
serve, drafts, vault, queue,
canary and triage.
MCP server #
fullspectrum mcp exposes the runtime to an agent over stdio:
{
"mcpServers": {
"fullspectrum": { "command": "pnpm", "args": ["--dir", "/path/to/FullSpectrum", "fs", "mcp"] }
}
}
Tools: list_workflows, show_workflow, replay_workflow,
run_history, workflow_stats, pending_repairs,
preview_repair, audit_run, triage_run,
recall, remember, list_drafts,
browser_status.
Recording is deliberately not exposed, and neither is “browse this page for me.” An agent cannot demonstrate a workflow, because the value of the artifact is that a human decided what the steps are. And the moment a model reads page content and picks the next action, every property that makes replay safe is gone. What an agent gets is the ability to run workflows a human already approved, read the results, and see what needs review.
Packages #
| Package | What it does |
|---|---|
@fullspectrum/cdp | Raw CDP client, launcher with a pinned binary, and BrowserSession — one page plus every out-of-process frame and popup it spawns. |
@fullspectrum/perception | Accessibility-tree snapshots with versioned refs, cross-frame splicing, token-budget compaction, and semantic locators that survive reloads. |
@fullspectrum/workflow | Record → validate → replay. Observational and programmatic recorders, static condition checking, actuation, extraction, repair proposals. |
@fullspectrum/store | Workflows, run history, heal history retained across runs, and the hash-chained audit log. |
@fullspectrum/document | The PageDocument schema — 38 block kinds, typed inline text, form fields — plus validation and live-page capture. |
@fullspectrum/render | The component kit. Server-rendered HTML, one function per block kind, design tokens, light/dark, nothing external. |
@fullspectrum/label | Region recovery: a deterministic labeler, a Claude-backed one, and the harness that scores them against each other. |
@fullspectrum/cli | The fullspectrum command, the MCP server, the mirror, and the browsing shell. |
Tests #
$ pnpm test # 1,733 unit tests, no browser required
$ pnpm regression # 20 end-to-end scenarios against local fixtures
$ pnpm label-eval # region-recovery accuracy against authored ground truth
$ pnpm typecheck
The unit suite runs the replay engine against a scripted browser and the CDP client against a real WebSocket server, so the fail-closed properties and the connection's failure modes are verified without launching Chrome. The regression suite covers what only a real browser can: observational recording driven by real input events, cross-origin iframes, popups, shadow DOM, drift-and-repair, polling for late elements, stale-ref refusal, capture fidelity, the mirror's action proxy, and the browser's link-following and history.
Reading further: docs/ARCHITECTURE.md in the repository carries the
full design, the three-phase plan, the security boundaries and the sourced research
behind every claim on this page.