voice-runtime
v2.1.0
Published
Local-first, provider-neutral runtime for realtime voice agents, powered by TVIC.
Maintainers
Readme
voice-runtime
voice-runtime is the public npm entry point for
TVIC, a provider-neutral runtime for realtime
voice agents with bring-your-own telephony, speech, model, and synthesis providers.
New to the package? Start with the beginner's guide. Use the voice-agent guide for application design, the TVIC provider overview for supported adapters, and transports for browser and phone connections. Sarvam API details are in its official TTS documentation.
Status
The 2.1.0 release follows 2.0.0 and adds optional audio taps and transient
per-session provider usage. Version 2.0.0 changed the public durable-store
contracts. If you maintain custom durable-store or idempotency-store adapters,
read the 2.0.0 migration guide
before upgrading.
[email protected] is TVIC's public Node.js entry point. It supports Node.js
22, 24, and 26, ships both ESM and CommonJS entry points, and is server-side
only. The main CI compatibility matrix exercises all three supported lines.
Check the installed package version before using APIs documented for a newer
source checkout.
Browser audio connects to a Node server through the browser-audio transport adapter. Browser code does not import this package directly.
The stable managed API assembles the STT -> LLM -> TTS pipeline from a prompt and provider configuration. The host application still owns its HTTP server, webhook authentication, browser-session authentication, and domain tools.
For a system overview, read How it works. For testing and deployment, read the testing guide and deployment guide.
Install
npm install voice-runtimeThe package has no provider SDK lock-in. You may use the built-in adapters, pass your own provider implementations, or mix both approaches.
Optional AI agent guidance
To give Claude Code, Codex, or another compatible coding agent project-scoped TVIC guidance, run this explicitly from your project directory:
npx voice-runtime skills installThe command asks for y before writing .claude/skills/tvic/SKILL.md for Claude
Code and .agents/skills/tvic/SKILL.md for Codex and OpenRouter-compatible
agents. It does not run automatically during npm install, write to a home
directory, or overwrite an existing skill without --force. Use
npx voice-runtime skills install --yes in automation. See the AI coding-agent
guide and
skills reference.
Prompt-first agent
The prompt-first API assembles the STT -> LLM -> TTS pipeline. It still needs an
authenticated transport CallHandle; npm install does not create a public
audio endpoint or a phone call for your application.
import { createVoiceAgent } from "voice-runtime";
const agent = createVoiceAgent({
prompt: "You schedule appointments for a doctor Be concise and confirm the time.",
providers: {
telephony: { provider: "web-client-audio" },
stt: { provider: "deepgram", apiKey: process.env.DEEPGRAM_API_KEY },
llm: {
provider: "groq",
apiKey: process.env.GROQ_API_KEY,
model: "openai/gpt-oss-20b",
},
tts: {
provider: "cartesia",
apiKey: process.env.CARTESIA_API_KEY,
voiceId: process.env.CARTESIA_VOICE_ID,
},
},
});
const session = await agent.start({
callHandle,
channel: "web_audio",
});
for await (const event of session.run) {
if (event.kind === "transcript_delta") console.log("caller:", event.text);
if (event.kind === "error") console.error(event.error);
}
const result = await session.run;
console.log(result.turnsHandled);
await agent.stop();agent.start() returns { sessionId, run }. The run value is both an async
event stream and an awaitable final result. The separate envelope is required by
JavaScript Promise assimilation rules; returning the Promise-like run directly
from an async start() method would make await agent.start() lose the session
handle. Use agent.run(options) when you only need the final result.
TVIC does not receive media from a prompt alone. A verified transport adapter
must produce the callHandle passed to start().
For a built-in Web Client Audio or Twilio adapter, use the factory form so the adapter receives the session ID created by TVIC:
import { createTwilioMediaStreamsProvider, createVoiceAgent } from "voice-runtime";
const telephony = createTwilioMediaStreamsProvider();
const session = await agent.start({
call: verifiedInboundCall,
channel: "phone",
callHandle: ({ sessionId, call }) => telephony.acceptWebSocket(socket, call.id, sessionId),
});The factory runs after the runtime session exists and receives
{ sessionId, call, channel }. The supplied call is required in this form, and
its ID must match the returned handle's callId. Replace the provider and
channel with createWebClientAudioProvider() and "web_audio" for browser
audio. A preconstructed custom CallHandle is also supported when the adapter
already knows how to correlate its events to the managed session.
Managed session lifecycle
agent.start() returns complete(), stop(), and finalSession alongside
sessionId and run. Call session.complete() when the caller or host has
finished sending input. It asks the CallHandle.endInput() transport hook to
half-close input, then resolves with the persisted terminal session. The call
handle must implement endInput() for graceful completion. Call
session.stop() to cancel only that session; cancellation remains distinct
from normal completion and remote hangup. finalSession resolves to the
persisted terminal result with an ephemeral usage summary after runtime
finalization and transport cleanup. Usage distinguishes runtime-measured and
provider-reported fields, counts LLM tool-continuation requests, and does not
include prices or currency. It is also available in the metadata-only session
trace and is not persisted.
Usage summaries are bounded to 128 entries, 32 metrics per entry, and 128
UTF-16 code units per provider, model, or metric identifier. If a bound omits
usage detail, usage.truncated is true. Text-to-speech failover usage is
grouped under the configured wrapper provider and model; primary and fallback
attempts are not reported as separate entries.
Custom providers should use usageReporter.report() only for observations
available immediately. They must call usageReporter.beginOperation() before
starting asynchronous work that may outlive session finalization so an unfinished
observation is marked as interrupted in the usage summary.
Per-session and agent shutdown use a bounded drain. If cleanup exceeds the
deadline, stop() and finalSession reject with
voice_runtime.shutdown_failed and lateCleanupPending; cleanup can continue
in the background, so keep its dependencies open while it remains active.
Start session.run—typically by beginning to consume its event stream—before
calling session.complete(). Calling complete() before the run starts rejects
with voice_runtime.session_not_running.
The runtime session.run promise still reports execution failure or
cancellation by rejecting. Use finalSession when the host needs a persisted
terminal status regardless of whether the run succeeded. See the
API reference
and transport lifecycle guide
for the complete contract.
agent.stop() is idempotent. If a session is still running, it cancels and
drains that session before closing the runtime and its stores. A deadline
failure rejects shutdown and requires the host to retain any dependencies that
late cleanup still uses.
Errors on VoiceEvent and MediaEvent payloads are JSON-safe
NormalizedError values. Check them with isNormalizedError; the
isTvicError marker is reserved for thrown TvicThrowableError wrappers and
is not present on plain event payloads.
TVIC-created error codes use lowercase, namespaced identifiers such as
auth.invalid_key and provider.open_failed. Custom calls to
normalizedError() and normalizeUnknownError() must use one or more
lowercase dot-separated segments; older persisted error records retain their
legacy codes during migration.
What TVIC does
Accepts call media, transcribes speech, decides when a caller actually finished their turn, runs a model and tools, synthesizes a reply incrementally, streams audio back, and handles interruption, cancellation, provider stalls, and hangup.
TVIC does not store recordings or run recording workflows. Hosts can opt into the
audioTap hook to receive copied caller
and agent PCM frames, plus clear and playout markers. The host owns recording
consent, storage, retention, and access controls. Session traces contain only
metadata, audio-tap counters, and usage; they never contain audio bytes or
conversation content.
The optional onDrained({ sessionId, summary }) callback identifies which
session's queued tap callbacks have settled, including a session stopped before
its pipeline starts. TVIC does not wait for host recording or storage work to
finish; keep synchronous callback work short.
Providers, models, and credentials
Each stage is configured independently. Built-in options currently include:
TVIC's supported provider set is listed in the repository provider overview. For current Sarvam API behavior and voice IDs, use the official Sarvam TTS documentation.
- STT: Deepgram, Sarvam, Cartesia Ink, ElevenLabs Scribe, AssemblyAI, and Soniox.
- LLM: Groq Chat Completions (the reference path) and an optional OpenAI Responses adapter.
- TTS: Cartesia, ElevenLabs (WebSocket, REST, and HTTP stream), and Sarvam Bulbul v3/v4 Flash.
- Transport: browser audio and inbound Twilio Media Streams.
Maturity is intentionally explicit: Web Client Audio and inbound Twilio Media
Streams remain the stable transport paths in 2.0.0. The paid STT, LLM, and TTS
adapters are currently experimental. validated is the intermediate label
for an exact adapter/model/configuration that has passed the small-scale live
evidence profile and is reasonable for low-volume use; stable additionally
requires a support matrix, release evidence, ownership, and an upgrade policy.
The root exports PROVIDER_STABILITY and PROVIDER_STABILITY_LEVELS contain
the machine-readable labels.
Pass an explicit apiKey or use the documented environment variable. Explicit
credentials take precedence; missing credentials fail before a live session is
created.
| Provider | Environment variable |
| ---------- | -------------------- |
| Deepgram | DEEPGRAM_API_KEY |
| Sarvam | SARVAM_API_KEY |
| ElevenLabs | ELEVENLABS_API_KEY |
| AssemblyAI | ASSEMBLYAI_API_KEY |
| Soniox | SONIOX_API_KEY |
| Groq | GROQ_API_KEY |
| OpenAI | OPENAI_API_KEY |
| Cartesia | CARTESIA_API_KEY |
| Sarvam TTS | SARVAM_API_KEY |
STT, LLM, and TTS models are independent settings. TTS voice selection is also
independent. Built-in TTS can read CARTESIA_VOICE_ID or
ELEVENLABS_VOICE_ID for its voice. For complete provider-specific control,
pass a constructed provider instance instead of a built-in configuration object.
Sarvam's managed configuration defaults to its WebSocket adapter; set
transport: "rest" or transport: "http-stream" on the Sarvam TTS config when
the downstream use case needs one-shot HTTP delivery.
Built-in STT and LLM configurations reject models outside the
dated TVIC catalog unless allowUnknownModel: true is explicitly set.
Tools are application-owned functions. Define their schema and executor once, then include them in the agent:
import { createVoiceAgent, defineTool } from "voice-runtime";
const bookAppointment = defineTool<{ date: string }, { booked: boolean }>({
id: "book_appointment",
name: "book_appointment",
description: "Books an appointment for a requested date.",
inputSchema: { type: "object", required: ["date"] },
outputSchema: { type: "object", required: ["booked"] },
async execute(input) {
return { booked: input.date.length > 0 };
},
});
const providers = {
telephony: { provider: "web-client-audio" as const },
stt: { provider: "deepgram" as const, apiKey: process.env.DEEPGRAM_API_KEY },
llm: { provider: "groq" as const, apiKey: process.env.GROQ_API_KEY },
tts: {
provider: "cartesia" as const,
apiKey: process.env.CARTESIA_API_KEY,
voiceId: process.env.CARTESIA_VOICE_ID,
},
};
const agentWithTools = createVoiceAgent({
prompt: "Schedule appointments and use the booking tool when needed.",
tools: [bookAppointment],
providers,
});Tool execution receives a session/turn-scoped AbortSignal; authorization,
confirmation for irreversible actions, and domain-state writes remain the host
application's responsibility.
Composable API
Advanced runtime, media, provider, normalized-error, and durable-adapter APIs
are exported from the package root. There are no advanced subpath imports in
2.0.0. This lets a developer start with createVoiceAgent and later own
the runtime/session/pipeline boundaries without changing package names.
Read the voice-agent guide for the managed to composable migration path and the API reference for the root exports.
The host remains responsible for creating authenticated transport connections, owning HTTP/webhook lifecycle, and implementing application-domain tools. TVIC does not automatically create public unauthenticated endpoints or choose a provider fallback policy.
Persistence adapters
The root also exposes the PostgreSQL and Redis durability building blocks. The
database drivers are intentionally injected, so install and configure pg and
redis in your application:
The persistence guide covers migrations, memory scopes, Docker, ownership, and production retention.
import {
createPostgresMemory,
createPostgresRedisDurableRuntimeStore,
runPostgresMemoryMigrations,
runPostgresMigrations,
} from "voice-runtime";
await runPostgresMigrations(pgPool);
await runPostgresMemoryMigrations(memoryPool);
const memory = createPostgresMemory({ pool: memoryPool });
const durable = createPostgresRedisDurableRuntimeStore({
pool: pgPool,
redis: redisClient,
});The migration SQL is bundled into the published package and can also be inspected in the package's migration sources. Run each migration function once at deploy time; repeated calls are idempotent. PostgreSQL is authoritative for the composite store, while Redis is used for its cache/projection layer.
