# Node server example

> A Node mint route and a server-to-server relay, with every file and how to run them.

Source: https://docs.voice.kheelona.com/examples/node-server/

Two small programs for your own Node server:

1. **The mint route** (`src/server.ts`): `POST /api/voice-session` creates a session with your API key and hands your
   page only the session ID, the client secret and the connection addresses. The key never leaves your server.
2. **The relay** (`src/relay.ts`): a server-to-server client for toys and backends. It creates a session, connects over a
   WebSocket, sends a typed turn and/or a WAV file at real time, prints the events and saves the reply audio.

`src/kheelona.ts` holds `createSession`: one idempotency key per session, up to 3 attempts on `429` and `503`, and errors
that carry the status, the code and the request ID.

You need Node 24 or newer and pnpm, and the example folder: the examples you received from the Kheelona team, with the SDK package.

## 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` / `5181` | Where the mint route listens |
| `ALLOWED_ORIGIN` | `http://localhost:5180` | The one page origin allowed to call the mint route (blank or `null` is refused at start) |

## Run

```sh
cd examples/node-server
pnpm install
export KHEELONA_API_KEY=kv_live_…
pnpm start                             # the mint route on port 5181

pnpm relay --text "Hello Lumi"         # a typed turn
pnpm relay --text "Hello" --wav question.wav --out reply.wav
```

Relay flags: `--text <s>`, `--wav <file>` (at least one), `--out <file>` (default `reply.wav`), `--voice`,
`--languages en,hi` and `--no-interruptions`. The WAV must be 16 kHz, 16-bit, mono:
`ffmpeg -i in.wav -ac 1 -ar 16000 -sample_fmt s16 out.wav`.

The relay prints each event on one line and ends with `session <id> ended <reason> replies <n> audio <s>s saved <file>`.
Errors go to standard error as `error <code> request_id <id>`. Exit codes: 0 ended normally, 1 API or session error, 2
usage error. See the [server quickstart](https://docs.voice.kheelona.com/get-started/quickstart-server.md) for a walk through.

## Before you copy it

- The mint route is same-origin by design and sends no CORS headers. It refuses a request whose `Origin` is missing or
  not `ALLOWED_ORIGIN`, and anything but JSON.
- **Add your own sign-in check** where `server.ts` says `Authenticate your user here`. Pass that user's opaque ID as
  `end_user_ref`. See [Security](https://docs.voice.kheelona.com/guides/security.md).
- Never print, log or return the API key. Errors show the code, the status and the request ID only.

## The files

**node-server/src/kheelona.ts**

```ts title="kheelona.ts"
// The Kheelona REST helper shared by the mint route and the relay, and the example's environment.

/** The settings every example reads (see Settings on the example's docs page). */
export interface ExampleEnv {
  apiKey: string;
  apiUrl: string;
  voiceId: string;
  /** Mint route only: the one page origin that may call it. */
  allowedOrigin: string;
  host: string;
  port: number;
}

/**
 * Reads the settings from environment variables. Throws an Error with a one-line hint when the key is missing or
 * `PORT` is not a port number (the commands exit 2 with that line).
 */
export function readEnv(env: Record<string, string | undefined>): ExampleEnv {
  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 port = Number(env['PORT'] ?? '5181');
  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,
    host: env['HOST'] || '127.0.0.1',
    port,
  };
}

const REQUEST_TIMEOUT_MS = 15_000;
/** The longest wait for one retry, whatever `Retry-After` says. */
const MAX_WAIT_S = 30;

export interface CreateSessionOptions {
  apiUrl: string;
  /** Your API key. Keep it on the server: read it from the environment, never ship it to a browser or a toy. */
  apiKey: string;
  voiceId: string;
  /** Default `["en", "hi"]`. */
  languages?: string[];
  /** An opaque ID for your end user (not a name, e-mail address or other personal data). */
  endUserRef?: string;
  /** `false` = the voice is never cut off (for devices without echo cancellation). */
  interruptions?: boolean;
}

export interface CreatedSession {
  sessionId: string;
  /** The single-use client secret (`kvcs_…`). It is valid for 60 seconds. */
  clientSecret: string;
  websocketUrl: string;
  webrtcUrl: string;
}

/** An error answer from the API: the HTTP status, the error code and the request ID to quote to support. */
export class KheelonaApiError extends Error {
  readonly status: number;
  readonly code: string;
  readonly requestId: string | undefined;
  /** Seconds from the `Retry-After` header, if the API sent one. */
  readonly retryAfterSeconds: number | undefined;
  /** For status 0 (no answer): the name of the failure, e.g. `TimeoutError`. Never its message. */
  readonly causeName: string | undefined;

  constructor(status: number, code: string, requestId?: string, retryAfterSeconds?: number, causeName?: string) {
    super(`Kheelona API error ${String(status)} ${code}${requestId ? ` (request_id ${requestId})` : ''}`);
    this.name = 'KheelonaApiError';
    this.status = status;
    this.code = code;
    this.requestId = requestId;
    this.retryAfterSeconds = retryAfterSeconds;
    this.causeName = causeName;
  }
}

/** Creates a session with `POST /v1/sessions`. Retries 429 and 503 (up to 3 attempts) with one idempotency key. */
export async function createSession(opts: CreateSessionOptions): Promise<CreatedSession> {
  const idempotencyKey = globalThis.crypto.randomUUID(); // one per logical session: a retry never creates a second one
  const body = JSON.stringify({
    voice_id: opts.voiceId,
    languages: opts.languages ?? ['en', 'hi'],
    ...(opts.endUserRef === undefined ? {} : { end_user_ref: opts.endUserRef }),
    ...(opts.interruptions === undefined ? {} : { interruptions: opts.interruptions }),
  });
  for (let attempt = 1; ; attempt += 1) {
    let res: Response;
    try {
      res = await fetch(`${opts.apiUrl.replace(/\/+$/, '')}/v1/sessions`, {
        method: 'POST',
        headers: { Authorization: `Bearer ${opts.apiKey}`, 'Content-Type': 'application/json', 'Idempotency-Key': idempotencyKey },
        body,
        signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), // never wait forever for the API
      });
    } catch (error) {
      // Keep only the kind of failure: its message can echo the request.
      throw new KheelonaApiError(0, 'upstream_unreachable', undefined, undefined, error instanceof Error ? error.name : 'Error');
    }
    if (res.ok) {
      const created = (await res.json()) as {
        session_id: string;
        client_secret: { value: string };
        connect: { websocket_url: string; webrtc_url: string };
      };
      return {
        sessionId: created.session_id,
        clientSecret: created.client_secret.value,
        websocketUrl: created.connect.websocket_url,
        webrtcUrl: created.connect.webrtc_url,
      };
    }
    const error = await readError(res);
    const retryable = res.status === 429 || res.status === 503;
    if (!retryable || attempt >= 3) throw error;
    await new Promise((resolve) => setTimeout(resolve, Math.min(error.retryAfterSeconds ?? 1, MAX_WAIT_S) * 1000));
  }
}

/** Keeps only the error code and request ID from the answer (never the whole body). */
async function readError(res: Response): Promise<KheelonaApiError> {
  const header = res.headers.get('retry-after');
  const retryAfter = header !== null && /^\d+$/.test(header) ? Math.min(Number(header), Number.MAX_SAFE_INTEGER) : undefined; // a huge value stays a number the cap handles
  try {
    const parsed = (await res.json()) as { error?: { code?: unknown; request_id?: unknown } };
    const code = plain(parsed.error?.code) ?? 'unknown_error';
    const requestId = plain(parsed.error?.request_id);
    return new KheelonaApiError(res.status, code, requestId, retryAfter);
  } catch {
    return new KheelonaApiError(res.status, 'unknown_error', undefined, retryAfter);
  }
}

/** 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;
}
```

**node-server/src/server.ts**

```ts title="server.ts"
// The mint route: your server creates the session with your API key and hands the page only a single-use client
// secret. Run it with `npm start` (see Settings on the example's docs page).
import type { Server } from 'node:http';
import { pathToFileURL } from 'node:url';
import express from 'express';
import { createSession, KheelonaApiError, readEnv, type ExampleEnv } from './kheelona.ts';

export function createApp(env: ExampleEnv): express.Express {
  const app = express();
  app.disable('x-powered-by');

  // Same-origin by design (no CORS headers): serve your page and this route from one origin.
  // Only your own page may call this route: a request with no Origin, or another one, never reaches Kheelona.
  const guard: express.RequestHandler = (req, res, next) => {
    const origin = req.get('origin');
    if (origin !== env.allowedOrigin) {
      res.status(403).json({ error: { code: 'origin_not_allowed' } });
      return;
    }
    res.set({ Vary: 'Origin', 'Cache-Control': 'no-store' });
    // JSON only: a cross-site HTML form cannot send it, so it cannot spend your key.
    if (!req.is('application/json')) {
      res.status(415).json({ error: { code: 'unsupported_media_type' } });
      return;
    }
    next();
  };

  app.all('/api/voice-session', guard);
  // express.json (body-parser) checks Content-Length against the limit before it reads the body.
  app.post('/api/voice-session', express.json({ limit: '2kb', type: 'application/json' }), async (req, res) => {
    const body = req.body as Record<string, unknown> | undefined;
    // Only `languages` is accepted. (Language codes here are 2-3 letters, as the API's are; the API validates too.)
    const languages = body?.['languages'];
    const knownKeys = typeof body === 'object' && !Array.isArray(body) && Object.keys(body).every((key) => key === 'languages');
    const validLanguages =
      languages === undefined ||
      (Array.isArray(languages) && languages.length >= 1 && languages.length <= 11 && languages.every((l) => typeof l === 'string' && /^[a-z]{2,3}$/.test(l)));
    if (!knownKeys || !validLanguages) {
      res.status(400).json({ error: { code: 'invalid_request' } });
      return;
    }
    try {
      // Authenticate your user here: only signed-in users may start a session.
      const session = await createSession({
        apiUrl: env.apiUrl,
        apiKey: env.apiKey,
        voiceId: env.voiceId,
        ...(languages === undefined ? {} : { languages: languages as string[] }),
        endUserRef: 'demo-user', // replace with the signed-in user's opaque ID
      });
      // Return only what the page needs: never the API key, and nothing else from the answer.
      res.status(201).json({
        sessionId: session.sessionId,
        clientSecret: session.clientSecret,
        endpoints: { webrtcUrl: session.webrtcUrl, websocketUrl: session.websocketUrl },
      });
    } catch (error) {
      const api = error instanceof KheelonaApiError ? error : undefined;
      console.error(`mint failed: ${api ? `${String(api.status)} ${api.code} request_id ${api.requestId ?? '-'}` : 'unexpected error'}`);
      if (api && (api.status === 429 || api.status === 503)) {
        if (api.retryAfterSeconds !== undefined) res.set('Retry-After', String(api.retryAfterSeconds));
        res.status(api.status);
      } else {
        res.status(502);
      }
      res.json({ error: { code: api?.code ?? 'internal', ...(api?.requestId ? { requestId: api.requestId } : {}) } });
    }
  });

  // A malformed JSON body is the caller's mistake, not ours.
  // Express recognises an error handler by its four parameters, so `_next` stays.
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
  app.use((error: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
    const tooLarge = (error as { status?: number }).status === 413;
    res.status(tooLarge ? 413 : 400).json({ error: { code: tooLarge ? 'payload_too_large' : 'invalid_request' } });
  });
  return app;
}

/** Starts the mint route on `env.host:env.port` (loopback by default) and resolves when it is listening. */
export function startServer(env: ExampleEnv): Promise<Server> {
  return new Promise((resolve, reject) => {
    const server = createApp(env).listen(env.port, env.host, () => {
      resolve(server);
    });
    server.once('error', reject);
  });
}

if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) {
  let env: ExampleEnv;
  try {
    env = readEnv(process.env);
  } catch (error) {
    console.error((error as Error).message);
    process.exit(2);
  }
  const server = await startServer(env);
  const address = server.address();
  console.log(`mint route on http://${env.host}:${typeof address === 'object' && address ? String(address.port) : String(env.port)}/api/voice-session (page origin ${env.allowedOrigin})`);
}
```

**node-server/src/relay.ts**

```ts title="relay.ts"
// A server-to-server relay: creates a session, streams a WAV (and/or a typed turn) to the voice over the WebSocket at
// real time, prints the events, and saves the reply audio to a WAV file. Run it with `pnpm relay --text "Hello"`.
import { readFile, writeFile } from 'node:fs/promises';
import type { IncomingMessage } from 'node:http';
import { parseArgs } from 'node:util';
import { pathToFileURL } from 'node:url';
import { WebSocket, type RawData } from 'ws';
import { createSession, KheelonaApiError, readEnv, type CreatedSession } from './kheelona.ts';
import { readPcm16Wav, writeWav } from './wav.ts';

export interface RelayOptions {
  apiUrl: string;
  apiKey: string;
  voiceId: string;
  languages?: string[];
  /** A typed turn, sent first. */
  text?: string;
  /** A 16 kHz mono PCM16 WAV, sent in real time after the typed turn's reply. */
  wavFile?: string;
  /** Where the reply audio (24 kHz mono WAV) is saved. */
  outFile: string;
  /** `false` = the voice is never cut off. Default: the API's default (on). */
  interruptions?: boolean;
  /** Declare `playback_pause` and pause the playback buffer on `reply.paused`. Default: on. `false` = the voice never pauses. */
  pausePlayback?: boolean;
  /** Where event lines go (default: standard output). */
  log?: (line: string) => void;
  /** Milliseconds to wait for a reply before giving up (default 20,000). */
  replyTimeoutMs?: number;
}

export interface RelaySummary {
  sessionId: string;
  endReason: string;
  replies: number;
  audioSeconds: number;
  outFile: string;
}

/** A bad command line or input file: the CLI exits 2. */
export class UsageError extends Error {}

/**
 * A failed session: the code, the request ID when an HTTP answer carried one, and the session ID once the session
 * existed (realtime `error` events carry no request ID: quote the session ID and the code). The CLI exits 1.
 */
export class RelayError extends Error {
  readonly code: string;
  readonly requestId: string | undefined;
  sessionId: string | undefined;
  constructor(code: string, message: string, requestId?: string) {
    super(message);
    this.name = 'RelayError';
    this.code = code;
    this.requestId = requestId;
  }
}

/**
 * Opens the WebSocket the way a server does: the client secret in the Authorization header, the `kheelona.v1`
 * subprotocol, and no Origin header (a request with one is treated as a browser). Attach your listeners straight
 * away: the server's first event can arrive in the same moment the connection opens.
 */
function openSocket(session: CreatedSession): WebSocket {
  return new WebSocket(session.websocketUrl, ['kheelona.v1'], {
    headers: { Authorization: `Bearer ${session.clientSecret}` },
    handshakeTimeout: 20_000,
  });
}

/** Before the upgrade, errors come back as HTTP errors with a JSON body: keep the code and the request ID. */
function readRefusal(res: IncomingMessage): Promise<RelayError> {
  return new Promise((resolve) => {
    const chunks: Buffer[] = [];
    res.on('data', (chunk: Buffer) => chunks.push(chunk));
    res.on('end', () => {
      let code = `http_${String(res.statusCode)}`;
      let requestId: string | undefined;
      try {
        const body = JSON.parse(Buffer.concat(chunks).toString('utf8').slice(0, 8192)) as { error?: { code?: string; request_id?: string } };
        if (typeof body.error?.code === 'string' && /^[a-z0-9_]{1,64}$/.test(body.error.code)) code = body.error.code;
        if (typeof body.error?.request_id === 'string' && /^[A-Za-z0-9_]{1,64}$/.test(body.error.request_id)) requestId = body.error.request_id;
      } catch {
        // Not JSON: keep the HTTP status as the code.
      }
      resolve(new RelayError(code, `The connection was refused (${code})`, requestId));
    });
  });
}

const FRAME_MS = 20;
const SAMPLES_PER_FRAME = 16 * FRAME_MS; // 16 kHz: 320 samples = 640 bytes
const SILENCE = Buffer.alloc(SAMPLES_PER_FRAME * 2);

/** Splits microphone audio (16 kHz PCM16) into 20 ms frames; the last one is padded with silence. */
export function wavToFrames(samples: Int16Array): Buffer[] {
  const frames: Buffer[] = [];
  for (let at = 0; at < samples.length; at += SAMPLES_PER_FRAME) {
    const frame = Buffer.alloc(SAMPLES_PER_FRAME * 2);
    samples.subarray(at, at + SAMPLES_PER_FRAME).forEach((sample, i) => {
      frame.writeInt16LE(sample, i * 2);
    });
    frames.push(frame);
  }
  return frames;
}

/**
 * Sends one frame every 20 ms, in real time. Frame k goes out at start + k × 20 ms, measured against the clock, so a
 * late wake-up never makes the stream drift (the server drops audio that arrives faster than 2× real time).
 * Stops when `next` returns nothing or `stop` says so.
 */
export async function sendPaced(opts: { next: () => Buffer | undefined; send: (frame: Buffer) => void; stop: () => boolean }): Promise<void> {
  const start = performance.now();
  for (let k = 0; !opts.stop(); k += 1) {
    const frame = opts.next();
    if (!frame) return;
    opts.send(frame);
    await new Promise((resolve) => setTimeout(resolve, Math.max(0, start + (k + 1) * FRAME_MS - performance.now())));
  }
}

/**
 * The playback buffer, with pause support. Frames wait in `push` until `drain` hands them to the speaker; while the
 * voice has paused its own playback (`reply.paused`) `drain` returns nothing and the frames are kept, so nothing is lost.
 * A pause ends at the first of: `reply.resumed`, `interrupted` (the buffer is flushed), `reply.done` or the next
 * `reply.started`. A pause, resume or done for any reply other than the one that is playing (or one that has already
 * ended) is stale and ignored.
 */
export class PausePlayback {
  private queue: Buffer[] = [];
  private current: string | undefined;
  private isPaused = false;
  /** Frames thrown away by `interrupted` or by the end of the session. */
  dropped = 0;

  /** `enabled = false` when the relay did not declare `playback_pause`: the server sends no pause events then. */
  private readonly enabled: boolean;
  constructor(enabled = true) {
    this.enabled = enabled;
  }

  get paused(): boolean {
    return this.isPaused;
  }

  onEvent(event: { type: string; reply_id?: unknown }): void {
    switch (event.type) {
      case 'reply.started':
        this.current = typeof event.reply_id === 'string' ? event.reply_id : undefined; // a new reply ends any pause
        this.isPaused = false;
        break;
      case 'reply.paused':
        if (this.enabled && this.current !== undefined && event.reply_id === this.current) this.isPaused = true;
        break;
      case 'reply.resumed':
        if (event.reply_id === this.current) this.isPaused = false;
        break;
      case 'interrupted': // flush: what was queued will never play
        this.dropped += this.queue.length;
        this.queue = [];
        this.isPaused = false;
        this.current = undefined; // that reply is over: a pause naming it is stale
        break;
      case 'reply.done':
        if (event.reply_id === this.current) {
          this.isPaused = false;
          this.current = undefined;
        }
        break;
      case 'session.ended':
        this.dropped += this.queue.length;
        this.queue = [];
        this.isPaused = false;
        break;
      default:
        break;
    }
  }

  push(frame: Buffer): void {
    this.queue.push(frame);
  }

  /** The frames to play now: none while paused. */
  drain(): Buffer[] {
    if (this.isPaused) return [];
    const frames = this.queue;
    this.queue = [];
    return frames;
  }
}

/** Collects the reply audio, dropping what the server told us to drop. */
export class ReplyAudio {
  private readonly chunks: Buffer[] = [];
  private readonly playback: PausePlayback;
  private newestSeq = -1;
  private droppedThrough = -1;

  constructor(playback = new PausePlayback()) {
    this.playback = playback;
  }

  /** A binary frame: an 8-byte header (kind, flags, sentence, reply_seq) then PCM16 24 kHz samples. */
  push(frame: Buffer): void {
    if (frame.length < 8 || frame.readUInt8(0) !== 0x01) return; // not reply audio: ignore unknown kinds
    const seq = frame.readUInt32LE(4);
    this.newestSeq = Math.max(this.newestSeq, seq);
    if (seq <= this.droppedThrough) return; // a late frame of an interrupted reply
    this.playback.push(frame.subarray(8));
    this.play();
  }

  /** Every event goes here first, so the playback buffer pauses, resumes and flushes with the voice. */
  onEvent(event: { type: string; reply_id?: unknown }): void {
    this.playback.onEvent(event);
    this.play(); // a resume, a done or a new reply lets the held frames out
  }

  /** `interrupted`: drop every later frame of the reply being played. (The playback buffer is flushed by `onEvent`.) */
  interrupted(): void {
    this.droppedThrough = this.newestSeq;
  }

  pcm(): Buffer {
    this.play();
    return Buffer.concat(this.chunks);
  }

  // A real player would hand these frames to the speaker; this example saves them.
  private play(): void {
    this.chunks.push(...this.playback.drain());
  }
}

/** The lines printed for an event: its type, plus the text of what was heard or said. */
export function describeEvent(event: { type: string; text?: unknown }): string[] {
  const lines = [`event ${event.type}`];
  if ((event.type === 'transcript.final' || event.type === 'reply.sentence') && typeof event.text === 'string') lines.push(`  text: ${event.text}`);
  return lines;
}

type ServerEvent = { type: string; [key: string]: unknown };

/** Runs one session: see the file header. Rejects with `KheelonaApiError`, `RelayError` or `UsageError`. */
export async function runRelay(opts: RelayOptions): Promise<RelaySummary> {
  const log =
    opts.log ??
    ((line: string) => {
      console.log(line);
    });
  const replyTimeoutMs = opts.replyTimeoutMs ?? 20_000;
  if (!opts.text && !opts.wavFile) throw new UsageError('Give --text and/or --wav.');
  if (opts.text && Array.from(opts.text).length > 2000) throw new UsageError('--text is at most 2000 characters');
  let wavFrames: Buffer[] | undefined;
  if (opts.wavFile) {
    try {
      wavFrames = wavToFrames(readPcm16Wav(await readFile(opts.wavFile)).samples);
    } catch (error) {
      throw new UsageError(error instanceof Error && 'code' in error ? `Cannot read ${opts.wavFile}.` : (error as Error).message);
    }
  }

  const session = await createSession({
    apiUrl: opts.apiUrl,
    apiKey: opts.apiKey,
    voiceId: opts.voiceId,
    ...(opts.languages ? { languages: opts.languages } : {}),
    ...(opts.interruptions === undefined ? {} : { interruptions: opts.interruptions }),
  });
  const ws = openSocket(session);
  const pause = opts.pausePlayback ?? true;

  const audio = new ReplyAudio(new PausePlayback(pause));
  const queue: Buffer[] = []; // microphone frames waiting to go out; silence fills the gaps
  let wavQueued = false;
  let turnsSent = 0;
  let turnsAnswered = 0;
  let replies = 0;
  let stopPacer = false;
  let pacer: Promise<void> | undefined;
  let timer: NodeJS.Timeout | undefined;
  let finished = false;

  const summary = await new Promise<RelaySummary>((resolve, reject) => {
    const finish = (error?: Error, endReason = 'unknown'): void => {
      if (finished) return;
      finished = true;
      stopPacer = true;
      clearTimeout(timer);
      if (error instanceof RelayError) error.sessionId ??= session.sessionId;
      if (error) reject(error);
      else resolve({ sessionId: session.sessionId, endReason, replies, audioSeconds: audio.pcm().length / 2 / 24_000, outFile: opts.outFile });
    };
    // Nothing from the server for a while (waiting for session.ready, a reply or session.ended): give up, so a copy
    // of this never hangs. Every message from the server restarts the wait.
    const armTimer = (): void => {
      clearTimeout(timer);
      timer = setTimeout(() => {
        finish(new RelayError('no_reply', `Nothing from the server for ${String(replyTimeoutMs / 1000)} s`));
      }, replyTimeoutMs);
    };
    const sendEvent = (event: Record<string, unknown>): void => {
      ws.send(JSON.stringify(event));
    };
    const queueWav = (): void => {
      if (!wavFrames || wavQueued) return;
      wavQueued = true;
      turnsSent += 1;
      queue.push(...wavFrames);
    };
    // Microphone audio at real time for the whole session: your turn's frames, then silence.
    const startPacer = (): void => {
      pacer = sendPaced({
        next: () => {
          const frame = queue.shift();
          if (frame) return frame;
          return SILENCE;
        },
        send: (frame) => {
          if (ws.readyState === WebSocket.OPEN) ws.send(frame);
        },
        stop: () => stopPacer,
      });
    };

    ws.once('open', armTimer);
    ws.on('message', (data: RawData, isBinary: boolean) => {
      armTimer();
      if (isBinary) {
        if (Buffer.isBuffer(data)) audio.push(data);
        return;
      }
      let event: ServerEvent;
      try {
        event = JSON.parse((data as Buffer).toString('utf8')) as ServerEvent;
      } catch {
        return;
      }
      if (typeof event.type !== 'string') return;
      for (const line of describeEvent(event)) log(line);
      audio.onEvent(event);

      switch (event.type) {
        case 'session.ready':
          // Nothing is sent before this: audio sent earlier is dropped.
          if (pause) sendEvent({ type: 'client.capabilities', playback_pause: true });
          if (opts.text) {
            sendEvent({ type: 'input.text', text: opts.text });
            turnsSent += 1;
          } else {
            queueWav();
          }
          startPacer();
          break;
        case 'interrupted':
          audio.interrupted();
          break;
        case 'reply.done':
          replies += 1;
          if (event['reason'] === 'interrupted') break; // the turn goes on with a new reply
          turnsAnswered += 1;
          if (turnsAnswered < turnsSent) break;
          if (!wavQueued && wavFrames) {
            queueWav(); // the typed reply is done: now the WAV
          } else {
            stopPacer = true;
            void (pacer ?? Promise.resolve()).then(() => {
              sendEvent({ type: 'session.end' });
            });
          }
          break;
        case 'error':
          if (event['fatal'] === true) {
            finish(
              new RelayError(typeof event['code'] === 'string' ? event['code'] : 'error', 'The session failed'),
            );
          }
          break;
        case 'session.ended':
          finish(undefined, typeof event['reason'] === 'string' ? event['reason'] : 'unknown');
          break;
        default:
          break;
      }
    });
    ws.on('unexpected-response', (_req, res) => {
      void readRefusal(res).then((error) => {
        finish(error);
      });
    });
    ws.on('close', () => {
      finish(new RelayError('disconnected', 'The connection closed before the session ended'));
    });
    ws.on('error', () => {
      finish(new RelayError('connection_failed', 'The WebSocket failed'));
    });
  }).finally(() => {
    ws.terminate();
  });

  await writeFile(opts.outFile, writeWav(audio.pcm(), 24_000));
  return summary;
}

/** The command line. Returns the exit code: 0 ended normally, 1 API or session error, 2 usage error. */
export async function runCli(
  argv: string[],
  env: Record<string, string | undefined>,
  out: (line: string) => void,
  err: (line: string) => void,
  overrides: { replyTimeoutMs?: number } = {},
): Promise<number> {
  try {
    const { values } = parseArgs({
      args: argv,
      options: {
        text: { type: 'string' },
        wav: { type: 'string' },
        out: { type: 'string', default: 'reply.wav' },
        voice: { type: 'string' },
        languages: { type: 'string' },
        'no-interruptions': { type: 'boolean', default: false },
        'no-pause': { type: 'boolean', default: false },
      },
      strict: true,
    });
    const settings = readEnv(env);
    const summary = await runRelay({
      apiUrl: settings.apiUrl,
      apiKey: settings.apiKey,
      voiceId: values.voice ?? settings.voiceId,
      ...(values.languages ? { languages: values.languages.split(',').map((l) => l.trim()) } : {}),
      ...(values.text ? { text: values.text } : {}),
      ...(values.wav ? { wavFile: values.wav } : {}),
      outFile: values.out,
      ...(values['no-interruptions'] ? { interruptions: false } : {}),
      ...(values['no-pause'] ? { pausePlayback: false } : {}),
      log: out,
      ...(overrides.replyTimeoutMs === undefined ? {} : { replyTimeoutMs: overrides.replyTimeoutMs }),
    });
    out(`session ${summary.sessionId} ended ${summary.endReason} replies ${String(summary.replies)} audio ${summary.audioSeconds.toFixed(1)}s saved ${summary.outFile}`);
    return 0;
  } catch (error) {
    if (error instanceof KheelonaApiError) {
      // No answer at all (status 0): name the failure, like the Python example.
      const code = error.status === 0 ? `api_unreachable_${error.causeName ?? 'Error'}` : error.code;
      err(`error ${code} request_id ${error.requestId ?? '-'}`);
      return 1;
    }
    if (error instanceof RelayError) {
      const session = error.sessionId ? ` session ${error.sessionId}` : '';
      err(`error ${error.code}${session} request_id ${error.requestId ?? '-'}`);
      return 1;
    }
    // A usage mistake (bad flag, missing key, unreadable WAV): one line, exit 2.
    err(error instanceof Error ? error.message : 'Invalid usage.');
    return 2;
  }
}

if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) {
  process.exitCode = await runCli(
    process.argv.slice(2),
    process.env,
    (line) => {
      console.log(line);
    },
    (line) => {
      console.error(line);
    },
  );
}
```

**node-server/src/wav.ts**

```ts title="wav.ts"
// Minimal WAV reading and writing for the relay: PCM, 16-bit, mono. No dependencies.

const HINT = 'Convert it with: ffmpeg -i in.wav -ac 1 -ar 16000 -sample_fmt s16 out.wav';

/**
 * Reads a PCM 16-bit mono WAV. `sampleRate` is the rate the file must have: 16000 for the microphone audio the
 * relay sends (the default), 24000 for the reply audio it saves.
 * Throws an Error that names the problem and the ffmpeg command that fixes it.
 */
export function readPcm16Wav(buf: Buffer, sampleRate = 16000): { sampleRate: number; samples: Int16Array } {
  if (buf.length < 12 || buf.toString('ascii', 0, 4) !== 'RIFF' || buf.toString('ascii', 8, 12) !== 'WAVE') {
    throw new Error(`This is not a WAV file. ${HINT}`);
  }
  let fmt: { format: number; channels: number; rate: number; bits: number } | undefined;
  let offset = 12;
  while (offset + 8 <= buf.length) {
    const id = buf.toString('ascii', offset, offset + 4);
    const size = buf.readUInt32LE(offset + 4);
    const body = offset + 8;
    if (id === 'fmt ' && body + 16 <= buf.length) {
      fmt = { format: buf.readUInt16LE(body), channels: buf.readUInt16LE(body + 2), rate: buf.readUInt32LE(body + 4), bits: buf.readUInt16LE(body + 14) };
    } else if (id === 'data') {
      if (!fmt) throw new Error(`The WAV has no fmt chunk before its audio. ${HINT}`);
      const problem = formatProblem(fmt, sampleRate);
      if (problem) throw new Error(`${problem}. ${HINT}`);
      // A streamed WAV can carry an unknown (0xFFFFFFFF) size: take what the file holds, in whole samples.
      const end = Math.min(buf.length, body + size);
      const bytes = end - body - ((end - body) % 2);
      const samples = new Int16Array(bytes / 2);
      for (let i = 0; i < samples.length; i += 1) samples[i] = buf.readInt16LE(body + i * 2);
      return { sampleRate, samples };
    }
    offset = body + size + (size % 2); // chunks are padded to an even size
  }
  throw new Error(`The WAV has no audio data. ${HINT}`);
}

function formatProblem(fmt: { format: number; channels: number; rate: number; bits: number }, rate: number): string | undefined {
  if (fmt.format !== 1) return `The WAV is not plain PCM (format ${String(fmt.format)})`;
  if (fmt.channels !== 1) return `The WAV has ${String(fmt.channels)} channels (${fmt.channels === 2 ? 'stereo' : 'not mono'}); it must be mono`;
  if (fmt.bits !== 16) return `The WAV is ${String(fmt.bits)}-bit; it must be 16-bit`;
  if (fmt.rate !== rate) return `The WAV is ${String(fmt.rate)} Hz; it must be ${String(rate)} Hz`;
  return undefined;
}

/** Wraps raw PCM 16-bit little-endian mono samples in a WAV container. */
export function writeWav(pcm: Buffer, sampleRate: number): Buffer {
  const header = Buffer.alloc(44);
  header.write('RIFF', 0, 'ascii');
  header.writeUInt32LE(36 + pcm.length, 4);
  header.write('WAVE', 8, 'ascii');
  header.write('fmt ', 12, 'ascii');
  header.writeUInt32LE(16, 16);
  header.writeUInt16LE(1, 20); // PCM
  header.writeUInt16LE(1, 22); // mono
  header.writeUInt32LE(sampleRate, 24);
  header.writeUInt32LE(sampleRate * 2, 28);
  header.writeUInt16LE(2, 32);
  header.writeUInt16LE(16, 34);
  header.write('data', 36, 'ascii');
  header.writeUInt32LE(pcm.length, 40);
  return Buffer.concat([header, pcm]);
}
```

**node-server/package.json**

```json title="package.json"
{
  "name": "@kheelona/example-node-server",
  "version": "0.0.0",
  "private": true,
  "description": "Kheelona Voice example: a client-secret mint route (Express) and a server-to-server WebSocket relay. Proprietary; private distribution only.",
  "license": "UNLICENSED",
  "type": "module",
  "engines": {
    "node": ">=24"
  },
  "scripts": {
    "start": "node src/server.ts",
    "relay": "node src/relay.ts"
  },
  "dependencies": {
    "express": "5.3.0",
    "ws": "8.22.0"
  },
  "devDependencies": {
    "@types/express": "5.0.6",
    "@types/ws": "8.18.2"
  }
}
```

The examples are Kheelona's own and are given to partners privately. Contact the Kheelona team for licensing.
