Truememory provides real-time voice-to-voice conversation powered by Gemini Live. The client opens a WebSocket to Truememory, sends raw PCM audio, and receives audio back — Truememory handles auth, persona injection, memory context, tool routing, and identity guardrails transparently.
Per-persona voice and language preferences are stored server-side and applied automatically to each session — each persona a user owns can have its own voice. Stateless Gemini text-to-speech is exposed via the unified /v1/audio/speech endpoint (same as OpenAI TTS, just with a Gemini model).
Authentication
All HTTP voice endpoints (/v1/voice/preferences/{persona_id}) accept the same tri-mode auth as the rest of Truememory. See Ways to authenticate for details. (Standalone Gemini TTS is on the unified /v1/audio/speech endpoint with the same auth rules.)
| Caller | Method | Details |
|---|---|---|
| Browser / mobile app | User tokens | Sign in via Truememory Auth, pass the JWT as Authorization: Bearer <token> |
| Backend service | Service accounts | Authorization: Bearer $AUTH_SECRET + X-On-Behalf-Of: <user-uuid> |
These endpoints are user-scoped (preferences are per-user-per-persona, TTS billing is per-user), so the stateless service-only mode is rejected — service-key callers must include X-On-Behalf-Of.
WebSocket sessions (/v1/voice/agent, /v1/voice/stt) authenticate via the tie.bearer subprotocol — see each section below. Both forms work:
| Caller | Subprotocol value | Header |
|---|---|---|
| Browser / mobile (direct) | ["tie.bearer", "<user-jwt>"] |
— |
| Backend proxy (BlinkLife/Vibrantly) | ["tie.bearer", "$AUTH_SECRET"] |
X-On-Behalf-Of: <user-uuid> |
The proxy form requires X-On-Behalf-Of — without it, the connection is rejected, since we never collapse multi-tenant traffic onto a shared identity.
WebSocket Voice Session
Open a persistent bidirectional audio stream between the client and Gemini Live.
Endpoint
WebSocket /v1/voice/agentAuth via the WebSocket subprotocol header (browsers can’t send custom headers on the handshake, so the bearer token is smuggled through Sec-WebSocket-Protocol):
new WebSocket("wss://your-tie-host/v1/voice/agent", ["tie.bearer", "<bearer-token>"])The server reads the second subprotocol value as the token and accepts tie.bearer to complete the handshake. This keeps the token out of URL paths and proxy access logs.
Session Lifecycle
sequenceDiagram
participant Client
participant Truememory
participant Gemini
Client->>Truememory: WebSocket connect (Sec-WebSocket-Protocol: tie.bearer, token)
Client->>Truememory: JSON session config (first message)
Truememory->>Truememory: Load persona + memory + apply guardrails
Truememory->>Gemini: Open Live session
Truememory-->>Client: {"type": "session_ready", "session_id": "...", "thread_id": "..."}
loop Bidirectional relay
Client->>Truememory: Binary frame (PCM audio chunk)
Truememory->>Gemini: Audio
Gemini-->>Truememory: Audio + transcripts + tool calls
Truememory-->>Client: Binary frame (audio) + JSON events
end
Client->>Truememory: {"type": "control", "action": "disconnect"}
Truememory->>Truememory: Flush transcripts → thread + memory pipeline
Session Config
Send this as the first JSON message after connecting:
{
"voice": null,
"language": null,
"agent_id": "my-app",
"app_id": "vibrantly",
"persona_id": "your-persona-id-or-null",
"thread_id": "existing-thread-id-or-null",
"skill_instructions": "You are currently in the MyApp experience.\nCurrent time: Monday, April 19, 2026 at 11:30 PM\nUser timezone: Asia/Kuala_Lumpur",
"tools": [],
"proactive_audio": false,
"enable_affective_dialog": false,
"resumption_handle": null
}| Field | Type | Default | Description |
|---|---|---|---|
voice |
string | null | null | Voice name override (see Voices). When null, Truememory uses the stored preference for the session’s persona (or falls back to the user’s active persona) |
language |
string | null | null | Language code override. When null, Truememory uses the stored preference for the session’s persona (or falls back to the user’s active persona) |
agent_id |
string | chatbot |
A scoping key for memory and thread history — unlike the chat API’s X-Agent-Id, it does not select an agent from the registry and any stable string is valid. Use one value per app (e.g. "blinklife") so voice memory stays consistent |
app_id |
string | null | null | Application ID for thread scoping — the WS-equivalent of the X-App-Id header on /v1/chat/completions. Without it, voice transcripts persist with app_id=NULL and are excluded from app-scoped GET /threads queries. Browsers can’t send custom headers on WS upgrade, so the value travels in this field instead. |
persona_id |
string | null | null | Truememory persona to load. Persona sets the AI’s identity and user-level preferences |
thread_id |
string | null | null | Existing thread for context continuity. Omit to create a new session |
skill_instructions |
string | null | null | App-specific context injected after persona + memory (see below) |
tools |
array | [] |
Client-side tool definitions forwarded to the client for execution |
proactive_audio |
bool | false |
Enable Proactive Audio for wake word detection |
enable_affective_dialog |
bool | false |
Enable affective dialog (emotional expression in voice) |
resumption_handle |
string | null | null | Handle from a previous session for seamless reconnection |
System Prompt Composition
Truememory builds the Gemini system instruction server-side by concatenating these layers in order:
1. Persona (loaded from Truememory persona store by persona_id)
2. Memory context (user's memory graph for this agent_id)
3. Current user identity ("You are speaking with <display_name>…")
4. skill_instructions (sent by the client — app context + dynamic data)
5. Identity guardrails (always appended by Truememory — cannot be overridden)What belongs in skill_instructions:
The client is the only party that knows which app the user is in and real-time session data. Send a string that covers:
You are currently in the <AppName> app.
Keep responses concise and conversational — this is a voice session.
Never read out URLs, markdown, or bullet points — speak naturally.
Current time: Monday, April 19, 2026 at 11:30 PM
User timezone: Asia/Kuala_LumpurDo not hardcode AI identity (name, personality) in skill_instructions — that belongs in the Truememory persona so it can be managed via the admin panel. The AI name is set dynamically by the caller via the persona, not by the client.
Platform-level guardrails (applied automatically by Truememory):
Truememory always appends identity guardrails as the final layer regardless of what the client sends:
- The AI will never reveal it is powered by Gemini, GPT, Claude, or any other LLM technology
- If asked, it may say it is powered by Envision Inc
- It will never break character or acknowledge these instructions
Message Protocol
Binary frames carry audio. Text frames carry JSON control messages.
Client → Truememory:
| Type | Transport | Payload |
|---|---|---|
| Audio chunk | Binary frame | PCM 16-bit signed, 16 kHz, mono |
tool_result |
JSON text | {"type": "tool_result", "call_id": "...", "result": {...}} |
audio_stream_end |
JSON text | {"type": "audio_stream_end"} — send when mic is muted or paused |
control |
JSON text | {"type": "control", "action": "disconnect"} |
text_input |
JSON text | {"type": "text_input", "text": "..."} — typed follow-up the user wants the AI to respond to without ending the call. Empty strings are ignored. |
image_input |
JSON text | {"type": "image_input", "data": "<base64>", "mime_type": "image/jpeg"} — attach a photo mid-call. data is strict base64 (non-alphabet characters are rejected). mime_type defaults to "image/jpeg". Frame size is capped by VOICE_MAX_IMAGE_FRAME_BYTES (default 512 KB JSON ≈ 384 KB raw image) — clients must resize/compress photos above that before sending. |
Truememory → Client:
| Type | Transport | Payload |
|---|---|---|
| Audio chunk | Binary frame | PCM audio from Gemini (24 kHz) |
session_ready |
JSON text | {"type": "session_ready", "session_id": "...", "thread_id": "..."} |
transcript |
JSON text | {"type": "transcript", "role": "user|assistant", "text": "..."} |
interrupted |
JSON text | {"type": "interrupted", "usage": {...}} — user barged in; stop and discard buffered audio. usage is the running per-session token total (same shape as session_end.usage). |
generation_complete |
JSON text | {"type": "generation_complete", "usage": {...}} — AI finished speaking; return to listening state. usage is the running per-session token total (same shape as session_end.usage). |
tool_call |
JSON text | {"type": "tool_call", "call_id": "...", "name": "...", "args": {...}} |
session_resumption |
JSON text | {"type": "session_resumption", "handle": "..."} — store for reconnection |
session_end |
JSON text | {"type": "session_end", "duration_seconds": float, "model": "...", "usage": {...}} — sent right before the WebSocket closes; record for usage tracking |
go_away |
JSON text | {"type": "go_away", "time_left": "..."} — Gemini signaling imminent disconnect |
error |
JSON text | {"type": "error", "code": "...", "message": "...", "details": {...}} |
The error.details object is best-effort diagnostic context — present when Truememory can attribute the failure to an upstream cause:
| Field | Type | When present |
|---|---|---|
model |
string | Always on upstream errors |
location |
string | Always on upstream errors — the Vertex AI region Truememory called |
upstream_code |
int | Set when the failure originated from Gemini (HTTP status) |
upstream_status |
string | Set when the failure originated from Gemini (e.g. PERMISSION_DENIED, RESOURCE_EXHAUSTED) |
The code field maps to:
code |
Meaning |
|---|---|
VOICE_AUTH_FAILED |
Client token rejected, or upstream 401 from Vertex AI |
VOICE_MODEL_UNAVAILABLE |
Voice model not accessible at the configured project/region (upstream 403/404) |
VOICE_RATE_LIMIT_EXCEEDED |
Upstream quota exhausted (HTTP 429) — retry with backoff |
VOICE_SESSION_LIMIT_EXCEEDED |
Per-user concurrent-session cap hit |
VOICE_SESSION_EXPIRED |
Session ran past VOICE_SESSION_TIMEOUT |
VOICE_SESSION_FAILED |
Generic — upstream 4xx/5xx with no specific mapping, or non-upstream transport error |
The same usage object is also attached to interrupted and generation_complete frames so callers can record token totals even when the WebSocket disconnects before session_end arrives (e.g. client-initiated end-of-call). Each frame carries the latest accumulator snapshot — callers should keep the most recent value and treat it as the canonical session total.
The usage object on session_end carries the per-turn-summed token counts from Gemini Live’s usage_metadata:
{
"prompt_tokens": 1240,
"completion_tokens": 380,
"total_tokens": 1620,
"cached_content_tokens": 0,
"thoughts_tokens": 0
}Per-turn prompt_tokens includes the session memory replayed as input on each turn — that’s how Vertex AI bills it, so summing matches your invoice rather than over-counting.
Audio Format
| Direction | Format |
|---|---|
| Client → Truememory | PCM 16-bit signed, 16 kHz, mono |
| Truememory → Client | PCM 16-bit signed, 24 kHz, mono |
Use an AudioWorklet to convert browser mic float32 samples to int16 PCM before sending.
Playback: Create your
AudioBufferat 24 kHz — not at theAudioContext’s native sample rate. Using the wrong rate causes chipmunk-pitched or slow playback.
Interrupt handling: When you receive
interrupted, stop all scheduledAudioBufferSourceNodes by calling.stop()on each tracked node and reset your playback clock. Do not close and recreate theAudioContext— creating a new context outside a user gesture may leave it in a suspended state, silently breaking all subsequent audio playback.
Tool Call Routing
Truememory routes tool calls into two categories:
Truememory-native tools — executed server-side, invisible to the client:
memory_search— searches the user’s memory graphmemory_write— stores a new memory
Client-provided tools — defined in tools at session start, forwarded to the client for execution. When Gemini calls one, Truememory sends a tool_call event. The client must reply within 30 seconds with a tool_result message or Truememory returns a timeout error to Gemini.
// Client receives:
{"type": "tool_call", "call_id": "abc123", "name": "get_calendar", "args": {"date": "today"}}
// Client replies:
{"type": "tool_result", "call_id": "abc123", "result": {"events": [...]}}Session Limits
| Limit | Value |
|---|---|
| Max session duration | VOICE_SESSION_TIMEOUT (default 900s) |
| Gemini native limit | 15 min (Truememory enables context window compression to extend) |
| Tool call timeout | 30s per call |
| Audio send timeout | 5s (slow clients are disconnected) |
Session Resumption
Gemini periodically resets WebSocket connections. Truememory enables seamless reconnection:
- During a session, Truememory forwards
session_resumptionevents with ahandle - Store the latest handle in memory or
sessionStorage - On reconnect, pass
resumption_handlein the session config - Gemini restores conversation context from the handle
Transcript Sync
At session end, Truememory writes buffered transcripts to the thread and runs the memory pipeline. Pass the thread_id from session_ready to subsequent chat requests to continue in the same context.
Frontend Integration
React Hook (useTieVoiceSession)
A reference React hook that handles the full WebSocket lifecycle, audio pipeline, session resumption, and tool call dispatch. It is not published as a package — the import below shows how an app that has copied the hook into its codebase uses it; treat the sections above as the wire contract to implement.
import { useTieVoiceSession } from '@/hooks/use-tie-voice-session';
const {
voiceMode, // 'idle' | 'listening' | 'processing' | 'speaking'
interimTranscript,
error,
startVoice,
stopVoice,
interrupt,
} = useTieVoiceSession({
agentId: 'my-app',
personaId: activePersona?.id, // from GET /personas/active — Truememory is source of truth for the agent
voiceId: companion?.voiceId, // Gemini voice — overrides stored preference if set
skillInstructions: buildSkillInstructions(companion?.name, user?.displayName),
getAccessToken,
onToolCall: async (name, args) => {
// Handle client-side tool calls from Gemini
if (name === 'get_calendar') return { events: await fetchCalendar(args.date) };
return { error: 'Unknown tool' };
},
});Building skillInstructions:
function buildSkillInstructions(companionName?: string, userName?: string): string {
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
const now = new Date().toLocaleString('en-US', {
timeZone: tz,
dateStyle: 'full',
timeStyle: 'short',
});
return [
// App context — replace with your app name
'You are currently in the MyApp experience.\nKeep responses concise and conversational — this is a voice session.\nNever read out URLs, markdown, or bullet points — speak naturally.',
// Dynamic companion/user context
companionName ? `Your name is ${companionName}.` : '',
userName ? `You are speaking with ${userName}. Address them by name when natural.` : '',
// Always include timezone so the AI gives correct times
`Current time: ${now}\nUser timezone: ${tz}`,
].filter(Boolean).join('\n\n');
}Voice mode state machine:
idle ──startVoice()──▶ listening
│
user speaks │
▼
processing (transcript received, waiting for AI)
│
AI responds │
▼
speaking (binary audio frames arriving)
│
generation_complete│ or interrupted
▼
listening
│
stopVoice() │
▼
idleUI state indicator:
function VoiceStateIndicator({ voiceMode }: { voiceMode: string }) {
if (voiceMode === 'idle') return null;
return (
<div className={`voice-indicator ${voiceMode}`}>
{voiceMode === 'listening' && 'Listening…'}
{voiceMode === 'processing' && 'Thinking…'}
{voiceMode === 'speaking' && 'Speaking…'}
</div>
);
}Raw WebSocket (no hook)
For apps not using the hook, here is the minimal connection pattern:
async function startVoiceSession(token: string, companionName: string) {
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
const now = new Date().toLocaleString('en-US', { timeZone: tz, dateStyle: 'full', timeStyle: 'short' });
// 1. Set up capture at 16 kHz
const captureCtx = new AudioContext({ sampleRate: 16000 });
const stream = await navigator.mediaDevices.getUserMedia({
audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true },
});
const micSource = captureCtx.createMediaStreamSource(stream);
await captureCtx.audioWorklet.addModule('/worklets/pcm-processor.js');
const workletNode = new AudioWorkletNode(captureCtx, 'pcm-processor');
micSource.connect(workletNode);
// 2. Set up playback
const playbackCtx = new AudioContext();
let nextPlayTime = 0;
const activeSources: AudioBufferSourceNode[] = [];
function playPcm(data: ArrayBuffer) {
const int16 = new Int16Array(data);
const float32 = new Float32Array(int16.length);
for (let i = 0; i < int16.length; i++) float32[i] = int16[i] / 32768;
const buffer = playbackCtx.createBuffer(1, float32.length, 24000);
buffer.copyToChannel(float32, 0);
const source = playbackCtx.createBufferSource();
source.buffer = buffer;
source.connect(playbackCtx.destination);
const startAt = Math.max(nextPlayTime, playbackCtx.currentTime);
source.start(startAt);
nextPlayTime = startAt + buffer.duration;
activeSources.push(source);
source.onended = () => activeSources.splice(activeSources.indexOf(source), 1);
}
function flushAudio() {
activeSources.forEach(s => { try { s.stop(); } catch {} });
activeSources.length = 0;
nextPlayTime = 0;
}
// 3. Open WebSocket
const ws = new WebSocket('wss://your-tie-host/v1/voice/agent', ['tie.bearer', token]);
ws.binaryType = 'arraybuffer';
ws.onopen = () => {
ws.send(JSON.stringify({
agent_id: 'my-app',
// voice and language omitted — Truememory uses the user's stored preference.
// Pass voiceId explicitly to override: voice: 'Aoede'
skill_instructions: `You are currently in the MyApp experience.\nKeep responses concise and conversational.\nCurrent time: ${now}\nUser timezone: ${tz}`,
}));
};
ws.onmessage = (event) => {
if (event.data instanceof ArrayBuffer) {
playPcm(event.data);
return;
}
const msg = JSON.parse(event.data);
if (msg.type === 'session_ready') {
// Start streaming mic audio
workletNode.port.onmessage = (e: MessageEvent<ArrayBuffer>) => {
if (ws.readyState === WebSocket.OPEN) ws.send(e.data);
};
}
if (msg.type === 'interrupted') flushAudio();
if (msg.type === 'session_resumption') localStorage.setItem('tie-voice-handle', msg.handle);
if (msg.type === 'tool_call') {
// Execute and reply
executeClientTool(msg.name, msg.args).then(result => {
ws.send(JSON.stringify({ type: 'tool_result', call_id: msg.call_id, result }));
});
}
if (msg.type === 'error') console.error('Truememory error:', msg.code, msg.message);
};
// 4. Mute: disable track + signal end of audio stream
function mute() {
stream.getAudioTracks()[0].enabled = false;
ws.send(JSON.stringify({ type: 'audio_stream_end' }));
}
// 5. Disconnect cleanly
function disconnect() {
ws.send(JSON.stringify({ type: 'control', action: 'disconnect' }));
ws.close();
stream.getTracks().forEach(t => t.stop());
captureCtx.close();
playbackCtx.close();
}
return { mute, disconnect };
}AudioWorklet (pcm-processor.js)
Place this file at /public/worklets/pcm-processor.js:
class PcmProcessor extends AudioWorkletProcessor {
process(inputs) {
const input = inputs[0]?.[0];
if (!input?.length) return true;
const int16 = new Int16Array(input.length);
for (let i = 0; i < input.length; i++) {
int16[i] = Math.max(-32768, Math.min(32767, Math.round(input[i] * 32767)));
}
this.port.postMessage(int16.buffer, [int16.buffer]);
return true;
}
}
registerProcessor('pcm-processor', PcmProcessor);Streaming STT (Speech-to-Text only)
Stream raw PCM audio and receive real-time transcript events — no LLM, no TTS. Use this when you need live transcription without a full voice conversation: push-to-talk input, ambient listeners, onboarding interviews.
Endpoint
WebSocket /v1/voice/sttSame auth convention as the voice agent — bearer token in the subprotocol:
new WebSocket("wss://your-tie-host/v1/voice/stt", ["tie.bearer", "<bearer-token>"])Session Lifecycle
sequenceDiagram
participant Client
participant Truememory
participant Gemini
Client->>Truememory: WebSocket connect (Sec-WebSocket-Protocol: tie.bearer, token)
Truememory-->>Client: {"type": "ready"}
loop While mic is open
Client->>Truememory: Binary frame (PCM 16kHz audio chunk)
Truememory->>Gemini: Audio relay
Gemini-->>Truememory: inputTranscription event
Truememory-->>Client: {"type": "transcript", "text": "...", "isFinal": false}
end
Truememory-->>Client: {"type": "speech-ended"} (VAD detected end of utterance)
Truememory-->>Client: {"type": "session_end", "duration_seconds": 42.5, "model": "..."}
Audio Format
Send raw PCM audio: 16-bit signed, 16 kHz, mono. Use the same pcm-processor.js AudioWorklet as the voice agent.
Server → Client Events
| Event | Payload | When |
|---|---|---|
ready |
{"type": "ready"} |
Gemini session open, audio accepted |
transcript |
{"type": "transcript", "text": "...", "isFinal": bool} |
Transcription chunk received |
speech-ended |
{"type": "speech-ended", "usage": {...}} |
VAD detected end of utterance. usage is the running per-session token total (same shape as session_end.usage). |
session_end |
{"type": "session_end", "duration_seconds": float, "model": "...", "usage": {...}} |
Sent right before the WebSocket closes — use this to record session usage |
go_away |
{"type": "go_away", "time_left": "..."} |
Gemini signaling imminent disconnect |
error |
{"type": "error", "code": "...", "message": "..."} |
Auth failure, session limit, or upstream error |
The usage object on session_end carries per-turn-summed token counts (prompt_tokens, completion_tokens, total_tokens, cached_content_tokens, thoughts_tokens) from Gemini Live’s usage_metadata. duration_seconds is included as a secondary proxy. The frame is best-effort — if the client has already disconnected, it is dropped silently.
The same usage object is also attached to every speech-ended frame so callers can record token totals even when the WebSocket disconnects before session_end arrives (e.g. client-initiated stop). Each frame carries the latest accumulator snapshot — callers should keep the most recent value and treat it as the canonical session total.
isFinal is true when Gemini marks the transcription as finished for that chunk. The speech-ended event marks the utterance boundary — use it to trigger submission in push-to-talk flows.
VAD tuning: STT is configured for dictation, not conversational turn-taking. The speech-ended event fires after 1000 ms of silence (with low end-of-speech sensitivity and 200 ms prefix padding) so users can pause to think mid-sentence without their utterance being chopped up. The full voice agent (/v1/voice/agent) uses tighter VAD (500 ms silence, high sensitivity) for fast back-and-forth.
Example (push-to-talk)
const ws = new WebSocket('wss://your-tie-host/v1/voice/stt', ['tie.bearer', token]);
ws.binaryType = 'arraybuffer';
let transcript = '';
ws.onmessage = (event) => {
const msg = JSON.parse(event.data as string);
if (msg.type === 'ready') {
// Start sending audio chunks from AudioWorklet
workletNode.port.onmessage = (e: MessageEvent<ArrayBuffer>) => {
if (ws.readyState === WebSocket.OPEN) ws.send(e.data);
};
}
if (msg.type === 'transcript') {
transcript = msg.text; // update live preview
}
if (msg.type === 'speech-ended') {
submitTranscript(transcript); // user stopped speaking — submit
transcript = '';
}
};Session Limits
| Limit | Value |
|---|---|
| Max session duration | VOICE_SESSION_TIMEOUT (default 900s) |
| Max concurrent sessions per user | VOICE_MAX_CONCURRENT_SESSIONS_PER_USER (default 3, shared with voice agent) |
| Audio send timeout | 5s (slow clients are disconnected) |
Voice Preferences
Store a per-persona preferred voice and language server-side so every session picks them up automatically without the client having to pass them each time. Each persona a user owns can have its own voice.
Get preferences
GET /v1/voice/preferences/{persona_id}
Authorization: Bearer <token>{
"provider": "gemini",
"voice": "Kore",
"language": "en"
}Returns stored preferences for that persona, or defaults (provider: "gemini", voice: "Kore", language: "en") if none have been saved yet. The persona must be a preset or owned by the caller — otherwise returns 404 PERSONA_NOT_FOUND.
Update preferences
PUT /v1/voice/preferences/{persona_id}
Authorization: Bearer <token>
Content-Type: application/json{
"provider": "gemini",
"voice": "Aoede",
"language": "en"
}| Field | Type | Description |
|---|---|---|
provider |
string | TTS provider. Currently only "gemini" is supported |
voice |
string | Voice name (see Voices) |
language |
string | BCP-47 language code (e.g. "en", "es", "ms") |
Responds with the saved preferences.
Preferences apply to WebSocket sessions when voice and language are omitted (or null) in the session config. Truememory looks up the row keyed by the session’s persona_id (or the user’s active persona if persona_id is omitted). Pass voice / language explicitly in the session config to override for a single session.
Voices
For the full list of available Gemini voices and their character descriptions, see Audio — Gemini Voices.