Destinations

Beta
Last updated  Sep 23, 2026

Destinations 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).

src/usher/destinations.ts
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:

src/usher/destinations.ts
// Config only: no discovery. Every destination comes from your manifest.export const DESTINATIONS = buildDestinationMap({ manifest: MANIFEST });

Manifest fields

FieldRequiredWhat it does
destinationIdRequiredA stable id the model picks from. Any string. Dotted ids like settings.billing read well in logs.
routePatternRequiredYour route with :param placeholders, e.g. /cases/:caseId. A trailing slash is ignored when merging.
titleOptionalThe human name. Shown in chat (“Took you to …”) and given to the voice model. Default: the last path word.
semanticDescriptionOptionalOne sentence on what the page is for. Given to the voice model. Default: the title.
aliasesOptionalPhrases your users say for this page. Replaces any derived aliases when non-empty.
roleScopesOptionalRoles allowed to reach it. Omitted or empty means every role.
parentOptionalThe 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:

FieldTypeValue
provenancestring"manifest" for entries from your manifest, "adapter" for discovered-only routes.
confidencenumber1 for manifest entries, 0.6 for discovered-only routes. Informational in this beta.
lastSeenAtstringISO timestamp set when the map is built.
appVersionFingerprintstringThe 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:

src/usher/destinations.ts
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 path or index becomes a candidate. Nested segments are joined into full patterns.
  • The id is the pattern with slashes turned into dots: /cases/:caseId becomes cases.:caseId, and / becomes root.
  • A route’s handle.usherLabel (or handle.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/:caseId is titled case.
src/router.tsx
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 rename cases.:caseId to cases.detail.
  • Each field you set wins. Fields you leave out keep the discovered value.
  • Non-empty aliases replace 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.
Write aliases the way users talk: “talk to a lawyer”, “where my money goes”, “invite a teammate”. Don’t give two pages the same one-word alias. When two pages score about the same, Usher asks which one the user meant.

Role scopes

  • Set roleScopes: ["owner", "admin"] on a destination, and pass the user’s role as context.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. onAction gets refused with no_matching_destination (see Refusals). The user isn’t told whether the page is restricted or doesn’t exist.
  • role_forbidden is 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.role is 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:

OrderSourceRule
1OrganizationA param named orgId, organizationId, or organization takes context.organizationId.
2Selected entityA param named after context.selectedEntity.type, or its type plus “Id” (case-insensitive), takes selectedEntity.id. A type of case fills :case or :caseId.
3Current URLA param already in the current route (for example :caseId while on /cases/42/documents) is reused.
4resolveEntityYour 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.

src/usher/usher-selection.tsx
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);}
src/usher/usher-layer.tsx
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}    />  );}
src/main.tsx
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>,);
src/pages/case-detail.tsx
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 :caseId with 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 its context prop no longer includes, which is what the provider relies on. Without a provider you can call ref.current.runtime().setContext({ selectedEntity }) instead. A selection set that way survives re-renders, and because setContext merges, clear it with setContext({ 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:

components/usher/case-selection.tsx
"use client";import { useUsherSelection } from "./usher-selection";export function CaseSelection({ id, title }: { id: string; title: string }) {  useUsherSelection({ type: "case", id, label: title });  return null;}
app/(signed-in)/cases/[caseId]/page.tsx
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>    </>  );}
app/(signed-in)/layout.tsx
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

src/usher.tsx
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 pass context.role.
  • Every :param can be filled by the selected entity, the URL, or resolveEntity, and has a parent list page.
  • The map is built once at module scope, not on every render.
© 2026 VoqalVoqal SDK & engine documentation