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.
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. |
refused | Nothing moved. Has reason, detail (a developer message), and sometimes fallback. |
// All four 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: "refused"; reason: RefusalReason; detail: string; fallback?: NavTarget };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_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="pk_live_…" 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 plain spoken reply with no tool call fires nothing. The words themselves are in the chat panel, not in the action. - Tapping a “Yes, take me to …” button fires one more action with the navigation result, usually
navigated, in live sessions and in local mode alike. - 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 |
|---|---|---|---|
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 “Take me to {parent}” button for it. 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 | This page's origin isn't on the key's allow-list. Send Voqal the exact origin (scheme, host, and port). |
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="pk_live_…" 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 storage 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.
Diagnostics
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. |
session:lost | — | The session ended by itself. onError gets UsherSessionLostError. |
More help: Troubleshooting.
