From nothing to a working guide.
Virgil ships as three entry points: a headless core, the React layer, and a Next.js route handler. You need the last two.
Installation
Virgil expects React 19 and Next.js 15 or newer with the app router. The model provider is yours to choose — anything the Vercel AI SDK speaks.
$ npm install virgil-ui ai @ai-sdk/openaiThe route handler
One catch-all route serves both endpoints: the step decisions and the contextual chat. The auth callback is not optional — it is the only thing standing between your model budget and the open internet.
import { createVirgilHandler } from "virgil-ui/next" import { openai } from "@ai-sdk/openai" export const { POST } = createVirgilHandler({ stepModel: openai("gpt-5.6-terra"), // decides what to point at chatModel: openai("gpt-5.4-mini"), // answers questions about the screen locale: "en", auth: async () => Boolean(await getSession()), })
Mounting the guide
One mount point, as high in the tree as the guide is allowed to roam. Until the flag and the user’s own switch are both on it renders nothing and registers no listeners — the cost when it is off is a single boolean check.
import { Virgil, VirgilToggle } from "virgil-ui/react" export default function Layout({ children }) { return ( <> <Header><VirgilToggle /></Header> {children} <Virgil endpoint="/api/virgil" /> </> ) }
While a tour runs, each step is a short sentence in a bubble beside the thing the arrow is pointing at. When there is nothing to point at — the next step is still being decided, or the tour is over — the bubble parks in a corner rather than vanishing, and bubbleCorner says which: the corner that is genuinely free differs per product, and a guide landing on your own toolbar is in the way at the moment it is meant to be helping.
Inspect mode
The other way in, and the one people reach for first: instead of typing what they want, they point at the thing they do not understand. With inspect mode on, the pointer outlines whatever it is over and names it, and a click asks about that element instead of pressing it. The answer arrives with no question typed — the box below it is for the follow-up.
Mod+I does both: the first press turns the mode on, the second asks about whatever is outlined. And it is always whatever is outlined — the marker follows the keyboard as well as the pointer, so tabbing to a control and pressing the shortcut asks about that control.
import { Virgil, VirgilLauncher } from "virgil-ui/react" <Virgil endpoint="/api/virgil" /> <VirgilLauncher /> // or setInspecting(true) from your own toolbar
It is a mode, and it says so: it takes your app’s clicks while it is on. It ends on Escape, on the same button, and when a tour starts — a tour asks for real clicks. With the panel open, a click anywhere outside it dismisses it and asks nothing: the gesture people make to put something away is not the one they make to ask about the next thing. Anything under data-virgil-ignore keeps its own clicks, which is how the guide’s own panel stays usable.
The colour is part of it. Over a red interface a red pointer disappears, so it is a setting rather than a constant: ship a default with accent, and call setAccent from your own preferences if your users should choose. It writes --virgil-accent inline on <html>, which outranks your :root rule, and setAccent(null) removes it again and hands the guide back to your theme.
What one request may spend
auth decides who may spend your model budget and rateLimit decides how often. Neither decides how much, and that is the one an endpoint on the open internet is asked first: the snapshot’s own bounds — sixty elements, eighty characters a label — run in the browser, and anyone can post their own JSON instead.
So the handler enforces its own, and they are not optional. A body over 32,000 characters is answered 413 without being parsed. A goal or a question is cut to 400 characters, the history to 24 steps, the snapshot to 60 elements, every label to 120. All of it is configurable through limits, and all of it applies before a single token is bought.
Teaching it your app
The DOM says what is on screen. It does not say that a protocol number is never reused, or that the supplier has to be picked before the cost centre means anything. That knowledge goes in a virgil.md placed next to the route it describes, and the generator compiles every one of them into a registry keyed by path.
--- title: Supplier invoices --- The protocol number is assigned when the draft is created and is never reused, including after a deletion. An invoice cannot be sent for approval until a cost centre is set, and the cost centres available depend on the supplier.
The generator’s --check mode verifies that every knowledge file sits at the route it claims and that the committed registry is current — the kind of thing that rots quietly otherwise. Put it in CI.
Theming
No design system is imposed. The guide reads CSS custom properties off its own root, so it inherits whatever you set.
:root { --virgil-accent: #e8503a; --virgil-surface: #0f1011; --virgil-text: #eae6e2; --virgil-radius: 16px; }
Props
Base path of the route handler. Defaults to /api/virgil.
Everything the guide says that the model did not write.
maxElements, maxNameLength, redact, placeholderVerbs — the bounds on what leaves the browser.
Fired on every decided step. Useful for analytics.
Hand over your router's value instead of having the History API wrapped.
Turns inspect mode on; pressed again, asks about whatever is outlined. Defaults to Mod+I.
Render without waiting for the user to switch the guide on.
Whether the arrow replaces the system cursor, or only shows while guiding. Inspect mode draws it either way.
Which corner the step bubble parks in while it has nothing to point at. Bottom right by default — set it to whichever corner your own furniture leaves free.
The colour the guide draws itself in, unless setAccent has been called.
How many steps one goal may take before the guide stops itself. Defaults to 12 — a tour going in circles is otherwise ended only by the user.
Show, in the bubble, why a step could not be decided.
What is sent
Roles, accessible names, the section a control sits in, a link’s destination, and three booleans: filled, disabled, visible. That is the entire payload.
Never sent: the value of any input, the contents of any table cell, and anything your own redact predicate rejects. The redaction happens in the browser, before the request is built — not on the server where it would already be too late.
Licence and credit
MIT. Use it commercially, fork it, rename it. The only thing asked in return is that the credit line stays where it is: a data-virgil attribute on the guide’s root, and the author’s name in the licence header. Both are one line, neither is visible to your users, and removing them does not break anything — it just makes the work invisible, which is the one thing open source has to offer its authors.