Browser example
A web page that talks to a Kheelona voice with the browser SDK, and the small backend it needs. Copy it as a starting point. The browser quickstart walks through it.
public/main.jsis a plain ES module with no build step. Start creates a session through your backend, then callsKheelonaVoice.connect. The listeners are attached afterawait connect(). Captions are set withtextContentonly. End callssession.end()and the text box callssendText. If the browser refuses to play the voice, a Tap to allow sound button callssession.resumeAudio()directly in its click handler.public/controls.jswires the Mute and Interrupt buttons.public/errors.jsholds the wording of the error line.server.tsis the whole backend, with only the Node standard library. It serves the page at/, the SDK’s files at/sdk/and the routePOST /api/voice-session, all from one origin, so there is no CORS. Your API key stays in this process.
You need Node 24 or newer, and the example folder and the SDK package: the examples you received from the Kheelona team, with the SDK package.
Settings
Section titled “Settings”| 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 route (blank or null is refused at start) |
KHEELONA_REALTIME_ORIGINS |
https://api.voice.kheelona.com wss://api.voice.kheelona.com |
The origins of the connect URLs, separated by spaces. The page’s Content Security Policy allows connections to these and to nothing else. |
cd examples/browser-basicnpm install # the SDK package file from the Kheelona team is already beside package.jsonexport KHEELONA_API_KEY=kv_live_…npm startOpen http://localhost:5180 and click Start talking. Open the page as localhost, which is what ALLOWED_ORIGIN
expects. If you serve it from another origin, set ALLOWED_ORIGIN to match, and add that origin in your console. See
Security.
Before you copy it
Section titled “Before you copy it”- Add your own sign-in check where
server.tssaysAuthenticate your user here, and pass that user’s opaque ID asend_user_ref. - The route refuses a request whose
Originis missing or notALLOWED_ORIGIN(403) and anything but JSON (415). It returns the session ID, the client secret and the connection addresses, and nothing else. - Every response carries a strict Content Security Policy. See Security.
- The page shows the error code and, when it is retryable, “try again in N s”. A retry creates a new session.
The files
Section titled “The files”browser-basic/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})`); });}browser-basic/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>browser-basic/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;});browser-basic/public/controls.js
// Mute and interrupt buttons for a live session. Kept apart from main.js so it can be tested without a browser.
/** Wires a mute toggle and an interrupt button. `getSession` returns the live session, or null when there is none. */export function wireControls(getSession, { muteButton, interruptButton }) { muteButton.addEventListener('click', () => { const session = getSession(); if (!session) return; // The button holds the state: aria-pressed is "true" while the microphone is muted. const muted = muteButton.getAttribute('aria-pressed') !== 'true'; session.mute(muted); muteButton.setAttribute('aria-pressed', String(muted)); muteButton.textContent = muted ? 'Unmute' : 'Mute'; }); interruptButton.addEventListener('click', () => { // Stops the voice now, as if the person had started speaking. getSession()?.interrupt(); });}browser-basic/public/errors.js
// What the page shows for an error. Kept apart from main.js (which imports the SDK) so it can be tested alone.
/** The mint route refused or failed: carries what the page needs to explain it. */export class MintError extends Error { constructor(code, status, retryAfterMs) { super(code); this.name = 'MintError'; this.code = code; this.retryable = status === 429 || status === 503; this.retryAfterMs = retryAfterMs; }}
/** One line for the person: the code, a friendlier hint for a refused microphone, and "try again in N s" if it helps. */export function describeError(e) { const code = typeof e?.code === 'string' ? e.code : 'error'; const wait = e?.retryable && e?.retryAfterMs ? `, try again in ${String(Math.ceil(e.retryAfterMs / 1000))} s` : ''; if (code === 'microphone_unavailable') { return `The microphone is blocked or missing. Allow it in the browser, then try again (${code})`; } return `Something went wrong (${code})${wait}`;}browser-basic/public/style.css
:root { color-scheme: light dark; font-family: system-ui, sans-serif; }body { margin: 0; }main { max-width: 40rem; margin: 0 auto; padding: 1rem 16px 3rem; }.row { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; margin: 1rem 0; }button { font: inherit; padding: 0.5rem 1rem; }input { font: inherit; flex: 1 1 12rem; padding: 0.5rem; }#error { padding: 0.5rem 0.75rem; border: 2px solid currentColor; border-radius: 0.25rem; }#captions p { margin: 0.4rem 0; }#captions .you { opacity: 0.75; }The examples are Kheelona’s own and are given to partners privately. Contact the Kheelona team for licensing.