Usher quickstart

Beta
Last updated  Oct 5, 2026

Connect your router and you’re done: Usher finds your pages on its own. Everything after that makes it better, and each layer is labelled by how much it matters. Using Next.js? Follow the same steps with the files on Framework adapters.

1. Install Required

terminal
# React Router appnpm install @voqal/usher-react @voqal/usher-core @voqal/usher-react-router# Next.js app (swap the adapter)npm install @voqal/usher-react @voqal/usher-core @voqal/usher-next

Peers: react 18+, and react-router-dom 6.4+ or 7 (or next 13+).

2. Get your key Required

.env.local
# .env.local (not committed). Restart the dev server after editing it.VITE_VOQAL_KEY=pk_test_…
  • Email team@voqal.ai and Voqal issues a pk_test_… key for development and a pk_live_… key for production. Both are safe in client code.
  • By default your key works from any website, so there’s nothing to register. Voqal can lock it to your domains on request.
  • Restart the dev server after editing .env.local. Without a key Usher quietly runs in text-only mode.
  • Voqal logs conversations, including audio, and stores them in the EU. See What leaves the browser before you ship.

3. Connect your router Required

One file. Usher discovers every route in your router, including dynamic ones like /cases/:caseId.

src/usher.tsx
import { buildDestinationMap } from "@voqal/usher-core";import { Usher } from "@voqal/usher-react";import { reactRouterAdapter } from "@voqal/usher-react-router";import { router } from "./router"; // your existing createBrowserRouter(...)const adapter = reactRouterAdapter(router);const destinations = buildDestinationMap({ discovered: adapter.discover() });export function UsherAssistant() {  return (    <Usher      voqalKey={import.meta.env.VITE_VOQAL_KEY}      router={adapter}      destinations={destinations}    />  );}

Render it next to your router:

src/main.tsx
import { StrictMode } from "react";import { createRoot } from "react-dom/client";import { RouterProvider } from "react-router-dom";import { router } from "./router";import { UsherAssistant } from "./usher";createRoot(document.getElementById("root")!).render(  <StrictMode>    <RouterProvider router={router} />    <UsherAssistant />  </StrictMode>,);

4. Try it

  • Tap the orb and say “take me to billing”. Your router moves.
  • Tap Aa and type the same thing. It’s the same conversation.
  • Ask for a page that doesn’t exist. It says so, and nothing moves.

Make it better

Steps 1 to 3 are all Usher needs. These layers make it good. Each is independent, and each is labelled by how much it matters.

Describe your productStrongly recommended

Without instructions the assistant is generic: it doesn't know your product, your users, or your rules.

src/usher.tsx
// Your product, users, tone, and rules. Up to 12,000 characters.const INSTRUCTIONS = [  "You are the in-app assistant for Counsel, a platform where people get help from licensed lawyers.",  "",  "Audience: people facing a legal problem, often for the first time and often stressed. Assume no legal knowledge.",  "",  "Tone: calm, plain, and brief. Avoid legal jargon; if you must use a term, explain it in a few words.",  "",  "Rules:",  "- Never give legal advice or predict how a case will turn out. For anything about the user's own situation, offer to book a consultation.",  "- If the user mentions a deadline, a court date, or an arrest, treat it as urgent and offer the fastest way to reach a lawyer.",  "- Only quote prices that appear in the knowledge base.",  "- Once a case has a lawyer, call them 'your lawyer', not by first name.",].join("\n");<Usher  voqalKey={import.meta.env.VITE_VOQAL_KEY}  router={adapter}  destinations={destinations}  instructions={INSTRUCTIONS}/>

More on instructions

Answer the questions users ask mostStrongly recommended

Without a knowledge base the assistant can only navigate. With one it answers, then offers the right page.

src/usher.tsx
import type { KnowledgeEntry } from "@voqal/usher-core";const knowledge: KnowledgeEntry[] = [  {    id: "consult-pricing",    title: "Consultation pricing",    content: "The first 20-minute consultation is free. Follow-up calls are billed at the lawyer's hourly rate, shown before you book.",    triggerPhrases: ["how much is a consult", "is it free", "consultation cost"],    destinationId: "consultations.book", // answer, then offer the booking page  },  {    id: "response-time",    title: "Lawyer response time",    content: "Lawyers reply to new case messages within one business day. Messages marked urgent get a reply within 4 hours.",    triggerPhrases: ["when will my lawyer reply", "how long does it take", "response time"],  },  {    id: "uploads",    title: "Uploading documents",    content: "Upload documents from a case's Documents tab. PDF, DOCX, JPG, and PNG files up to 25 MB each are accepted.",    triggerPhrases: ["upload a document", "file size limit", "what files can I upload"],  },  {    id: "refunds",    title: "Refund policy",    content: "Unused consultation credits can be refunded within 14 days of purchase. Contact support to request one.",    triggerPhrases: ["refund", "money back", "cancel my credits"],  },];<Usher  voqalKey={import.meta.env.VITE_VOQAL_KEY}  router={adapter}  destinations={destinations}  knowledge={knowledge}/>

More on knowledge

Name your top ~10 pagesStrongly recommended

Discovery names pages from URL words like “billing”, which misses how people ask. Add titles and the phrases users actually say.

src/usher.tsx
const destinations = buildDestinationMap({  discovered: adapter.discover(),  manifest: [    {      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: ["talk to a lawyer", "book a consult", "get legal advice"],    },  ],});

More on the manifest

Let users open records by name (if you have detail pages)Strongly recommended

Users ask for records by name: “open the security deposit dispute”, “open the first one”. Usher remembers records the user recently had open, so “take me back to that case” works on its own, but opening any other record by name needs resolveEntity. Without it the assistant asks for an id or refuses.

Search your own records by the user’s words and return the id. The query is their whole message, so match record names inside it.

src/resolve-entity.ts
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;};
src/usher.tsx
<Usher  voqalKey={import.meta.env.VITE_VOQAL_KEY}  router={adapter}  destinations={destinations}  resolveEntity={resolveEntity}/>

For “open this one” on a detail page, also tell Usher which record is open: Pass what’s on screen. More on dynamic params

Give the assistant your app's actionsStrongly recommended

Without tools the assistant can only point users to pages. With a usher.tools.ts, it can look things up and do things through your own API, as the signed-in user. Works on every key.

One file of functions that call your API the way your frontend already does, passed as <Usher tools={tools}>. Add confirm: true to anything that creates, changes, or deletes. More on tools

Hide sign-in and error routesRecommended when relevant

Discovery finds every route, including /login, /signup, and your 404 catch-all. Filter out the ones Usher should never offer.

src/usher.tsx
// 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/")),});

Show Usher only to signed-in usersRecommended when relevant

If your app has a sign-in, render Usher only once the user is signed in, and end its session when they sign out.

Render <UsherAssistant /> only when your session says the user is signed in, and call runtime().logout() through a shared ref before you clear the session. The four small files, with no circular imports, are on Framework adapters.

src/sign-out.ts
import { setSession } from "./session";import { usherRef } from "./usher-ref";export async function signOut(): Promise<void> {  // End the live session and clear the conversation first, while <Usher> is still mounted.  await usherRef.current?.runtime().logout();  await fetch("/api/logout", { method: "POST" }); // your own sign-out  setSession(null); // unmounts <Usher>}

Hide admin-only pagesRecommended when relevant

If some pages are for certain roles only, so other users are never offered them.

src/usher.tsx
// 1. In the manifest entry, list the roles that may reach the page:{  destinationId: "settings.billing",  routePattern: "/settings/billing",  title: "Billing",  roleScopes: ["owner", "admin"],},// 2. Pass the signed-in user's role:<Usher  voqalKey={import.meta.env.VITE_VOQAL_KEY}  router={adapter}  destinations={destinations}  context={{ role: currentUser.role }}/>

Tell Usher what's on screenRecommended when relevant

If you have detail pages like /cases/:id, so “open the documents for this one” works.

It’s a small provider and one hook per page: Pass what’s on screen.

Show your brand's nameRecommended when relevant

The panel and orb say your product's name instead of “Assistant”.

src/usher.tsx
<Usher  voqalKey={import.meta.env.VITE_VOQAL_KEY}  router={adapter}  destinations={destinations}  title="Counsel" // panel header, "Counsel conversation", "Talk to Counsel"/>

Match your colorsOptional

The default orb works on light and dark pages. Change it to fit your brand.

src/usher.tsx
<Usher  voqalKey={import.meta.env.VITE_VOQAL_KEY}  router={adapter}  destinations={destinations}  theme={{ finish: "midnight", mode: "auto" }}/>

Lock your key to your domainsOptional

By default your key works from any website. Ask Voqal to restrict it to your domains, and it's refused everywhere else.

Optional: email team@voqal.ai the exact scheme, host, and port of each domain to allow. Localhost keeps working on any port.

Hold to talkOptional

Recommended in noisy or public settings, so bystanders and speakers can't trigger the assistant.

src/usher.tsx
<Usher  voqalKey={import.meta.env.VITE_VOQAL_KEY}  router={adapter}  destinations={destinations}  voice={{ interaction: "push-to-talk" }}/>

Opt out of loggingOptional

Turn off audio, or all logging, if your privacy policy requires it.

src/usher.tsx
<Usher  voqalKey={import.meta.env.VITE_VOQAL_KEY}  router={adapter}  destinations={destinations}  cloud={{ captureAudio: false }} // or { logConversation: false } to log nothing/>

Tune the idle timeoutOptional

The defaults are sensible: the mic pauses after 30 s without speech, and a session ends after 5 min idle or 10 min in total.

src/usher.tsx
<Usher  voqalKey={import.meta.env.VITE_VOQAL_KEY}  router={adapter}  destinations={destinations}  idleTimeout={{ maxSessionMs: 20 * 60_000 }} // allow 20-minute sessions; other defaults stay/>

Idle timeout & session limits

Before you go live

  • Production uses a pk_live_… key from its own environment variable; pk_test_… stays in development.
  • Instructions describe your product, users, tone, and rules.
  • The knowledge base covers the questions users ask most.
  • The manifest names your top ~10 pages with the phrases users actually say.
  • Every dynamic route (like /cases/:caseId) can be opened by name: resolveEntity is wired to your data.
  • Your app's most common actions are tools in usher.tools.ts, with confirm on everything that creates, changes, or deletes.
  • Private areas (bank and payout details, personal data, API key settings) are marked data-usher-operate="off", so the assistant never reads them.
  • Admin-only pages have roleScopes, and context.role is passed.
  • Sign-in and error routes are filtered out of discovery.
  • Usher renders only for signed-in users, and signing out calls runtime().logout() first.
  • Your privacy notice says conversations, including audio, are recorded, and that the assistant may read the current page to answer questions.
  • If you send a Content Security Policy, it allows Voqal's hosts.
  • You know where the trace id is printed in the browser console, and your team sends it to Voqal support with every issue report.

Local text-only mode

Without a key, Usher runs in the browser with a rules-based reasoner: no microphone, no model, same map and validation. Handy for checking your wiring. Don’t pass a voice prop in this mode; without a key it throws.

src/usher.tsx
// No key: a local, text-only Usher for wiring tests.<Usher router={adapter} destinations={destinations} />
© 2026 VoqalVoqal SDK & engine documentation