Usher quickstart
BetaGet the orb running in a React Router app in six steps. For Next.js, follow the same steps and use the adapter from Framework adapters.
1. Install
Install the React bindings, the core, and the adapter for your router from the beta tag. Peer dependencies: react and react-dom 18+, plus react-router-dom 6.4+ (React Router 7 works) or next 13+.
# React Router appnpm install @voqal/usher-react@beta @voqal/usher-core@beta @voqal/usher-react-router@beta# Next.js app (swap the adapter)npm install @voqal/usher-react@beta @voqal/usher-core@beta @voqal/usher-next@beta# Pin the exact beta in production# (every package at 0.1.0-beta.0)2. Get your key
Voqal issues each app a publishable key (pk_live_…). The key identifies your assistant, and Voqal hosts the voice session behind it, so you don’t run a server. It’s safe to ship in client code. To get one, email hello@voqal.ai with every origin your app is served from.
- Test and live keys. Voqal gives you a
pk_test_…key for development and apk_live_…key for production. They have the same features. A test key is registered for your localhost origins, and its conversations are logged under a separate test prefix so they never mix with real users’ logs. Keep the key in an environment variable so each environment uses its own. - Origins are allow-listed per key. A key works only from the exact origins registered for it, such as
https://app.example.com. List production, staging, and every preview domain.http://localhostandhttp://127.0.0.1work on any port for development. From any other origin the hosted service answers 403origin_not_allowed. - Keys are rate-limited. Past the limit, starting a session fails with
rate_limiteduntil the window resets. - Both errors reach
voice.onError. See Hosted-key errors.
cloud prop; see What leaves the browser.3. Lay out the files
Four files, with imports flowing one way: your router knows nothing about Usher, the map imports the router, the Usher layer imports the map, and the entry point renders both. Keep it that way and there are no circular imports.
src/ router.tsx # createBrowserRouter(...): imports your pages only usher/destinations.ts # the map: imports router.tsx usher/usher-layer.tsx # renders <Usher>: imports destinations.ts main.tsx # renders <RouterProvider> and <UsherLayer>.env.local # VITE_VOQAL_KEY=pk_test_…Using Next.js? The equivalent layout is on Framework adapters.
4. Let Usher find your pages
Your router stays as it is. Hand Usher the router and it discovers every route automatically, including dynamic ones like /cases/:caseId. Each page is titled from its path words (/settings/billing becomes “billing”), or from handle.usherLabel when a route sets one, as the cases and booking routes below do. That’s all Usher needs to start navigating.
import { createBrowserRouter } from "react-router-dom";import { Billing, BookConsultation, CaseDetail, Cases, Home, Layout } from "./pages";export const router = createBrowserRouter([ { path: "/", element: <Layout />, children: [ { index: true, element: <Home /> }, { path: "cases", element: <Cases />, handle: { usherLabel: "My cases" } }, { path: "cases/:caseId", element: <CaseDetail /> }, { path: "consultations/book", element: <BookConsultation />, handle: { usherLabel: "Book a consultation" } }, { path: "settings/billing", element: <Billing /> }, ], },]);import { buildDestinationMap } from "@voqal/usher-core";import { reactRouterAdapter } from "@voqal/usher-react-router";import { router } from "../router";export const adapter = reactRouterAdapter(router);// Every route in your router becomes a destination. No list to maintain.export const DESTINATIONS = buildDestinationMap({ discovered: adapter.discover() });Optional, recommended for your top pages: a manifest
Discovery knows a page’s path; it doesn’t know what the page is for or how your users ask for it. For the pages people ask for by name, add a manifest entry with a title, a one-line description, and the phrases they actually say. You can also restrict pages to roles and set a fallback page. The manifest wins over what was discovered, and every other route stays reachable.
import { buildDestinationMap, type DestinationManifestEntry } from "@voqal/usher-core";import { reactRouterAdapter } from "@voqal/usher-react-router";import { router } from "../router";// Optional: richer meaning for the pages users ask for by name. It wins over discovery.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: "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"], },];export const adapter = reactRouterAdapter(router);export const DESTINATIONS = buildDestinationMap({ discovered: adapter.discover(), manifest: MANIFEST,});The Destinations guide covers every field, role scopes, dynamic ids, and the parent-page fallback.
5. Mount <Usher>
Render <Usher> once, beside your router, not inside a route. It draws a fixed orb in the bottom-right corner and keeps its session when routes change.
import { useMemo } from "react";import { Usher } from "@voqal/usher-react";import { adapter, DESTINATIONS } from "./destinations";export function UsherLayer({ role }: { role: string }) { const context = useMemo(() => ({ role }), [role]); 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";createRoot(document.getElementById("root")!).render( <StrictMode> <RouterProvider router={router} /> <UsherLayer role="member" /> {/* the signed-in user's role */} </StrictMode>,);# .env.local: not committed. Use a pk_test_ key locally and pk_live_ in production.VITE_VOQAL_KEY=pk_test_…voqalKeyturns on voice and chat on one hosted live session, with the browser microphone and speaker wired for you. Novoiceprop is needed.- If the variable is missing,
voqalKeyis undefined and Usher quietly runs in local text-only mode. Vite reads.env.localonly at startup, so restart the dev server after creating or changing it. If the orb only opens a chat panel, check this first. context.rolescopes which destinations this user can hear about and reach. Pass the same role labels you use inroleScopes.
6. Try it
- Tap the orb and allow the microphone. Say “take me to my cases”. It confirms out loud and your router moves.
- Ask “how do I book a consult?”. It answers and offers to take you there.
- Tap Aa and type the same thing. It’s the same conversation.
- Ask for a page that isn’t in your map. It says so, and nothing moves.
Local text-only mode
Without a key, Usher runs entirely in the browser with a built-in rules-based reasoner. There’s no microphone and no model call, and typed turns go through the same destination map and validation chain. Use this mode to check your map and your router wiring before you add the key. Passing a voice prop without a key throws, so leave voice out in this mode.
// No key: a local, text-only Usher for wiring tests.<Usher router={adapter} destinations={DESTINATIONS} />