Skip to content

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.js is a plain ES module with no build step. Start creates a session through your backend, then calls KheelonaVoice.connect. The listeners are attached after await connect(). Captions are set with textContent only. End calls session.end() and the text box calls sendText. If the browser refuses to play the voice, a Tap to allow sound button calls session.resumeAudio() directly in its click handler.
  • public/controls.js wires the Mute and Interrupt buttons.
  • public/errors.js holds the wording of the error line.
  • server.ts is the whole backend, with only the Node standard library. It serves the page at /, the SDK’s files at /sdk/ and the route POST /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.

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.
Terminal window
cd examples/browser-basic
npm install # the SDK package file from the Kheelona team is already beside package.json
export KHEELONA_API_KEY=kv_live_…
npm start

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

  • Add your own sign-in check where server.ts says Authenticate your user here, and pass that user’s opaque ID as end_user_ref.
  • The route refuses a request whose Origin is missing or not ALLOWED_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.
browser-basic/server.ts
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
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
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
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
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
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.