How it worksDocsAboutLive demoStar on GitHub
Documentation

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.

terminal
$ npm install virgil-ui ai @ai-sdk/openai

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

app/api/virgil/[...route]/route.ts
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.

app/dashboard/layout.tsx
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.

app/dashboard/layout.tsx
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.

app/dashboard/invoices/virgil.md
---
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.

globals.css
:root {
  --virgil-accent: #e8503a;
  --virgil-surface: #0f1011;
  --virgil-text: #eae6e2;
  --virgil-radius: 16px;
}

Props

endpoint
string

Base path of the route handler. Defaults to /api/virgil.

labels
Partial<VirgilLabels>

Everything the guide says that the model did not write.

snapshot
SnapshotOptions

maxElements, maxNameLength, redact, placeholderVerbs — the bounds on what leaves the browser.

onStep
(step) => void

Fired on every decided step. Useful for analytics.

pathname
string

Hand over your router's value instead of having the History API wrapped.

hotkey
string | false

Turns inspect mode on; pressed again, asks about whatever is outlined. Defaults to Mod+I.

alwaysOn
boolean

Render without waiting for the user to switch the guide on.

cursor
"always" | "tour"

Whether the arrow replaces the system cursor, or only shows while guiding. Inspect mode draws it either way.

bubbleCorner
"bottom-right" | "bottom-left" | "top-right" | "top-left"

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.

accent
VirgilAccent

The colour the guide draws itself in, unless setAccent has been called.

maxSteps
number

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.

debug
boolean

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.

Virgil was built by Simone Fratini, first as an internal companion for a real estate back office, then extracted and rewritten as a standalone package.