diff --git a/README.md b/README.md
index 6226483..9c33c14 100644
--- a/README.md
+++ b/README.md
@@ -34,6 +34,20 @@ Provider and model allowlists are tracked explicitly. Secrets, payment credentia
- [Migration manifest](docs/migration/manifest.md)
- [Full execution plan](docs/migration/execution-plan.md)
+## Local realtime voice
+
+The optional voice stack adds a browser voice surface, short-lived WebSocket ticket gateway, and a local Hugging Face speech pipeline while preserving the AI Gateway as the single LLM policy boundary.
+
+```text
+Browser -> apps/zvoice -> services/voice-gateway -> services/voice-agent
+ -> services/ai-gateway
+ -> Ollama / llama.cpp / vLLM
+```
+
+- [Voice architecture](docs/architecture/voice-agent.md)
+- [Voice operations runbook](docs/operations/voice-agent.md)
+- [Voice Compose overlay](compose.voice.yml)
+
## Security
No secrets, payment credentials, wallet keys, MPC shares, Cloudflare tokens, or provider API keys may be committed. See [SECURITY.md](SECURITY.md).
diff --git a/apps/zvoice/Dockerfile b/apps/zvoice/Dockerfile
new file mode 100644
index 0000000..3b4afc4
--- /dev/null
+++ b/apps/zvoice/Dockerfile
@@ -0,0 +1,23 @@
+FROM node:22.16.0-alpine3.22
+
+ARG OCI_REVISION=unknown
+ARG OCI_SOURCE=https://github.com/cvsz/z-platform
+ARG OCI_CREATED=unknown
+ARG OCI_VERSION=dev
+
+LABEL org.opencontainers.image.revision="${OCI_REVISION}" \
+ org.opencontainers.image.source="${OCI_SOURCE}" \
+ org.opencontainers.image.created="${OCI_CREATED}" \
+ org.opencontainers.image.version="${OCI_VERSION}"
+
+ENV NODE_ENV=production \
+ HOST=0.0.0.0 \
+ PORT=3022
+
+WORKDIR /app
+COPY --chown=node:node server.mjs ./server.mjs
+COPY --chown=node:node public ./public
+
+USER node
+EXPOSE 3022
+CMD ["node", "server.mjs"]
diff --git a/apps/zvoice/README.md b/apps/zvoice/README.md
new file mode 100644
index 0000000..fc9a4e7
--- /dev/null
+++ b/apps/zvoice/README.md
@@ -0,0 +1,7 @@
+# ZVoice
+
+`apps/zvoice` is the browser voice surface for the Z Platform realtime voice-agent stack.
+
+It requests short-lived signed WebSocket tickets from `services/voice-gateway`, captures microphone audio with an AudioWorklet, sends 16 kHz PCM16 events using the OpenAI Realtime protocol, plays streaming response audio, and stops queued playback when the user interrupts.
+
+The app never exposes `Z_PLATFORM_SERVICE_TOKEN` or provider keys to the browser. In production, place it behind Cloudflare Access or another approved identity boundary and set `ZVOICE_ALLOW_ANONYMOUS=false`.
diff --git a/apps/zvoice/public/app.js b/apps/zvoice/public/app.js
new file mode 100644
index 0000000..2aa9a7b
--- /dev/null
+++ b/apps/zvoice/public/app.js
@@ -0,0 +1,288 @@
+const startButton = document.querySelector("#start");
+const muteButton = document.querySelector("#mute");
+const stopButton = document.querySelector("#stop");
+const cancelButton = document.querySelector("#cancel");
+const clearButton = document.querySelector("#clear");
+const stateBadge = document.querySelector("#voice-state");
+const emptyState = document.querySelector("#empty");
+const voiceStatus = document.querySelector("#status");
+const voiceTranscript = document.querySelector("#transcript");
+const systemPrompt = document.querySelector("#instructions");
+const modelField = document.querySelector("#model");
+
+// The upstream realtime pipeline accepts native mono PCM16 at 16 kHz.
+const PIPELINE_SAMPLE_RATE = 16000;
+let socket = null;
+let audioContext = null;
+let mediaStream = null;
+let captureNode = null;
+let muted = false;
+let sessionConfigured = false;
+let playhead = 0;
+let activeSources = new Set();
+
+function setVoiceStatus(message, tone = "idle") {
+ voiceStatus.textContent = message;
+ voiceStatus.dataset.tone = tone;
+ stateBadge.textContent = message;
+ stateBadge.dataset.tone = tone;
+}
+
+function appendTranscript(role, text) {
+ if (!text) return;
+ const item = document.createElement("li");
+ const label = document.createElement("strong");
+ label.textContent = role === "assistant" ? "AI: " : "You: ";
+ item.append(label, document.createTextNode(text));
+ item.dataset.role = role;
+ voiceTranscript.append(item);
+ emptyState.hidden = true;
+ voiceTranscript.scrollTop = voiceTranscript.scrollHeight;
+}
+
+function bytesToBase64(bytes) {
+ let binary = "";
+ const chunkSize = 0x8000;
+ for (let index = 0; index < bytes.length; index += chunkSize) {
+ binary += String.fromCharCode(...bytes.subarray(index, index + chunkSize));
+ }
+ return btoa(binary);
+}
+
+function base64ToBytes(value) {
+ const binary = atob(value);
+ const bytes = new Uint8Array(binary.length);
+ for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
+ return bytes;
+}
+
+function resampleToPcm16(input, inputRate, outputRate) {
+ if (!input.length) return new Uint8Array();
+ const ratio = inputRate / outputRate;
+ const outputLength = Math.max(1, Math.floor(input.length / ratio));
+ const output = new ArrayBuffer(outputLength * 2);
+ const view = new DataView(output);
+
+ for (let index = 0; index < outputLength; index += 1) {
+ const position = index * ratio;
+ const leftIndex = Math.floor(position);
+ const rightIndex = Math.min(input.length - 1, leftIndex + 1);
+ const fraction = position - leftIndex;
+ const sample = input[leftIndex] * (1 - fraction) + input[rightIndex] * fraction;
+ const clamped = Math.max(-1, Math.min(1, sample));
+ view.setInt16(index * 2, clamped < 0 ? clamped * 0x8000 : clamped * 0x7fff, true);
+ }
+ return new Uint8Array(output);
+}
+
+function pcm16ToFloat32(bytes) {
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
+ const samples = new Float32Array(Math.floor(bytes.byteLength / 2));
+ for (let index = 0; index < samples.length; index += 1) {
+ const value = view.getInt16(index * 2, true);
+ samples[index] = value < 0 ? value / 0x8000 : value / 0x7fff;
+ }
+ return samples;
+}
+
+function stopPlayback() {
+ for (const source of activeSources) {
+ try {
+ source.stop();
+ } catch {
+ // The source may already have ended.
+ }
+ }
+ activeSources = new Set();
+ playhead = audioContext?.currentTime || 0;
+}
+
+function queueAudio(base64Audio) {
+ if (!audioContext || !base64Audio) return;
+ const samples = pcm16ToFloat32(base64ToBytes(base64Audio));
+ if (!samples.length) return;
+ const buffer = audioContext.createBuffer(1, samples.length, PIPELINE_SAMPLE_RATE);
+ buffer.copyToChannel(samples, 0);
+
+ const source = audioContext.createBufferSource();
+ source.buffer = buffer;
+ source.connect(audioContext.destination);
+ const startAt = Math.max(audioContext.currentTime + 0.02, playhead);
+ source.start(startAt);
+ playhead = startAt + buffer.duration;
+ activeSources.add(source);
+ source.addEventListener("ended", () => activeSources.delete(source), { once: true });
+}
+
+function sendSessionUpdate(instructions) {
+ socket.send(JSON.stringify({
+ type: "session.update",
+ session: {
+ type: "realtime",
+ instructions,
+ },
+ }));
+ sessionConfigured = true;
+}
+
+function handleRealtimeEvent(event) {
+ let payload;
+ try {
+ payload = JSON.parse(event.data);
+ } catch {
+ return;
+ }
+
+ switch (payload.type) {
+ case "session.created":
+ sendSessionUpdate(systemPrompt?.value || "You are a concise, helpful voice assistant.");
+ setVoiceStatus("Connected — speak naturally", "ready");
+ break;
+ case "input_audio_buffer.speech_started":
+ stopPlayback();
+ setVoiceStatus("Listening…", "busy");
+ break;
+ case "input_audio_buffer.speech_stopped":
+ setVoiceStatus("Thinking…", "busy");
+ break;
+ case "conversation.item.input_audio_transcription.completed":
+ appendTranscript("user", payload.transcript || payload.text || "");
+ break;
+ case "response.audio.delta":
+ case "response.output_audio.delta":
+ queueAudio(payload.delta);
+ setVoiceStatus("Speaking…", "busy");
+ break;
+ case "response.audio_transcript.done":
+ case "response.output_audio_transcript.done":
+ appendTranscript("assistant", payload.transcript || payload.text || "");
+ break;
+ case "response.done":
+ setVoiceStatus("Connected — speak naturally", "ready");
+ break;
+ case "error":
+ setVoiceStatus(payload.error?.message || "Voice session error", "error");
+ break;
+ default:
+ break;
+ }
+}
+
+async function requestVoiceSession() {
+ const response = await fetch("/api/voice/session", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ instructions: systemPrompt?.value || "You are a concise, helpful voice assistant.",
+ }),
+ });
+ const payload = await response.json();
+ if (!response.ok) {
+ throw new Error(payload.error?.message || payload.error || "Unable to start voice session");
+ }
+ return payload;
+}
+
+async function startCapture() {
+ audioContext = new AudioContext({ latencyHint: "interactive" });
+ await audioContext.audioWorklet.addModule("/voice-worklet.js");
+ mediaStream = await navigator.mediaDevices.getUserMedia({
+ audio: {
+ echoCancellation: true,
+ noiseSuppression: true,
+ autoGainControl: true,
+ channelCount: 1,
+ },
+ });
+
+ const source = audioContext.createMediaStreamSource(mediaStream);
+ captureNode = new AudioWorkletNode(audioContext, "z-platform-voice-capture");
+ captureNode.port.onmessage = ({ data }) => {
+ if (muted || !sessionConfigured || socket?.readyState !== WebSocket.OPEN) return;
+ const bytes = resampleToPcm16(data, audioContext.sampleRate, PIPELINE_SAMPLE_RATE);
+ if (!bytes.length) return;
+ socket.send(JSON.stringify({
+ type: "input_audio_buffer.append",
+ audio: bytesToBase64(bytes),
+ }));
+ };
+ source.connect(captureNode);
+ const silent = audioContext.createGain();
+ silent.gain.value = 0;
+ captureNode.connect(silent).connect(audioContext.destination);
+}
+
+async function startVoice() {
+ startButton.disabled = true;
+ sessionConfigured = false;
+ setVoiceStatus("Requesting secure voice ticket…", "busy");
+ try {
+ const session = await requestVoiceSession();
+ if (modelField) modelField.value = session.model || modelField.value;
+ await startCapture();
+
+ socket = new WebSocket(session.websocket_url, [`zticket.${session.ticket}`]);
+ socket.addEventListener("message", handleRealtimeEvent);
+ socket.addEventListener("open", () => {
+ muteButton.disabled = false;
+ cancelButton.disabled = false;
+ stopButton.disabled = false;
+ setVoiceStatus("Configuring realtime session…", "busy");
+ });
+ socket.addEventListener("close", () => {
+ setVoiceStatus("Disconnected", "idle");
+ void stopVoice();
+ }, { once: true });
+ socket.addEventListener("error", () => {
+ setVoiceStatus("Unable to connect to the voice gateway", "error");
+ });
+ } catch (error) {
+ setVoiceStatus(error instanceof Error ? error.message : "Unable to start voice", "error");
+ await stopVoice();
+ }
+}
+
+async function stopVoice() {
+ sessionConfigured = false;
+ if (socket && socket.readyState < WebSocket.CLOSING) socket.close(1000, "client_stop");
+ socket = null;
+ stopPlayback();
+ captureNode?.disconnect();
+ captureNode = null;
+ for (const track of mediaStream?.getTracks() || []) track.stop();
+ mediaStream = null;
+ if (audioContext && audioContext.state !== "closed") await audioContext.close();
+ audioContext = null;
+ muted = false;
+ startButton.disabled = false;
+ muteButton.disabled = true;
+ muteButton.textContent = "Mute";
+ cancelButton.disabled = true;
+ stopButton.disabled = true;
+}
+
+startButton?.addEventListener("click", () => void startVoice());
+stopButton?.addEventListener("click", () => void stopVoice());
+muteButton?.addEventListener("click", () => {
+ muted = !muted;
+ muteButton.textContent = muted ? "Unmute" : "Mute";
+ setVoiceStatus(muted ? "Microphone muted" : "Connected — speak naturally", muted ? "idle" : "ready");
+});
+
+cancelButton?.addEventListener("click", () => {
+ if (socket?.readyState === WebSocket.OPEN) {
+ socket.send(JSON.stringify({ type: "response.cancel" }));
+ stopPlayback();
+ setVoiceStatus("Response cancelled", "ready");
+ }
+});
+
+clearButton?.addEventListener("click", () => {
+ voiceTranscript.replaceChildren();
+ emptyState.hidden = false;
+});
+
+window.addEventListener("beforeunload", () => {
+ if (socket && socket.readyState < WebSocket.CLOSING) socket.close();
+ for (const track of mediaStream?.getTracks() || []) track.stop();
+});
diff --git a/apps/zvoice/public/index.html b/apps/zvoice/public/index.html
new file mode 100644
index 0000000..226b1ed
--- /dev/null
+++ b/apps/zvoice/public/index.html
@@ -0,0 +1,65 @@
+
+
+
+
+
+
+ ZVoice — Local Realtime Voice Agent
+
+
+
+
+
+
+
Z Platform · Local Voice
+
Realtime voice agent
+
Local VAD, speech recognition and speech synthesis, with LLM traffic governed by the Z Platform AI Gateway.
+
+ Audio is streamed to your configured local runtime. Browser credentials never include provider or platform service keys.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Ready to request microphone access.
+
+
+
+
+
+ Start a session and speak naturally.
+
+
+
+
+
diff --git a/apps/zvoice/public/styles.css b/apps/zvoice/public/styles.css
new file mode 100644
index 0000000..7dc793b
--- /dev/null
+++ b/apps/zvoice/public/styles.css
@@ -0,0 +1,223 @@
+:root {
+ color-scheme: dark;
+ --bg: #07111f;
+ --surface: rgba(15, 29, 48, 0.82);
+ --surface-2: #10233b;
+ --border: rgba(143, 177, 221, 0.22);
+ --text: #edf5ff;
+ --muted: #9db0c9;
+ --accent: #50d4c6;
+ --accent-strong: #8cebe1;
+ --danger: #ff7272;
+ --shadow: 0 26px 90px rgba(0, 0, 0, 0.38);
+}
+
+* { box-sizing: border-box; }
+
+body {
+ min-height: 100vh;
+ margin: 0;
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ color: var(--text);
+ background:
+ radial-gradient(circle at 15% 5%, rgba(31, 200, 183, 0.18), transparent 33%),
+ radial-gradient(circle at 86% 18%, rgba(79, 119, 255, 0.20), transparent 31%),
+ linear-gradient(160deg, #06101d, #0b1a2e 55%, #07111f);
+}
+
+button, input, textarea { font: inherit; }
+
+.shell {
+ width: min(980px, calc(100vw - 28px));
+ margin: 0 auto;
+ padding: 40px 0 60px;
+}
+
+.hero {
+ display: grid;
+ grid-template-columns: 1.4fr 0.8fr;
+ gap: 24px;
+ align-items: end;
+ margin-bottom: 22px;
+}
+
+.eyebrow {
+ margin: 0 0 8px;
+ color: var(--accent);
+ font-size: 0.78rem;
+ font-weight: 750;
+ letter-spacing: 0.13em;
+ text-transform: uppercase;
+}
+
+h1, h2, p { margin-top: 0; }
+
+h1 {
+ margin-bottom: 12px;
+ font-size: clamp(2.1rem, 5vw, 4rem);
+ line-height: 0.98;
+ letter-spacing: -0.05em;
+}
+
+.lede {
+ max-width: 64ch;
+ margin-bottom: 0;
+ color: var(--muted);
+ line-height: 1.55;
+}
+
+.privacy {
+ padding: 16px;
+ border: 1px solid var(--border);
+ border-radius: 16px;
+ background: rgba(8, 20, 35, 0.74);
+ color: var(--muted);
+ font-size: 0.9rem;
+ line-height: 1.5;
+}
+
+.panel {
+ margin-top: 16px;
+ padding: 22px;
+ border: 1px solid var(--border);
+ border-radius: 20px;
+ background: var(--surface);
+ box-shadow: var(--shadow);
+ backdrop-filter: blur(18px);
+}
+
+.panel__header {
+ display: flex;
+ justify-content: space-between;
+ gap: 18px;
+ align-items: flex-start;
+ margin-bottom: 18px;
+}
+
+.panel__header h2 {
+ margin-bottom: 5px;
+ font-size: 1.05rem;
+}
+
+.panel__header p {
+ margin-bottom: 0;
+ color: var(--muted);
+ font-size: 0.9rem;
+}
+
+.badge {
+ flex: none;
+ padding: 7px 11px;
+ border: 1px solid var(--border);
+ border-radius: 999px;
+ color: var(--muted);
+ background: var(--surface-2);
+ font-size: 0.82rem;
+}
+
+.badge[data-tone="ready"] { color: var(--accent-strong); }
+.badge[data-tone="busy"] { color: #ffd166; }
+.badge[data-tone="error"] { color: var(--danger); }
+
+.grid {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr);
+ gap: 14px;
+}
+
+.grid label {
+ display: grid;
+ gap: 7px;
+ color: var(--muted);
+ font-size: 0.87rem;
+ font-weight: 650;
+}
+
+input, textarea {
+ width: 100%;
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ padding: 12px 13px;
+ background: rgba(5, 16, 29, 0.72);
+ color: var(--text);
+ outline: none;
+}
+
+input:focus, textarea:focus {
+ border-color: var(--accent);
+ box-shadow: 0 0 0 3px rgba(80, 212, 198, 0.12);
+}
+
+textarea { resize: vertical; line-height: 1.5; }
+
+.controls {
+ display: flex;
+ gap: 10px;
+ flex-wrap: wrap;
+ margin-top: 16px;
+}
+
+button {
+ min-height: 42px;
+ padding: 0 16px;
+ border: 1px solid transparent;
+ border-radius: 12px;
+ background: var(--accent);
+ color: #03211f;
+ font-weight: 750;
+ cursor: pointer;
+}
+
+button:hover:not(:disabled) { filter: brightness(1.08); }
+button:disabled { cursor: not-allowed; opacity: 0.45; }
+button.danger { background: var(--danger); color: #2b0707; }
+button.secondary { background: transparent; color: var(--text); border-color: var(--border); }
+
+.status {
+ margin: 14px 0 0;
+ padding: 12px 13px;
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ background: rgba(5, 16, 29, 0.48);
+ color: var(--muted);
+}
+
+.transcript-panel { min-height: 260px; }
+
+.transcript {
+ list-style: none;
+ display: grid;
+ gap: 10px;
+ max-height: 340px;
+ overflow: auto;
+ margin: 0;
+ padding: 0;
+}
+
+.transcript li {
+ padding: 12px 13px;
+ border: 1px solid var(--border);
+ border-radius: 14px;
+ background: rgba(5, 16, 29, 0.62);
+ line-height: 1.5;
+}
+
+.transcript li[data-role="assistant"] { border-left: 3px solid var(--accent); }
+.transcript li[data-role="user"] { border-left: 3px solid #7398ff; }
+
+.empty {
+ display: grid;
+ place-items: center;
+ min-height: 150px;
+ color: var(--muted);
+}
+
+.empty[hidden] { display: none; }
+
+@media (max-width: 720px) {
+ .shell { padding-top: 24px; }
+ .hero { grid-template-columns: 1fr; }
+ .panel { padding: 18px; }
+ .panel__header { align-items: stretch; flex-direction: column; }
+ .badge { align-self: flex-start; }
+}
diff --git a/apps/zvoice/public/voice-worklet.js b/apps/zvoice/public/voice-worklet.js
new file mode 100644
index 0000000..becb0fc
--- /dev/null
+++ b/apps/zvoice/public/voice-worklet.js
@@ -0,0 +1,28 @@
+class ZPlatformVoiceCaptureProcessor extends AudioWorkletProcessor {
+ constructor() {
+ super();
+ this.buffer = new Float32Array(2048);
+ this.offset = 0;
+ }
+
+ process(inputs) {
+ const channel = inputs[0]?.[0];
+ if (!channel) return true;
+
+ let sourceOffset = 0;
+ while (sourceOffset < channel.length) {
+ const count = Math.min(channel.length - sourceOffset, this.buffer.length - this.offset);
+ this.buffer.set(channel.subarray(sourceOffset, sourceOffset + count), this.offset);
+ this.offset += count;
+ sourceOffset += count;
+ if (this.offset === this.buffer.length) {
+ this.port.postMessage(this.buffer, [this.buffer.buffer]);
+ this.buffer = new Float32Array(2048);
+ this.offset = 0;
+ }
+ }
+ return true;
+ }
+}
+
+registerProcessor("z-platform-voice-capture", ZPlatformVoiceCaptureProcessor);
diff --git a/apps/zvoice/server.mjs b/apps/zvoice/server.mjs
new file mode 100644
index 0000000..595ac41
--- /dev/null
+++ b/apps/zvoice/server.mjs
@@ -0,0 +1,184 @@
+import { createServer } from "node:http";
+import { readFile } from "node:fs/promises";
+import { randomUUID } from "node:crypto";
+import { fileURLToPath } from "node:url";
+
+const staticAssets = {
+ "/": { file: "index.html", type: "text/html; charset=utf-8" },
+ "/app.js": { file: "app.js", type: "text/javascript; charset=utf-8" },
+ "/voice-worklet.js": { file: "voice-worklet.js", type: "text/javascript; charset=utf-8" },
+ "/styles.css": { file: "styles.css", type: "text/css; charset=utf-8" },
+};
+
+const SECURITY_HEADERS = {
+ "Cache-Control": "no-store",
+ "Content-Security-Policy": [
+ "default-src 'self'",
+ "connect-src 'self' ws: wss:",
+ "media-src 'self' blob:",
+ "script-src 'self'",
+ "style-src 'self'",
+ "img-src 'self' data:",
+ "object-src 'none'",
+ "base-uri 'none'",
+ "frame-ancestors 'none'",
+ ].join("; "),
+ "Cross-Origin-Opener-Policy": "same-origin",
+ "Permissions-Policy": "camera=(), geolocation=(), microphone=(self)",
+ "Referrer-Policy": "no-referrer",
+ "X-Content-Type-Options": "nosniff",
+ "X-Frame-Options": "DENY",
+};
+
+function send(response, status, body, type = "application/json; charset=utf-8", headers = {}) {
+ response.writeHead(status, {
+ "Content-Type": type,
+ "Content-Length": Buffer.byteLength(body),
+ ...SECURITY_HEADERS,
+ ...headers,
+ });
+ response.end(body);
+}
+
+async function json(request) {
+ let size = 0;
+ const chunks = [];
+ for await (const chunk of request) {
+ size += chunk.length;
+ if (size > 32 * 1024) throw new Error("Request body is too large");
+ chunks.push(chunk);
+ }
+ if (!chunks.length) return {};
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
+}
+
+function cleanText(value, fallback, maxLength) {
+ if (value == null || value === "") return fallback;
+ if (typeof value !== "string") throw new Error("Expected text value");
+ const text = value.trim();
+ if (!text || text.length > maxLength) throw new Error("Invalid text value");
+ return text;
+}
+
+function identity(request, env) {
+ const tenantId = String(request.headers["x-tenant-id"] || "").trim();
+ const subjectId = String(
+ request.headers["x-subject-id"] || request.headers["cf-access-authenticated-user-email"] || "",
+ ).trim();
+ const allowAnonymous = String(env.ZVOICE_ALLOW_ANONYMOUS || "false").toLowerCase() === "true";
+ if (tenantId && subjectId) return { tenantId, subjectId };
+ if (allowAnonymous) {
+ return {
+ tenantId: tenantId || "anonymous",
+ subjectId: subjectId || "anonymous",
+ };
+ }
+ throw new Error("Authenticated tenant and subject are required");
+}
+
+export function healthSnapshot(env = process.env) {
+ return {
+ status: "ok",
+ service: "zvoice",
+ voice_gateway_configured: Boolean(
+ env.Z_PLATFORM_VOICE_GATEWAY_URL && env.Z_PLATFORM_SERVICE_TOKEN,
+ ),
+ anonymous_access: String(env.ZVOICE_ALLOW_ANONYMOUS || "false").toLowerCase() === "true",
+ };
+}
+
+export async function createVoiceSession(
+ body,
+ request,
+ env = process.env,
+ fetchImpl = fetch,
+) {
+ const gatewayUrl = env.Z_PLATFORM_VOICE_GATEWAY_URL?.replace(/\/$/, "");
+ const serviceToken = env.Z_PLATFORM_SERVICE_TOKEN;
+ if (!gatewayUrl || !serviceToken) throw new Error("Voice gateway is not configured");
+
+ const { tenantId, subjectId } = identity(request, env);
+ const instructions = cleanText(
+ body.instructions,
+ "You are a concise, helpful voice assistant. Reply in the user's language.",
+ 8000,
+ );
+ const model = cleanText(env.VOICE_LLM_MODEL, "default", 256);
+
+ const result = await fetchImpl(`${gatewayUrl}/v1/voice/tickets`, {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${serviceToken}`,
+ "Content-Type": "application/json",
+ "X-Tenant-Id": tenantId,
+ "X-Subject-Id": subjectId,
+ "X-Request-Id": request.headers["x-request-id"] || randomUUID(),
+ },
+ body: JSON.stringify({ model }),
+ signal: AbortSignal.timeout(5000),
+ });
+
+ const payload = await result.json().catch(() => ({}));
+ if (!result.ok) {
+ throw new Error(payload?.error?.message || "Voice gateway rejected the session request");
+ }
+
+ return {
+ ...payload,
+ model,
+ instructions,
+ };
+}
+
+export function createZVoiceRequestHandler({ env = process.env, fetchImpl = fetch } = {}) {
+ return async (request, response) => {
+ try {
+ const url = new URL(request.url || "/", "http://zvoice.local");
+ if (request.method === "GET" && url.pathname === "/health/live") {
+ return send(response, 200, JSON.stringify({ status: "ok", service: "zvoice" }));
+ }
+ if (request.method === "GET" && url.pathname === "/health") {
+ return send(response, 200, JSON.stringify(healthSnapshot(env)));
+ }
+ if (request.method === "POST" && url.pathname === "/api/voice/session") {
+ const body = await json(request);
+ const session = await createVoiceSession(body, request, env, fetchImpl);
+ return send(response, 201, JSON.stringify(session));
+ }
+
+ const asset = staticAssets[url.pathname];
+ if (request.method === "GET" && asset) {
+ const content = await readFile(new URL(`./public/${asset.file}`, import.meta.url));
+ return send(response, 200, content, asset.type, {
+ "Cache-Control": asset.file === "index.html" ? "no-store" : "public, max-age=300",
+ });
+ }
+ return send(response, 404, JSON.stringify({ error: "Not found" }));
+ } catch (error) {
+ return send(
+ response,
+ 400,
+ JSON.stringify({ error: error instanceof Error ? error.message : "Request failed" }),
+ );
+ }
+ };
+}
+
+export function createZVoiceServer(options = {}) {
+ return createServer(createZVoiceRequestHandler(options));
+}
+
+if (process.argv[1] === fileURLToPath(import.meta.url)) {
+ const host = process.env.HOST || "127.0.0.1";
+ const port = Number(process.env.PORT || 3022);
+ createZVoiceServer().listen(port, host, () => {
+ process.stdout.write(`${JSON.stringify({
+ timestamp: new Date().toISOString(),
+ level: "info",
+ service: "zvoice",
+ event: "listening",
+ host,
+ port,
+ })}\n`);
+ });
+}
diff --git a/apps/zvoice/test/server.test.mjs b/apps/zvoice/test/server.test.mjs
new file mode 100644
index 0000000..8935337
--- /dev/null
+++ b/apps/zvoice/test/server.test.mjs
@@ -0,0 +1,43 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { createVoiceSession, healthSnapshot } from "../server.mjs";
+
+test("health snapshot does not disclose secrets", () => {
+ const result = healthSnapshot({
+ Z_PLATFORM_VOICE_GATEWAY_URL: "http://voice-gateway:8450",
+ Z_PLATFORM_SERVICE_TOKEN: "secret",
+ ZVOICE_ALLOW_ANONYMOUS: "true",
+ });
+ assert.equal(result.voice_gateway_configured, true);
+ assert.equal(JSON.stringify(result).includes("secret"), false);
+});
+
+test("session request proxies identity and returns browser-safe data", async () => {
+ let captured;
+ const fetchImpl = async (url, options) => {
+ captured = { url, options };
+ return new Response(JSON.stringify({
+ ticket: "signed-ticket",
+ websocket_url: "ws://localhost:8450/v1/realtime",
+ expires_at: "2030-01-01T00:00:00.000Z",
+ ticket_transport: "sec-websocket-protocol",
+ }), { status: 201, headers: { "Content-Type": "application/json" } });
+ };
+
+ const result = await createVoiceSession(
+ { model: "qwen3:8b", instructions: "Be helpful" },
+ { headers: { "x-tenant-id": "tenant-1", "x-subject-id": "user-1" } },
+ {
+ Z_PLATFORM_VOICE_GATEWAY_URL: "http://voice-gateway:8450",
+ Z_PLATFORM_SERVICE_TOKEN: "service-token",
+ ZVOICE_ALLOW_ANONYMOUS: "false",
+ },
+ fetchImpl,
+ );
+
+ assert.equal(result.ticket, "signed-ticket");
+ assert.equal(result.instructions, "Be helpful");
+ assert.equal(captured.options.headers.Authorization, "Bearer service-token");
+ assert.equal(captured.options.headers["X-Tenant-Id"], "tenant-1");
+ assert.equal(captured.options.headers["X-Subject-Id"], "user-1");
+});
diff --git a/compose.voice.yml b/compose.voice.yml
new file mode 100644
index 0000000..3fdcd1d
--- /dev/null
+++ b/compose.voice.yml
@@ -0,0 +1,193 @@
+services:
+ ai-gateway:
+ environment:
+ UPSTREAM_BASE_URL: ${UPSTREAM_BASE_URL:-http://ollama:11434/v1}
+ UPSTREAM_PROVIDER: ${UPSTREAM_PROVIDER:-ollama}
+ AI_DEFAULT_PROVIDER: ${AI_DEFAULT_PROVIDER:-ollama}
+ AI_PROVIDER_KEYS_JSON: ${AI_PROVIDER_KEYS_JSON:?set AI_PROVIDER_KEYS_JSON in .env}
+ AI_MODELS_JSON: ${AI_MODELS_JSON:-["qwen3:8b"]}
+ AI_MODEL: ${AI_MODEL:-qwen3:8b}
+
+ voice-agent:
+ build:
+ context: ./services/voice-agent
+ dockerfile: Dockerfile
+ args:
+ SPEECH_TO_SPEECH_VERSION: ${SPEECH_TO_SPEECH_VERSION:-0.2.11}
+ QWENTTS_CPP_VERSION: ${QWENTTS_CPP_VERSION:-0.3.1+cpu}
+ QWENTTS_WHEEL_INDEX: ${QWENTTS_WHEEL_INDEX:-https://huggingface.co/datasets/andito/qwentts-cpp-python-wheels/tree/main/whl/cpu}
+ restart: unless-stopped
+ init: true
+ networks:
+ - z-platform-internal
+ security_opt:
+ - no-new-privileges:true
+ environment:
+ VOICE_AGENT_PORT: 8765
+ VOICE_LLM_MODEL: ${VOICE_LLM_MODEL:-qwen3:8b}
+ VOICE_LLM_BASE_URL: http://ai-gateway:8400/v1
+ VOICE_LLM_API_KEY: ${Z_PLATFORM_SERVICE_TOKEN:?set Z_PLATFORM_SERVICE_TOKEN in .env}
+ VOICE_STT_BACKEND: ${VOICE_STT_BACKEND:-faster-whisper}
+ VOICE_STT_MODEL: ${VOICE_STT_MODEL:-small}
+ VOICE_STT_DEVICE: ${VOICE_STT_DEVICE:-auto}
+ VOICE_STT_COMPUTE_TYPE: ${VOICE_STT_COMPUTE_TYPE:-auto}
+ VOICE_STT_LANGUAGE: ${VOICE_STT_LANGUAGE:-th}
+ VOICE_TTS_BACKEND: ${VOICE_TTS_BACKEND:-qwen3}
+ VOICE_TTS_MODEL: ${VOICE_TTS_MODEL:-Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice}
+ VOICE_TTS_SPEAKER: ${VOICE_TTS_SPEAKER:-Aiden}
+ VOICE_TTS_LANGUAGE: ${VOICE_TTS_LANGUAGE:-auto}
+ VOICE_TTS_DEVICE: ${VOICE_TTS_DEVICE:-cpu}
+ VOICE_TTS_BACKEND_ENGINE: ${VOICE_TTS_BACKEND_ENGINE:-ggml}
+ VOICE_TTS_QUANTIZATION: ${VOICE_TTS_QUANTIZATION:-Q4_K_M}
+ VOICE_NUM_PIPELINES: ${VOICE_NUM_PIPELINES:-1}
+ VOICE_VAD_THRESHOLD: ${VOICE_VAD_THRESHOLD:-0.6}
+ VOICE_MIN_SPEECH_MS: ${VOICE_MIN_SPEECH_MS:-250}
+ VOICE_MIN_SILENCE_MS: ${VOICE_MIN_SILENCE_MS:-500}
+ VOICE_QWEN3_NON_STREAMING: ${VOICE_QWEN3_NON_STREAMING:-True}
+ VOICE_LOG_LEVEL: ${VOICE_LOG_LEVEL:-info}
+ HF_TOKEN: ${HF_TOKEN:-}
+ volumes:
+ - voice_hf_cache:/models
+ depends_on:
+ ai-gateway:
+ condition: service_healthy
+
+ voice-gateway:
+ build:
+ context: ./services/voice-gateway
+ dockerfile: Dockerfile
+ restart: unless-stopped
+ init: true
+ networks:
+ - z-platform-internal
+ security_opt:
+ - no-new-privileges:true
+ read_only: true
+ tmpfs:
+ - /tmp
+ environment:
+ HOST: 0.0.0.0
+ PORT: 8450
+ Z_PLATFORM_SERVICE_TOKEN: ${Z_PLATFORM_SERVICE_TOKEN:?set Z_PLATFORM_SERVICE_TOKEN in .env}
+ VOICE_TICKET_SECRET: ${VOICE_TICKET_SECRET:?set VOICE_TICKET_SECRET in .env}
+ VOICE_AGENT_URL: http://voice-agent:8765
+ VOICE_PUBLIC_WS_URL: ${VOICE_PUBLIC_WS_URL:-ws://127.0.0.1:8450/v1/realtime}
+ VOICE_TICKET_TTL_SECONDS: ${VOICE_TICKET_TTL_SECONDS:-60}
+ VOICE_MAX_SESSIONS: ${VOICE_MAX_SESSIONS:-2}
+ VOICE_MAX_SESSIONS_PER_IP: ${VOICE_MAX_SESSIONS_PER_IP:-1}
+ VOICE_ALLOW_ANONYMOUS: ${VOICE_ALLOW_ANONYMOUS:-true}
+ ports:
+ - "127.0.0.1:${VOICE_GATEWAY_PORT:-8450}:8450"
+ depends_on:
+ voice-agent:
+ condition: service_healthy
+ healthcheck:
+ test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8450/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"]
+ interval: 10s
+ timeout: 3s
+ retries: 12
+ start_period: 5s
+
+ zvoice:
+ build:
+ context: ./apps/zvoice
+ dockerfile: Dockerfile
+ restart: unless-stopped
+ init: true
+ networks:
+ - z-platform-internal
+ security_opt:
+ - no-new-privileges:true
+ read_only: true
+ tmpfs:
+ - /tmp
+ environment:
+ HOST: 0.0.0.0
+ PORT: 3022
+ Z_PLATFORM_SERVICE_TOKEN: ${Z_PLATFORM_SERVICE_TOKEN:?set Z_PLATFORM_SERVICE_TOKEN in .env}
+ Z_PLATFORM_VOICE_GATEWAY_URL: http://voice-gateway:8450
+ VOICE_LLM_MODEL: ${VOICE_LLM_MODEL:-qwen3:8b}
+ ZVOICE_ALLOW_ANONYMOUS: ${VOICE_ALLOW_ANONYMOUS:-true}
+ ports:
+ - "127.0.0.1:${ZVOICE_PORT:-3022}:3022"
+ depends_on:
+ voice-gateway:
+ condition: service_healthy
+ healthcheck:
+ test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3022/health/live').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"]
+ interval: 10s
+ timeout: 3s
+ retries: 10
+ start_period: 5s
+
+ ollama:
+ profiles: ["voice-ollama"]
+ image: ${OLLAMA_IMAGE:-ollama/ollama:0.32.3}
+ restart: unless-stopped
+ networks:
+ - z-platform-internal
+ volumes:
+ - ollama_data:/root/.ollama
+ ports:
+ - "127.0.0.1:${OLLAMA_PORT:-11434}:11434"
+
+ llama-cpp:
+ profiles: ["voice-llamacpp"]
+ image: ${LLAMA_CPP_IMAGE:-ghcr.io/ggml-org/llama.cpp:server-b9404}
+ restart: unless-stopped
+ networks:
+ - z-platform-internal
+ command:
+ - --host
+ - 0.0.0.0
+ - --port
+ - "8080"
+ - --model
+ - /models/${LLAMA_CPP_MODEL_FILE:-model.gguf}
+ - --alias
+ - ${VOICE_LLM_MODEL:-local-voice-model}
+ - --ctx-size
+ - ${LLAMA_CPP_CONTEXT_SIZE:-8192}
+ - --parallel
+ - ${LLAMA_CPP_PARALLEL:-2}
+ volumes:
+ - ${LLAMA_CPP_MODELS_DIR:-./models/llama.cpp}:/models:ro
+ ports:
+ - "127.0.0.1:${LLAMA_CPP_PORT:-8080}:8080"
+
+ vllm:
+ profiles: ["voice-vllm"]
+ image: ${VLLM_IMAGE:-vllm/vllm-openai:v0.26.0}
+ restart: unless-stopped
+ networks:
+ - z-platform-internal
+ ipc: host
+ command:
+ - --model
+ - ${VLLM_MODEL:-Qwen/Qwen3-4B-Instruct-2507}
+ - --served-model-name
+ - ${VOICE_LLM_MODEL:-local-voice-model}
+ - --api-key
+ - ${VLLM_API_KEY:-local}
+ - --generation-config
+ - vllm
+ - --max-model-len
+ - ${VLLM_MAX_MODEL_LEN:-8192}
+ environment:
+ HUGGING_FACE_HUB_TOKEN: ${HF_TOKEN:-}
+ volumes:
+ - vllm_cache:/root/.cache/huggingface
+ ports:
+ - "127.0.0.1:${VLLM_PORT:-8000}:8000"
+ deploy:
+ resources:
+ reservations:
+ devices:
+ - driver: nvidia
+ count: ${VLLM_GPU_COUNT:-1}
+ capabilities: [gpu]
+
+volumes:
+ voice_hf_cache:
+ ollama_data:
+ vllm_cache:
diff --git a/configs/voice-agent.env.example b/configs/voice-agent.env.example
new file mode 100644
index 0000000..75f097f
--- /dev/null
+++ b/configs/voice-agent.env.example
@@ -0,0 +1,72 @@
+# Generate both values; never commit the resulting .env file.
+# openssl rand -hex 32
+Z_PLATFORM_SERVICE_TOKEN=
+VOICE_TICKET_SECRET=
+
+# Public browser endpoint. Use wss:// behind Cloudflare/Caddy in production.
+VOICE_PUBLIC_WS_URL=ws://127.0.0.1:8450/v1/realtime
+VOICE_GATEWAY_PORT=8450
+VOICE_TICKET_TTL_SECONDS=60
+VOICE_MAX_SESSIONS=2
+VOICE_MAX_SESSIONS_PER_IP=1
+VOICE_ALLOW_ANONYMOUS=true
+
+# Speech pipeline
+SPEECH_TO_SPEECH_VERSION=0.2.11
+VOICE_STT_BACKEND=faster-whisper
+VOICE_STT_MODEL=small
+VOICE_STT_DEVICE=auto
+VOICE_STT_COMPUTE_TYPE=auto
+VOICE_STT_LANGUAGE=th
+VOICE_TTS_BACKEND=qwen3
+VOICE_TTS_MODEL=Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice
+VOICE_TTS_SPEAKER=Aiden
+VOICE_TTS_LANGUAGE=auto
+VOICE_TTS_DEVICE=cpu
+VOICE_TTS_BACKEND_ENGINE=ggml
+VOICE_TTS_QUANTIZATION=Q4_K_M
+VOICE_NUM_PIPELINES=1
+VOICE_VAD_THRESHOLD=0.6
+VOICE_MIN_SPEECH_MS=250
+VOICE_MIN_SILENCE_MS=500
+VOICE_QWEN3_NON_STREAMING=True
+
+# Select exactly one local LLM upstream and keep AI Gateway as the policy boundary.
+#
+# Ollama:
+# docker compose -f compose.yml -f compose.voice.yml --profile voice-ollama up -d
+# docker compose exec ollama ollama pull qwen3:8b
+UPSTREAM_PROVIDER=ollama
+UPSTREAM_BASE_URL=http://ollama:11434/v1
+AI_DEFAULT_PROVIDER=ollama
+AI_PROVIDER_KEYS_JSON={"ollama":["ollama"]}
+AI_MODELS_JSON=["qwen3:8b"]
+AI_MODEL=qwen3:8b
+VOICE_LLM_MODEL=qwen3:8b
+#
+# llama.cpp:
+# UPSTREAM_PROVIDER=llama-cpp
+# UPSTREAM_BASE_URL=http://llama-cpp:8080/v1
+# AI_DEFAULT_PROVIDER=llama-cpp
+# AI_PROVIDER_KEYS_JSON={"llama-cpp":["local"]}
+# AI_MODELS_JSON=["local-voice-model"]
+# AI_MODEL=local-voice-model
+# VOICE_LLM_MODEL=local-voice-model
+# LLAMA_CPP_MODELS_DIR=./models/llama.cpp
+# LLAMA_CPP_MODEL_FILE=model.gguf
+#
+# vLLM:
+# UPSTREAM_PROVIDER=vllm
+# UPSTREAM_BASE_URL=http://vllm:8000/v1
+# AI_DEFAULT_PROVIDER=vllm
+# VLLM_API_KEY=local
+# AI_PROVIDER_KEYS_JSON={"vllm":["local"]}
+# AI_MODELS_JSON=["local-voice-model"]
+# AI_MODEL=local-voice-model
+# VOICE_LLM_MODEL=local-voice-model
+# VLLM_MODEL=Qwen/Qwen3-4B-Instruct-2507
+
+# Pin runtime images. Review and update them deliberately.
+OLLAMA_IMAGE=ollama/ollama:0.32.3
+LLAMA_CPP_IMAGE=ghcr.io/ggml-org/llama.cpp:server-b9404
+VLLM_IMAGE=vllm/vllm-openai:v0.26.0
diff --git a/docs/architecture/voice-agent.md b/docs/architecture/voice-agent.md
new file mode 100644
index 0000000..a3f8a36
--- /dev/null
+++ b/docs/architecture/voice-agent.md
@@ -0,0 +1,95 @@
+# Local Realtime Voice Agent Architecture
+
+## Status
+
+Initial production-oriented vertical slice. External traffic remains disabled by default; published ports bind to loopback.
+
+## Goals
+
+- Real-time speech conversation with interruption handling.
+- Local VAD, STT, and TTS, with a local or hosted OpenAI-compatible LLM.
+- Keep model/provider credentials and platform service tokens out of the browser.
+- Preserve `apps -> services -> packages/contracts` dependency direction.
+- Route all LLM traffic through `services/ai-gateway`.
+- Support Ollama, llama.cpp, and vLLM without coupling the speech pipeline to one runtime.
+
+## Components
+
+```text
+Browser / ZVoice
+ │ POST /api/voice/session
+ ▼
+apps/zvoice (server-side)
+ │ service-token authenticated ticket request
+ ▼
+services/voice-gateway :8450
+ │ signed one-time ticket + WebSocket tunnel
+ ▼
+services/voice-agent :8765 (internal only)
+ │ VAD -> STT -> LLM -> TTS
+ │ │
+ │ ▼
+ │ services/ai-gateway :8400
+ │ │
+ │ ├─ Ollama :11434/v1
+ │ ├─ llama.cpp :8080/v1
+ │ ├─ vLLM :8000/v1
+ │ └─ hosted OpenAI-compatible provider
+ ▼
+PCM audio events returned to the browser
+```
+
+## Security boundary
+
+1. `zvoice` holds `Z_PLATFORM_SERVICE_TOKEN` server-side.
+2. The browser requests a voice session from `zvoice`; it never receives the service token.
+3. `voice-gateway` issues a signed ticket with a 10–300 second lifetime.
+4. The browser sends the ticket through `Sec-WebSocket-Protocol` as `zticket.`.
+5. The gateway validates the HMAC, expiry, tenant, subject, and nonce, then consumes the nonce.
+6. The speech runtime has no published host port and only accepts traffic from the internal Compose network.
+7. The speech runtime calls `ai-gateway`; provider credentials remain in gateway/secret storage.
+
+The first slice keeps consumed ticket nonces in memory and therefore runs `voice-gateway` as a single replica. Before horizontal scaling, move nonce consumption and active-session admission to Redis using an atomic `SET NX EX` operation.
+
+## Realtime protocol
+
+The speech runtime exposes the OpenAI Realtime-compatible `/v1/realtime` WebSocket endpoint. The ZVoice client sends 16 kHz mono PCM16 audio with `input_audio_buffer.append` and handles:
+
+- `input_audio_buffer.speech_started`
+- `input_audio_buffer.speech_stopped`
+- live/final transcription events
+- `response.output_audio.delta`
+- `response.output_audio_transcript.done`
+- `response.done`
+- `error`
+
+New user speech stops queued playback immediately, enabling barge-in behavior.
+
+## Model/runtime profiles
+
+| Profile | Best fit | AI Gateway upstream |
+|---|---|---|
+| `voice-ollama` | Simple local setup, CPU/GPU, easy model management | `http://ollama:11434/v1` |
+| `voice-llamacpp` | GGUF models, CPU/Metal/CUDA/Vulkan, tight memory control | `http://llama-cpp:8080/v1` |
+| `voice-vllm` | NVIDIA GPU throughput, continuous batching, multi-session workloads | `http://vllm:8000/v1` |
+
+Only one LLM profile should be selected per local deployment unless a separate provider router is configured.
+
+## Capacity model
+
+- `VOICE_NUM_PIPELINES` controls concurrent Hugging Face realtime pipelines. Each pipeline loads its own conversation handlers and can materially increase VRAM/RAM.
+- `VOICE_MAX_SESSIONS` is enforced at `voice-gateway`.
+- Start with one pipeline/session on CPU and two only after measuring memory and latency.
+- The browser sends approximately 32 KB/s of uncompressed 16 kHz mono PCM16 before WebSocket framing.
+
+## Target SLOs
+
+These are engineering targets, not guarantees:
+
+- Ticket issuance p95: under 100 ms on the local network.
+- VAD end-of-turn decision: 300–600 ms after speech ends.
+- First partial transcript: under 700 ms where the STT backend supports streaming.
+- First synthesized audio: under 1.5 seconds after end-of-turn on a suitable GPU.
+- Session setup success: at least 99.5% in a healthy single-node deployment.
+
+Record actual values before enabling external production traffic.
diff --git a/docs/operations/voice-agent.md b/docs/operations/voice-agent.md
new file mode 100644
index 0000000..cad6df0
--- /dev/null
+++ b/docs/operations/voice-agent.md
@@ -0,0 +1,95 @@
+# Voice Agent Operations Runbook
+
+## Prerequisites
+
+- Docker Engine and Docker Compose.
+- A microphone-capable browser served from `localhost` or HTTPS.
+- Sufficient disk for model caches.
+- NVIDIA Container Toolkit only for the vLLM profile or a CUDA-enabled custom voice-agent image.
+
+## Configure
+
+```bash
+cp configs/voice-agent.env.example .env.voice
+openssl rand -hex 32
+```
+
+Set the generated values as `Z_PLATFORM_SERVICE_TOKEN` and `VOICE_TICKET_SECRET` in the environment file. Keep `.env.voice` outside version control.
+
+## Ollama path
+
+```bash
+docker compose --env-file .env.voice -f compose.yml -f compose.voice.yml --profile voice-ollama up -d --build
+
+docker compose --env-file .env.voice -f compose.yml -f compose.voice.yml exec ollama ollama pull qwen3:8b
+```
+
+## llama.cpp path
+
+Place a GGUF model at `./models/llama.cpp/model.gguf`, select the llama.cpp block in `.env.voice`, then run:
+
+```bash
+docker compose --env-file .env.voice -f compose.yml -f compose.voice.yml --profile voice-llamacpp up -d --build
+```
+
+## vLLM path
+
+Select the vLLM block in `.env.voice`, verify the NVIDIA runtime, then run:
+
+```bash
+docker compose --env-file .env.voice -f compose.yml -f compose.voice.yml --profile voice-vllm up -d --build
+```
+
+## Validate
+
+```bash
+set -a
+source .env.voice
+set +a
+bash scripts/voice-agent-smoke.sh
+```
+
+Open `http://127.0.0.1:3022`, allow microphone access, and use the Voice panel.
+
+Validate all of the following:
+
+- microphone permission and capture;
+- final transcription quality for the target language;
+- response audio playback;
+- barge-in while the assistant is speaking;
+- ticket expiry and replay rejection;
+- model/provider failure behavior;
+- browser reconnect behavior;
+- CPU, RAM, VRAM, and disk-cache growth.
+
+## Observability
+
+- `GET /health` on port 8450 reports active and maximum sessions.
+- `GET /metrics` exposes Prometheus text metrics.
+- Structured gateway logs include ticket/session lifecycle events but never ticket values or audio.
+- Add the voice gateway metrics endpoint to the platform Prometheus scrape configuration before staging.
+
+## Failure modes
+
+| Symptom | Likely cause | Action |
+|---|---|---|
+| Voice ticket returns 401 | Service token mismatch | Verify the same `Z_PLATFORM_SERVICE_TOKEN` reaches ZVoice and voice-gateway |
+| WebSocket returns 401 | Ticket expired, replayed, or malformed | Request a new session; inspect gateway rejection logs |
+| Voice agent unhealthy for several minutes | Initial model download or insufficient RAM/disk | Inspect `voice-agent` logs and model-cache volume |
+| No transcription | Wrong STT model/language or no microphone frames | Confirm browser permission and test a smaller Whisper model |
+| LLM 503 from AI Gateway | Provider key pool not seeded | Verify `AI_PROVIDER_KEYS_JSON`, `AI_DEFAULT_PROVIDER`, and upstream URL |
+| High response latency | CPU-only STT/TTS/LLM or oversized model | Move one stage to GPU, reduce model size, and benchmark stages separately |
+| Audio overlaps after interruption | Client did not stop queued sources | Confirm `speech_started` events reach the browser and clear playback queue |
+
+## Production gate
+
+Do not expose the port publicly until:
+
+- Cloudflare/OIDC identity supplies stable tenant and subject headers;
+- `VOICE_ALLOW_ANONYMOUS=false`;
+- TLS terminates at a reviewed reverse proxy;
+- ticket replay state is moved to Redis for multi-replica operation;
+- rate limits and concurrency limits match hardware capacity;
+- privacy notice, retention policy, and consent flow cover microphone/audio processing;
+- load, interruption, recovery, and data-boundary tests pass;
+- a human release owner signs off.
diff --git a/package.json b/package.json
index 6f5e259..0ff44e6 100644
--- a/package.json
+++ b/package.json
@@ -6,7 +6,7 @@
"packageManager": "pnpm@11.4.0",
"scripts": {
"build": "pnpm -r --if-present build",
- "test": "pnpm -r --workspace-concurrency=1 --if-present test && node --test scripts/test/*.test.mjs",
+ "test": "pnpm -r --workspace-concurrency=1 --if-present test && node --test scripts/test/*.test.mjs apps/zvoice/test/*.test.mjs services/voice-gateway/test/*.test.mjs",
"lint": "pnpm -r --if-present lint",
"typecheck": "pnpm -r --if-present typecheck",
"keys:validate": "node scripts/validate-api-keys.mjs",
diff --git a/scripts/voice-agent-smoke.sh b/scripts/voice-agent-smoke.sh
new file mode 100644
index 0000000..ad25b89
--- /dev/null
+++ b/scripts/voice-agent-smoke.sh
@@ -0,0 +1,32 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+voice_gateway_url="${VOICE_GATEWAY_HTTP_URL:-http://127.0.0.1:8450}"
+zvoice_url="${ZVOICE_URL:-http://127.0.0.1:3022}"
+
+curl --fail --silent --show-error "${voice_gateway_url}/health" | python3 -m json.tool
+curl --fail --silent --show-error "${zvoice_url}/health/live" | python3 -m json.tool
+
+ticket_json="$(
+ curl --fail --silent --show-error \
+ -X POST "${voice_gateway_url}/v1/voice/tickets" \
+ -H "Authorization: Bearer ${Z_PLATFORM_SERVICE_TOKEN:?Z_PLATFORM_SERVICE_TOKEN is required}" \
+ -H "X-Tenant-Id: smoke-test" \
+ -H "X-Subject-Id: smoke-test" \
+ -H "Content-Type: application/json" \
+ -d '{}'
+)"
+
+python3 - "${ticket_json}" <<'PY'
+import json
+import sys
+
+payload = json.loads(sys.argv[1])
+assert payload["ticket"]
+assert payload["websocket_url"].endswith("/v1/realtime")
+assert payload["ticket_transport"] == "sec-websocket-protocol"
+print("voice ticket contract: ok")
+PY
+
+echo "HTTP and ticket smoke checks passed."
+echo "Run the browser test at ${zvoice_url} to validate microphone capture, interruption, transcription, and audio playback."
diff --git a/services/voice-agent/Dockerfile b/services/voice-agent/Dockerfile
new file mode 100644
index 0000000..697351f
--- /dev/null
+++ b/services/voice-agent/Dockerfile
@@ -0,0 +1,38 @@
+FROM python:3.12-slim-bookworm
+
+ARG SPEECH_TO_SPEECH_VERSION=0.2.11
+ARG QWENTTS_CPP_VERSION=0.3.1+cpu
+ARG QWENTTS_WHEEL_INDEX=https://huggingface.co/datasets/andito/qwentts-cpp-python-wheels/tree/main/whl/cpu
+
+ENV PYTHONDONTWRITEBYTECODE=1 \
+ PYTHONUNBUFFERED=1 \
+ PIP_DISABLE_PIP_VERSION_CHECK=1 \
+ HF_HOME=/models/huggingface \
+ TRANSFORMERS_CACHE=/models/huggingface/transformers \
+ XDG_CACHE_HOME=/models/cache
+
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends ca-certificates curl ffmpeg libgomp1 libsndfile1 \
+ && rm -rf /var/lib/apt/lists/*
+
+RUN python -m pip install --no-cache-dir --upgrade pip setuptools wheel \
+ && python -m pip install --no-cache-dir \
+ "qwentts-cpp-python==${QWENTTS_CPP_VERSION}" \
+ --find-links "${QWENTTS_WHEEL_INDEX}" \
+ && python -m pip install --no-cache-dir \
+ "speech-to-speech[faster-whisper]==${SPEECH_TO_SPEECH_VERSION}"
+
+RUN groupadd --system zplatform \
+ && useradd --system --gid zplatform --home-dir /app --create-home zplatform \
+ && mkdir -p /models/huggingface /models/cache \
+ && chown -R zplatform:zplatform /app /models
+
+WORKDIR /app
+COPY entrypoint.sh healthcheck.py /app/
+RUN chmod 0555 /app/entrypoint.sh /app/healthcheck.py
+
+USER zplatform
+
+EXPOSE 8765
+HEALTHCHECK --interval=15s --timeout=5s --start-period=120s --retries=12 CMD ["python", "/app/healthcheck.py"]
+ENTRYPOINT ["/app/entrypoint.sh"]
diff --git a/services/voice-agent/entrypoint.sh b/services/voice-agent/entrypoint.sh
new file mode 100644
index 0000000..4386523
--- /dev/null
+++ b/services/voice-agent/entrypoint.sh
@@ -0,0 +1,69 @@
+#!/bin/sh
+set -eu
+
+: "${VOICE_LLM_MODEL:?VOICE_LLM_MODEL is required}"
+: "${VOICE_LLM_BASE_URL:?VOICE_LLM_BASE_URL is required}"
+: "${VOICE_LLM_API_KEY:?VOICE_LLM_API_KEY is required}"
+
+VOICE_AGENT_PORT="${VOICE_AGENT_PORT:-8765}"
+VOICE_STT_BACKEND="${VOICE_STT_BACKEND:-faster-whisper}"
+VOICE_STT_MODEL="${VOICE_STT_MODEL:-small}"
+VOICE_STT_DEVICE="${VOICE_STT_DEVICE:-auto}"
+VOICE_STT_COMPUTE_TYPE="${VOICE_STT_COMPUTE_TYPE:-auto}"
+VOICE_STT_LANGUAGE="${VOICE_STT_LANGUAGE:-th}"
+VOICE_TTS_BACKEND="${VOICE_TTS_BACKEND:-qwen3}"
+VOICE_TTS_MODEL="${VOICE_TTS_MODEL:-Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice}"
+VOICE_TTS_SPEAKER="${VOICE_TTS_SPEAKER:-Aiden}"
+VOICE_TTS_LANGUAGE="${VOICE_TTS_LANGUAGE:-auto}"
+VOICE_TTS_DEVICE="${VOICE_TTS_DEVICE:-cpu}"
+VOICE_TTS_BACKEND_ENGINE="${VOICE_TTS_BACKEND_ENGINE:-ggml}"
+VOICE_TTS_QUANTIZATION="${VOICE_TTS_QUANTIZATION:-Q4_K_M}"
+VOICE_NUM_PIPELINES="${VOICE_NUM_PIPELINES:-1}"
+VOICE_VAD_THRESHOLD="${VOICE_VAD_THRESHOLD:-0.6}"
+VOICE_MIN_SPEECH_MS="${VOICE_MIN_SPEECH_MS:-250}"
+VOICE_MIN_SILENCE_MS="${VOICE_MIN_SILENCE_MS:-500}"
+VOICE_CHAT_SIZE="${VOICE_CHAT_SIZE:-30}"
+VOICE_LOG_LEVEL="${VOICE_LOG_LEVEL:-info}"
+VOICE_REASONING_EFFORT="${VOICE_REASONING_EFFORT:-none}"
+VOICE_QWEN3_NON_STREAMING="${VOICE_QWEN3_NON_STREAMING:-True}"
+
+set -- speech-to-speech \
+ --mode realtime \
+ --ws_host 0.0.0.0 \
+ --ws_port "${VOICE_AGENT_PORT}" \
+ --stt "${VOICE_STT_BACKEND}" \
+ --llm_backend chat-completions \
+ --tts "${VOICE_TTS_BACKEND}" \
+ --model_name "${VOICE_LLM_MODEL}" \
+ --responses_api_base_url "${VOICE_LLM_BASE_URL}" \
+ --responses_api_api_key "${VOICE_LLM_API_KEY}" \
+ --responses_api_reasoning_effort "${VOICE_REASONING_EFFORT}" \
+ --chat_size "${VOICE_CHAT_SIZE}" \
+ --thresh "${VOICE_VAD_THRESHOLD}" \
+ --min_speech_ms "${VOICE_MIN_SPEECH_MS}" \
+ --min_silence_ms "${VOICE_MIN_SILENCE_MS}" \
+ --num_pipelines "${VOICE_NUM_PIPELINES}" \
+ --log_level "${VOICE_LOG_LEVEL}"
+
+if [ "${VOICE_STT_BACKEND}" = "faster-whisper" ]; then
+ set -- "$@" \
+ --faster_whisper_stt_model_name "${VOICE_STT_MODEL}" \
+ --faster_whisper_stt_device "${VOICE_STT_DEVICE}" \
+ --faster_whisper_stt_compute_type "${VOICE_STT_COMPUTE_TYPE}" \
+ --faster_whisper_stt_gen_language "${VOICE_STT_LANGUAGE}"
+elif [ "${VOICE_STT_BACKEND}" = "whisper" ]; then
+ set -- "$@" --whisper_stt_model_name "${VOICE_STT_MODEL}"
+fi
+
+if [ "${VOICE_TTS_BACKEND}" = "qwen3" ]; then
+ set -- "$@" \
+ --qwen3_tts_model_name "${VOICE_TTS_MODEL}" \
+ --qwen3_tts_speaker "${VOICE_TTS_SPEAKER}" \
+ --qwen3_tts_language "${VOICE_TTS_LANGUAGE}" \
+ --qwen3_tts_device "${VOICE_TTS_DEVICE}" \
+ --qwen3_tts_backend "${VOICE_TTS_BACKEND_ENGINE}" \
+ --qwen3_tts_ggml_quantization "${VOICE_TTS_QUANTIZATION}" \
+ --qwen3_tts_non_streaming_mode "${VOICE_QWEN3_NON_STREAMING}"
+fi
+
+exec "$@"
diff --git a/services/voice-agent/healthcheck.py b/services/voice-agent/healthcheck.py
new file mode 100644
index 0000000..0695b23
--- /dev/null
+++ b/services/voice-agent/healthcheck.py
@@ -0,0 +1,13 @@
+#!/usr/bin/env python3
+import os
+import socket
+import sys
+
+host = os.getenv("VOICE_AGENT_HEALTH_HOST", "127.0.0.1")
+port = int(os.getenv("VOICE_AGENT_PORT", "8765"))
+
+try:
+ with socket.create_connection((host, port), timeout=3):
+ pass
+except OSError:
+ sys.exit(1)
diff --git a/services/voice-gateway/Dockerfile b/services/voice-gateway/Dockerfile
new file mode 100644
index 0000000..87d31ad
--- /dev/null
+++ b/services/voice-gateway/Dockerfile
@@ -0,0 +1,22 @@
+FROM node:22.16.0-alpine3.22
+
+ARG OCI_REVISION=unknown
+ARG OCI_SOURCE=https://github.com/cvsz/z-platform
+ARG OCI_CREATED=unknown
+ARG OCI_VERSION=dev
+
+LABEL org.opencontainers.image.revision="${OCI_REVISION}" \
+ org.opencontainers.image.source="${OCI_SOURCE}" \
+ org.opencontainers.image.created="${OCI_CREATED}" \
+ org.opencontainers.image.version="${OCI_VERSION}"
+
+ENV NODE_ENV=production \
+ HOST=0.0.0.0 \
+ PORT=8450
+
+WORKDIR /app
+COPY --chown=node:node index.mjs ./index.mjs
+
+USER node
+EXPOSE 8450
+CMD ["node", "index.mjs"]
diff --git a/services/voice-gateway/index.mjs b/services/voice-gateway/index.mjs
new file mode 100644
index 0000000..c926862
--- /dev/null
+++ b/services/voice-gateway/index.mjs
@@ -0,0 +1,395 @@
+import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
+import { createServer } from "node:http";
+import { connect as connectTcp } from "node:net";
+import { fileURLToPath } from "node:url";
+
+const DEFAULT_TICKET_TTL_SECONDS = 60;
+const MAX_BODY_BYTES = 32 * 1024;
+
+function log(level, event, fields = {}) {
+ process.stdout.write(`${JSON.stringify({
+ timestamp: new Date().toISOString(),
+ level,
+ service: "voice-gateway",
+ event,
+ ...fields,
+ })}\n`);
+}
+
+function sendJson(response, status, payload, headers = {}) {
+ const body = JSON.stringify(payload);
+ response.writeHead(status, {
+ "Content-Type": "application/json; charset=utf-8",
+ "Content-Length": Buffer.byteLength(body),
+ "Cache-Control": "no-store",
+ ...headers,
+ });
+ response.end(body);
+}
+
+async function readJson(request) {
+ let size = 0;
+ const chunks = [];
+ for await (const chunk of request) {
+ size += chunk.length;
+ if (size > MAX_BODY_BYTES) throw new Error("Request body is too large");
+ chunks.push(chunk);
+ }
+ if (!chunks.length) return {};
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
+}
+
+function base64UrlEncode(value) {
+ return Buffer.from(value).toString("base64url");
+}
+
+function base64UrlDecode(value) {
+ return Buffer.from(value, "base64url");
+}
+
+function boundedInteger(value, fallback, minimum, maximum) {
+ const number = Number(value);
+ if (!Number.isInteger(number) || number < minimum || number > maximum) return fallback;
+ return number;
+}
+
+export function parseBearer(header) {
+ if (typeof header !== "string") return null;
+ const match = /^Bearer\s+(.+)$/i.exec(header.trim());
+ return match?.[1] || null;
+}
+
+export function createTicketCodec(secret, { now = () => Date.now(), usedNonces = new Map() } = {}) {
+ if (typeof secret !== "string" || secret.length < 32) {
+ throw new Error("VOICE_TICKET_SECRET must be at least 32 characters");
+ }
+
+ function purgeExpiredNonces(currentSeconds) {
+ for (const [nonce, expiresAt] of usedNonces) {
+ if (expiresAt <= currentSeconds) usedNonces.delete(nonce);
+ }
+ }
+
+ function sign(encodedPayload) {
+ return createHmac("sha256", secret).update(encodedPayload).digest();
+ }
+
+ function issue({ tenantId, subjectId, ttlSeconds = DEFAULT_TICKET_TTL_SECONDS }) {
+ const issuedAt = Math.floor(now() / 1000);
+ const ttl = boundedInteger(ttlSeconds, DEFAULT_TICKET_TTL_SECONDS, 10, 300);
+ const claims = {
+ v: 1,
+ iat: issuedAt,
+ nbf: issuedAt - 5,
+ exp: issuedAt + ttl,
+ nonce: randomBytes(18).toString("base64url"),
+ tenant_id: tenantId,
+ subject_id: subjectId,
+ };
+ const encodedPayload = base64UrlEncode(JSON.stringify(claims));
+ const signature = base64UrlEncode(sign(encodedPayload));
+ return {
+ ticket: `${encodedPayload}.${signature}`,
+ claims,
+ };
+ }
+
+ function verify(ticket, { consume = true } = {}) {
+ if (typeof ticket !== "string" || ticket.length > 4096) {
+ throw new Error("Invalid voice ticket");
+ }
+ const parts = ticket.split(".");
+ if (parts.length !== 2) throw new Error("Invalid voice ticket");
+
+ const [encodedPayload, encodedSignature] = parts;
+ const expected = sign(encodedPayload);
+ const actual = base64UrlDecode(encodedSignature);
+ if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) {
+ throw new Error("Invalid voice ticket signature");
+ }
+
+ let claims;
+ try {
+ claims = JSON.parse(base64UrlDecode(encodedPayload).toString("utf8"));
+ } catch {
+ throw new Error("Invalid voice ticket payload");
+ }
+
+ const currentSeconds = Math.floor(now() / 1000);
+ purgeExpiredNonces(currentSeconds);
+ if (claims?.v !== 1 || !claims.nonce || !claims.tenant_id || !claims.subject_id) {
+ throw new Error("Incomplete voice ticket");
+ }
+ if (claims.nbf > currentSeconds || claims.exp <= currentSeconds) {
+ throw new Error("Expired voice ticket");
+ }
+ if (usedNonces.has(claims.nonce)) {
+ throw new Error("Voice ticket was already used");
+ }
+ if (consume) usedNonces.set(claims.nonce, claims.exp);
+ return claims;
+ }
+
+ return { issue, verify, usedNonces };
+}
+
+function clientIp(request) {
+ const forwarded = request.headers["x-forwarded-for"];
+ if (typeof forwarded === "string" && forwarded.trim()) return forwarded.split(",")[0].trim();
+ return request.socket.remoteAddress || "unknown";
+}
+
+function requestIdentity(request, allowAnonymous) {
+ const tenantId = String(request.headers["x-tenant-id"] || "").trim();
+ const subjectId = String(
+ request.headers["x-subject-id"] || request.headers["x-user-id"] || "",
+ ).trim();
+
+ if (tenantId && subjectId) return { tenantId, subjectId };
+ if (allowAnonymous) {
+ return {
+ tenantId: tenantId || "anonymous",
+ subjectId: subjectId || "anonymous",
+ };
+ }
+ throw new Error("Tenant and subject headers are required");
+}
+
+function extractTicket(request) {
+ const protocols = String(request.headers["sec-websocket-protocol"] || "")
+ .split(",")
+ .map((value) => value.trim())
+ .filter(Boolean);
+ const ticketProtocol = protocols.find((value) => value.startsWith("zticket."));
+ if (ticketProtocol) {
+ return {
+ ticket: ticketProtocol.slice("zticket.".length),
+ forwardedProtocols: protocols.filter((value) => !value.startsWith("zticket.")),
+ };
+ }
+
+ const url = new URL(request.url || "/", "http://voice-gateway.local");
+ return {
+ ticket: url.searchParams.get("ticket"),
+ forwardedProtocols: protocols,
+ };
+}
+
+function cleanUpstreamPath(rawUrl) {
+ const url = new URL(rawUrl || "/v1/realtime", "http://voice-gateway.local");
+ url.searchParams.delete("ticket");
+ const query = url.searchParams.toString();
+ return `${url.pathname}${query ? `?${query}` : ""}`;
+}
+
+function serializeUpgradeRequest(request, target, claims, forwardedProtocols) {
+ const headers = { ...request.headers };
+ delete headers["proxy-connection"];
+ delete headers["content-length"];
+ delete headers["sec-websocket-protocol"];
+ headers.host = target.host;
+ headers.connection = "Upgrade";
+ headers.upgrade = "websocket";
+ headers["x-z-platform-tenant"] = claims.tenant_id;
+ headers["x-z-platform-subject"] = claims.subject_id;
+ headers["x-z-platform-voice-session"] = claims.nonce;
+ if (forwardedProtocols.length) {
+ headers["sec-websocket-protocol"] = forwardedProtocols.join(", ");
+ }
+
+ const lines = [`GET ${cleanUpstreamPath(request.url)} HTTP/1.1`];
+ for (const [name, value] of Object.entries(headers)) {
+ if (value == null) continue;
+ if (Array.isArray(value)) {
+ for (const item of value) lines.push(`${name}: ${item}`);
+ } else {
+ lines.push(`${name}: ${value}`);
+ }
+ }
+ return `${lines.join("\r\n")}\r\n\r\n`;
+}
+
+function closeSocket(socket, status, message) {
+ if (!socket.destroyed) {
+ socket.end(
+ `HTTP/1.1 ${status}\r\nConnection: close\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: ${Buffer.byteLength(message)}\r\n\r\n${message}`,
+ );
+ }
+}
+
+export function createVoiceGateway({ env = process.env, now = () => Date.now() } = {}) {
+ const serviceToken = env.Z_PLATFORM_SERVICE_TOKEN;
+ if (!serviceToken) throw new Error("Z_PLATFORM_SERVICE_TOKEN is required");
+
+ const codec = createTicketCodec(env.VOICE_TICKET_SECRET || "", { now });
+ const upstream = new URL(env.VOICE_AGENT_URL || "http://voice-agent:8765");
+ const publicWebSocketUrl = env.VOICE_PUBLIC_WS_URL || "ws://127.0.0.1:8450/v1/realtime";
+ const allowAnonymous = String(env.VOICE_ALLOW_ANONYMOUS || "false").toLowerCase() === "true";
+ const ticketTtlSeconds = boundedInteger(env.VOICE_TICKET_TTL_SECONDS, 60, 10, 300);
+ const maxSessions = boundedInteger(env.VOICE_MAX_SESSIONS, 4, 1, 1024);
+ const maxSessionsPerIp = boundedInteger(env.VOICE_MAX_SESSIONS_PER_IP, 2, 1, 128);
+
+ let activeSessions = 0;
+ const sessionsByIp = new Map();
+ const metrics = {
+ ticketsIssued: 0,
+ ticketFailures: 0,
+ websocketAccepted: 0,
+ websocketRejected: 0,
+ upstreamFailures: 0,
+ };
+
+ const requestHandler = async (request, response) => {
+ try {
+ if (request.method === "GET" && request.url === "/health") {
+ return sendJson(response, 200, {
+ status: "ok",
+ service: "voice-gateway",
+ active_sessions: activeSessions,
+ max_sessions: maxSessions,
+ });
+ }
+
+ if (request.method === "GET" && request.url === "/metrics") {
+ const body = [
+ "# TYPE z_platform_voice_active_sessions gauge",
+ `z_platform_voice_active_sessions ${activeSessions}`,
+ "# TYPE z_platform_voice_tickets_issued_total counter",
+ `z_platform_voice_tickets_issued_total ${metrics.ticketsIssued}`,
+ "# TYPE z_platform_voice_ticket_failures_total counter",
+ `z_platform_voice_ticket_failures_total ${metrics.ticketFailures}`,
+ "# TYPE z_platform_voice_websocket_accepted_total counter",
+ `z_platform_voice_websocket_accepted_total ${metrics.websocketAccepted}`,
+ "# TYPE z_platform_voice_websocket_rejected_total counter",
+ `z_platform_voice_websocket_rejected_total ${metrics.websocketRejected}`,
+ "# TYPE z_platform_voice_upstream_failures_total counter",
+ `z_platform_voice_upstream_failures_total ${metrics.upstreamFailures}`,
+ "",
+ ].join("\n");
+ response.writeHead(200, {
+ "Content-Type": "text/plain; version=0.0.4; charset=utf-8",
+ "Cache-Control": "no-store",
+ });
+ return response.end(body);
+ }
+
+ if (request.method === "POST" && request.url === "/v1/voice/tickets") {
+ if (parseBearer(request.headers.authorization) !== serviceToken) {
+ metrics.ticketFailures += 1;
+ return sendJson(response, 401, { error: { code: "UNAUTHORIZED", message: "Invalid service token" } });
+ }
+
+ const identity = requestIdentity(request, allowAnonymous);
+ await readJson(request); // Parse and bound the body for forward-compatible metadata.
+ const { ticket, claims } = codec.issue({
+ tenantId: identity.tenantId,
+ subjectId: identity.subjectId,
+ ttlSeconds: ticketTtlSeconds,
+ });
+ metrics.ticketsIssued += 1;
+ log("info", "ticket_issued", {
+ tenant_id: identity.tenantId,
+ expires_at: claims.exp,
+ });
+ return sendJson(response, 201, {
+ ticket,
+ expires_at: new Date(claims.exp * 1000).toISOString(),
+ websocket_url: publicWebSocketUrl,
+ ticket_transport: "sec-websocket-protocol",
+ });
+ }
+
+ return sendJson(response, 404, { error: { code: "NOT_FOUND", message: "Route not found" } });
+ } catch (error) {
+ metrics.ticketFailures += 1;
+ log("warn", "request_rejected", { message: error instanceof Error ? error.message : "Request failed" });
+ return sendJson(response, 400, {
+ error: { code: "BAD_REQUEST", message: error instanceof Error ? error.message : "Request failed" },
+ });
+ }
+ };
+
+ const server = createServer(requestHandler);
+
+ server.on("upgrade", (request, socket, head) => {
+ const ip = clientIp(request);
+ try {
+ if (!request.url?.startsWith("/v1/realtime")) {
+ metrics.websocketRejected += 1;
+ return closeSocket(socket, "404 Not Found", "Not found");
+ }
+ if (activeSessions >= maxSessions || (sessionsByIp.get(ip) || 0) >= maxSessionsPerIp) {
+ metrics.websocketRejected += 1;
+ return closeSocket(socket, "429 Too Many Requests", "Voice session limit reached");
+ }
+
+ const { ticket, forwardedProtocols } = extractTicket(request);
+ const claims = codec.verify(ticket);
+ activeSessions += 1;
+ sessionsByIp.set(ip, (sessionsByIp.get(ip) || 0) + 1);
+ metrics.websocketAccepted += 1;
+
+ let released = false;
+ const release = () => {
+ if (released) return;
+ released = true;
+ activeSessions = Math.max(0, activeSessions - 1);
+ const remaining = Math.max(0, (sessionsByIp.get(ip) || 1) - 1);
+ if (remaining) sessionsByIp.set(ip, remaining);
+ else sessionsByIp.delete(ip);
+ log("info", "session_closed", {
+ tenant_id: claims.tenant_id,
+ active_sessions: activeSessions,
+ });
+ };
+
+ const upstreamSocket = connectTcp(
+ {
+ host: upstream.hostname,
+ port: Number(upstream.port || 80),
+ },
+ () => {
+ upstreamSocket.write(serializeUpgradeRequest(request, upstream, claims, forwardedProtocols));
+ if (head?.length) upstreamSocket.write(head);
+ socket.pipe(upstreamSocket);
+ upstreamSocket.pipe(socket);
+ log("info", "session_opened", {
+ tenant_id: claims.tenant_id,
+ active_sessions: activeSessions,
+ });
+ },
+ );
+
+ socket.setTimeout(0);
+ upstreamSocket.setTimeout(0);
+ socket.on("close", release);
+ socket.on("error", release);
+ upstreamSocket.on("close", release);
+ upstreamSocket.on("error", (error) => {
+ metrics.upstreamFailures += 1;
+ log("error", "upstream_socket_error", { message: error.message });
+ if (!socket.destroyed) socket.destroy();
+ release();
+ });
+ } catch (error) {
+ metrics.websocketRejected += 1;
+ log("warn", "websocket_rejected", { ip, message: error instanceof Error ? error.message : "Rejected" });
+ closeSocket(socket, "401 Unauthorized", "Invalid or expired voice ticket");
+ }
+ });
+
+ return { server, codec, metrics };
+}
+
+export function startVoiceGateway(options = {}) {
+ const env = options.env || process.env;
+ const { server } = createVoiceGateway(options);
+ const host = env.HOST || "0.0.0.0";
+ const port = Number(env.PORT || 8450);
+ server.listen(port, host, () => log("info", "listening", { host, port }));
+ return server;
+}
+
+if (process.argv[1] === fileURLToPath(import.meta.url)) {
+ startVoiceGateway();
+}
diff --git a/services/voice-gateway/test/tickets.test.mjs b/services/voice-gateway/test/tickets.test.mjs
new file mode 100644
index 0000000..905fc24
--- /dev/null
+++ b/services/voice-gateway/test/tickets.test.mjs
@@ -0,0 +1,68 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { createTicketCodec, createVoiceGateway, parseBearer } from "../index.mjs";
+
+const TEST_ENV = {
+ Z_PLATFORM_SERVICE_TOKEN: "service-token",
+ VOICE_TICKET_SECRET: "a".repeat(64),
+ VOICE_AGENT_URL: "http://127.0.0.1:65534",
+ VOICE_PUBLIC_WS_URL: "ws://127.0.0.1:8450/v1/realtime",
+ VOICE_ALLOW_ANONYMOUS: "false",
+};
+
+test("parseBearer accepts a bearer token", () => {
+ assert.equal(parseBearer("Bearer secret"), "secret");
+ assert.equal(parseBearer("basic secret"), null);
+});
+
+test("ticket is signed, expires, and is single-use", () => {
+ let now = 1_700_000_000_000;
+ const codec = createTicketCodec("a".repeat(64), { now: () => now });
+ const issued = codec.issue({ tenantId: "tenant-1", subjectId: "user-1", ttlSeconds: 60 });
+
+ const claims = codec.verify(issued.ticket);
+ assert.equal(claims.tenant_id, "tenant-1");
+ assert.equal(claims.subject_id, "user-1");
+ assert.throws(() => codec.verify(issued.ticket), /already used/);
+
+ const second = codec.issue({ tenantId: "tenant-1", subjectId: "user-1", ttlSeconds: 10 });
+ now += 11_000;
+ assert.throws(() => codec.verify(second.ticket), /Expired/);
+});
+
+test("tampered tickets are rejected", () => {
+ const codec = createTicketCodec("b".repeat(64));
+ const issued = codec.issue({ tenantId: "tenant-1", subjectId: "user-1" });
+ assert.throws(() => codec.verify(`${issued.ticket}x`), /signature/);
+});
+
+test("ticket endpoint requires service authentication and returns browser-safe fields", async (t) => {
+ const { server, codec } = createVoiceGateway({ env: TEST_ENV });
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
+ t.after(() => new Promise((resolve) => server.close(resolve)));
+ const address = server.address();
+ const endpoint = `http://127.0.0.1:${address.port}/v1/voice/tickets`;
+
+ const unauthorized = await fetch(endpoint, { method: "POST", body: "{}" });
+ assert.equal(unauthorized.status, 401);
+
+ const authorized = await fetch(endpoint, {
+ method: "POST",
+ headers: {
+ Authorization: "Bearer service-token",
+ "Content-Type": "application/json",
+ "X-Tenant-Id": "tenant-1",
+ "X-Subject-Id": "user-1",
+ },
+ body: "{}",
+ });
+ assert.equal(authorized.status, 201);
+ const payload = await authorized.json();
+ assert.equal(payload.websocket_url, TEST_ENV.VOICE_PUBLIC_WS_URL);
+ assert.equal(payload.ticket_transport, "sec-websocket-protocol");
+ assert.equal(JSON.stringify(payload).includes(TEST_ENV.Z_PLATFORM_SERVICE_TOKEN), false);
+
+ const claims = codec.verify(payload.ticket, { consume: false });
+ assert.equal(claims.tenant_id, "tenant-1");
+ assert.equal(claims.subject_id, "user-1");
+});