Developer platform · v2

Voice

View as MarkdownAll docs

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 onyour serveryour public web page
HeaderX-Api-KeyX-Publishable-Key
Can reachevery /v2/* route its scopes allowPOST /v2/browser/sessions, nothing else
Bound toa projectone agent in one project
Origin-checkednoyes, against the key's own list
If it leaksrotate immediately, assume compromiseit works only on origins you allowed
bash
npm install @dvaarik/web

1. 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#

html
<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:

css
dvaarik-call { --dvaarik-accent: #0f766e; --dvaarik-radius: 8px; }
dvaarik-call::part(button) { letter-spacing: .02em; }
AttributeRequiredMeaning
publishable-keyyespk_live_… from the console
agent-idyesthe agent the key was minted for
labelnobutton text when idle
end-labelnobutton text during a call
base-urlnoAPI 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#

tsx
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.

CodeCause
MicPermissionDeniedthe visitor refused the prompt, or policy blocked it
MicUnavailableno device, or the page is not on HTTPS
InvalidKey401 — unknown or revoked key
OriginNotAllowed403 — this origin is not on the key's list
AgentNotFound404 — wrong or foreign agent
InvalidRequest422 — usually a missing prompt variable
RateLimited429, or close 4429
TemporarilyUnavailable503, or close 1013
ProviderUnavailable503, or close 4453
SessionEndedclose 4401, 4404, 4408, 4409
NetworkErrornothing 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.

LimitDefaultRange
Concurrent sessions31–50
Sessions per minute (whole key)101–120
Sessions per minute (one visitor)51–60
Longest call, seconds3001–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.