samai-sdk
Core concepts

Voice agents — pipeline, engine, and realtime

A provider-agnostic pipeline (STT → LLM → TTS), a deterministic conversation engine, and OpenAI Realtime over WebRTC or WebSocket — all with mock-based testing and tracing.

Realtime is GA, not preview

The Realtime preview API is retired. samai-sdk 0.3.6+ targets the GA interface: default model gpt-realtime, session.type: "realtime", audio config nested under session.audio.input/output, ephemeral client secrets, and no OpenAI-Beta header. If you were on gpt-4o-realtime-preview, move the model and drop any preview-only parameters. See the CHANGELOG's breaking changes.

Heads up

generateSpeech()/transcribeAudio() are straightforward REST calls (same shape as createWebSearchTool()) but haven't been exercised against a live key from this SDK's dev environment. createRealtimeSession()'s wire-protocol logic has been verified against a real local mock WebSocket server — catching and fixing a real race condition and an auth bug in the process — but the exact event names/fields haven't been confirmed against OpenAI's live server, since that API moves quickly. Read the disclaimer at the top of src/voice.ts before production use.

Browser quick start (WebRTC)

A browser voice app needs three things and nothing else from this SDK: a server route that mints an ephemeral client secret, a client that asks for the microphone, and a connection.samai-sdk/voice is browser-safe — it imports no node:* modules and uses no unguarded Buffer — so npm i samai-sdk is the whole install. The long-lived API key never leaves your server: the client holds a short-lived ek_… secret instead.

Start with the agent definition, which both sides share. It is safe to import in a client component because samai-sdk/voice reaches no Node built-ins:

lib/agent.ts
// lib/agent.ts — shared by the route and the client. Safe to import in a
// client component: samai-sdk/voice pulls in no Node built-ins.
import { defineTool, defineVoiceAgent } from "samai-sdk/voice";
import { z } from "zod";

export const getTimeTool = defineTool({
  name: "get_time",
  description: "Gets the current time in the user's timezone",
  parameters: z.object({ timezone: z.string().optional() }),
  execute: async ({ timezone }) => new Date().toLocaleTimeString("en-US", { timeZone: timezone }),
});

export const agent = defineVoiceAgent({
  name: "assistant",
  instructions: "You are a helpful, concise voice assistant.",
  model: "gpt-realtime",
  tools: [getTimeTool],
});

Then the server route. This is the only code that touches OPENAI_API_KEY, and it calls the GA POST /v1/realtime/client_secrets endpoint:

app/api/realtime-secret/route.ts
// app/api/realtime-secret/route.ts — SERVER ONLY.
// This is the one place the long-lived API key is allowed to appear.
import { NextResponse } from "next/server";
import { createRealtimeClientSecret } from "samai-sdk/voice";

export async function POST() {
  const { value, expiresAt } = await createRealtimeClientSecret({
    apiKey: process.env.OPENAI_API_KEY!,
    model: "gpt-realtime",
    ttlSeconds: 600,             // 10–7200; the browser holds it for this long
    safetyIdentifier: "user-1234", // OpenAI's abuse-tracking header
  });
  return NextResponse.json({ clientSecret: value, expiresAt });
}

Finally the client. It fetches the secret, requests the mic itself (so permission errors stay its own to surface), and connects over WebRTC — the browser hands its track to the API and renders the returned track on an <audio> element, with no base64 PCM plumbing and no playback buffer to manage:

components/VoiceAgent.tsx
"use client";
// components/VoiceAgent.tsx
import { useState } from "react";
import { openaiRealtime, stopMediaStream, type VoiceAgentEvent } from "samai-sdk/voice";

export function VoiceAgent() {
  const [status, setStatus] = useState("idle");
  const [caption, setCaption] = useState("");
  const audioRef = useRef<HTMLAudioElement>(null);
  const sessionRef = useRef<any>(null);
  const streamRef = useRef<MediaStream | null>(null);

  async function start() {
    // 1. Mint the ephemeral secret on the server. Never ship OPENAI_API_KEY here.
    const { clientSecret } = await fetch("/api/realtime-secret", { method: "POST" })
      .then((r) => r.json());

    // 2. Ask for the mic ourselves, so permission errors stay ours to surface.
    const inputStream = await navigator.mediaDevices.getUserMedia({ audio: true });
    streamRef.current = inputStream;

    // 3. Connect over WebRTC. transport: "webrtc" is implied by inputStream.
    const provider = openaiRealtime({ clientSecret, inputStream });
    const session = await provider.connect({ agent, session });
    sessionRef.current = session;

    // 4. The assistant's voice arrives as a MediaStream — no PCM plumbing needed.
    if (audioRef.current && session.getRemoteStream()) {
      audioRef.current.srcObject = session.getRemoteStream();
      await audioRef.current.play();
    }

    session.on("assistant-transcript-delta", (e: any) => setCaption((c) => c + e.delta));
    session.on("user-transcript-delta", (e: any) => setCaption((c) => c + e.delta));
    session.on("connection-state", (e: any) => setStatus(e.state));
  }

  async function stop() {
    await sessionRef.current?.close();
    stopMediaStream(streamRef.current); // release the microphone
    sessionRef.current = null;
    streamRef.current = null;
    setStatus("idle");
  }

  return (
    <div>
      <button onClick={status === "idle" ? start : stop}>{status === "idle" ? "Talk" : status}</button>
      <p>{caption}</p>
      <audio ref={audioRef} autoPlay />
    </div>
  );
}

Captions, barge-in, and fallback

transport: "webrtc" is implied when you pass an inputStream. Pass it explicitly to force the choice; without a client secret the transport throws rather than shipping a long-lived key to the browser. Turn-taking and barge-in come from OpenAI's server-side VAD, so there is no push-to-talk button — captions arrive on assistant-transcript-delta and user-transcript-delta. On a host without RTCPeerConnection (or for a server-side agent), use the WebSocket transport instead: openaiRealtime({ clientSecret }), which owns its playback buffer and calls truncateLastResponse() on barge-in.

Pipeline: STT → LLM → TTS (provider-agnostic)

pipelineVoice({ stt, llm, tts }) returns a VoiceProvider composable with any of the 8 text Providers. The LLM is just a Provider — swap anthropic() for openai() and nothing else changes. Tools, guardrails, sessions, and tracing all flow through the same conversation engine that powers realtime.

voice-pipeline.ts
import { pipelineVoice } from "samai-sdk/voice";
import { deepgramSTT } from "samai-sdk/voice";
import { elevenLabsTTS } from "samai-sdk/voice";
import { defineVoiceAgent } from "samai-sdk/voice";
import { anthropic } from "samai-sdk";

// Any text Provider works as the LLM — swap anthropic() for openai() etc.
const voiceProvider = pipelineVoice({
  stt: deepgramSTT({ apiKey: process.env.DEEPGRAM_KEY }),
  llm: anthropic({ apiKey: process.env.ANTHROPIC_KEY }),
  tts: elevenLabsTTS({ apiKey: process.env.ELEVEN_KEY }),
});

const agent = defineVoiceAgent({
  name: "concierge",
  instructions: "You are a helpful, concise voice assistant.",
  model: "claude-sonnet-4-5",
  voice: { interruption: "confidence-gated", backchannel: true },
});

const session = await voiceProvider.connect({ agent, session });
session.on("user-speech-ended", (e) => console.log("user:", e.transcript));
session.on("interruption", () => console.log("barge-in — TTS cancelled"));
session.sendAudio(micChunk); // ArrayBuffer from your mic
session.interrupt();         // explicit barge-in
await session.close();

Conversation engine & turn-taking

ConversationEngine is a deterministic state machine — idle → listening → thinking → speaking → interrupted → idle — reused by both pipeline and realtime. Barge-in is confidence-gated via VoiceActivityDetector + InterruptionController and cancels the in-flight LLM+TTS with an AbortController; low-confidence blips (coughs, TV) are ignored.

voice-engine.ts
import { ConversationEngine } from "samai-sdk/voice";

const engine = new ConversationEngine({ agent, session, trace });
// engine.handleUserSpeechEnded(transcript, confidence) -> clarification or null
// engine.handleBargeIn({ confidence, durationMs })     -> true if barge-in accepted
// engine.getState() // "idle" | "connecting" | "listening" | "user_speaking" | "thinking"
//                   // | "speaking" | "assistant_speaking" | "interrupting" | "interrupted"
//                   // | "reconnecting" | "error"

Behavior: goals, clarification, and shaping

IntentTracker persists goal state via the same Session store used for text chat (so voice + text share a store); ClarificationPolicy asks for missing/low-confidence slots before calling the LLM; ResponseShaper trims replies for voice (short inputs → short answers) and optionally emits backchannels on long turns.

STT/TTS adapters (optional peers)

deepgramSTT() and elevenLabsTTS() are thin adapters over @deepgram/sdk and elevenlabs — both optional, lazily imported. Nothing in samai-sdk core requires them to install or build. In tests or without a key they fall back to a mock transport so the pipeline stays testable.

WebRTC transport

WebRTCVoiceTransport wraps the browser RTCPeerConnection with a Node-compatible mock fallback; webrtc-signaling.ts exposes createOffer / handleAnswer / handleOffer / addIceCandidate so you can wire any signaling channel (WebSocket, etc.). OpenAIRealtimeWebRTCTransport is the ready-made OpenAI implementation on top of it — it does the SDP offer/answer against /v1/realtime/calls for you, andstopMediaStream() releases the microphone when the call ends. All of these, pluscreateRealtimeClientSecret, are exported from samai-sdk/voice.

Realtime as a VoiceProvider

openaiRealtime() (in samai-sdk/voice) is a thin VoiceProvider over either transport — WebRTC when you pass an inputStream, WebSocket otherwise — same VoiceSession surface as the pipeline, so agents can switch transports without changing call sites.

React hook

useVoiceAgent() from samai-sdk/react-voice mirrors useAgent from samai-sdk/react — connect() / disconnect() / sendAudio() / interrupt() plus isSpeaking / isListening / transcript state.

VoiceButton.tsx
import { useVoiceAgent } from "samai-sdk/react-voice";
import { pipelineVoice } from "samai-sdk/voice";

function VoiceButton({ provider, agent }) {
  const { isConnected, isSpeaking, transcript, connect, disconnect, sendAudio, interrupt } =
    useVoiceAgent(provider, agent);
  return (
    <button onClick={isConnected ? disconnect : connect}>
      {isSpeaking ? "Speaking…" : isConnected ? "Listening" : "Connect"}
    </button>
  );
}

Testing & observability

createMockSTTProvider() / createMockTTSProvider() / createMockVoiceTransport() from samai-sdk/voice let you drive the full pipeline deterministically — no audio deps, no network. Every VoiceAgentEvent also records into RunTrace as voice-turn / interruption / clarification / goal-update so exportRunTraceToOtel() and renderTraceHTML() work unchanged.

Legacy: TTS / transcription REST

voice-rest.ts
import { generateSpeech, transcribeAudio } from "samai-sdk";
import { writeFile, readFile } from "node:fs/promises";

const { audio } = await generateSpeech({ input: "Hello there!", voice: "nova" });
await writeFile("out.mp3", audio);

const { text } = await transcribeAudio({ audio: await readFile("recording.mp3"), filename: "recording.mp3" });

Raw realtime WebSocket

realtime.ts
import { createRealtimeSession } from "samai-sdk";

// Server-side, or any host with a WebSocket-capable runtime.
const session = createRealtimeSession({
  instructions: "You are a helpful, concise voice assistant.",
  voice: "alloy",
  tools: [getWeatherTool],
});

session.on((event) => {
  if (event.type === "audio.delta") playAudioChunk(event.audio);
  if (event.type === "speech_started") session.truncateLastResponse(playedMs);
});

await session.connect();
session.sendText("What's the weather in Tokyo?");
session.interrupt();
await session.close();

The WebSocket transport, for servers and any host without WebRTC. It handles the network/protocol side only — pairing it with actual mic capture and speaker playback is up to your app, which is also why it owns the playback buffer and has to call truncateLastResponse(playedMs) when the user barges in (interrupt() does this for you). The global WebSocket is preferred and is all browsers, edge runtimes, and Node 22+ need; on Node < 22, or for header-based auth, install the optional ws peer dependency. Without it, connections authenticate via OpenAI's documented subprotocol scheme instead.