Configuration & theming
BetaEverything is a prop on one component. The minimum is three props; every other prop is optional and makes the assistant better.
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} /> );}<Usher> props
| Prop | Type | Description |
|---|---|---|
voqalKey | string | Your publishable key, pk_test_… or pk_live_…. Turns on hosted voice and chat, with the browser mic and speaker wired by default. |
cloud | UsherCloudOptions | Hosted-key options: logging, audio capture, log errors. Only used with the key. See below. |
router | RouterAdapter | Required. From reactRouterAdapter, nextAdapter, or your own adapter. |
destinations | Destination[] | Required. The map from buildDestinationMap. Pass [] for answer-only. |
knowledge | KnowledgeEntry[] | Optional knowledge base. See below. |
instructions | string | Your product brief: its name, audience, tone, and rules. Up to 12,000 characters. See below. |
idleTimeout | UsherIdleTimeout | false | When an idle or long session winds down. Defaults: mic pause 30 s, idle end 5 min, maximum 10 min. See Voice & chat. |
context | Omit<Partial<SafeContext>, "currentRoute"> | Safe labels: role, organizationId, selectedEntity, plan, locale. |
resolveEntity | ResolveEntity | (query, paramName) => id | null. Resolves names like “the Acme case” to ids. |
tools | CustomToolSet | Optional. Your app’s own actions, from defineTools. Every key. See Tools. |
onAction | (action: AgentAction) => void | Called with every resolved decision. See Events & errors. |
voice | UsherVoiceConfig | Optional: interaction mode, greeting, error and diagnostic callbacks. See Voice & chat. |
theme | UsherTheme | { finish, mode }. Default { finish: "aurora", mode: "auto" }. |
reasoner | Reasoner | Advanced. Replaces the local reasoner used by text turns without a live session. |
title | string | Optional, default "Assistant". The chat panel header. Also names the panel dialog ("{title} conversation") and the orb ("Talk to {title}"), and it's the name the assistant uses for itself. It never calls itself Usher or Voqal. |
assistantId | string | Optional, default "usher". Keys the conversation. Changing it starts a new one. |
ref | Ref<UsherHandle> | Imperative handle: sendText(text), runtime(), and traceId. |
cloud options Optional
Turn off audio, or all logging, if your privacy policy requires it.
Used only with voqalKey. Logging runs in the background, in batches, and never delays a reply.
| Option | Type | Description |
|---|---|---|
logConversation | boolean | Default true. Log each turn: the user's text or speech transcript, the assistant's spoken text, and its tool calls with arguments and results. |
captureAudio | boolean | Default true. Also upload WAV clips of what the user said (16 kHz, from about 300 ms before they start speaking) and of replies played aloud (24 kHz). Chat turns, and chat while voice is paused, upload no assistant audio. Clips with no speech aren't uploaded. Needs logConversation. |
onLogError | (error: unknown) => void | Logging failures. Default: one console warning. Logging never blocks or breaks the conversation. |
diagnostics | boolean | Default true. Send support diagnostics (never conversation content) so Voqal can help. See Support & diagnostics. |
baseUrl | string | Point at another Voqal deployment. Leave unset in production. |
// Keep transcripts, but don't upload audio.<Usher voqalKey={import.meta.env.VITE_VOQAL_KEY} router={adapter} destinations={destinations} cloud={{ captureAudio: false, onLogError: (error) => console.warn("Usher logging failed", error) }}/>// Log nothing at all.<Usher voqalKey={import.meta.env.VITE_VOQAL_KEY} router={adapter} destinations={destinations} cloud={{ logConversation: false }} />When prop changes apply
<Usher> keeps one conversation per assistantId across remounts and route changes. Other props can change at any time:
context,onAction, andthemeapply on the next render.router,destinations,knowledge,resolveEntity, andreasonerapply to text turns at once. An open live session keeps the destinations and knowledge it started with, and the next session picks up the change. A live tool call is still checked against the new map.voice,voqalKey, andcloudare read when the next session starts.
context with useMemo. A new object on every render rebuilds the runtime’s config each time. That’s harmless, but it does wasted work.Safe context
Context is a short list of labels you choose to share. It is never read from the page: Usher doesn’t read form fields, hidden inputs, or page text. The current route is added for you on every turn.
| Field | Effect |
|---|---|
role | Filters destinations by roleScopes. Local text reasoners also see it. |
organizationId | Fills :orgId, :organizationId, or :organization params. When it changes, the conversation resets so one org never sees another org's history. |
selectedEntity | { type, id, label } for the record on screen. Fills :<type> or :<type>Id params. |
plan | Accepted and stored. Not used by any decision in this beta. |
locale | Accepted and stored. Not used by any decision in this beta. |
const context = useMemo( () => ({ role: user.role, organizationId: user.orgId }), [user.role, user.orgId],);<Usher voqalKey={import.meta.env.VITE_VOQAL_KEY} router={adapter} destinations={destinations} context={context} />On sign-out, await usherRef.current?.runtime().logout() before you clear your own session. It ends any live session, closes and clears the chat panel, and drops the stored conversation and context, so the next user starts clean. Changing organizationId does the same for the live session. The shared-ref pattern is on Framework adapters.
Knowledge base Strongly recommended
Without a knowledge base the assistant can only navigate. With one it answers, then offers the right page.
A small list of answers that lives in your code. In a live session the model reads the entries directly (see below). In local text mode and on ref.sendText, Usher matches the question against each entry’s title, content, and trigger phrases, with light stemming and stop words removed. There an entry is used when it covers at least 30% of the question’s words, up to three entries are considered, and each one used is cited.
| Field | Required | Description |
|---|---|---|
id | Required | Stable id, used in citations. |
title | Required | Short title. Also matched against the question. |
content | Required | The answer text, shown as-is. Keep it to one or two sentences. |
triggerPhrases | Optional | Other ways users ask the question. Widens matching. |
destinationId | Optional | The page this answer is about. When the offered page matches, Usher answers and offers it. |
source | Optional | Citation label. Defaults to the title. |
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}/>How answers combine with pages (local text mode)
- A plain answer or a navigation gets the knowledge answer attached.
- An offer for the page the entry links to becomes answer and offer.
- An offer for a different page becomes an answer only, because the knowledge base already answered it.
In a live session
- When a live session starts, Usher adds your entries to the model’s instructions as reference facts, in the order you list them. Voice and live chat can both answer from them.
- The section is capped at 8,000 characters (about 2,000 tokens) and only whole entries are included. Entries that don’t fit are left out, and Usher logs a
console.warnsaying how many. Put the entries that matter most first. - The model answers with the fact first. If the entry names a page, it may offer the page after the answer, never instead of it, and only pages this user’s role can reach.
- Entries are facts, not instructions. Text in an entry can’t widen what the assistant is allowed to do.
Product instructions Strongly recommended
Without instructions the assistant is generic: it doesn't know your product, your users, or your rules.
A short brief, written by you, about your product, who uses it, the tone to take, and your rules. The assistant follows it in voice and chat. Write it the way you’d brief a new support teammate: what the product is, who the users are, how to sound, and what never to do.
// 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}/>- Name your product. Without instructions the assistant won’t guess what your product is called, so tell it the name here.
- Voqal’s own rules come first, and your instructions can’t change them: the assistant moves users only through its tools, and only to pages you list; it never invents a page or an id; it keeps its spoken lines short; and it declines requests your app can’t help with. Your instructions shape tone, focus, and product rules on top of that.
- Up to 12,000 characters (
APP_INSTRUCTIONS_MAX_CHARSfrom@voqal/usher-core). Longer text is cut, with aconsole.warn. Put facts in the knowledge base and page meaning in the manifest. - Write it yourself. Never put end-user text (a user’s name, message, or profile) into it: it becomes part of the assistant’s instructions. Lines starting with
===are removed. - A change applies from the next live session.
Theme Optional
The default orb works on light and dark pages. Change it to fit your brand.
Orb finish
"aurora"(default): cyan to lavender glass."iridescent": a slow hue drift, so it looks alive at rest."midnight": made for dark surfaces, with a hot cyan core.- A custom
FinishTokensobject:background(a CSS background shorthand),shadow(a box-shadow stack),ringRgb(the glow ring as"r, g, b"), and an optionalanimationshorthand.
<Usher voqalKey={import.meta.env.VITE_VOQAL_KEY} router={adapter} destinations={destinations} theme={{ finish: "midnight", mode: "dark" }}/>import { Usher, type FinishTokens, type UsherTheme } from "@voqal/usher-react";const BRAND_FINISH: FinishTokens = { background: "radial-gradient(ellipse 45% 35% at 33% 20%, rgba(255,255,255,.9) 0%, rgba(255,255,255,0) 62%)," + "linear-gradient(160deg, #ffb36b, #e0457b)", shadow: "inset 0 -10px 24px rgba(255,255,255,.35), 0 18px 44px rgba(224,69,123,.45)", ringRgb: "224, 69, 123", // the glow ring, as "r, g, b"};const theme: UsherTheme = { finish: BRAND_FINISH, mode: "auto" };<Usher voqalKey={import.meta.env.VITE_VOQAL_KEY} router={adapter} destinations={destinations} theme={theme} />The finish also paints the small orb in the chat header and the assistant avatars. An unknown finish name falls back to aurora. The built-in tokens are exported as ORB_FINISHES if you want to start from one.
Mode
mode skins the chat panel and the orb’s corner controls. "auto" (default) follows the visitor’s prefers-color-scheme and updates live. If your app has its own theme toggle, pass "light" or "dark" from it. During server rendering, "auto" renders light until it mounts.
Placement and accessibility
- The orb is fixed 20px from the right and 24px from the bottom, at z-index 40. The chat panel opens in the same corner at z-index 41. Position is not configurable in this beta, so keep that corner clear or raise your own overlays above 41.
- Everything uses inline styles. There’s no stylesheet to import and no global CSS. On first use Usher adds
<style>elements for its animations. They are named with ausher-prefix. - Accessible names: the orb is “Talk to the assistant” (or “Talk to {title}” when you set
title), its Aa button “Type a message”, and its × “End conversation”. In the panel, the message box is “Message the assistant”, the voice button is “Turn on voice” or “Turn off voice” (its name says what a tap does), the buttons are “Minimize chat”, “End conversation”, and “Jump to latest”, a suggestion card is a button named “Go to {page title}” with a “No thanks” button below it, and the panel is a dialog named “{title} conversation”, “Assistant conversation” by default. Enter sends a chat message. - Testing tip: the orb’s × and the panel’s End button are both named “End conversation”. In Testing Library or Playwright, scope the query: find the panel’s button inside the dialog, for example
getByRole("dialog", { name: /conversation/ }), and the orb’s outside it. - All motion stops when the visitor has
prefers-reduced-motionset.
The ref and the runtime
Pass a ref to reach Usher from code. The handle has sendText(text), which runs one local text turn and resolves to its AgentAction; traceId, this page load’s support trace id; and runtime(). There is one runtime per page, and it outlives <Usher> remounts.
| Runtime member | Description |
|---|---|
sessionId, assistantId | Read-only. sessionId changes on resetSession, logout, or an organizationId change. |
setContext(context) | Merges safe context. Pass selectedEntity: undefined to clear it. A new organizationId resets the conversation and ends any live session. |
logout() | Promise<void>. Ends any live session, closes and clears the panel, and drops the conversation and context. |
onSessionEnd(listener) | Called when the runtime ends the user's session: logout, or a change of organizationId. Returns an unsubscribe. |
subscribe(listener) | Called with every AgentAction, the same as onAction. Returns an unsubscribe. |
confirm(destinationId) | Navigate to a destination the user confirmed. Runs the full validation chain, so it can still be refused. |
sendText(text) | One local text turn, as on the handle. |
lastAction(), conversation() | The latest AgentAction, and every recorded turn ({ role, text?, action? }[]). |
resetSession() | A new session id and an empty conversation. Keeps the config. |
stopVoice() | End the live session from code. |
Custom reasoner
Text turns that don’t go through a live session (local mode and ref.sendText) use a rules-based DeterministicReasoner by default. You can pass any object with a name and decide(input) method. Its decisions still pass through the same policy and validation chain, so a custom reasoner can’t reach a page the map doesn’t allow. Don’t put a model API key in the browser to power one.
interface Reasoner { readonly name: string; decide(input: ReasonerInput): Promise<ReasonerDecision>;}interface ReasonerInput { transcript: string; allowlist: readonly { destination: Destination; score: number }[]; // pick ids from here only safeContext: SafeContext; currentLocation: AppLocation; kbSnippets?: readonly { id: string; title: string; content: string }[];}type ReasonerDecision = | { tool: "navigate"; destinationId: string; params?: Record<string, unknown>; text?: string } | { tool: "offer_navigation"; destinationId: string; text?: string } | { tool: "clarify"; question: string } | { tool: "answer"; text?: string };