Events & errors
BetaEvery decision Usher makes arrives in one callback as a typed AgentAction. Voice failures arrive in another. Neither is required: Usher explains refusals and errors to the user on its own.
<Usher voqalKey={import.meta.env.VITE_VOQAL_KEY} router={adapter} destinations={destinations} onAction={(action) => console.log(action.kind, action)} voice={{ onError: (error) => console.error(error) }}/>onAction
| kind | Meaning |
|---|---|
navigated | Your router was called and didn't throw. Has destinationId, path, and an optional kbAnswer. |
offer | A page was offered and nothing moved yet. Has destinationId and title. |
clarify | The assistant asked a question. Has question. |
answer | A reply with no navigation. Has text and kbAnswer in local mode. In a live session it's the downgraded result of an unusable offer. |
tool | One of your app’s tools was called. Has name, status (ok, invalid_args, declined, error, timeout, not_run), durationMs, and response, what the model was told. |
refused | Nothing moved. Has reason, detail (a developer message), and sometimes fallback. An already_here refusal also has destinationId. |
// All five types are exported from @voqal/usher-core.type AgentAction = | { kind: "navigated"; destinationId: string; path: string; reason: string; kbAnswer?: KbAnswer } | { kind: "offer"; destinationId: string; title: string; reason: string; kbAnswer?: KbAnswer } | { kind: "clarify"; question: string; reason: string } | { kind: "answer"; reason: string; text?: string; kbAnswer?: KbAnswer } | { kind: "tool"; name: string; status: ToolRunStatus; durationMs: number; response: Record<string, unknown> } | { kind: "refused"; reason: RefusalReason; detail: string; fallback?: NavTarget; destinationId?: string };type ToolRunStatus = "ok" | "invalid_args" | "declined" | "error" | "timeout" | "not_run";interface KbAnswer { text: string; citations: { sourceId: string; title: string }[];}type RefusalReason = | "no_matching_destination" // live: no page this user can reach matches the request | "not_requested" // live: a navigation on a message that asked for nothing ("hi", "ok") | "already_here" // the page the user is already on; destinationId names it | "needs_destination" // live: "take me somewhere" without saying where | "repeat_offer" // live: the same page offered twice for one message | "tool_loop" // live: too many tool calls for one message | "unknown_tool" // live: the model called a tool that doesn't exist | "not_in_allowlist" | "role_forbidden" | "param_unresolved" | "low_confidence" | "unsafe_target" | "navigation_failed";interface NavTarget { destinationId: string; path: string; // the concrete path, params filled in}import type { AgentAction } from "@voqal/usher-core";function handleUsherAction(action: AgentAction) { switch (action.kind) { case "navigated": analytics.track("usher_navigated", { destination: action.destinationId, path: action.path }); break; case "offer": analytics.track("usher_offered", { destination: action.destinationId }); break; case "clarify": case "answer": break; case "refused": analytics.track("usher_refused", { reason: action.reason }); break; }}<Usher voqalKey={import.meta.env.VITE_VOQAL_KEY} router={adapter} destinations={destinations} onAction={handleUsherAction} />- In a live session,
onActionfires for each tool call the model makes:navigate→navigated(or a refusal),offer_navigation→offer,clarify→clarify, anddecline_navigation→refusedwithno_matching_destination. A navigation attempted on a message that asked for nothing (“hi”, “👍”, “ok”) is not run and arrives asrefusedwithnot_requested. A navigation or offer for the page the user is on arrives asrefusedwithalready_here. A plain spoken reply with no tool call fires nothing. The words themselves are in the chat panel, not in the action. - Tapping a suggestion card fires one more action with the navigation result, usually
navigated, in live sessions and in local mode alike. “No thanks” fires nothing. - Treat
reasonanddetailas developer text. Don’t show them to users, and don’t branch on their wording.
Refusals
A refusal means nothing moved. In a live session the assistant explains it in its own words. In local mode the panel shows the fixed line below. detail can quote what the user asked for, so treat it as user content in your analytics.
| reason | Cause | Local panel text | What to do |
|---|---|---|---|
not_requested | Live session: the model tried to navigate on a message that asked for nothing, such as “hi”, “👍”, or “ok”. The move isn’t run. | None: the assistant just replies. | Nothing to fix. It shows the guard working. |
already_here | The model navigated to or offered the page the user is on (on a dynamic page, the same record), or the user tapped a card for it. Nothing runs and no card is shown. destinationId names the page. | “You're already here.” (after a card tap) | Nothing to fix. It shows the guard working. |
needs_destination | Live session: the user asked to go somewhere without saying where, and the model picked a page anyway. The move isn’t run. | None: the assistant asks where they’d like to go. | Nothing to fix. |
repeat_offer | Live session: the model offered a page it had already offered for the same message. The second card isn’t shown. | None. | Nothing to fix. |
tool_loop | Live session: the model kept calling tools for one message. The extra call isn’t run, and the model is told to reply. | None. | Nothing to fix. Tell Voqal if it’s frequent. |
unknown_tool | Live session: the model called a tool that doesn’t exist. Nothing runs. | None. | Nothing to fix. |
no_matching_destination | Live session: the user asked for a page they can’t reach, because it doesn’t exist or is out of their role. The model is never told which, and the user isn’t either. detail quotes the request. | “I can’t take you there — it may not exist, or you may not have access.” | Count these to find pages users expect. If it should exist, add it to your map. |
not_in_allowlist | The id wasn't in the list offered for this turn (an invented or out-of-role id). | “I can't take you there from here.” | Add the page to your map, or check its roleScopes. |
role_forbidden | The destination's roleScopes don't include context.role. | “You don't have access to that page.” | Expected. Check that you pass the right role. |
param_unresolved | A :param couldn't be filled from a safe source. | “I couldn't tell which one you mean — could you be more specific?” | Pass selectedEntity or add resolveEntity. Use fallback. |
low_confidence | The match score was under 0.15. | “I'm not sure where you'd like to go — could you rephrase?” | Add aliases for how users phrase it. |
unsafe_target | The final path wasn't a relative in-app path (an absolute URL, //host, or a javascript: scheme). | “Sorry, I can't open that destination.” | Fix the routePattern. It must start with a single /. |
navigation_failed | Your adapter's navigate threw or rejected. | “Sorry, that page couldn't be opened.” | Check detail for your router's error. |
fallback appears on param_unresolved when the destination has a parent without params. Usher doesn’t follow it on its own. In local text mode the panel shows a suggestion card for the parent page, without “No thanks”. To follow it automatically instead:
// Take the user to the list page when Usher couldn't tell which record they meant.function handleUsherAction(action: AgentAction) { if (action.kind === "refused" && action.reason === "param_unresolved" && action.fallback) { void router.navigate(action.fallback.path); // e.g. "/cases" }}Voice errors
voice.onError gets anything that stops a session from starting or keeps it from running. With a key and no other voice options, pass just voice={{ onError }}. Each time, the orb goes back to rest. Usher also logs start failures with console.error("[usher] startVoice failed:", error).
| Error | Import from | When |
|---|---|---|
UsherSessionLostError | @voqal/usher-react | A live session ended without the user ending it (network loss, or the server closed it after three failed reconnects). |
LiveSessionNotConnectedError | @voqal/usher-react | A typed message couldn't be sent because the socket was down. Nothing reached the model. |
ModelApiUnavailableError | @voqal/usher-react | The voice model couldn't be reached: a connection error or setup timeout. Has a code. |
UsherCloudError | @voqal/usher-core | The hosted service refused or couldn't be reached. Has status and code. See below. |
DOMException | Browser | Microphone errors from getUserMedia, such as NotAllowedError (permission denied) or NotFoundError (no microphone). |
Hosted-key errors
With a voqalKey, a session that can’t start because of the key arrives as UsherCloudError, with the HTTP status (0 for a network failure) and a code:
| code | Status | What to do |
|---|---|---|
origin_not_allowed | 403 | Your key is locked to certain domains and this page's origin isn't one. Ask Voqal to add it (scheme, host, and port) or remove the restriction. |
invalid_key | 401 or 0 | The key is unknown or disabled, or doesn't start with pk_live_ or pk_test_. |
rate_limited | 429 | Too many sessions for this key in the current minute. Ask the user to try again shortly. |
grant_unavailable | 503 | The hosted service couldn't create a voice session. Retry later. |
timeout, network_error | 0 | The hosted service didn't answer within 10 seconds, or the network failed. |
internal_error | 500 | A server error. Retry, and tell Voqal if it persists. |
import { UsherCloudError } from "@voqal/usher-core";import { LiveSessionNotConnectedError, Usher, UsherSessionLostError, type UsherVoiceConfig,} from "@voqal/usher-react";const voice: UsherVoiceConfig = { onError: (error) => { if (error instanceof UsherSessionLostError) { toast("The assistant disconnected. Tap the orb to reconnect."); return; } if (error instanceof LiveSessionNotConnectedError) { toast("That message didn't send. Try again in a moment."); return; } if (error instanceof UsherCloudError && error.code === "rate_limited") { toast("The assistant is busy. Try again in a minute."); return; } if (error instanceof DOMException && error.name === "NotAllowedError") { toast("Allow microphone access to talk to the assistant, or tap Aa to type."); return; } reportError(error); // origin_not_allowed, grant_unavailable, socket errors, … },};<Usher voqalKey={import.meta.env.VITE_VOQAL_KEY} router={adapter} destinations={destinations} voice={voice} />A connect cancelled with × is not an error. VoiceSessionSupersededError is swallowed and reported only as the start-voice:cancelled diagnostic.
Logging errors
With a key, conversation logging runs in the background and never interrupts the user. Its failures go to cloud.onLogError, not to voice.onError. By default Usher prints one console warning. The calls involved are POST {base}/v1/sessions/{sessionId}/events for turns, and POST {base}/v1/sessions/{sessionId}/audio followed by a PUT to Voqal’s upload host for each audio clip. Codes you may see there, as UsherCloudError: session_quota_exceeded (429, the session hit 500 turns or 200 clips), session_expired (410, the session is over 2 hours old), and audio_exists (409, a clip was already uploaded). None of them affect the conversation.
Diagnostic events
voice.onDiagnostic(event, detail) reports the session lifecycle. Use it while you integrate, and turn it off in production. The detail values are for debugging and can change between releases.
| Event | Detail | Meaning |
|---|---|---|
orb-tap:start-voice | — | A voice session is starting from the orb. |
chat:start-text-session | — | A session is starting from the chat panel (text first). |
grant:fetch | — | Usher asked Voqal to start a session. |
grant:ok | Session details | Voqal started the session. |
start-voice:connected | — | The session is live. |
start-voice:cancelled | — | × was pressed while connecting. Not an error. |
start-voice:error | "Name: message" | Starting failed. onError gets the error too. |
phase | "idle" | "listening" | "thinking" | "speaking" | The orb phase changed. |
levels | { mic, out } | Peak microphone and playback levels, sampled periodically while audio runs. |
mic-device | { label, muted, enabled, readyState, deviceId, sampleRate } | Which microphone opened, and its state. Useful for a mic that records silence. |
idle:pause-mic | — | The microphone paused after 30 s without speech (see Idle timeout & session limits). |
idle:end-session | — | The session ended after the idle limit. |
max-session:closing-line | — | The maximum length was reached; the assistant is saying its closing line. |
max-session:end | — | The session ended at the maximum length. |
session:lost | — | The session ended by itself. onError gets UsherSessionLostError. |
tool:run | { status, durationMs } | One of your app’s tools ran. Never its arguments or result. |
voice:on | { source } | Voice turned on. source is orb or mic-button. |
voice:off | { source } | Voice turned off: the microphone and playback stopped. source is orb, mic-button, chat (Aa), minimize, idle, end, max, or lost. |
playback:start | — | The assistant’s audio started playing. |
playback:stop | { reason } | The assistant’s audio stopped: ended (it finished), interrupted (the user talked over it), or paused (voice was turned off). |
turn:silent | — | The model ended a turn without saying anything, even after two nudges. The fallback line was shown. |
turn:empty | { events } | The voice connection reported a turn that ended with no content. |
turn:tool-loop | — | The model started repeating tool calls; Usher told it to stop and reply. |
fallback:shown | { reason } | Usher showed one of its own lines (no reply, connection lost, rate limited) instead of a model reply. |
reconnect:resumed | { resent } | A dropped connection came back; resent is how many held messages were sent again. |
The reliability events (turn:silent, turn:empty, turn:tool-loop, fallback:shown, reconnect:resumed) and the voice events (voice:on, voice:off, playback:start, playback:stop) are also sent in support diagnostics, and Voqal’s trace findings name them when you share a trace id.
More help: Troubleshooting.
Support & diagnostics
Every page load gets a trace id. Usher prints it once in the browser console:
[usher] trace tr_xxxxxxxxxxxxxxxx — share with Voqal support if something goes wrongIt’s also on ref.current.traceId, and getUsherTraceId() from @voqal/usher-react returns it anywhere, for example to attach to your own error reports. When you contact support, include it: it lets us find exactly what happened in that page load.
To diagnose problems, the SDK sends Voqal structured diagnostics, kept for 30 days:
- Setup: SDK and React versions, the adapter kind, destination counts, route patterns, alias count, role names from your roleScopes, knowledge entry count and size, instructions length, theme, interaction mode, and logging options.
- Browser: the user agent, the browser locale, whether the page is a secure context, and the page as your origin plus the matching route pattern (never the concrete path).
- Discovery: routes missing from your map, manifest overrides, duplicate ids or patterns, and invalid entries.
- Environment: microphone and AudioWorklet support, requests blocked by your Content Security Policy, a denied, dismissed, missing, or silent microphone, and a missing or malformed key.
- Sessions: start latency and failures (with codes such as origin_not_allowed), reconnects, and how each session ended.
- Turns: reply latency, what each tool call became, and the reliability events below.
- Errors: the error class, a redacted message, and the SDK's own stack frames.
Diagnostics never contain anything a user said or typed, your instructions or knowledge base text, context values such as selected record ids and labels or user ids, concrete page paths or URL query strings, or tokens, or more than a key's first four characters. They do contain the browser’s user agent and locale. They’re separate from conversation logs. They’re on by default with a key. Without a valid key, only setup, discovery, environment, key, and session-start failures are sent, never sessions or turns, since a missing key is a common reason a setup doesn’t work. Turn them off with cloud={{ diagnostics: false }}, though support can then see much less.
