realtime-voice-agents 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +298 -0
- package/assets/elevator-jazz.ulaw +1 -0
- package/assets/keyboard-typing.ulaw +1 -0
- package/assets/lofi.ulaw +1 -0
- package/assets/ringing.ulaw +1 -0
- package/assets/thinking-hum.ulaw +1 -0
- package/dist/BackgroundAudioPlayer-iMcivjis.mjs +282 -0
- package/dist/BackgroundAudioPlayer-jfRULWKC.cjs +313 -0
- package/dist/BaseRealtimeProvider-BQigr5mB.mjs +62 -0
- package/dist/BaseRealtimeProvider-BehPNT1r.d.cts +239 -0
- package/dist/BaseRealtimeProvider-ClP8Wx1X.d.mts +239 -0
- package/dist/BaseRealtimeProvider-DI4pKtOb.cjs +91 -0
- package/dist/GeminiLiveProvider-DvkTgzjG.d.cts +101 -0
- package/dist/GeminiLiveProvider-x2nyx5aO.d.mts +101 -0
- package/dist/InMemorySessionStore-B0_i-DOU.cjs +34 -0
- package/dist/InMemorySessionStore-B5_rq61L.d.cts +83 -0
- package/dist/InMemorySessionStore-B5_rq61L.d.mts +83 -0
- package/dist/InMemorySessionStore-DYParOJO.mjs +29 -0
- package/dist/OpenAICompatibleProvider-Bdtl-UXH.mjs +402 -0
- package/dist/OpenAICompatibleProvider-NS4cKVQj.cjs +409 -0
- package/dist/audio.cjs +21 -0
- package/dist/audio.d.cts +165 -0
- package/dist/audio.d.mts +165 -0
- package/dist/audio.mjs +4 -0
- package/dist/env-DSnGaERV.cjs +19 -0
- package/dist/env-DUwUWTsg.mjs +14 -0
- package/dist/events-BUMYdETO.d.cts +24 -0
- package/dist/events-BUMYdETO.d.mts +24 -0
- package/dist/events-BxDTIKKq.cjs +48 -0
- package/dist/events-BylBSBW-.mjs +43 -0
- package/dist/gemini.cjs +401 -0
- package/dist/gemini.d.cts +41 -0
- package/dist/gemini.d.mts +41 -0
- package/dist/gemini.mjs +395 -0
- package/dist/index.cjs +2142 -0
- package/dist/index.d.cts +920 -0
- package/dist/index.d.mts +920 -0
- package/dist/index.mjs +2113 -0
- package/dist/mulaw--cwU2c9L.mjs +64 -0
- package/dist/mulaw-DLUObjdP.cjs +117 -0
- package/dist/openai.cjs +61 -0
- package/dist/openai.d.cts +121 -0
- package/dist/openai.d.mts +121 -0
- package/dist/openai.mjs +53 -0
- package/dist/presets-Bf75YXs5.d.cts +24 -0
- package/dist/presets-Bf75YXs5.d.mts +24 -0
- package/dist/rest-BYqiVOhe.mjs +265 -0
- package/dist/rest-BvUKut_k.cjs +300 -0
- package/dist/rolldown-runtime-VH7oDXx4.cjs +28 -0
- package/dist/session-config-BVLl7-ha.mjs +74 -0
- package/dist/session-config-c8sOw1XL.cjs +85 -0
- package/dist/store.cjs +3 -0
- package/dist/store.d.cts +2 -0
- package/dist/store.d.mts +2 -0
- package/dist/store.mjs +2 -0
- package/dist/testing.cjs +544 -0
- package/dist/testing.d.cts +191 -0
- package/dist/testing.d.mts +191 -0
- package/dist/testing.mjs +538 -0
- package/dist/transcode-C9aJG7_W.cjs +215 -0
- package/dist/transcode-CsAp97G9.mjs +198 -0
- package/dist/transport-B_PJFIVd.d.cts +155 -0
- package/dist/transport-CEaLFV4E.d.mts +155 -0
- package/dist/twilio.cjs +8 -0
- package/dist/twilio.d.cts +3 -0
- package/dist/twilio.d.mts +3 -0
- package/dist/twilio.mjs +2 -0
- package/dist/twiml-z9LjoF4_.d.cts +67 -0
- package/dist/twiml-z9LjoF4_.d.mts +67 -0
- package/dist/xai.cjs +92 -0
- package/dist/xai.d.cts +35 -0
- package/dist/xai.d.mts +35 -0
- package/dist/xai.mjs +86 -0
- package/package.json +183 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yakov Sinwani
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
# realtime-voice-agents
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/realtime-voice-agents)
|
|
4
|
+
[](https://github.com/yakovsinwani/twilio-realtime-agents/actions/workflows/ci.yml)
|
|
5
|
+
[](https://www.npmjs.com/package/realtime-voice-agents)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
|
|
8
|
+
**Provider-agnostic bridge between Twilio Media Streams and realtime speech-to-speech AI.** Build phone voice agents in Node.js with one `Agent` / `tool()` / session API across **OpenAI Realtime**, **xAI Grok Voice**, and **Gemini Live** — with multi-agent handoffs, tool execution strategies, hardware-confirmed playback tracking, true barge-in, and hold audio.
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
Caller ── PSTN ── Twilio ── Media Stream WS ──▶ TwilioRealtimeBridge ──▶ OpenAI / xAI / Gemini
|
|
12
|
+
μ-law 8kHz CallSession realtime S2S
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Why this exists
|
|
16
|
+
|
|
17
|
+
Bridging a phone call to a realtime model looks like "pipe two WebSockets together" — until you hit the real problems:
|
|
18
|
+
|
|
19
|
+
- **Twilio buffers seconds of audio.** Generation-side events run far ahead of what the caller hears. This SDK interleaves a **mark after every audio chunk**; Twilio echoes each mark when playout actually reaches it, giving you `playback.started` / `playback.finished` / `playback.interrupted` events that reflect the phone line, not the model.
|
|
20
|
+
- **Barge-in needs three things, not one.** On interruption we flush Twilio's buffer (`clear`), and on providers that support it send `conversation.item.truncate` with the _actually-heard_ milliseconds — so the model's memory of what it said matches reality.
|
|
21
|
+
- **Codecs differ.** OpenAI and xAI speak `audio/pcmu` natively → **zero transcoding**, byte-for-byte passthrough. Gemini speaks PCM (16k in / 24k out) → a stateful polyphase resampler with inter-chunk filter memory (no per-chunk boundary clicks).
|
|
22
|
+
- **Hangups cut off goodbyes.** `finish_call` uses a goodbye contract: the tool result _instructs_ the model to say farewell, marks confirm the farewell finished playing, then the leg completes via REST — with a watchdog if the echo never comes.
|
|
23
|
+
- **Slow tools sound like dead air.** Bundled μ-law hold loops (typing, hold music, ambient) start after a delay (fast tools stay silent), pace in near-realtime, and yield instantly when real speech arrives.
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npm install realtime-voice-agents zod
|
|
29
|
+
# optional, per feature:
|
|
30
|
+
npm install twilio # REST hangup/transfer/SMS
|
|
31
|
+
npm install @google/genai # Gemini Live provider
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Node 20+. Zod 3.25+ or 4.
|
|
35
|
+
|
|
36
|
+
> Previously published as `twilio-realtime-agents` (through 1.2.0, now deprecated). v2 is the same package under the new name — no API changes, only the import specifier. Swap the dependency and update your imports.
|
|
37
|
+
|
|
38
|
+
## Quick start
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
import Fastify from 'fastify';
|
|
42
|
+
import websocket from '@fastify/websocket';
|
|
43
|
+
import * as z from 'zod';
|
|
44
|
+
import { Agent, TwilioRealtimeBridge, connectStreamTwiml, tool } from 'realtime-voice-agents';
|
|
45
|
+
import { openaiRealtime } from 'realtime-voice-agents/openai';
|
|
46
|
+
|
|
47
|
+
const weather = tool({
|
|
48
|
+
name: 'get_weather',
|
|
49
|
+
description: 'Current weather for a city',
|
|
50
|
+
parameters: z.object({ city: z.string() }),
|
|
51
|
+
execute: async ({ city }) => ({ city, tempC: 22, sky: 'clear' }),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const bridge = new TwilioRealtimeBridge({
|
|
55
|
+
agent: new Agent({
|
|
56
|
+
name: 'Receptionist',
|
|
57
|
+
instructions: 'Answer the phone briefly and warmly.',
|
|
58
|
+
voice: 'marin',
|
|
59
|
+
tools: [weather],
|
|
60
|
+
}),
|
|
61
|
+
provider: openaiRealtime(), // OPENAI_API_KEY from env
|
|
62
|
+
builtinTools: { finishCall: true },
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const app = Fastify();
|
|
66
|
+
await app.register(websocket);
|
|
67
|
+
|
|
68
|
+
app.post('/twilio/voice', async (_req, reply) =>
|
|
69
|
+
reply.type('text/xml').send(connectStreamTwiml({ wsUrl: 'wss://your-host/twilio/media-stream' })),
|
|
70
|
+
);
|
|
71
|
+
app.register(async (i) => {
|
|
72
|
+
i.get('/twilio/media-stream', { websocket: true }, (socket) => bridge.handleConnection(socket));
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
await app.listen({ port: 3000 });
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Point your Twilio number's Voice webhook at `POST /twilio/voice`. That's a working agent. See [examples/fastify](examples/fastify) for the full tour (handoffs, strategies, approvals, outbound calls) and [examples/express-ws](examples/express-ws) for the minimal version.
|
|
79
|
+
|
|
80
|
+
## Providers
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
import { openaiRealtime } from 'realtime-voice-agents/openai';
|
|
84
|
+
import { xaiRealtime } from 'realtime-voice-agents/xai';
|
|
85
|
+
import { geminiLive } from 'realtime-voice-agents/gemini';
|
|
86
|
+
|
|
87
|
+
openaiRealtime({
|
|
88
|
+
model: 'gpt-realtime',
|
|
89
|
+
voice: 'marin',
|
|
90
|
+
vad: { type: 'server', silenceDurationMs: 700 },
|
|
91
|
+
});
|
|
92
|
+
xaiRealtime({ model: 'grok-voice-latest', voice: 'eve' });
|
|
93
|
+
geminiLive({ model: 'gemini-2.5-flash-native-audio-preview-12-2025', voice: 'Aoede' });
|
|
94
|
+
// or bring your own: implement BaseRealtimeProvider and pass a factory.
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
| | OpenAI | xAI | Gemini Live |
|
|
98
|
+
| ---------------------- | --------------------------------- | --------------------------------- | -------------------------------------- |
|
|
99
|
+
| Audio path | μ-law passthrough | μ-law passthrough | transcoded (stateful resampler) |
|
|
100
|
+
| Barge-in truncation | ✅ `item.truncate` | buffer flush only | server self-truncates |
|
|
101
|
+
| Mid-session agent swap | ✅ `session.update` | ✅ `session.update` | reconnect + context carry |
|
|
102
|
+
| Session resumption | — | — | ✅ handles, replayed on reconnect |
|
|
103
|
+
| Reconnect | backoff + transcript re-injection | backoff + transcript re-injection | backoff + resumption (or re-injection) |
|
|
104
|
+
|
|
105
|
+
One `SessionOptions` surface configures all three; where a provider can't honor a knob, the fallback is documented and pinned by the parity test suite.
|
|
106
|
+
|
|
107
|
+
## Tools: Zod schemas + execution strategies
|
|
108
|
+
|
|
109
|
+
```ts
|
|
110
|
+
tool({
|
|
111
|
+
name: 'run_credit_check',
|
|
112
|
+
description: 'Credit check across bureaus (slow).',
|
|
113
|
+
parameters: z.object({ customerId: z.string() }),
|
|
114
|
+
strategy: 'deferred', // ← how it executes relative to the conversation
|
|
115
|
+
timeoutMs: 30_000,
|
|
116
|
+
backgroundAudio: 'elevator-jazz', // hold audio while the caller waits (sync/HITL)
|
|
117
|
+
onBeforeExecute: async (input) => {
|
|
118
|
+
/* veto or rewrite input */
|
|
119
|
+
},
|
|
120
|
+
onAfterExecute: async (result) => {
|
|
121
|
+
/* transform what the model sees */
|
|
122
|
+
},
|
|
123
|
+
onError: async (err) => ({ error: 'Bureau unavailable, offer a callback.' }),
|
|
124
|
+
execute: async ({ customerId }, ctx) => creditApi.check(customerId),
|
|
125
|
+
});
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
| Strategy | The model… | Use for |
|
|
129
|
+
| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- |
|
|
130
|
+
| `sync` (default) | waits for the result (hold audio covers the gap) | lookups, account data |
|
|
131
|
+
| `dispatch` | gets `{status:'queued'}` instantly and keeps talking | SMS, webhooks, analytics |
|
|
132
|
+
| `deferred` | acknowledges; the result is **injected as a new turn** when ready — from `execute()` or from your backend via `session.submitToolResult(id, result)` | slow third-party APIs |
|
|
133
|
+
| `humanInTheLoop` | waits while `tool.approval.required` fires; resolve with `session.approveTool(id, editedInput?)` / `rejectTool(id, reason)` (auto-reject on timeout) | refunds, deletions, high-risk actions |
|
|
134
|
+
|
|
135
|
+
**Tool context** gives every tool capability closures — never raw sockets: `ctx.session.sendText/finishCall/transferTo/handoffTo/playBackgroundAudio/submitToolResult`, `ctx.context` (session KV carried across handoffs), `ctx.callInfo`, `ctx.signal`.
|
|
136
|
+
|
|
137
|
+
**Cross-cutting middleware** (an onion, first registered = outermost; may short-circuit):
|
|
138
|
+
|
|
139
|
+
```ts
|
|
140
|
+
bridge.use({
|
|
141
|
+
decorate: (tool) => ({ description: `${tool.description} (All actions are audited.)` }),
|
|
142
|
+
wrapExecute: async (tool, input, ctx, next) => {
|
|
143
|
+
audit.start(ctx.callSid, tool.name, input);
|
|
144
|
+
try {
|
|
145
|
+
return await next();
|
|
146
|
+
} finally {
|
|
147
|
+
audit.end(ctx.callSid, tool.name);
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## Multi-agent handoffs (swarm)
|
|
154
|
+
|
|
155
|
+
```ts
|
|
156
|
+
const billing = new Agent({
|
|
157
|
+
name: 'Billing',
|
|
158
|
+
instructions: '…',
|
|
159
|
+
handoffDescription: 'Transfer for invoices, payments, refunds.',
|
|
160
|
+
tools: [issueRefund],
|
|
161
|
+
});
|
|
162
|
+
const receptionist = new Agent({ name: 'Receptionist', instructions: '…', handoffs: [billing] });
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Each agent in `handoffs` becomes a `transfer_to_<id>` tool. On handoff the session settles the function call, swaps instructions + tools (`session.update` on OpenAI/xAI; close-and-reopen with context carry on Gemini), and triggers a natural continuation — the caller never hears a seam. Also available programmatically: `session.handoffTo('billing')`. Cycles are fine (billing can hand back).
|
|
166
|
+
|
|
167
|
+
## Built-in call controls
|
|
168
|
+
|
|
169
|
+
```ts
|
|
170
|
+
builtinTools: {
|
|
171
|
+
finishCall: true, // graceful goodbye-aware hangup
|
|
172
|
+
transferCall: { enabled: true, defaultPhoneNumber: '+18005550199' }, // REST <Dial> transfer
|
|
173
|
+
},
|
|
174
|
+
twilio: { accountSid, authToken }, // enables the REST control plane
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
`transfer_call` validates and XML-escapes numbers (never trust model output in markup), waits for current playback, and updates the live call's TwiML.
|
|
178
|
+
|
|
179
|
+
## Interruption control
|
|
180
|
+
|
|
181
|
+
```ts
|
|
182
|
+
session: {
|
|
183
|
+
interruptions: {
|
|
184
|
+
guardDurationMs: 1500, // no barge-in for the first 1.5s of each reply
|
|
185
|
+
firstResponseOnly: false,
|
|
186
|
+
rateLimit: { // noisy-environment defense
|
|
187
|
+
windowMs: 30_000, threshold: 4,
|
|
188
|
+
instruction: 'Ask the caller to move somewhere quieter.',
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
}
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Blocked attempts emit `interruption.blocked` with a cause (`guard` | `rate_limit` | `tool_running` | …). Honored ones flush Twilio, truncate the model's context to the heard milliseconds, and emit `playback.interrupted` with exactly how much the caller heard.
|
|
195
|
+
|
|
196
|
+
## Pre-synthesized greeting (~1.5s to first word)
|
|
197
|
+
|
|
198
|
+
The slowest part of answering is the provider handshake. Pre-record the greeting once, and the bridge burst-writes it onto the call **while the session is still connecting** — then keeps the model from greeting twice (instruction reinforcement + assistant-turn seeding + suppressed auto-greet) and gates caller audio until Twilio's mark confirms playout.
|
|
199
|
+
|
|
200
|
+
```ts
|
|
201
|
+
import { captureGreetingAudio } from 'realtime-voice-agents';
|
|
202
|
+
|
|
203
|
+
// once, at deploy/config time — records from a real session so the voice matches:
|
|
204
|
+
const { audio } = await captureGreetingAudio({
|
|
205
|
+
apiKey: process.env.OPENAI_API_KEY!, voice: 'marin',
|
|
206
|
+
text: 'Hi, thanks for calling Acme! How can I help?',
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// per call:
|
|
210
|
+
session: { greeting: { mode: 'agent-initiates',
|
|
211
|
+
preSynthesized: { audio, text: 'Hi, thanks for calling Acme! How can I help?' } } }
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
## Background / hold audio
|
|
215
|
+
|
|
216
|
+
Bundled presets (all synthesized, license-free, seamless loops): `elevator-jazz`, `lofi`, `keyboard-typing`, `thinking-hum`, `ringing` — or `{ custom: bufferOrPath }` with your own 8 kHz μ-law. Drift-corrected 20 ms pacing, refcounted across concurrent tools, ~1 s start delay so fast tools stay silent, fade in/out, 60 s failsafe, and instant preemption when real speech arrives. Manual control: `session.playBackgroundAudio('lofi')` / `stopBackgroundAudio()`.
|
|
217
|
+
|
|
218
|
+
## Events (session)
|
|
219
|
+
|
|
220
|
+
`call.started/ended/failed` · `provider.connected/reconnecting/reconnected/closed` · `agent.speech.started/ended` (generation) · **`playback.started/finished/interrupted`** (what the caller heard, mark-confirmed) · `user.speech.started/ended` · `transcript.user/agent` · `tool.started/completed/failed` · `tool.approval.required` · `agent.handoff` · `interruption` / `interruption.blocked` · `background_audio.started/stopped` · `dtmf` · `usage.updated` · `error`.
|
|
221
|
+
|
|
222
|
+
```ts
|
|
223
|
+
bridge.on('session.started', (session) => {
|
|
224
|
+
session.on('playback.finished', ({ responseId, playedMs }) => {
|
|
225
|
+
/* caller heard it all */
|
|
226
|
+
});
|
|
227
|
+
session.on('usage.updated', (usage) => console.log(usage.totalTokens));
|
|
228
|
+
});
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
## Session options (defaults shown)
|
|
232
|
+
|
|
233
|
+
```ts
|
|
234
|
+
session: {
|
|
235
|
+
greeting: { mode: 'agent-initiates' }, // 'user-initiates' to wait
|
|
236
|
+
interruptions: { enabled: true },
|
|
237
|
+
deafness: {
|
|
238
|
+
ignoreUserAudioUntilFirstTurnDone: true, // auto-false with greeting.mode 'user-initiates' (caller must be heard to start)
|
|
239
|
+
muteDuringToolExecution: true,
|
|
240
|
+
muteWhileAgentSpeaking: false, // half-duplex: deaf while agent audio plays (caller speech is lost, not queued)
|
|
241
|
+
},
|
|
242
|
+
idle: undefined, // { timeoutSeconds, prompts, maxNudges, goodbye }
|
|
243
|
+
maxCallDurationSeconds: undefined,
|
|
244
|
+
reconnect: { maxAttempts: 5, initialDelayMs: 250, maxDelayMs: 8000, jitter: true },
|
|
245
|
+
hangup: { markTimeoutMs: 7000 }, // goodbye watchdog
|
|
246
|
+
vad: undefined, // normalized VAD, mapped per provider
|
|
247
|
+
toolResultDelivery: 'afterPlayback', // or 'immediate'
|
|
248
|
+
toolBackgroundAudio: undefined, // default hold audio for tools
|
|
249
|
+
handoffVoicePolicy: 'keep', // or 'reconnect' to switch voices
|
|
250
|
+
context: {}, // seed session KV for tools/instructions
|
|
251
|
+
}
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
Multi-tenant: `agent`, `session`, and `validateConnection` all accept per-call resolvers receiving the Twilio start frame (check a signed token from `<Parameter>`s there).
|
|
255
|
+
|
|
256
|
+
Outbound calls: the greeting waits for a human — feed your status callback into `bridge.notifyAnswered(callSid)`.
|
|
257
|
+
|
|
258
|
+
## Testing without phone calls
|
|
259
|
+
|
|
260
|
+
`realtime-voice-agents/testing` ships the harness this package is tested with:
|
|
261
|
+
|
|
262
|
+
- **`FakeTwilioMediaStream`** — a scripted caller with an exact playout simulation: marks echo only after the media before them "plays"; `clear` discards buffered audio and echoes pending marks, like real Twilio.
|
|
263
|
+
- **`FakeOpenAIServer`** — a real-WebSocket GA-protocol server you script (`sendAudioResponse`, `sendToolCall`, `sendSpeechStarted`, drops).
|
|
264
|
+
- **`FakeGeminiLive`** — a scripted `@google/genai` seam for the Gemini provider.
|
|
265
|
+
|
|
266
|
+
```ts
|
|
267
|
+
const server = await FakeOpenAIServer.start();
|
|
268
|
+
const bridge = new TwilioRealtimeBridge({
|
|
269
|
+
agent,
|
|
270
|
+
provider: openaiRealtime({ apiKey: 't', baseUrl: server.url }),
|
|
271
|
+
});
|
|
272
|
+
const caller = new FakeTwilioMediaStream();
|
|
273
|
+
bridge.handleConnection(caller);
|
|
274
|
+
caller.connect();
|
|
275
|
+
server.latest.sendAudioResponse({ chunks: [mulawSilenceBase64(200)], transcript: 'Hello!' });
|
|
276
|
+
caller.advancePlayback(200); // deterministic playout — assert on playback events
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
## Subpath exports
|
|
280
|
+
|
|
281
|
+
`realtime-voice-agents` (core) · `/openai` · `/xai` · `/gemini` · `/twilio` (wire types, TwiML, REST) · `/audio` (μ-law, resampler, transcoders, background player) · `/store` (SessionStore + in-memory) · `/testing`.
|
|
282
|
+
|
|
283
|
+
## Observability & state
|
|
284
|
+
|
|
285
|
+
Every call checkpoint (start, handoffs, tool completions, end) snapshots to a `SessionStore` — transcript, usage, context KV, handoff history, Gemini resumption handle. `InMemorySessionStore` ships; the interface is three methods, so a Redis/Postgres store is a page of code.
|
|
286
|
+
|
|
287
|
+
## How this compares
|
|
288
|
+
|
|
289
|
+
An independent, MIT-licensed package — not affiliated with Twilio, OpenAI, xAI, or Google. Where it sits among the alternatives:
|
|
290
|
+
|
|
291
|
+
- **[Pipecat](https://github.com/pipecat-ai/pipecat)** — a Python-first framework for general realtime media pipelines: many transports (WebRTC, Daily, LiveKit, Twilio), cascading STT→LLM→TTS as well as speech-to-speech, and a large provider matrix. Reach for it if you work in Python or need transports beyond phone calls. This package is the TypeScript-native answer to one specific job — Twilio phone calls into speech-to-speech models — with a single runtime dependency (`ws`).
|
|
292
|
+
- **[LiveKit Agents](https://github.com/livekit/agents)** — agents run inside LiveKit's WebRTC infrastructure; phone calls enter via SIP into a LiveKit room. A strong production stack, at the cost of operating (or paying for) a media server between Twilio and your model. This package connects your Node.js server to Twilio Media Streams directly — no infrastructure in the middle.
|
|
293
|
+
- **[`@openai/agents-extensions`](https://www.npmjs.com/package/@openai/agents-extensions)** (`TwilioRealtimeTransportLayer`) — the official OpenAI transport for Twilio, OpenAI-only by design. If OpenAI Realtime is certain to be enough, it's a solid choice. This package keeps comparable ergonomics behind a provider seam (OpenAI, xAI, Gemini, or your own `BaseRealtimeProvider`) and adds mark-confirmed playback tracking, interruption guards, tool execution strategies, and hold audio.
|
|
294
|
+
- **A hand-rolled bridge** — Twilio's wire protocol is genuinely simple (~200 lines to pipe audio both ways). What remains is the hard 90%: playback truth while Twilio buffers seconds ahead of the phone, truncating the model's memory to the milliseconds actually heard, reconnects that carry context, tool-result timing, goodbye-aware hangups. Those problems are this package.
|
|
295
|
+
|
|
296
|
+
## License
|
|
297
|
+
|
|
298
|
+
MIT
|