Browser calls
A call button on your own website#
A visitor clicks, allows the microphone, and talks to your agent. No server in the loop, no secret in the page.
That works because a publishable key is a different kind of credential from a project key. It is bound to one agent, refused on any origin you have not listed, rate-limited per key and per visitor, and it can do exactly one thing: start a browser voice call. It is meant to be read by anyone who views your page source.
Project key dvk_live_… | Publishable key pk_live_… | |
|---|---|---|
| Lives on | your server | your public web page |
| Header | X-Api-Key | X-Publishable-Key |
| Can reach | every /v2/* route its scopes allow | POST /v2/browser/sessions, nothing else |
| Bound to | a project | one agent in one project |
| Origin-checked | no | yes, against the key's own list |
| If it leaks | rotate immediately, assume compromise | it works only on origins you allowed |
npm install @dvaarik/web1. Mint a key#
Open Publishable keys in the console, pick an active agent whose provider accounts are all verified, and list the origins the button will run on. The console shows the full key value — it is not a secret, and hiding it would only push you to keep a copy somewhere less safe.
2. Paste two lines#
<script src="https://cdn.jsdelivr.net/npm/@dvaarik/web/dist/dvaarik-web.global.js"></script>
<dvaarik-call
publishable-key="pk_live_YOUR_KEY"
agent-id="YOUR_AGENT_ID"
label="Talk to us"
></dvaarik-call>That is a real <button> in a shadow root with a live status line — "Allow the microphone…", "Connecting…", "Live · 00:14", and any refusal in plain words. It is keyboard reachable, announced to screen readers, and it releases the microphone when the visitor navigates away. Style it from the host page:
dvaarik-call { --dvaarik-accent: #0f766e; --dvaarik-radius: 8px; }
dvaarik-call::part(button) { letter-spacing: .02em; }| Attribute | Required | Meaning |
|---|---|---|
publishable-key | yes | pk_live_… from the console |
agent-id | yes | the agent the key was minted for |
label | no | button text when idle |
end-label | no | button text during a call |
base-url | no | API origin, for a staging deployment |
agent_id is in the request body because the API requires it, not because the page gets to choose: the key is bound to one agent and any other id is the same 404 an id that never existed would get. The console prints the right value beside the key.
3. Or drive it yourself#
import { useEffect, useRef, useState } from "react";
import { DvaarikCall, type DvaarikCallStatus, type DvaarikTranscript } from "@dvaarik/web";
export function CallButton() {
const call = useRef<DvaarikCall | null>(null);
const [status, setStatus] = useState<DvaarikCallStatus>("idle");
const [lines, setLines] = useState<DvaarikTranscript[]>([]);
const [problem, setProblem] = useState<string | null>(null);
const [minutes, setMinutes] = useState(0);
useEffect(() => {
const instance = new DvaarikCall({
publishableKey: process.env.NEXT_PUBLIC_DVAARIK_PUBLISHABLE_KEY!,
agentId: process.env.NEXT_PUBLIC_DVAARIK_AGENT_ID!,
});
call.current = instance;
const off = [
instance.on("status", setStatus),
instance.on("transcript", (line) => setLines((all) => [...all, line])),
instance.on("error", (error) => setProblem(error.message)),
];
// Unmounting must release the microphone.
return () => {
off.forEach((unsubscribe) => unsubscribe());
void instance.stop();
};
}, []);
useEffect(() => {
if (status !== "live") return;
const tick = setInterval(() => setMinutes(call.current?.minutesBilled ?? 0), 1000);
return () => clearInterval(tick);
}, [status]);
const live = status === "live";
return (
<>
<button
type="button"
disabled={status === "requesting-mic" || status === "connecting"}
// Straight from the click: the audio context is resumed inside the
// gesture, or autoplay policy means the agent is never heard.
onClick={() => void (live ? call.current?.stop() : call.current?.start())}
>
{live ? "End call" : "Talk to us"}
</button>
{live && <span>{minutes} min so far</span>}
{problem && <p role="alert">{problem}</p>}
<ol>{lines.map((line, i) => <li key={i}>{line.role}: {line.text}</li>)}</ol>
</>
);
}status moves idle → requesting-mic → connecting → live → ended, or to error. mute(true) silences the microphone at the source. elapsedSeconds and minutesBilled let a page show the running cost.
What the key cannot do#
There is no list, no read, no configuration, and no outbound dial behind a publishable key. POST /v2/browser/sessions returns four fields — session_id, ws_url, expires_in_seconds, max_duration_seconds — and nothing about your project, your rate, your wallet, or your pipeline.
Two refusals are deliberately vague, and the SDK stays vague with them. An unfunded wallet and a dead speech provider are both 503, separated only by a machine code, because a visitor who could tell those apart would be reading your business over your shoulder. A wrong agent, a foreign agent, and an agent that never existed are one 404, so a scraped key cannot be walked across your project.
| Code | Cause |
|---|---|
MicPermissionDenied | the visitor refused the prompt, or policy blocked it |
MicUnavailable | no device, or the page is not on HTTPS |
InvalidKey | 401 — unknown or revoked key |
OriginNotAllowed | 403 — this origin is not on the key's list |
AgentNotFound | 404 — wrong or foreign agent |
InvalidRequest | 422 — usually a missing prompt variable |
RateLimited | 429, or close 4429 |
TemporarilyUnavailable | 503, or close 1013 |
ProviderUnavailable | 503, or close 4453 |
SessionEnded | close 4401, 4404, 4408, 4409 |
NetworkError | nothing came back, or the socket dropped |
Every one carries a sentence you may show a visitor as-is, plus status, closeCode, serverCode and retryAfterSeconds for your logs.
Origins#
An origin is scheme://host[:port] — no path, no query, no credentials. https://acme.com and https://acme.com:443 are the same origin and both work. Up to 20 per key. A key with no origins refuses every request, which is the safe state for a key you have not finished setting up; the console says so on the row.
The one wildcard is a loopback port — http://localhost:* and http://127.0.0.1:* — because you cannot know which port your dev server will pick, and the alternative is that everybody develops with the allowlist switched off.
Origins cannot be edited. There is no route that changes them, on purpose: where a key may run is part of what the key is. To change the list, create a key with the origins you want, redeploy the page with the new value, then revoke the old key. Rotating a key keeps its origins and limits and replaces only the value — that is the tool for "this key leaked", not for "this key should run somewhere else".
Limits#
Every limit has a conservative default, so a key created without touching any of them is already bounded. There is no unlimited value; the smallest is 1, which makes "no traffic at all" something you express by revoking the key.
| Limit | Default | Range |
|---|---|---|
| Concurrent sessions | 3 | 1–50 |
| Sessions per minute (whole key) | 10 | 1–120 |
| Sessions per minute (one visitor) | 5 | 1–60 |
| Longest call, seconds | 300 | 1–3600 |
The effective ceiling on one call is the minimum of the key's, the agent's and the project's. A key cannot raise a limit set anywhere else. A project may hold up to 10 live browser keys, a separate budget from your project keys, so minting call buttons cannot exhaust your server-key allowance.
Billing#
A browser call is not a cheaper call. It runs through the same admission, the same verified provider pipeline, the same project concurrency lease and the same wallet hold as a call from your own server, and it settles the same way: whole connected minutes, rounded up, at $0.005 per minute. One second is one minute; sixty-one seconds is two. See Billing.
A browser call has no carrier in the path, so there is nothing to record and no recording reference on the call. store_transcript still applies, on the same 90-day window as any other call.
minutesBilled on the call object is ceil(elapsedSeconds / 60) — the honest number to show a visitor while they talk. The settled figure on the call record, visible under Calls, is the one you are charged.
The socket, if you are not using the SDK#
ws_url already carries the session's single-use token; do not append one. Send raw PCM16 mono at 16 kHz as binary frames and play the raw PCM16 mono at 24 kHz that comes back. JSON control frames are ready, transcript ({role, text}), interrupted (drop everything you have queued — this is barge-in), turn_complete, error, and session_ended. Send {"type":"stop"} to hang up. The close codes are the ones in the table above.
Use one project per boundary
Provider secrets stay encrypted and project API keys stay on your server.