Gemini Live Transport Helpers (@invariant-tech/live-gemini)
Realtime audio models (such as Google Gemini Live WebSockets) require low-latency bidirectional streaming while maintaining durable execution guarantees.
Invariant keeps realtime transport normalization separate from durable execution through a Realtime Adapter.
"Realtime adapters normalize the media transport boundary. Invariant owns execution truth."
"Audio is realtime. Actions are durable."
Current Beta boundary
The package provisions ephemeral credentials through the official @google/genai client and normalizes Live tool declarations, invocations, and results. Your application owns the actual WebSocket, authentication, reconnection, sample rates, and audio/video/image buffering. The adapter has no public connectSession(), processAudioChunk(), disconnectSession(), sendImage(), sendAudio(), or sendVideo() method and does not persist media frames. Provider-native realtime media can travel over the application-owned Gemini connection; only normalized semantic tool calls cross into Invariant's durable authority boundary.
Media Plane vs. Durable Execution Plane
Invariant explicitly separates streaming media responsibilities from durable execution guarantees:
1. Installation
bash
npm install @invariant-tech/sdk@beta @invariant-tech/sqlite@beta @invariant-tech/live-gemini@beta2. Server Configuration & Ephemeral Tokens
ts
import { invariant } from "@invariant-tech/sdk";
import { sqlite } from "@invariant-tech/sqlite";
import { liveGemini, GEMINI_LIVE_DEFAULT_MODEL } from "@invariant-tech/live-gemini";
import Fastify from "fastify";
export const app = invariant({
storage: sqlite("./data/voice.db"),
});
export const live = liveGemini({
apiKey: process.env.GEMINI_API_KEY,
defaultModel: GEMINI_LIVE_DEFAULT_MODEL, // 'gemini-3.1-flash-live-preview'
});
const server = Fastify();
// Authenticate this route before issuing a browser token.
server.post<{ Params: { sessionId: string } }>("/api/voice/:sessionId/token", async (request, reply) => {
await requireAuthenticatedUser(request);
const { sessionId } = request.params;
const session = await app.sessions.loadOrCreateSession(sessionId, request.user.id);
const tokenResponse = await live.createClientToken({
sessionId: session.sessionId,
expireTimeMs: 10 * 60 * 1000, // 10 minutes
});
return reply.send(tokenResponse);
});createClientToken() calls GoogleGenAI.authTokens.create() against Gemini v1beta, constrains the token to the requested Live model, and returns token.name. It never returns the configured long-lived API key. Protect this application endpoint with authentication and authorization; a short-lived token is only as secure as the endpoint issuing it. See Google's ephemeral token guidance.
3. The Transport Carrier Pattern (inputJson)
Gemini Live requires all tool declarations upfront during the initial WebSocket handshake and does not support dynamically redefining JSON schema shapes on open connections.
To provide dynamic schemas at .wait() nodes without restarting the WebSocket connection, @invariant-tech/live-gemini uses a scalar transport carrier:
text
Provider Tool Call:
submit_input({ inputJson: "{\"haircutLength\":\"short\"}" })
↓ (normalizeLiveInvocation)
Canonical Agent Action:
submit_input({ haircutLength: "short" })
↓ (Runtime Authority Validation)
Durable Kernel State TransitionCritical Architectural Guarantee: The
inputJsoncarrier is strictly transport serialization. It does not define runtime authority. Runtime validity is verified after normalization against the authoritative.wait()node schema.
4. Full Turn Lifecycle: Prepare $\rightarrow$ Invoke $\rightarrow$ Re-enter
ts
// 1. Prepare initial handshake payload for Gemini Live
const preparedTurn = live.prepareSession({
prepared: {
instruction: "You are a friendly salon voice concierge.",
context: { availableCategories: ["Grooming", "Styling"] },
actions: [
{
name: "start_workflow",
description: "Start a booking workflow",
schema: { type: "object", properties: { workflowId: { type: "string" } } },
},
],
},
});
// 2. Normalize incoming tool call from Gemini Live WebSocket
const normalized = live.normalizeLiveInvocation("submit_input", {
inputJson: JSON.stringify({ haircutLength: "short" }),
});
// 3. Format result response for persistent session re-entry
const toolResult = live.formatActionResult({
result: {
status: "accepted",
execution: {
runId: "run_123",
workflowId: "booking",
status: "waiting_input",
revision: 8,
boundary: { kind: "input_required" },
},
boundaryView: {
revision: 8,
presentation: { prompt: "Great! Which stylist would you prefer?" },
projection: { client: { firstName: "Elena" } },
actionSurface: [
{ name: "submit_input", schema: { type: "object", properties: {} } },
],
},
},
projection: {
spokenPrompt: "Great! Which stylist would you prefer?",
},
});spokenPrompt above is application-authored projection content and is returned unchanged. If it is absent, formatActionResult() omits speakText; the adapter never invents phrases such as “Action accepted,” never speaks rejection diagnostics, and never chooses language or tone. Map result.error to user-facing copy in your voice projector or channel application when needed.
For live tool calls, derive the voice projection from result.boundaryView; do not call session.snapshot() after agent.handleAction(). The result frame is causal to that action even if another channel immediately advances the same Session. formatActionResult() preserves execution, revision, presentation, projection, and action surface in its structured next payload.
5. Next Steps
- Model Adapter Input Matrix — Distinguish provider media capability from the current Invariant adapter contract.
- Tarot 3D & Gemini Live Example — Complete full-stack reference implementation of Gemini Live with 3D Canvas.
- Agents & Action Authority — Learn how Invariant validates proposed tool invocations.
- SDK Reference — Browse all SDK and adapter interfaces.