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

```bash
npm install @dvaarik/web
```

## 1. Mint a key

Open [Publishable keys](/console/publishable-keys) in the console, pick an
active agent whose [provider accounts](/docs/providers) 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; }
```

| 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

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

| 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](/docs/providers), 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](/docs/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](/console/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.

---

Source: https://developers.dvaarik.com/docs/browser-calls · every page as one file: https://developers.dvaarik.com/docs.md
