Events & errors

Beta
Last updated  Sep 23, 2026

Every 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

kindMeaning
navigatedYour router was called and didn't throw. Has destinationId, path, and an optional kbAnswer.
offerA page was offered and nothing moved yet. Has destinationId and title.
clarifyThe assistant asked a question. Has question.
answerA reply with no navigation. Has text and kbAnswer in local mode. In a live session it's the downgraded result of an unusable offer.
refusedNothing moved. Has reason, detail (a developer message), and sometimes fallback.
@voqal/usher-core (types)
// 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}
src/usher.tsx
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, onAction fires for each tool call the model makes: navigatenavigated (or a refusal), offer_navigationoffer, clarifyclarify, and decline_navigationrefused with no_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 reason and detail as 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.

reasonCauseLocal panel textWhat to do
no_matching_destinationLive 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_allowlistThe 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_forbiddenThe 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_unresolvedA :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_confidenceThe 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_targetThe 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_failedYour 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:

src/usher.tsx
// 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).

ErrorImport fromWhen
UsherSessionLostError@voqal/usher-reactA live session ended without the user ending it (network loss, or the server closed it after three failed reconnects).
LiveSessionNotConnectedError@voqal/usher-reactA typed message couldn't be sent because the socket was down. Nothing reached the model.
ModelApiUnavailableError@voqal/usher-reactThe voice model couldn't be reached: a connection error or setup timeout. Has a code.
UsherCloudError@voqal/usher-coreThe hosted service refused or couldn't be reached. Has status and code. See below.
DOMExceptionBrowserMicrophone 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:

codeStatusWhat to do
origin_not_allowed403This page's origin isn't on the key's allow-list. Send Voqal the exact origin (scheme, host, and port).
invalid_key401 or 0The key is unknown or disabled, or doesn't start with pk_live_ or pk_test_.
rate_limited429Too many sessions for this key in the current minute. Ask the user to try again shortly.
grant_unavailable503The hosted service couldn't create a voice session. Retry later.
timeout, network_error0The hosted service didn't answer within 10 seconds, or the network failed.
internal_error500A server error. Retry, and tell Voqal if it persists.
src/usher.tsx
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.

EventDetailMeaning
orb-tap:start-voiceA voice session is starting from the orb.
chat:start-text-sessionA session is starting from the chat panel (text first).
grant:fetchUsher asked Voqal to start a session.
grant:okSession detailsVoqal started the session.
start-voice:connectedThe 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:lostThe session ended by itself. onError gets UsherSessionLostError.

More help: Troubleshooting.

© 2026 VoqalVoqal SDK & engine documentation