realtime-voice-agents 2.1.0 → 2.3.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/README.md CHANGED
@@ -104,6 +104,26 @@ geminiLive({ model: 'gemini-2.5-flash-native-audio-preview-12-2025', voice: 'Aoe
104
104
 
105
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
106
 
107
+ ## Provider fallbacks
108
+
109
+ One bad API key, an exhausted quota, or a provider outage should not send your calls to dead air. Give the bridge backup providers and it tries them in order while the call is being established:
110
+
111
+ ```ts
112
+ const bridge = new TwilioRealtimeBridge({
113
+ agent,
114
+ provider: openaiRealtime(), // primary
115
+ fallbacks: [xaiRealtime(), geminiLive()], // tried in order if it fails to come up
116
+ });
117
+ ```
118
+
119
+ - **Covers the real failure modes.** A missing API key (the factories defer their credential check to call time precisely so the chain can absorb it), an expired/revoked key (HTTP 401), exhausted credits/quota (403/429), a provider internal error (5xx or a dropped socket), and a hung endpoint (connect timeout) all walk the chain — anything that keeps a provider from coming up.
120
+ - **Connect-time only.** A dead provider is dropped and the next one is tried immediately — no backoff between attempts. Once a provider answers, the call stays with it: mid-call reconnects reuse the same provider (per the `session.reconnect` policy), and a mid-call death past that budget fails the call rather than switching voices mid-conversation.
121
+ - **Observable.** Each advance emits `provider.fallback` (`{ from, to, error }`) on the session — count these to alarm on a degraded primary.
122
+ - **Voices don't cross vendors.** Configure the voice per factory (`openaiRealtime({ voice: 'marin' })`, `xaiRealtime({ voice: 'eve' })`) rather than on the `Agent` — an OpenAI voice name would fail the xAI/Gemini connect and the chain would skip past a healthy provider.
123
+ - **Latency.** Each dead provider costs up to its `connectTimeoutMs` (default 10s) before the next is tried — set a tighter one on the primary if its endpoint tends to hang rather than refuse. A [pre-synthesized greeting](#pre-synthesized-greeting-15s-to-first-word) bursts onto the line before any handshake, so the caller hears a voice while the chain walks.
124
+
125
+ Testing it: `FakeOpenAIServer.start({ refuseConnections: true })` gives you a provider that is "down", and `{ rejectUpgrade: { status: 401, body: 'invalid_api_key' } }` one that rejects like a real auth/quota failure (both flippable at runtime to script recoveries) — see `src/bridge/fallback.test.ts` for ready-made scenarios.
126
+
107
127
  ## Tools: Zod schemas + execution strategies
108
128
 
109
129
  ```ts
@@ -250,9 +270,38 @@ session: { greeting: { mode: 'agent-initiates',
250
270
 
251
271
  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()`.
252
272
 
273
+ ## Keypad input (DTMF, opt-in)
274
+
275
+ Callers type an ID, a phone number, a confirmation code — Twilio delivers each key as its own `dtmf` frame, ~1s apart, and a model that sees ten fragments answers "I didn't get that" ten times. `keypad` turns keypresses into one entry: digits buffer, `#` submits, `*` clears, `maxDigits` auto-submits, 4s without a key flushes what's there (so the agent can say "that's only 7 digits — again, please"). Each keypress stops the agent mid-sentence (typing means "I'm answering"), and the entry reaches the model as a **user** turn — `[keypad] I typed on my phone keypad: 0541234567 — 10 digits. Digit by digit: 0 5 4 …` — that triggers the response answering it. A short note appended to the agent instructions tells the model what `[keypad]` messages are.
276
+
277
+ ```ts
278
+ session: {
279
+ keypad: {}, // {} = defaults below
280
+ // maxDigits: 9, // auto-submit at N digits (no # needed)
281
+ // interDigitTimeoutMs: 4000, submitKey: '#', clearKey: '*',
282
+ // interruptOnKeypress: true, // false: the agent keeps talking while the caller types
283
+ // message: (entry) => string | false, // wording of the injected user turn; false = events only
284
+ // clearMessage: string | false, // what the model hears on *; false = nothing
285
+ // instructions: string | false, // the appended note; false = your prompt says it
286
+ }
287
+ ```
288
+
289
+ Observe or take over with events and the `session.keypad` handle (`digits`, `clear()`, `submit()`):
290
+
291
+ ```ts
292
+ session.on('keypad.entry', ({ digits, reason }) => { /* reason: 'submit' | 'timeout' | 'maxDigits' */ });
293
+ session.on('keypad.cleared', ({ discarded }) => { /* caller pressed * */ });
294
+ // Raw keypresses still fire per key — AFTER the collector consumed them, so the handle is current:
295
+ session.on('dtmf', ({ digit }) => {
296
+ if (digit === '0' && session.keypad.digits === '0') { session.keypad.clear(); void session.transferTo(OPERATOR); }
297
+ });
298
+ ```
299
+
300
+ `message: false` keeps the collection and events but injects nothing — validate the entry yourself and `session.sendText(..., { role: 'user', triggerResponse: true })` what the model should hear. Role `user`, not `system`: a trailing system item is skipped by the response it triggers and only lands one response later (field-tested on xAI). Without `keypad` configured nothing changes: raw `dtmf` events only, as before. Letters A–D are ignored; the buffer dies with the call.
301
+
253
302
  ## Events (session)
254
303
 
255
- `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` · `vad.suggestion` / `vad.adjusted` (noise-adaptive VAD) · `background_audio.started/stopped` · `dtmf` · `usage.updated` · `error`.
304
+ `call.started/ended/failed` · `provider.connected/fallback/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` · `vad.suggestion` / `vad.adjusted` (noise-adaptive VAD) · `background_audio.started/stopped` · `dtmf` (raw keypress) · `keypad.entry` / `keypad.cleared` (keypad input) · `usage.updated` · `error`.
256
305
 
257
306
  ```ts
258
307
  bridge.on('session.started', (session) => {
@@ -280,6 +329,7 @@ session: {
280
329
  hangup: { markTimeoutMs: 7000 }, // goodbye watchdog
281
330
  vad: undefined, // normalized VAD, mapped per provider
282
331
  noiseAdaptiveVad: undefined, // opt-in noise → VAD escalation ({} enables; see its section)
332
+ keypad: undefined, // opt-in DTMF → one user turn per entry ({} enables; see its section)
283
333
  toolResultDelivery: 'afterPlayback', // or 'immediate'
284
334
  toolBackgroundAudio: undefined, // default hold audio for tools
285
335
  handoffVoicePolicy: 'keep', // or 'reconnect' to switch voices
@@ -296,7 +346,7 @@ Outbound calls: the greeting waits for a human — feed your status callback int
296
346
  `realtime-voice-agents/testing` ships the harness this package is tested with:
297
347
 
298
348
  - **`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.
299
- - **`FakeOpenAIServer`** — a real-WebSocket GA-protocol server you script (`sendAudioResponse`, `sendToolCall`, `sendSpeechStarted`, drops).
349
+ - **`FakeOpenAIServer`** — a real-WebSocket GA-protocol server you script (`sendAudioResponse`, `sendToolCall`, `sendSpeechStarted`, drops, `refuseConnections` for down-provider/fallback scenarios).
300
350
  - **`FakeGeminiLive`** — a scripted `@google/genai` seam for the Gemini provider.
301
351
 
302
352
  ```ts
package/dist/gemini.cjs CHANGED
@@ -373,12 +373,14 @@ const GEMINI_VOICES = [
373
373
  "Kore",
374
374
  "Puck"
375
375
  ];
376
- /** Create a Gemini Live provider factory for the bridge. */
376
+ /**
377
+ * Create a Gemini Live provider factory for the bridge. Credentials are
378
+ * resolved per call (see `openaiRealtime` — missing credentials fail that
379
+ * call's connect so a fallback chain can absorb it, instead of crashing
380
+ * config construction).
381
+ */
377
382
  function geminiLive(options = {}) {
378
- const apiKey = require_env.resolveApiKey(options.apiKey, GEMINI_KEY_ENV_VARS);
379
- if (!apiKey && !options.vertex && !options.connector) throw new Error(`geminiLive: credentials missing (pass apiKey, set one of ${GEMINI_KEY_ENV_VARS.join("/")}, or configure vertex)`);
380
383
  const config = {
381
- apiKey,
382
384
  vertex: options.vertex,
383
385
  model: options.model ?? "gemini-2.5-flash-native-audio-preview-12-2025",
384
386
  voice: options.voice ?? "Aoede",
@@ -391,7 +393,14 @@ function geminiLive(options = {}) {
391
393
  connectTimeoutMs: options.connectTimeoutMs,
392
394
  connector: options.connector
393
395
  };
394
- return ({ logger }) => new GeminiLiveProvider(config, logger);
396
+ return ({ logger }) => {
397
+ const apiKey = require_env.resolveApiKey(options.apiKey, GEMINI_KEY_ENV_VARS);
398
+ if (!apiKey && !options.vertex && !options.connector) throw new Error(`geminiLive: credentials missing (pass apiKey, set one of ${GEMINI_KEY_ENV_VARS.join("/")}, or configure vertex)`);
399
+ return new GeminiLiveProvider({
400
+ ...config,
401
+ apiKey
402
+ }, logger);
403
+ };
395
404
  }
396
405
  //#endregion
397
406
  exports.GEMINI_DEFAULT_MODEL = GEMINI_DEFAULT_MODEL;
package/dist/gemini.d.cts CHANGED
@@ -35,7 +35,12 @@ interface GeminiLiveOptions {
35
35
  /** Test seam: replaces the @google/genai live connection. */
36
36
  connector?: GeminiLiveConnector;
37
37
  }
38
- /** Create a Gemini Live provider factory for the bridge. */
38
+ /**
39
+ * Create a Gemini Live provider factory for the bridge. Credentials are
40
+ * resolved per call (see `openaiRealtime` — missing credentials fail that
41
+ * call's connect so a fallback chain can absorb it, instead of crashing
42
+ * config construction).
43
+ */
39
44
  declare function geminiLive(options?: GeminiLiveOptions): ProviderFactory;
40
45
  //#endregion
41
46
  export { GEMINI_DEFAULT_MODEL, GEMINI_DEFAULT_VOICE, GEMINI_KEY_ENV_VARS, GEMINI_VOICES, type GeminiConnectParams, type GeminiLiveConnector, GeminiLiveOptions, GeminiLiveProvider, type GeminiLiveProviderConfig, type GeminiLiveSessionLike, geminiLive };
package/dist/gemini.d.mts CHANGED
@@ -35,7 +35,12 @@ interface GeminiLiveOptions {
35
35
  /** Test seam: replaces the @google/genai live connection. */
36
36
  connector?: GeminiLiveConnector;
37
37
  }
38
- /** Create a Gemini Live provider factory for the bridge. */
38
+ /**
39
+ * Create a Gemini Live provider factory for the bridge. Credentials are
40
+ * resolved per call (see `openaiRealtime` — missing credentials fail that
41
+ * call's connect so a fallback chain can absorb it, instead of crashing
42
+ * config construction).
43
+ */
39
44
  declare function geminiLive(options?: GeminiLiveOptions): ProviderFactory;
40
45
  //#endregion
41
46
  export { GEMINI_DEFAULT_MODEL, GEMINI_DEFAULT_VOICE, GEMINI_KEY_ENV_VARS, GEMINI_VOICES, type GeminiConnectParams, type GeminiLiveConnector, GeminiLiveOptions, GeminiLiveProvider, type GeminiLiveProviderConfig, type GeminiLiveSessionLike, geminiLive };
package/dist/gemini.mjs CHANGED
@@ -372,12 +372,14 @@ const GEMINI_VOICES = [
372
372
  "Kore",
373
373
  "Puck"
374
374
  ];
375
- /** Create a Gemini Live provider factory for the bridge. */
375
+ /**
376
+ * Create a Gemini Live provider factory for the bridge. Credentials are
377
+ * resolved per call (see `openaiRealtime` — missing credentials fail that
378
+ * call's connect so a fallback chain can absorb it, instead of crashing
379
+ * config construction).
380
+ */
376
381
  function geminiLive(options = {}) {
377
- const apiKey = resolveApiKey(options.apiKey, GEMINI_KEY_ENV_VARS);
378
- if (!apiKey && !options.vertex && !options.connector) throw new Error(`geminiLive: credentials missing (pass apiKey, set one of ${GEMINI_KEY_ENV_VARS.join("/")}, or configure vertex)`);
379
382
  const config = {
380
- apiKey,
381
383
  vertex: options.vertex,
382
384
  model: options.model ?? "gemini-2.5-flash-native-audio-preview-12-2025",
383
385
  voice: options.voice ?? "Aoede",
@@ -390,7 +392,14 @@ function geminiLive(options = {}) {
390
392
  connectTimeoutMs: options.connectTimeoutMs,
391
393
  connector: options.connector
392
394
  };
393
- return ({ logger }) => new GeminiLiveProvider(config, logger);
395
+ return ({ logger }) => {
396
+ const apiKey = resolveApiKey(options.apiKey, GEMINI_KEY_ENV_VARS);
397
+ if (!apiKey && !options.vertex && !options.connector) throw new Error(`geminiLive: credentials missing (pass apiKey, set one of ${GEMINI_KEY_ENV_VARS.join("/")}, or configure vertex)`);
398
+ return new GeminiLiveProvider({
399
+ ...config,
400
+ apiKey
401
+ }, logger);
402
+ };
394
403
  }
395
404
  //#endregion
396
405
  export { GEMINI_DEFAULT_MODEL, GEMINI_DEFAULT_VOICE, GEMINI_KEY_ENV_VARS, GEMINI_VOICES, GeminiLiveProvider, geminiLive };
package/dist/index.cjs CHANGED
@@ -193,6 +193,105 @@ function createHandoffTool(target) {
193
193
  });
194
194
  }
195
195
  //#endregion
196
+ //#region src/dtmf/KeypadCollector.ts
197
+ const DEFAULT_KEYPAD_OPTIONS = {
198
+ submitKey: "#",
199
+ clearKey: "*",
200
+ interDigitTimeoutMs: 4e3,
201
+ interruptOnKeypress: true
202
+ };
203
+ /**
204
+ * Appended to the agent instructions when keypad input is enabled. Short and
205
+ * neutral on purpose: what the messages are, not how to run the dialog.
206
+ */
207
+ const DEFAULT_KEYPAD_INSTRUCTIONS = "Keypad input: the caller may type on their phone keypad instead of speaking. Keypresses arrive as a user message starting with \"[keypad]\" that contains the typed digits — treat it as the caller's answer.";
208
+ /** Default text of the user turn injected for a completed entry. */
209
+ function defaultKeypadMessage(entry) {
210
+ const { digits } = entry;
211
+ return `[keypad] I typed on my phone keypad: ${digits} — ${digits.length} digit${digits.length === 1 ? "" : "s"}. Digit by digit: ${[...digits].join(" ")}`;
212
+ }
213
+ /** Default text of the user turn injected when the caller presses the clear key. */
214
+ const DEFAULT_KEYPAD_CLEAR_MESSAGE = "[keypad] I pressed star — I want to start over and retype from the beginning.";
215
+ var KeypadCollector = class {
216
+ submitKey;
217
+ clearKey;
218
+ interDigitTimeoutMs;
219
+ maxDigits;
220
+ hooks;
221
+ buffer = "";
222
+ timer = null;
223
+ disposed = false;
224
+ constructor(options, hooks) {
225
+ this.submitKey = options.submitKey ?? DEFAULT_KEYPAD_OPTIONS.submitKey;
226
+ this.clearKey = options.clearKey ?? DEFAULT_KEYPAD_OPTIONS.clearKey;
227
+ this.interDigitTimeoutMs = options.interDigitTimeoutMs ?? DEFAULT_KEYPAD_OPTIONS.interDigitTimeoutMs;
228
+ this.maxDigits = options.maxDigits !== void 0 && options.maxDigits > 0 ? options.maxDigits : void 0;
229
+ this.hooks = hooks;
230
+ }
231
+ get digits() {
232
+ return this.buffer;
233
+ }
234
+ /** Feed one keypress (a Twilio `dtmf` frame). Returns what the key meant. */
235
+ press(key) {
236
+ if (this.disposed) return "ignored";
237
+ if (key === this.submitKey) {
238
+ this.cancelTimer();
239
+ this.complete("submit");
240
+ return "submit";
241
+ }
242
+ if (key === this.clearKey) {
243
+ this.cancelTimer();
244
+ const discarded = this.buffer;
245
+ this.buffer = "";
246
+ this.hooks.onClear({ discarded });
247
+ return "clear";
248
+ }
249
+ if (!/^[0-9]$/.test(key)) return "ignored";
250
+ this.cancelTimer();
251
+ this.buffer += key;
252
+ if (this.maxDigits !== void 0 && this.buffer.length >= this.maxDigits) this.complete("maxDigits");
253
+ else this.armTimer();
254
+ return "digit";
255
+ }
256
+ clear() {
257
+ this.cancelTimer();
258
+ this.buffer = "";
259
+ }
260
+ submit() {
261
+ if (this.disposed) return;
262
+ this.cancelTimer();
263
+ this.complete("submit");
264
+ }
265
+ /** Release the timer; pending digits are dropped (the call is over). */
266
+ dispose() {
267
+ this.disposed = true;
268
+ this.cancelTimer();
269
+ this.buffer = "";
270
+ }
271
+ complete(reason) {
272
+ if (!this.buffer) return;
273
+ const digits = this.buffer;
274
+ this.buffer = "";
275
+ this.hooks.onEntry({
276
+ digits,
277
+ reason
278
+ });
279
+ }
280
+ armTimer() {
281
+ this.timer = setTimeout(() => {
282
+ this.timer = null;
283
+ this.complete("timeout");
284
+ }, this.interDigitTimeoutMs);
285
+ this.timer.unref?.();
286
+ }
287
+ cancelTimer() {
288
+ if (this.timer) {
289
+ clearTimeout(this.timer);
290
+ this.timer = null;
291
+ }
292
+ }
293
+ };
294
+ //#endregion
196
295
  //#region src/interruption/InterruptionController.ts
197
296
  var InterruptionController = class {
198
297
  settings;
@@ -893,11 +992,17 @@ var CallSession = class extends require_events.TypedEmitter {
893
992
  streamSid;
894
993
  callInfo;
895
994
  context;
995
+ /**
996
+ * Keypad (DTMF) input handle: digits buffered so far, `clear()`, `submit()`.
997
+ * Inert (empty, no-ops) unless the `keypad` session option is configured.
998
+ */
999
+ keypad;
896
1000
  stateValue = "connecting";
897
1001
  deps;
898
1002
  log;
899
1003
  tracker = new PlaybackTracker();
900
1004
  interruptions;
1005
+ keypadCollector;
901
1006
  usageAccumulator = new UsageAccumulator();
902
1007
  toolQueue = new ToolResultQueue();
903
1008
  transcriptEntries = [];
@@ -907,6 +1012,8 @@ var CallSession = class extends require_events.TypedEmitter {
907
1012
  runningTools = /* @__PURE__ */ new Map();
908
1013
  timers = /* @__PURE__ */ new Set();
909
1014
  provider = null;
1015
+ /** Primary + fallbacks, in try-order. Only walked while connecting. */
1016
+ providerChain;
910
1017
  activeAgentValue;
911
1018
  generating = false;
912
1019
  currentResponseId = null;
@@ -965,9 +1072,22 @@ var CallSession = class extends require_events.TypedEmitter {
965
1072
  this.callSid = deps.start.start.callSid;
966
1073
  this.streamSid = deps.start.start.streamSid ?? deps.start.streamSid;
967
1074
  this.log = require_BaseRealtimeProvider.childLogger(deps.logger, { callSid: this.callSid });
1075
+ this.providerChain = [deps.providerFactory, ...deps.fallbacks ?? []];
968
1076
  this.activeAgentValue = deps.agent;
969
1077
  this.context = new SessionContext(deps.options.context);
970
1078
  this.interruptions = new InterruptionController(deps.options.interruptions);
1079
+ this.keypadCollector = deps.options.keypad ? new KeypadCollector(deps.options.keypad, {
1080
+ onEntry: (entry) => this.onKeypadEntry(entry),
1081
+ onClear: (info) => this.onKeypadCleared(info)
1082
+ }) : null;
1083
+ const collector = this.keypadCollector;
1084
+ this.keypad = {
1085
+ get digits() {
1086
+ return collector?.digits ?? "";
1087
+ },
1088
+ clear: () => collector?.clear(),
1089
+ submit: () => collector?.submit()
1090
+ };
971
1091
  const params = deps.start.start.customParameters ?? {};
972
1092
  this.callInfo = {
973
1093
  direction: params.direction === "outbound" ? "outbound" : "inbound",
@@ -1003,17 +1123,7 @@ var CallSession = class extends require_events.TypedEmitter {
1003
1123
  /** Connect the provider and activate the call. Called by the bridge. */
1004
1124
  async begin() {
1005
1125
  this.playPreGreeting();
1006
- try {
1007
- this.provider = this.deps.providerFactory({
1008
- logger: this.log,
1009
- callSid: this.callSid
1010
- });
1011
- this.wireProvider(this.provider);
1012
- await this.provider.connect(this.buildProviderInit());
1013
- } catch (error) {
1014
- this.fail(error instanceof Error ? error : new Error(String(error)), "provider-failed");
1015
- return;
1016
- }
1126
+ if (!await this.connectInitialProvider()) return;
1017
1127
  if (this.stateValue !== "connecting") return;
1018
1128
  this.stateValue = "active";
1019
1129
  this.emit("provider.connected");
@@ -1039,6 +1149,55 @@ var CallSession = class extends require_events.TypedEmitter {
1039
1149
  this.saveSnapshot();
1040
1150
  this.maybeGreet();
1041
1151
  }
1152
+ /**
1153
+ * Walk primary + fallbacks until one connects. A provider that fails to
1154
+ * come up is detached BEFORE the chain advances, so a half-open socket's
1155
+ * late events can never reach the session once the next provider owns the
1156
+ * call. Connect-time only by design: once a provider has answered, the
1157
+ * call stays with it. Returns false after failing the call (chain
1158
+ * exhausted) or when the call was torn down while connecting.
1159
+ */
1160
+ async connectInitialProvider() {
1161
+ let lastFrom = "provider";
1162
+ let lastError = null;
1163
+ for (const factory of this.providerChain) {
1164
+ let provider;
1165
+ try {
1166
+ provider = factory({
1167
+ logger: this.log,
1168
+ callSid: this.callSid
1169
+ });
1170
+ } catch (error) {
1171
+ lastError = toError(error);
1172
+ this.log.warn("provider factory threw — trying the next fallback", { error: String(lastError) });
1173
+ continue;
1174
+ }
1175
+ if (lastError) this.emit("provider.fallback", {
1176
+ from: lastFrom,
1177
+ to: provider.name,
1178
+ error: lastError
1179
+ });
1180
+ this.provider = provider;
1181
+ this.wireProvider(provider);
1182
+ try {
1183
+ await provider.connect(this.buildProviderInit());
1184
+ return true;
1185
+ } catch (error) {
1186
+ provider.removeAllListeners();
1187
+ provider.close().catch(() => {});
1188
+ this.provider = null;
1189
+ if (this.stateValue !== "connecting") return false;
1190
+ lastFrom = provider.name;
1191
+ lastError = toError(error);
1192
+ this.log.warn("provider failed to connect", {
1193
+ provider: provider.name,
1194
+ error: String(lastError)
1195
+ });
1196
+ }
1197
+ }
1198
+ this.fail(lastError ?? /* @__PURE__ */ new Error("provider connect failed"), "provider-failed");
1199
+ return false;
1200
+ }
1042
1201
  /** The outbound leg was answered (host's Twilio status callback). */
1043
1202
  notifyAnswered() {
1044
1203
  if (this.answered) return;
@@ -1170,9 +1329,16 @@ var CallSession = class extends require_events.TypedEmitter {
1170
1329
  }
1171
1330
  return tools;
1172
1331
  }
1332
+ /** Agent instructions plus the kit's own notes that must survive handoffs (keypad). */
1333
+ composeInstructions() {
1334
+ const base = this.activeAgentValue.resolveInstructions(this.context);
1335
+ const keypad = this.deps.options.keypad;
1336
+ if (!keypad || keypad.instructions === false) return base;
1337
+ return `${base}\n\n${keypad.instructions ?? "Keypad input: the caller may type on their phone keypad instead of speaking. Keypresses arrive as a user message starting with \"[keypad]\" that contains the typed digits — treat it as the caller's answer."}`;
1338
+ }
1173
1339
  buildProviderInit() {
1174
1340
  return {
1175
- instructions: this.activeAgentValue.resolveInstructions(this.context) + (this.pregreeting ? `\n\nYou already opened the call by saying: "${this.pregreeting.text}". Do not greet again — continue the conversation from there.` : ""),
1341
+ instructions: this.composeInstructions() + (this.pregreeting ? `\n\nYou already opened the call by saying: "${this.pregreeting.text}". Do not greet again — continue the conversation from there.` : ""),
1176
1342
  voice: this.activeAgentValue.voice,
1177
1343
  vad: this.vadOverride !== void 0 ? this.vadOverride : this.deps.options.vad,
1178
1344
  bridgeOwnsInterruptions: true,
@@ -1195,7 +1361,9 @@ var CallSession = class extends require_events.TypedEmitter {
1195
1361
  transport.on("dtmf", (event) => {
1196
1362
  this.clearIdleTimer();
1197
1363
  this.nudgeCount = 0;
1198
- this.emit("dtmf", { digit: event.dtmf.digit });
1364
+ const digit = event.dtmf.digit;
1365
+ this.handleKeypress(digit);
1366
+ this.emit("dtmf", { digit });
1199
1367
  });
1200
1368
  transport.on("stop", () => void this.teardown("caller-hangup"));
1201
1369
  transport.on("close", () => void this.teardown("caller-hangup"));
@@ -1969,7 +2137,7 @@ var CallSession = class extends require_events.TypedEmitter {
1969
2137
  if (!(!this.provider.capabilities.sessionUpdate || voiceChanges && !this.provider.capabilities.voiceChangeMidSession && this.deps.options.handoffVoicePolicy === "reconnect")) {
1970
2138
  if (voiceChanges && !this.provider.capabilities.voiceChangeMidSession) this.log.warn(`agent "${target.id}" declares voice "${target.voice}" but the provider cannot change voice mid-session — keeping the current voice (set handoffVoicePolicy: 'reconnect' to switch)`);
1971
2139
  await this.provider.updateSession({
1972
- instructions: this.activeAgentValue.resolveInstructions(this.context),
2140
+ instructions: this.composeInstructions(),
1973
2141
  tools: this.buildProviderInit().tools,
1974
2142
  providerOptions: target.providerOptions
1975
2143
  });
@@ -2045,6 +2213,29 @@ var CallSession = class extends require_events.TypedEmitter {
2045
2213
  this.transcriptEntries.push(entry);
2046
2214
  this.emit("transcript.agent", entry);
2047
2215
  }
2216
+ handleKeypress(key) {
2217
+ if (!this.keypadCollector) return;
2218
+ if (this.deps.options.keypad?.interruptOnKeypress !== false) this.interrupt();
2219
+ this.keypadCollector.press(key);
2220
+ }
2221
+ onKeypadEntry(entry) {
2222
+ this.emit("keypad.entry", entry);
2223
+ const message = this.deps.options.keypad?.message;
2224
+ if (message === false) return;
2225
+ this.provider?.sendText((message ?? defaultKeypadMessage)(entry), {
2226
+ role: "user",
2227
+ triggerResponse: true
2228
+ });
2229
+ }
2230
+ onKeypadCleared(info) {
2231
+ this.emit("keypad.cleared", info);
2232
+ const clearMessage = this.deps.options.keypad?.clearMessage;
2233
+ if (clearMessage === false) return;
2234
+ this.provider?.sendText(clearMessage ?? "[keypad] I pressed star — I want to start over and retype from the beginning.", {
2235
+ role: "user",
2236
+ triggerResponse: true
2237
+ });
2238
+ }
2048
2239
  /** Armed whenever the agent goes quiet and we're waiting on the caller. */
2049
2240
  armIdleTimer() {
2050
2241
  const idle = this.deps.options.idle;
@@ -2104,6 +2295,7 @@ var CallSession = class extends require_events.TypedEmitter {
2104
2295
  for (const timer of this.timers) clearTimeout(timer);
2105
2296
  this.timers.clear();
2106
2297
  this.clearIdleTimer();
2298
+ this.keypadCollector?.dispose();
2107
2299
  this.bgAudio.stop({ immediate: true });
2108
2300
  for (const controller of this.runningTools.values()) controller.abort(/* @__PURE__ */ new Error("call ended"));
2109
2301
  this.runningTools.clear();
@@ -2159,6 +2351,9 @@ var CallSession = class extends require_events.TypedEmitter {
2159
2351
  const PREGREETING_MARK = "pre:greeting";
2160
2352
  /** 400ms per frame — matches production burst-write implementations. */
2161
2353
  const PREGREETING_CHUNK_BYTES = 3200;
2354
+ function toError(value) {
2355
+ return value instanceof Error ? value : new Error(String(value));
2356
+ }
2162
2357
  function safeJsonStringify(value) {
2163
2358
  try {
2164
2359
  return JSON.stringify(value) ?? "null";
@@ -2293,6 +2488,7 @@ var TwilioRealtimeBridge = class extends require_events.TypedEmitter {
2293
2488
  transport,
2294
2489
  start,
2295
2490
  providerFactory: this.config.provider,
2491
+ fallbacks: this.config.fallbacks,
2296
2492
  agent,
2297
2493
  options,
2298
2494
  store: this.store,
@@ -2410,10 +2606,14 @@ async function captureGreetingAudio(options) {
2410
2606
  exports.Agent = Agent;
2411
2607
  exports.BaseRealtimeProvider = require_BaseRealtimeProvider.BaseRealtimeProvider;
2412
2608
  exports.CallSession = CallSession;
2609
+ exports.DEFAULT_KEYPAD_CLEAR_MESSAGE = DEFAULT_KEYPAD_CLEAR_MESSAGE;
2610
+ exports.DEFAULT_KEYPAD_INSTRUCTIONS = DEFAULT_KEYPAD_INSTRUCTIONS;
2611
+ exports.DEFAULT_KEYPAD_OPTIONS = DEFAULT_KEYPAD_OPTIONS;
2413
2612
  exports.DEFAULT_RECONNECT_POLICY = DEFAULT_RECONNECT_POLICY;
2414
2613
  exports.DEFAULT_SESSION_OPTIONS = DEFAULT_SESSION_OPTIONS;
2415
2614
  exports.InMemorySessionStore = require_InMemorySessionStore.InMemorySessionStore;
2416
2615
  exports.InterruptionController = InterruptionController;
2616
+ exports.KeypadCollector = KeypadCollector;
2417
2617
  exports.NoiseAdaptiveVadController = NoiseAdaptiveVadController;
2418
2618
  exports.PlaybackTracker = PlaybackTracker;
2419
2619
  exports.SessionContext = SessionContext;
@@ -2427,6 +2627,7 @@ exports.createFinishCallTool = createFinishCallTool;
2427
2627
  exports.createHandoffTool = createHandoffTool;
2428
2628
  exports.createTransferCallTool = createTransferCallTool;
2429
2629
  exports.decorateTool = decorateTool;
2630
+ exports.defaultKeypadMessage = defaultKeypadMessage;
2430
2631
  exports.emptyUsage = emptyUsage;
2431
2632
  exports.handoffToolName = handoffToolName;
2432
2633
  exports.isHandoffDirective = isHandoffDirective;