Quickstart for browsers
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-basicexample 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, socd examples/browser-basicmeans thebrowser-basicfolder of what you received. Contact the Kheelona team for access.
1. Get an API key and allow your page
Section titled “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
Section titled “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:
cd examples/browser-basicnpm installTo 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
Section titled “3. Run it”export KHEELONA_API_KEY=kv_live_…npm startOpen 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
Section titled “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 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:
# $ANSWER holds the JSON answer of POST /v1/sessionsecho "$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
Section titled “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 page.
The backend: server.ts
Section titled “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.
// 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
Section titled “The page: 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
Section titled “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 explains each step.
/* 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
Section titled “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.
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. Keep the code, and the request ID or the session ID when your page has one, if you ask us for help.
- Browser SDK guide: every option, event and call.
- Security: before this page goes to real users.
- Going live: the checklist.