How it worksDocsAboutLive demoStar on GitHub
Mechanism

A guide that cannot point at what is not there.

Everything below runs on every step. It is three stages, and only one of them involves a model.

Stage 01

The snapshot

Written by hand, on purpose. No model sits on this path — reading the DOM is free, and a round trip would cost the interaction its immediacy.

Every element that can be acted on right now — links, buttons, inputs, tabs, anything carrying an interactive ARIA role — is collected, capped at sixty, and described by what it is rather than where it sits. Each one gets a short-lived id that means nothing outside this one request.

The accessible name is resolved roughly the way the accname spec does it: aria-label, then aria-labelledby, then the associated <label>, then the placeholder, then the text content. An icon-only button with no label legitimately comes back empty — and is dropped, rather than sent as anonymous noise.

This is also where privacy is decided, and it is decided by omission. A field is reported as filled or empty; what it was filled with is never read. Inside a data region — a table row or cell, an ARIA row/cell, or anything carrying data-virgil-data — the text is withheld as well, and the element is described by its shape instead: “a cell in Supplier”. Those elements are also left out of the list the model chooses from, because a choice that cannot be described is not a choice.

The line is between what your app calls a control and what it shows. An aria-label is authored by a developer and is forwarded, because without it the guide could never say “press Approve” on a row action. Which also means an authored label that interpolates data — aria-label="Approve PR-2048 for Nordgren" — is data, and no rule here can tell. That is what redact is for; and dataText: "send" is the opposite switch, for rows that are not private at all.

POST /api/virgil/step — request body, abridged
{
  "goal": "register a supplier invoice",
  "route": "/invoices/new",
  "elements": [
    { "id": "e12", "role": "link",     "name": "Invoices",  "href": "/invoices" },
    { "id": "e27", "role": "combobox", "name": "Supplier",  "filled": false },
    { "id": "e28", "role": "textbox",  "name": "Amount",    "filled": true },
    { "id": "e31", "role": "button",   "name": "Send for approval", "disabled": true }
  ],
  "history": [{ "did": "opened Invoices" }]
}

Note what is absent: no selectors, no class names, no DOM paths, and not one value a user typed.

Stage 02

The decision

One step at a time, against the screen that actually rendered — not against a path someone wrote down last quarter.

The model receives the goal, the steps already taken, and that list. It replies with a structured object: a status, one sentence for the user, and an id taken from the list.

That last constraint is the whole safety property. The model never writes a selector and never invents a label, so it cannot point at an element that does not exist — there is nothing else it is able to name. The client resolves the id back to the exact node the list was built from.

It also decides how the step completes: on the click, on the route change, or on the user confirming they checked something. Route beats click wherever a link is involved, because navigation can start from the keyboard or from somewhere else entirely and the step is complete either way.

— response
{
  "status": "step",
  "elementId": "e27",
  "say": "Pick the supplier first — it decides which cost centres you can charge.",
  "advanceOn": "click"
}
Stage 03

The pointer

The part people remember, and the least interesting part to build.

The cursor travels to the element and the reticle locks onto its box, tracking it as the page scrolls or reflows. One sentence sits above it — imperative, quoting the exact label, and saying why when the why is something the user could not guess.

Then the step is marked done and the next one is decided from scratch. Nothing is cached about the path, because the path was never the point.

Things that cost us a week each

It kept pointing backwards

A nav item you already used is still on screen. Without the history of what was pointed at, the guide happily sends the user back through the door they just walked through.

It filled in filled fields

Knowing whether a field has something in it — without knowing what — was the difference between a guide that reads the screen and one that recites a form.

Navigation beat content

Given two plausible targets, the sidebar always won. Now content beats navigation: navigation is how you arrive, not how you do the thing.

Long sentences became a wall

The bubble sits over the interface. Three sentences stop being an explanation and start being the thing standing between the user and what you are pointing at.