Configuration & theming
BetaEverything is set through props on one component. There is no dashboard to configure in the beta: your map, knowledge base, and theme live in your code and ship with your app.
<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. |
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. |
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}"). |
assistantId | string | Optional, default "usher". Keys the conversation. Changing it starts a new one. |
ref | Ref<UsherHandle> | Imperative handle: sendText(text) and runtime(). |
cloud options
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. |
baseUrl | string | Point at another Voqal deployment. Leave unset in production. |
// Keep transcripts, but don't upload audio.<Usher voqalKey="pk_live_…" router={adapter} destinations={DESTINATIONS} cloud={{ captureAudio: false, onLogError: (error) => console.warn("Usher logging failed", error) }}/>// Log nothing at all.<Usher voqalKey="pk_live_…" 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="pk_live_…" router={adapter} destinations={DESTINATIONS} context={context} />On sign-out, await ref.current.runtime().logout(). 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, so a conversation never carries over to another organization.
Knowledge base
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";export const KNOWLEDGE: KnowledgeEntry[] = [ { id: "kb-consult-price", title: "Consultation pricing", content: "A first 20-minute consultation is free. Follow-up calls are billed at the lawyer's hourly rate.", triggerPhrases: ["how much is a consult", "is it free", "consultation cost"], destinationId: "consultations.book", // answer AND offer the booking page source: "Help Center — Consultations", // shown as the citation }, { id: "kb-refunds", title: "Refund policy", content: "Refunds are available within 14 days of a charge. Contact support to request one.", triggerPhrases: ["refund", "money back"], },];<Usher voqalKey="pk_live_…" 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.
Theme
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="pk_live_…" 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="pk_live_…" 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 buttons are “Minimize chat”, “End conversation”, and “Jump to latest”, 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, 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 };