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";export const adapter = reactRouterAdapter(router);export const DESTINATIONS = buildDestinationMap({ discovered: adapter.discover() });With Next.js there’s no automatic discovery. You pass your route patterns instead, which does the same job (see Next.js App Router).
Optional: a manifest for your top pages
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.export 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. Default: the title. |
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 },];export const adapter = reactRouterAdapter(router); // the same adapter you pass to <Usher>export 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. - 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
- 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
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. |
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
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 { Usher } from "@voqal/usher-react";import { adapter, DESTINATIONS } from "./destinations";import { useSelectedEntity } from "./usher-selection";export function UsherLayer({ role }: { role: string }) { const selectedEntity = useSelectedEntity(); const context = useMemo(() => ({ role, selectedEntity }), [role, 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 { UsherLayer } from "./usher/usher-layer";import { UsherSelectionProvider } from "./usher/usher-selection";createRoot(document.getElementById("root")!).render( <StrictMode> <UsherSelectionProvider> <RouterProvider router={router} /> <UsherLayer role="member" /> </UsherSelectionProvider> </StrictMode>,);import { useUsherSelection } from "../usher/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> );}Resolve names to ids
import type { ResolveEntity } from "@voqal/usher-core";// Called with the user's words and the param name. Return an id, or null.const resolveEntity: ResolveEntity = async (query, paramName) => { if (paramName !== "caseId") return null; const response = await fetch(`/api/cases/search?q=${encodeURIComponent(query)}`); if (!response.ok) return null; const [first, second] = (await response.json()) as { id: string }[]; return first && !second ? first.id : null; // none or several: let Usher fall back};<Usher voqalKey="pk_live_…" router={adapter} destinations={DESTINATIONS} resolveEntity={resolveEntity} />The query is the user’s latest message, spoken or typed. Return null when it doesn’t pick out exactly one record, and Usher falls back instead of guessing.
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.
