Destinations
BetaDestinations are the pages Usher is allowed to reach, and the words it uses to recognise them. With React Router you don’t have to list them: Usher discovers every route from your router. A manifest is an optional layer on top, for the pages your users ask for by name.
The destination map
Minimal: pass your router
buildDestinationMap turns what your adapter discovers into the destinations you pass to <Usher>. With React Router, that’s every route, including dynamic ones, with no list to maintain. Each page gets a title from its path words, or from handle.usherLabel when the route sets one (see Discovery and merging).
import { buildDestinationMap } from "@voqal/usher-core";import { reactRouterAdapter } from "@voqal/usher-react-router";import { router } from "./router";const adapter = reactRouterAdapter(router);const destinations = buildDestinationMap({ discovered: adapter.discover() });Leave routes outRecommended when relevant
Discovery finds every route, including /login, /signup, and your 404 catch-all. Filter out the ones Usher should never offer.
// Pages Usher should never offer: sign-in screens and the 404 catch-all.const HIDDEN_ROUTES = ["/login", "/signup", "/forgot-password", "/*"];const destinations = buildDestinationMap({ discovered: adapter .discover() .filter((route) => !HIDDEN_ROUTES.includes(route.routePattern) && !route.routePattern.startsWith("/auth/")),});discover() returns DestinationCandidate[] ({ destinationId, routePattern, labels? }), so filter on routePattern. A path: "*" catch-all is discovered as /*. A filtered route isn’t a destination unless you also list it in your manifest, so don’t.
With Next.js there’s no automatic discovery. You pass your route patterns instead, which does the same job (see Next.js App Router).
A manifest for your top pages Strongly recommended
Discovery names pages from URL words like “billing”, which misses how people ask. Add titles and the phrases users actually say.
Discovery knows the path, not what the page is for or how people ask for it. A DestinationManifestEntry[] adds that meaning: titles, descriptions, aliases, roles, and fallbacks. It always wins over what was discovered, and routes it doesn’t mention stay reachable. We recommend it for the handful of pages users ask for most, and for any page that needs a role scope. You can also build a map from the manifest alone, for a router with nothing to discover:
// Config only: no discovery. Every destination comes from your manifest.const destinations = buildDestinationMap({ manifest: MANIFEST });Manifest fields
| Field | Required | What it does |
|---|---|---|
destinationId | Required | A stable id the model picks from. Any string. Dotted ids like settings.billing read well in logs. |
routePattern | Required | Your route with :param placeholders, e.g. /cases/:caseId. A trailing slash is ignored when merging. |
title | Optional | The human name. Shown in chat (“Took you to …”) and given to the voice model. Default: the last path word. |
semanticDescription | Optional | One sentence on what the page is for. Given to the voice model, and shown to users under the title on the page’s suggestion card. Default: the title (the card then shows the title alone). |
aliases | Optional | Phrases your users say for this page. Replaces any derived aliases when non-empty. |
roleScopes | Optional | Roles allowed to reach it. Omitted or empty means every role. |
parent | Optional | The destinationId to fall back to when a :param can't be resolved. The parent must have no params. |
buildDestinationMap fills in four more fields on each resulting Destination. You don’t set them yourself:
| Field | Type | Value |
|---|---|---|
provenance | string | "manifest" for entries from your manifest, "adapter" for discovered-only routes. |
confidence | number | 1 for manifest entries, 0.6 for discovered-only routes. Informational in this beta. |
lastSeenAt | string | ISO timestamp set when the map is built. |
appVersionFingerprint | string | The fingerprint you pass to buildDestinationMap, or "runtime". |
Worked example
A legal-services app with a case list, a case detail page, a booking flow, and an admin-only billing page:
import { buildDestinationMap, type DestinationManifestEntry } from "@voqal/usher-core";import { reactRouterAdapter } from "@voqal/usher-react-router";import { router } from "./router";const MANIFEST: DestinationManifestEntry[] = [ { destinationId: "cases", routePattern: "/cases", title: "My cases", semanticDescription: "Every legal matter the user has open or closed.", aliases: ["my cases", "case list", "my matters"], }, { destinationId: "cases.detail", routePattern: "/cases/:caseId", title: "Case details", semanticDescription: "One case: status, deadlines, and the assigned lawyer.", aliases: ["case details", "that case", "my case status"], parent: "cases", // where to go when the case id can't be resolved }, { destinationId: "consultations.book", routePattern: "/consultations/book", title: "Book a consultation", semanticDescription: "Schedule a call with a lawyer: pick a practice area, a time, and a lawyer.", aliases: ["book a consult", "talk to a lawyer", "schedule a call", "get legal advice"], }, { destinationId: "settings.billing", routePattern: "/settings/billing", title: "Billing", semanticDescription: "Plan, payment method, and invoices.", aliases: ["billing", "payment method", "invoices", "subscription"], roleScopes: ["owner", "admin"], // hidden from every other role },];const adapter = reactRouterAdapter(router); // the same adapter you pass to <Usher>const destinations = buildDestinationMap({ discovered: adapter.discover(), manifest: MANIFEST, appVersionFingerprint: "web@2026.09.23", // optional; defaults to "runtime"});Discovery and merging
What React Router discovery produces
- Every route with a
pathorindexbecomes a candidate. Nested segments are joined into full patterns. - The id is the pattern with slashes turned into dots:
/cases/:caseIdbecomescases.:caseId, and/becomesroot. - A route’s
handle.usherLabel(orhandle.label) becomes its title and first alias. Without one, the title is the last path word. A route ending in a param uses the singular entity name, so/cases/:caseIdis titledcase.
const router = createBrowserRouter([ { path: "/", element: <Layout />, children: [ // discovered as id "cases", title "My cases", alias "My cases" { path: "cases", element: <Cases />, handle: { usherLabel: "My cases" } }, // discovered as id "cases.:caseId", title "case", alias "case" { path: "cases/:caseId", element: <CaseDetail /> }, ], },]);How the manifest merges
- A manifest entry replaces the discovered route with the same routePattern, or failing that the same
destinationId. Your id wins, so you can renamecases.:caseIdtocases.detail. - Each field you set wins. Fields you leave out keep the discovered value.
- Non-empty
aliasesreplace the derived ones rather than adding to them. That stops a generic word like “invoices” from matching two sibling pages. - A manifest entry with no matching route is still added. Config is a first-class source.
- Next.js has no route tree at runtime. There you pass the routes yourself; see Next.js App Router.
How phrases match
Which fields matter depends on the turn:
- Voice and hosted chat. The model is given every destination this user can reach as
id: title — semanticDescription. It reads those in any language the model understands, so write a clear title and a one-line description for every page that matters. Users see them too: when the assistant suggests a page, its card shows the title and the description, so write the description as a short line a user would read (“Schedule a call with a lawyer: pick a practice area, a time, and a lawyer.”). - Local text mode and
sendText. A built-in lexical ranker scores each destination against the words typed. Aliases carry 55% of the score, the title 25%, and the description 20%. Multi-word aliases that appear word for word get a small bonus. The top 6 become that turn’s allow-list. Matching works on ASCII letters and digits only, so write aliases in those characters.
Role scopes Recommended when relevant
If some pages are for certain roles only, so other users are never offered them.
- Set
roleScopes: ["owner", "admin"]on a destination, and pass the user’s role ascontext.role. - Out-of-role destinations are left out of the model’s tool list and out of the ranking. The model is never told they exist.
- In a live session, when a user asks for an out-of-role page, the model has no such page to pick, so it says it can’t take them there.
onActiongetsrefusedwithno_matching_destination(see Refusals). The user isn’t told whether the page is restricted or doesn’t exist. role_forbiddenis the validation chain’s refusal, for a navigation that names an out-of-role destination anyway, for example from local text mode or a custom reasoner. An offer for one becomes a plain answer.- A destination with scopes is unreachable when
context.roleis unset. Unscoped destinations are open to everyone. - Roles decide what Usher offers, not what the user may see. Keep your route guards. They run on every Usher navigation because Usher uses your router.
Dynamic params
If your app has detail pages like /cases/:caseId, opening a record by name is what users will ask for most: “open the security deposit dispute”, “open the first one”. Usher never takes an id from the model, so it needs your help to turn those words into an id. Without that help, the assistant asks the user for an id, or refuses with param_unresolved.
Open records by name: resolveEntityStrongly recommended
Give <Usher> a resolveEntity(query, paramName) function. query is the user’s whole latest message, spoken or typed, not just the name; paramName is the route param to fill, such as caseId. Search the records this user can see with your own data or API, and return the id, or null when nothing or more than one record matches. Usher then asks instead of guessing.
import type { ResolveEntity } from "@voqal/usher-core";import { getVisibleCases, listMyCases } from "./cases"; // your own data: [{ id, title }]const ORDINALS = ["first", "second", "third", "fourth", "fifth"];const words = (text: string) => text.toLowerCase().match(/[a-z0-9]+/g) ?? [];// query is the user's whole message, e.g. "open the security deposit dispute".export const resolveEntity: ResolveEntity = async (query, paramName) => { if (paramName !== "caseId") return null; const said = new Set(words(query)); // "open the first one": use the list the user is looking at. const shown = getVisibleCases()[ORDINALS.findIndex((ordinal) => said.has(ordinal))]; if (shown) return shown.id; // "open the security deposit dispute": match record names against the words. const scored = (await listMyCases()) .map(({ id, title }) => { const titleWords = words(title); const found = titleWords.filter((word) => said.has(word)).length; return { id, score: titleWords.length > 0 ? found / titleWords.length : 0 }; }) .filter((match) => match.score >= 0.6) .sort((a, b) => b.score - a.score); const [best, runnerUp] = scored; if (!best || runnerUp?.score === best.score) return null; // none or a tie: Usher asks return best.id;};<Usher voqalKey={import.meta.env.VITE_VOQAL_KEY} router={adapter} destinations={destinations} resolveEntity={resolveEntity}/>Its type is (query: string, paramName: string) => Promise<string | null> | string | null. Without resolveEntity, Usher still remembers the last five records of each type that your pages passed as selectedEntity in this session, so “take me back to that case” works after the user has left the page. Records they haven’t opened yet still need resolveEntity. Handle every param name your routes use. listMyCases() and getVisibleCases() stand for your own data: all the records this user can open, and the list currently on screen (have your list page keep it in a small store, like the selection below), which is what makes “the first one” work. For “this one”, pass the record that’s open (next section).
Where param values come from
The model may suggest a value for a param like :caseId. Usher ignores it. Every param is filled from these sources, in this order:
| Order | Source | Rule |
|---|---|---|
| 1 | Organization | A param named orgId, organizationId, or organization takes context.organizationId. |
| 2 | Selected entity | A param named after context.selectedEntity.type, or its type plus “Id” (case-insensitive), takes selectedEntity.id. A type of case fills :case or :caseId. |
| 3 | Current URL | A param already in the current route (for example :caseId while on /cases/42/documents) is reused. |
| 4 | resolveEntity | Your function, called with the user's words and the param name. Return an id or null. |
| 5 | Recent records | The last five records of each type your pages passed as selectedEntity in this session: one whose label words the user said, or the latest one when they point back (“that case”). |
Resolved values are URL-encoded into the pattern. If any param is still missing, the navigation is refused with param_unresolved. Usher never guesses an id.
Pass what’s on screen Recommended when relevant
If you have detail pages like /cases/:id, so “open the documents for this one” works.
Your app renders one <Usher>, outside your pages, so pages need a way to tell it which record is open. A small React context does it: pages set the selection, and the component that renders <Usher> passes it in as context.selectedEntity. When the page unmounts, the selection clears.
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";import type { SafeContext } from "@voqal/usher-core";type SelectedEntity = NonNullable<SafeContext["selectedEntity"]>;const SelectionContext = createContext<SelectedEntity | undefined>(undefined);const SetSelectionContext = createContext<(entity: SelectedEntity | undefined) => void>(() => {});/** Holds the record the user has open, for the one <Usher> in the app. */export function UsherSelectionProvider({ children }: { children: ReactNode }) { const [selected, setSelected] = useState<SelectedEntity | undefined>(undefined); return ( <SetSelectionContext.Provider value={setSelected}> <SelectionContext.Provider value={selected}>{children}</SelectionContext.Provider> </SetSelectionContext.Provider> );}/** Call from a page: tells Usher what's on screen, and clears it when the page unmounts. */export function useUsherSelection(entity: SelectedEntity | undefined): void { const setSelected = useContext(SetSelectionContext); const type = entity?.type; const id = entity?.id; const label = entity?.label; useEffect(() => { setSelected(type && id && label ? { type, id, label } : undefined); return () => setSelected(undefined); }, [setSelected, type, id, label]);}/** Read by the component that renders <Usher>. */export function useSelectedEntity(): SelectedEntity | undefined { return useContext(SelectionContext);}import { useMemo } from "react";import { buildDestinationMap } from "@voqal/usher-core";import { Usher } from "@voqal/usher-react";import { reactRouterAdapter } from "@voqal/usher-react-router";import { router } from "./router";import { useSelectedEntity } from "./usher-selection";const adapter = reactRouterAdapter(router);const destinations = buildDestinationMap({ discovered: adapter.discover() });export function UsherAssistant() { const selectedEntity = useSelectedEntity(); const context = useMemo(() => ({ selectedEntity }), [selectedEntity]); return ( <Usher voqalKey={import.meta.env.VITE_VOQAL_KEY} router={adapter} destinations={destinations} context={context} /> );}import { StrictMode } from "react";import { createRoot } from "react-dom/client";import { RouterProvider } from "react-router-dom";import { router } from "./router";import { UsherAssistant } from "./usher";import { UsherSelectionProvider } from "./usher-selection";createRoot(document.getElementById("root")!).render( <StrictMode> <UsherSelectionProvider> <RouterProvider router={router} /> <UsherAssistant /> </UsherSelectionProvider> </StrictMode>,);import { useUsherSelection } from "../usher-selection";export function CaseDetail() { const openCase = useCase(); // however your page loads its record useUsherSelection(openCase && { type: "case", id: openCase.id, label: openCase.title }); // ...render the case}- Now “open the documents for this case” fills
:caseIdwith the open case’s id. - In a live session, the model is told the selected item’s type and label (for example, a case labelled “Smith v. Jones”), so it can talk about it. The label is flattened to one line of at most 80 characters. The model is told again when the selection changes or clears. It is never given the id: when the user says “open that matter”, the id is filled in by the validation chain in the browser.
<Usher>clears a selection that itscontextprop no longer includes, which is what the provider relies on. Without a provider you can callref.current.runtime().setContext({ selectedEntity })instead. A selection set that way survives re-renders, and becausesetContextmerges, clear it withsetContext({ selectedEntity: undefined }).
In Next.js
Put usher-selection.tsx in components/usher/ with "use client" as its first line, and read useSelectedEntity() into context in your Usher layer the same way as above. App Router pages are server components and can’t call the hook, so give each detail page a tiny client child that does:
"use client";import { useUsherSelection } from "./usher-selection";export function CaseSelection({ id, title }: { id: string; title: string }) { useUsherSelection({ type: "case", id, label: title }); return null;}import { CaseSelection } from "@/components/usher/case-selection";import { getCase } from "@/lib/cases"; // your data access// A server component. Next.js 15+ passes params as a Promise.export default async function CasePage({ params }: { params: Promise<{ caseId: string }> }) { const { caseId } = await params; const legalCase = await getCase(caseId); return ( <> <CaseSelection id={legalCase.id} title={legalCase.title} /> <h1>{legalCase.title}</h1> </> );}import type { ReactNode } from "react";import { UsherLayerClientOnly } from "@/components/usher/usher-layer-client-only";import { UsherSelectionProvider } from "@/components/usher/usher-selection";import { getSessionUser } from "@/lib/auth"; // your authexport default async function SignedInLayout({ children }: { children: ReactNode }) { const user = await getSessionUser(); return ( <UsherSelectionProvider> {children} <UsherLayerClientOnly role={user.role} /> </UsherSelectionProvider> );}Parent fallbacks
When a param can’t be resolved and the destination has a parent with no params of its own, the refusal carries fallback: a ready target such as { destinationId: "cases", path: "/cases" }. Usher does not navigate there on its own. Use it in onAction if you want to take the user to the list page (see Refusals).
Checklist
- Every page a user asks for by name has a manifest entry with a title and a description.
- Aliases are real user phrases, and no two sibling pages share a generic one.
- Admin-only pages carry
roleScopes, and you passcontext.role. - Every
:paramcan be filled by the selected entity, the URL, orresolveEntity, and has aparentlist page. - The map is built once at module scope, not on every render.
