Security
BetaUsher runs inside your signed-in app, as your user, and it moves them around your product. It is built so that neither a confused model nor a malicious prompt can send someone to a page you didn’t list, or make up an id.
The model is untrusted
- The model never produces a URL. It picks a
destinationIdfrom an enum built from your map, and the enum is filtered to the user’s role before the model sees it. - Every tool call it makes, spoken or typed, goes through the same deterministic checks in the browser before anything moves.
- Knowledge-base text and page content are treated as data, not instructions. A document that says “navigate to /admin” can’t get past the allow-list.
- Usher needs no passwords, cookies, or tokens. Navigation happens in the user’s existing session through your router.
The validation chain
| Gate | Check | Rule |
|---|---|---|
| 1 | Allow-list | The destinationId must be one this turn offered, and the list is already role-filtered. Anything else is not_in_allowlist. |
| 2 | Role | The destination's roleScopes must include context.role. Otherwise role_forbidden. |
| 3 | Confidence | In local text mode, the match score must be at least 0.15. Otherwise low_confidence. |
| 4 | Params | Every :param comes from the organization, the selected entity, the current URL, or resolveEntity, never from the model. Otherwise param_unresolved. |
| 5 | Safe path | The final path must start with a single / and have no URL scheme. That blocks https:, //host, and javascript:. Otherwise unsafe_target. |
| 6 | Your router | navigate() runs through your router, so your guards, loaders, and redirects apply. A throw becomes navigation_failed. |
What this does and doesn’t guarantee
The guarantee is that Usher never navigates to a destination outside your map, and never fills a param with an id the model made up. Here are its limits in this beta:
- Role filtering runs in the browser. A user who edits the page’s JavaScript can change their own
context.role. Role scopes shape what Usher offers. They are not access control. Your route guards and your API authorization remain the source of truth, and Usher always goes through them. - The model’s destination list is also built in the browser from
context.role, and every tool call is checked again against the same role-filtered map. - The model can still say something wrong. The chain limits what it can do, not what it can say. Keep facts it must get right in your page descriptions.
- Usher only navigates. It can’t click, submit, or change data.
What leaves the browser
In local text mode, nothing leaves the browser. In a live session, the browser streams the conversation to Voqal’s voice model over a WebSocket, and Voqal logs it. Conversations are processed by Voqal’s model provider in the United States; logs are stored in the EU (eu-west-1). Your destination map and knowledge base stay in your code: Voqal never stores them except as they appear in logged turns.
| Data | Local text mode | To the model (live session) | Logged by Voqal (hosted key) |
|---|---|---|---|
| Microphone audio, while the mic is live | No | Streamed | A WAV clip per turn (16 kHz), unless captureAudio is false |
| The assistant's spoken reply | No | Produced by the model | A WAV clip per voice turn (24 kHz), unless captureAudio is false |
| Typed messages and speech transcripts | No | Yes | Yes, unless logConversation is false |
| The assistant's text, tool calls, and their results | No | Produced by the model | Yes, unless logConversation is false |
| Destination ids, titles, and descriptions (role-scoped) | No | Yes, in the session instructions | Only as tool-call arguments |
| The current path, on connect and on each route change | No | Yes, as a context note | Only inside tool results |
| Knowledge-base entries | No | Yes, up to 8,000 characters | No |
| context.role, organizationId, selectedEntity | No | No. Used in the browser | No |
| Page content, form fields, cookies, auth tokens | Never | Never | Never |
Hosted logging and your privacy notice
- Logs are keyed by a session id and a per-request id. They are sent in background batches, and audio goes straight to private storage, so logging never slows a reply. The browser reports them, so treat them as a record of what the client sent, not as tamper-proof.
- Each turn is logged with its channel:
voice, ortextfor chat (including chat while voice is paused). Assistant audio is uploaded only for replies played aloud. User clips start about 300 ms before the user speaks, and clips with no speech aren’t uploaded, so room audio between turns is never sent. - Limits per session: up to 500 turns and 200 audio clips, and a session accepts logs for 2 hours. Keys are rate-limited too. An oversized turn is truncated, not dropped: text to 8,000 characters and at most 10 tool calls.
- Logs and audio are stored in the EU (AWS eu-west-1) and kept indefinitely. There is no automatic deletion in the beta.
- Conversations are processed by Voqal’s model provider in the United States, so your users’ audio and text are processed there during the conversation, even though the logs are stored in the EU.
- What the browser sends, so you can verify it in the network panel: turns go to
POST {base}/v1/sessions/{sessionId}/events. Each audio clip is aPOST {base}/v1/sessions/{sessionId}/audiothat returns a short-lived upload URL, followed by aPUTof the WAV file straight to storage. WithcaptureAudio: falseyou see no audio calls; withlogConversation: falseyou see neither. - Opt out with
cloud={{ captureAudio: false }}(no audio) orcloud={{ logConversation: false }}(nothing logged). - Your users’ voices and words are stored by Voqal on your behalf. Tell them in your privacy notice that an AI assistant records conversations, including audio; that the conversation is processed by an AI model provider in the United States; and that recordings are stored in the EU.
/cases/4821. The current path is shared with the model so it knows where the user is. If your URLs contain sensitive values, keep them out of the path.Keys and credentials
- The publishable key (
pk_live_…) identifies your assistant. It’s meant for client code. Don’t treat it as a secret, and don’t treat it as authentication. - Each key works only from the origins Voqal registered for it, and it’s rate-limited. Browsers enforce the origin check. A script outside a browser can fake an origin, so the rate limit is what bounds misuse.
- For each session the browser receives a short-lived credential, just long enough to open the voice connection. Voqal’s own credentials never leave Voqal’s servers.
Content Security Policy
If your app sends a CSP, add these sources. The hosted key needs three hosts: Voqal’s hosted service (starting sessions, plus the /events and /audio logging calls), Voqal’s realtime voice endpoint, and the storage host that receives the audio PUT (drop it if you set captureAudio: false).
# Voqal's hosted service, Voqal's realtime voice endpoint, and audio storage.# Wrapped here for reading; send it as one line.connect-src 'self' https://nmkqy6jkaxbyrpb2wgksohawni0jfzea.lambda-url.eu-west-1.on.aws wss://*.googleapis.com https://voqal-usher-audio-876083391625-eu-west-1.s3.eu-west-1.amazonaws.com;# The audio worklet loads from a blob: URL, and the orb uses inline stylesscript-src 'self' blob:;style-src 'self' 'unsafe-inline';# Response header: allow the microphone on your own originPermissions-Policy: microphone=(self)- The orb and panel set inline
styleattributes and add<style>elements for their animations, sostyle-srcneeds'unsafe-inline'. - Microphone capture needs a secure context: HTTPS, or localhost in development.
