# Quickstart for browsers

> Talk to a Kheelona voice from a web page in about 10 minutes, with the example page and its small backend.

Source: https://docs.voice.kheelona.com/get-started/quickstart-browser/

In about 10 minutes you will talk to a voice from a web page on your own machine. The page uses the Kheelona browser
SDK. A small backend of yours creates the session, so your API key never reaches the page.

You need:

- Node 24 or newer;
- a Kheelona API key;
- the `browser-basic` example folder and the browser SDK package: the examples you received from the Kheelona team, with
  the SDK package. They are proprietary and shared privately, and not on a public package registry. The commands below
  run inside that folder, so `cd examples/browser-basic` means the `browser-basic` folder of what you received. Contact
  the Kheelona team for access.

## 1. Get an API key and allow your page

Sign in to your Kheelona console. From the public launch, the first time anyone signs in to your console, they set up a second sign-in step with an authenticator app on their phone (before the launch it may be optional). Have the phone at hand if asked. Then open **API keys** and create a key. Copy it when it is shown: it is shown once. It
starts with `kv_live_`.

Then tell Kheelona which page may talk to your voices. Open **Allowed origins** and add the address of your page. An
origin is the scheme, the host and the port, for example `https://app.example.com`.

For development you can run the page on `http://localhost:5180`. Kheelona has to turn on **development access** for
your account first: it lets any `http://localhost` address connect, on any port. To ask for it, write to the Kheelona
team and name the account it is for. Until it is on, `http://localhost` is refused like any origin that is not in your
list. Without it, or without
your origin in the list, the session is refused with `origin_not_allowed`.

## 2. Install the SDK

The SDK is the package file you received from the Kheelona team. In the example folder you received, the package file
is already beside `package.json`, which points at it, so a plain install is enough:

```sh
cd examples/browser-basic
npm install
```

To add the SDK to your own app instead, install it from the package file:
`npm install ./kheelona-voice-<version>.tgz`.

The example's backend serves the SDK files to the page from this install, so the page needs no bundler.

## 3. Run it

```sh
export KHEELONA_API_KEY=kv_live_…
npm start
```

Open `http://localhost:5180`, click **Start talking** and allow the microphone when the browser asks. Say hello. The
state line moves from `listening` to `thinking` to `speaking`, and your words and the voice's words appear as captions.
You can also type a message instead of speaking.

The example has four more controls. **End** ends the session. **Mute** switches the microphone off and on.
**Interrupt** stops the voice at once. If the browser will not play the voice, **Tap to allow sound** appears.

## 4. Settings

The backend reads these variables. Only the key is required.

| Variable | Default | Meaning |
|---|---|---|
| `KHEELONA_API_KEY` | none (exit 2 with a hint when missing) | Your API key. Server side only. |
| `KHEELONA_API_URL` | `https://api.voice.kheelona.com` | The API |
| `KHEELONA_VOICE_ID` | `lumi-dragon` | The voice |
| `HOST` / `PORT` | `127.0.0.1` / `5180` | Where the page and the backend listen |
| `ALLOWED_ORIGIN` | `http://localhost:5180` | The one page origin allowed to call the backend |
| `KHEELONA_REALTIME_ORIGINS` | `https://api.voice.kheelona.com wss://api.voice.kheelona.com` | The origins of the `connect` URLs, separated by spaces. The page may connect to these and to nothing else. |

`KHEELONA_REALTIME_ORIGINS` is the origins (scheme, host and port, no path) of the two `connect` URLs in the answer to
`POST /v1/sessions`: `websocket_url` and `webrtc_url`. The page's Content Security Policy lets it connect to these and to
nothing else. The default fits the hosted service, so leave it alone if your `connect` URLs are on
`api.voice.kheelona.com`. If they are on another host, read the origins from a session you created (the
[curl example](https://docs.voice.kheelona.com/examples/curl.md) creates one) and join them with a space. The curl example has a one-line helper, `realtime-origins.sh`, that reads the answer on standard input
(`./create-session.sh | ./realtime-origins.sh`, needs `jq`). It is this:

```bash title="realtime-origins.sh"
# $ANSWER holds the JSON answer of POST /v1/sessions
echo "$ANSWER" | jq -r '.connect | [.websocket_url, .webrtc_url] | map(select(. != null) | capture("^(?<o>[a-z]+://[^/]+)").o) | unique | join(" ")'
```

Export what it prints as `KHEELONA_REALTIME_ORIGINS`. A `ws://` or `wss://` origin and its `http://` or `https://`
twin are separate origins: list each one that appears in a `connect` URL.

## 5. The three files

These are the files that make it work. All of them, with the rest of the folder, are on the
[browser-basic example](https://docs.voice.kheelona.com/examples/browser-basic.md) page.

### The backend: `server.ts`

It serves the page, the SDK files and one route, `POST /api/voice-session`. The route creates a session with your key
and returns only what the page needs: the session ID, the client secret and the connection addresses.

```ts title="server.ts"
// The example's whole backend: it serves the page, the SDK files and the mint route from ONE origin (so no CORS), and
// it holds your API key. Run it with `npm start`, then open http://localhost:5180 (see Settings on the example's docs page).
import { readFile, realpath, stat } from 'node:fs/promises';
import { createServer as createHttpServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
import { dirname, extname, resolve, sep } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';

const HERE = dirname(fileURLToPath(import.meta.url));
const REQUEST_TIMEOUT_MS = 15_000;

/** The settings (see Settings on the example's docs page). */
export interface BrowserEnv {
  apiKey: string;
  apiUrl: string;
  voiceId: string;
  /** The one page origin that may call the mint route. */
  allowedOrigin: string;
  /** Origins the page may connect to (the `connect` URLs the API returns), space separated, for the CSP. */
  realtimeOrigins: string;
  host: string;
  port: number;
}

/** Where the files come from. Tests pass their own folders. */
export interface ServerOptions {
  publicDir?: string;
  sdkDir?: string;
}

/** Reads the settings; throws an Error with a one-line hint when something is missing or wrong (exit code 2). */
export function readEnv(env: Record<string, string | undefined>): BrowserEnv {
  const apiKey = env['KHEELONA_API_KEY'];
  if (!apiKey) throw new Error('KHEELONA_API_KEY is not set. Create an API key in the Kheelona console and export it.');
  const allowedOrigin = (env['ALLOWED_ORIGIN'] ?? 'http://localhost:5180').trim();
  if (allowedOrigin === '' || allowedOrigin.toLowerCase() === 'null') {
    throw new Error("ALLOWED_ORIGIN must be your page's origin, for example https://app.example.com");
  }
  const realtimeOrigins = (env['KHEELONA_REALTIME_ORIGINS'] ?? 'https://api.voice.kheelona.com wss://api.voice.kheelona.com').trim();
  // The value goes into a response header: plain origins only.
  if (!/^[A-Za-z][A-Za-z0-9+.-]*:\/\/[^\s;,'"]+(?: [A-Za-z][A-Za-z0-9+.-]*:\/\/[^\s;,'"]+)*$/.test(realtimeOrigins)) {
    throw new Error('KHEELONA_REALTIME_ORIGINS must be origins separated by spaces, for example https://host wss://host');
  }
  const port = Number(env['PORT'] ?? '5180');
  if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error('PORT must be a port number.');
  return {
    apiKey,
    apiUrl: env['KHEELONA_API_URL'] || 'https://api.voice.kheelona.com',
    voiceId: env['KHEELONA_VOICE_ID'] || 'lumi-dragon',
    allowedOrigin,
    realtimeOrigins,
    host: env['HOST'] || '127.0.0.1',
    port,
  };
}

/** The strict policy the page runs under: this origin's files, and the Kheelona realtime origins to connect to. */
export function contentSecurityPolicy(realtimeOrigins: string): string {
  return [
    "default-src 'self'",
    "script-src 'self'",
    "style-src 'self'",
    `connect-src 'self' ${realtimeOrigins}`,
    "img-src 'self'",
    "media-src 'self'",
    "object-src 'none'",
    "base-uri 'self'",
    "form-action 'self'",
    "frame-ancestors 'none'",
  ].join('; ');
}

const TYPES: Readonly<Record<string, string>> = {
  '.html': 'text/html; charset=utf-8',
  '.js': 'text/javascript; charset=utf-8',
  '.css': 'text/css; charset=utf-8',
  '.map': 'application/json; charset=utf-8',
};

function send(res: ServerResponse, csp: string, status: number, type: string, body: string | Buffer, headers: Record<string, string> = {}): void {
  res.writeHead(status, {
    'Content-Type': type,
    'Content-Security-Policy': csp,
    'X-Content-Type-Options': 'nosniff',
    'Referrer-Policy': 'no-referrer',
    'Cache-Control': 'no-store',
    ...headers,
  });
  res.end(body);
}

const sendJson = (res: ServerResponse, csp: string, status: number, body: unknown, headers: Record<string, string> = {}): void => {
  send(res, csp, status, 'application/json', JSON.stringify(body), headers);
};

/**
 * Maps a request path to a file inside `root`, or `undefined`. The path is decoded first, then resolved, and refused
 * unless the result is still inside `root`: `..`, encoded dots and slashes, backslashes and NUL never get out.
 */
async function fileInside(root: string, requestPath: string): Promise<string | undefined> {
  let decoded: string;
  try {
    decoded = decodeURIComponent(requestPath);
  } catch {
    return undefined;
  }
  if (decoded.includes('\0') || decoded.includes('\\')) return undefined;
  const base = resolve(root);
  const file = resolve(base, `.${decoded}`);
  if (!file.startsWith(base + sep)) return undefined;
  if (!(extname(file) in TYPES)) return undefined;
  try {
    // A symlink must not lead out either: compare real paths. The root itself may be a link (pnpm links packages),
    // so it is resolved first and is the one explicitly trusted folder.
    const realFile = await realpath(file);
    if (!realFile.startsWith((await realpath(base)) + sep)) return undefined;
    return (await stat(realFile)).isFile() ? realFile : undefined;
  } catch {
    return undefined;
  }
}

async function serveStatic(req: IncomingMessage, res: ServerResponse, csp: string, dirs: Required<ServerOptions>): Promise<void> {
  const path = (req.url ?? '/').split('?')[0] ?? '/';
  const isSdk = path.startsWith('/sdk/');
  const file = await fileInside(isSdk ? dirs.sdkDir : dirs.publicDir, isSdk ? path.slice('/sdk'.length) : path === '/' ? '/index.html' : path);
  if (file === undefined) {
    sendJson(res, csp, 404, { error: { code: 'not_found' } });
    return;
  }
  send(res, csp, 200, TYPES[extname(file)] ?? 'application/octet-stream', req.method === 'HEAD' ? '' : await readFile(file));
}

/** The mint route: create the session with your API key, hand the page only what it needs. */
async function mintSession(env: BrowserEnv, req: IncomingMessage, res: ServerResponse, csp: string): Promise<void> {
  // Same-origin by design (no CORS headers). Only your own page may call this route: a request with no Origin, or
  // another one, never reaches Kheelona. JSON only, so a cross-site HTML form cannot spend your key.
  if (req.headers.origin !== env.allowedOrigin) {
    sendJson(res, csp, 403, { error: { code: 'origin_not_allowed' } });
    return;
  }
  if (!req.headers['content-type']?.toLowerCase().startsWith('application/json')) {
    sendJson(res, csp, 415, { error: { code: 'unsupported_media_type' } });
    return;
  }
  // Authenticate your user here: only signed-in users may start a session.
  // The request body is never read (this route takes no input), so its size and Content-Length don't matter.
  // One attempt only: to retry on 429/503, reuse one Idempotency-Key as examples/node-server does.
  const upstream = await fetch(`${env.apiUrl.replace(/\/+$/, '')}/v1/sessions`, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${env.apiKey}`,
      'Content-Type': 'application/json',
      'Idempotency-Key': globalThis.crypto.randomUUID(),
    },
    body: JSON.stringify({
      voice_id: env.voiceId,
      languages: ['en', 'hi'],
      end_user_ref: 'demo-user', // replace with the signed-in user's opaque ID
    }),
    signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
  });
  if (!upstream.ok) {
    const body = (await upstream.json().catch(() => ({}))) as { error?: { code?: unknown; request_id?: unknown } };
    const code = plain(body.error?.code) ?? 'unknown_error';
    const requestId = plain(body.error?.request_id);
    console.error(`error ${code} request_id ${requestId ?? '-'}`);
    const retryable = upstream.status === 429 || upstream.status === 503;
    const retryAfter = upstream.headers.get('retry-after');
    sendJson(res, csp, retryable ? upstream.status : 502, { error: { code, ...(requestId ? { requestId } : {}) } }, retryable && retryAfter && /^\d+$/.test(retryAfter) ? { 'Retry-After': retryAfter } : {});
    return;
  }
  const created = (await upstream.json()) as {
    session_id: string;
    client_secret: { value: string };
    connect: { webrtc_url: string; websocket_url: string };
  };
  // Return only what the page needs: never the API key, and nothing else from the answer.
  sendJson(res, csp, 201, {
    sessionId: created.session_id,
    clientSecret: created.client_secret.value,
    endpoints: { webrtcUrl: created.connect.webrtc_url, websocketUrl: created.connect.websocket_url },
  });
}

/** A short identifier made of plain characters, or undefined: nothing else from an answer is repeated. */
function plain(value: unknown): string | undefined {
  return typeof value === 'string' && /^[A-Za-z0-9_.:-]{1,64}$/.test(value) ? value : undefined;
}

/** Creates the server (not listening yet): the page, the SDK files and the mint route on one origin. */
export function createServer(env: BrowserEnv, options: ServerOptions = {}): Server {
  const dirs: Required<ServerOptions> = {
    publicDir: options.publicDir ?? resolve(HERE, 'public'),
    sdkDir: options.sdkDir ?? resolve(HERE, 'node_modules/@kheelona/voice/dist'),
  };
  const csp = contentSecurityPolicy(env.realtimeOrigins);
  return createHttpServer((req, res) => {
    void (async () => {
      const path = (req.url ?? '/').split('?')[0];
      if (path === '/api/voice-session') {
        if (req.method !== 'POST') {
          sendJson(res, csp, 405, { error: { code: 'method_not_allowed' } }, { Allow: 'POST' });
          return;
        }
        await mintSession(env, req, res, csp);
      } else if (req.method === 'GET' || req.method === 'HEAD') {
        await serveStatic(req, res, csp, dirs);
      } else {
        sendJson(res, csp, 405, { error: { code: 'method_not_allowed' } }, { Allow: 'GET, HEAD' });
      }
    })().catch((error: unknown) => {
      // Only the kind of failure: an error message can echo a request.
      console.error(`error ${error instanceof Error ? error.name : 'unexpected'} request_id -`);
      if (!res.headersSent) sendJson(res, csp, 502, { error: { code: 'internal' } });
      else res.end();
    });
  });
}

if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) {
  let env: BrowserEnv;
  try {
    env = readEnv(process.env);
  } catch (error) {
    console.error((error as Error).message);
    process.exit(2);
  }
  createServer(env).listen(env.port, env.host, () => {
    console.log(`page on http://${env.host === '127.0.0.1' ? 'localhost' : env.host}:${String(env.port)} (page origin ${env.allowedOrigin})`);
  });
}
```

### The page: `public/index.html`

```html title="public/index.html"
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Kheelona Voice: browser example</title>
    <link rel="stylesheet" href="/style.css" />
  </head>
  <body>
    <main>
      <h1>Talk to a Kheelona voice</h1>
      <p class="status">State: <strong id="state">not connected</strong></p>
      <div class="row">
        <button id="start" type="button">Start talking</button>
        <button id="end" type="button" disabled>End</button>
        <button id="allow-sound" type="button" hidden>Tap to allow sound</button>
      </div>
      <div class="row">
        <button id="mute" type="button" aria-pressed="false" disabled>Mute</button>
        <button id="interrupt" type="button" disabled>Interrupt</button>
      </div>
      <p id="error" role="alert" hidden></p>
      <div id="captions" aria-live="polite"></div>
      <div class="row">
        <label for="say">Or type a message</label>
        <input id="say" type="text" maxlength="2000" autocomplete="off" disabled />
        <button id="send" type="button" disabled>Send</button>
      </div>
    </main>
    <script type="module" src="/main.js"></script>
  </body>
</html>
```

### The script: `public/main.js`

It asks your backend for a session, connects with the SDK, and listens. The listeners are attached **after**
`await connect()`. The [browser SDK guide](https://docs.voice.kheelona.com/guides/browser-sdk.md) explains each step.

```js title="public/main.js"
/* global document, fetch */
// The page: mint a session on your server, connect with the SDK, show what is said.
// A plain ES module, no build step. The CSP allows only this origin: no inline scripts or styles.
import { KheelonaVoice } from '/sdk/index.js';
import { describeError, MintError } from '/errors.js';
import { wireControls } from '/controls.js';

const $ = (id) => document.getElementById(id);
let session = null;

async function connectToVoice() {
  // Your server creates the session with your API key. The page only ever gets a single-use client secret.
  const res = await fetch('/api/voice-session', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: '{}',
  });
  if (!res.ok) {
    const body = await res.json().catch(() => ({}));
    const retryAfter = Number(res.headers.get('Retry-After'));
    throw new MintError(body.error?.code ?? 'mint_failed', res.status, retryAfter > 0 ? retryAfter * 1000 : undefined);
  }
  const { sessionId, clientSecret, endpoints } = await res.json();
  // Call this from a click: the browser starts the audio and the microphone only after a user gesture.
  // A failed connect needs a new session (a new secret), so every attempt mints again.
  return KheelonaVoice.connect({ sessionId, clientSecret, endpoints, workletBaseUrl: '/sdk/' });
}

function listen(voice) {
  // Attach after `await connect()`: nothing is missed, and `state` first gives the current state.
  voice.on('state', (state) => {
    $('state').textContent = state;
  });
  voice.on('transcript', ({ text }) => addCaption('You', text));
  voice.on('replySentence', ({ text }) => addCaption('Voice', text));
  voice.on('error', (e) => {
    if (e.code === 'audio_unavailable') {
      // The browser refused to play the voice: ask for a tap (see the "Tap to allow sound" button).
      $('allow-sound').hidden = false;
    } else {
      showError(e);
    }
  });
  voice.on('ended', () => finish());
}

// Captions are set with textContent only: what people and the voice say is never read as HTML.
function addCaption(who, text) {
  const line = document.createElement('p');
  line.className = who === 'You' ? 'you' : 'voice';
  line.textContent = `${who}: ${text}`;
  $('captions').append(line);
}

function showError(e) {
  // Show the code (and when to try again); never log error.cause, it can quote connection details.
  $('error').textContent = describeError(e);
  $('error').hidden = false;
}

function setRunning(running) {
  $('start').disabled = running;
  $('end').disabled = !running;
  $('say').disabled = !running;
  $('send').disabled = !running;
  $('mute').disabled = !running;
  $('interrupt').disabled = !running;
}

function finish() {
  session = null;
  setRunning(false);
  $('allow-sound').hidden = true;
  // The next session starts unmuted.
  $('mute').setAttribute('aria-pressed', 'false');
  $('mute').textContent = 'Mute';
  $('state').textContent = 'ended';
}

$('start').addEventListener('click', async () => {
  $('error').hidden = true;
  setRunning(true);
  $('state').textContent = 'connecting';
  try {
    session = await connectToVoice();
    listen(session);
  } catch (e) {
    showError(e);
    finish();
    $('state').textContent = 'not connected';
  }
});

wireControls(() => session, { muteButton: $('mute'), interruptButton: $('interrupt') });

$('end').addEventListener('click', async () => {
  if (session) await session.end();
});

function sendTyped() {
  const text = $('say').value.trim();
  if (!session || text === '') return;
  // The caption comes back as a `transcript` event, like a spoken turn's.
  session.sendText(text);
  $('say').value = '';
}
$('send').addEventListener('click', sendTyped);
$('say').addEventListener('keydown', (e) => {
  if (e.key === 'Enter') sendTyped();
});

// A tap is the gesture the browser needs: call resumeAudio() directly in the handler, with no await before it.
$('allow-sound').addEventListener('click', () => {
  session?.resumeAudio();
  $('allow-sound').hidden = true;
});
```

## Troubleshooting

**`origin_not_allowed`.** The origin of your page is not in your allowed origins. Add it in the console, with the exact
scheme, host and port. For `http://localhost`, ask the Kheelona team to turn on development access. A page opened from a
file (`file://`) has no usable origin and is always refused. Open it through the backend instead.

**The microphone is denied.** The page shows `microphone_unavailable`. Allow the microphone for the page in the browser's
site settings, then click Start again: a failed start needs a new session, and the page makes one each time. Browsers
give a page the microphone only on `https` or on `localhost`. Check that no other program is using it.

**You hear nothing.** Browsers play sound only after a click or a tap. Start the session from a click, as the example
does. If the browser still refuses, the SDK sends an `audio_unavailable` error and the example shows **Tap to allow
sound**: tap it. In your own page, call `session.resumeAudio()` directly in the click handler. See the
[browser SDK guide](https://docs.voice.kheelona.com/guides/browser-sdk.md#no-sound).

**It connected, but through the WebSocket.** The SDK tries WebRTC first and moves to a WebSocket if WebRTC is not
connected within 3 seconds, for example on a network that blocks it. Everything works the same. Check
`session.transport` to see which one you got.

**Something else went wrong.** The page shows an error code. Look it up in [Errors and retries](https://docs.voice.kheelona.com/guides/errors-and-retries.md).
Keep the code, and the request ID or the session ID when your page has one, if you ask us for help.

## Next

- [Browser SDK guide](https://docs.voice.kheelona.com/guides/browser-sdk.md): every option, event and call.
- [Security](https://docs.voice.kheelona.com/guides/security.md): before this page goes to real users.
- [Going live](https://docs.voice.kheelona.com/guides/going-live.md): the checklist.
