@openclaw/voice-call 2026.1.29 → 2026.2.1

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.
Files changed (41) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +13 -9
  3. package/index.ts +45 -49
  4. package/openclaw.plugin.json +11 -53
  5. package/package.json +6 -3
  6. package/src/cli.ts +80 -113
  7. package/src/config.test.ts +1 -4
  8. package/src/config.ts +88 -110
  9. package/src/core-bridge.ts +14 -12
  10. package/src/manager/context.ts +1 -1
  11. package/src/manager/events.ts +18 -9
  12. package/src/manager/lookup.ts +3 -1
  13. package/src/manager/outbound.ts +46 -19
  14. package/src/manager/state.ts +4 -6
  15. package/src/manager/store.ts +6 -3
  16. package/src/manager/timers.ts +11 -8
  17. package/src/manager.test.ts +7 -10
  18. package/src/manager.ts +53 -75
  19. package/src/media-stream.test.ts +0 -1
  20. package/src/media-stream.ts +12 -26
  21. package/src/providers/mock.ts +13 -16
  22. package/src/providers/plivo.test.ts +0 -1
  23. package/src/providers/plivo.ts +27 -29
  24. package/src/providers/stt-openai-realtime.ts +8 -8
  25. package/src/providers/telnyx.ts +5 -11
  26. package/src/providers/tts-openai.ts +9 -14
  27. package/src/providers/twilio/api.ts +9 -12
  28. package/src/providers/twilio/webhook.ts +2 -4
  29. package/src/providers/twilio.test.ts +1 -5
  30. package/src/providers/twilio.ts +34 -46
  31. package/src/response-generator.ts +7 -20
  32. package/src/runtime.ts +12 -25
  33. package/src/telephony-audio.ts +14 -12
  34. package/src/telephony-tts.ts +21 -12
  35. package/src/tunnel.ts +7 -24
  36. package/src/types.ts +0 -1
  37. package/src/utils.ts +3 -1
  38. package/src/voice-mapping.ts +3 -1
  39. package/src/webhook-security.test.ts +12 -21
  40. package/src/webhook-security.ts +25 -29
  41. package/src/webhook.ts +22 -57
package/CHANGELOG.md CHANGED
@@ -1,13 +1,33 @@
1
1
  # Changelog
2
2
 
3
+ ## 2026.2.1
4
+
5
+ ### Changes
6
+
7
+ - Version alignment with core OpenClaw release numbers.
8
+
9
+ ## 2026.1.31
10
+
11
+ ### Changes
12
+
13
+ - Version alignment with core OpenClaw release numbers.
14
+
15
+ ## 2026.1.30
16
+
17
+ ### Changes
18
+
19
+ - Version alignment with core OpenClaw release numbers.
20
+
3
21
  ## 2026.1.29
4
22
 
5
23
  ### Changes
24
+
6
25
  - Version alignment with core OpenClaw release numbers.
7
26
 
8
27
  ## 2026.1.26
9
28
 
10
29
  ### Changes
30
+
11
31
  - Breaking: voice-call TTS now uses core `messages.tts` (plugin TTS config deep‑merges with core).
12
32
  - Telephony TTS supports OpenAI + ElevenLabs; Edge TTS is ignored for calls.
13
33
  - Removed legacy `tts.model`/`tts.voice`/`tts.instructions` plugin fields.
@@ -16,54 +36,65 @@
16
36
  ## 2026.1.23
17
37
 
18
38
  ### Changes
39
+
19
40
  - Version alignment with core OpenClaw release numbers.
20
41
 
21
42
  ## 2026.1.22
22
43
 
23
44
  ### Changes
45
+
24
46
  - Version alignment with core OpenClaw release numbers.
25
47
 
26
48
  ## 2026.1.21
27
49
 
28
50
  ### Changes
51
+
29
52
  - Version alignment with core OpenClaw release numbers.
30
53
 
31
54
  ## 2026.1.20
32
55
 
33
56
  ### Changes
57
+
34
58
  - Version alignment with core OpenClaw release numbers.
35
59
 
36
60
  ## 2026.1.17-1
37
61
 
38
62
  ### Changes
63
+
39
64
  - Version alignment with core OpenClaw release numbers.
40
65
 
41
66
  ## 2026.1.17
42
67
 
43
68
  ### Changes
69
+
44
70
  - Version alignment with core OpenClaw release numbers.
45
71
 
46
72
  ## 2026.1.16
47
73
 
48
74
  ### Changes
75
+
49
76
  - Version alignment with core OpenClaw release numbers.
50
77
 
51
78
  ## 2026.1.15
52
79
 
53
80
  ### Changes
81
+
54
82
  - Version alignment with core OpenClaw release numbers.
55
83
 
56
84
  ## 2026.1.14
57
85
 
58
86
  ### Changes
87
+
59
88
  - Version alignment with core OpenClaw release numbers.
60
89
 
61
90
  ## 0.1.0
62
91
 
63
92
  ### Highlights
93
+
64
94
  - First public release of the @openclaw/voice-call plugin.
65
95
 
66
96
  ### Features
97
+
67
98
  - Providers: Twilio (Programmable Voice + Media Streams), Telnyx (Call Control v2), and mock provider for local dev.
68
99
  - Call flows: outbound notify vs. conversation modes, configurable auto‑hangup, and multi‑turn continuation.
69
100
  - Inbound handling: policy controls (disabled/allowlist/open), allowlist matching, and inbound greeting.
package/README.md CHANGED
@@ -3,6 +3,7 @@
3
3
  Official Voice Call plugin for **OpenClaw**.
4
4
 
5
5
  Providers:
6
+
6
7
  - **Twilio** (Programmable Voice + Media Streams)
7
8
  - **Telnyx** (Call Control v2)
8
9
  - **Plivo** (Voice API + XML transfer + GetInput speech)
@@ -41,18 +42,18 @@ Put under `plugins.entries.voice-call.config`:
41
42
 
42
43
  twilio: {
43
44
  accountSid: "ACxxxxxxxx",
44
- authToken: "your_token"
45
+ authToken: "your_token",
45
46
  },
46
47
 
47
48
  plivo: {
48
49
  authId: "MAxxxxxxxxxxxxxxxxxxxx",
49
- authToken: "your_token"
50
+ authToken: "your_token",
50
51
  },
51
52
 
52
53
  // Webhook server
53
54
  serve: {
54
55
  port: 3334,
55
- path: "/voice/webhook"
56
+ path: "/voice/webhook",
56
57
  },
57
58
 
58
59
  // Public exposure (pick one):
@@ -61,17 +62,18 @@ Put under `plugins.entries.voice-call.config`:
61
62
  // tailscale: { mode: "funnel", path: "/voice/webhook" }
62
63
 
63
64
  outbound: {
64
- defaultMode: "notify" // or "conversation"
65
+ defaultMode: "notify", // or "conversation"
65
66
  },
66
67
 
67
68
  streaming: {
68
69
  enabled: true,
69
- streamPath: "/voice/stream"
70
- }
70
+ streamPath: "/voice/stream",
71
+ },
71
72
  }
72
73
  ```
73
74
 
74
75
  Notes:
76
+
75
77
  - Twilio/Telnyx/Plivo require a **publicly reachable** webhook URL.
76
78
  - `mock` is a local dev provider (no network calls).
77
79
  - `tunnel.allowNgrokFreeTierLoopbackBypass: true` allows Twilio webhooks with invalid signatures **only** when `tunnel.provider="ngrok"` and `serve.bind` is loopback (ngrok local agent). Use for local dev only.
@@ -87,13 +89,14 @@ same shape — overrides deep-merge with `messages.tts`.
87
89
  tts: {
88
90
  provider: "openai",
89
91
  openai: {
90
- voice: "alloy"
91
- }
92
- }
92
+ voice: "alloy",
93
+ },
94
+ },
93
95
  }
94
96
  ```
95
97
 
96
98
  Notes:
99
+
97
100
  - Edge TTS is ignored for voice calls (telephony audio needs PCM; Edge output is unreliable).
98
101
  - Core TTS is used when Twilio media streaming is enabled; otherwise calls fall back to provider native voices.
99
102
 
@@ -114,6 +117,7 @@ openclaw voicecall expose --mode funnel
114
117
  Tool name: `voice_call`
115
118
 
116
119
  Actions:
120
+
117
121
  - `initiate_call` (message, to?, mode?)
118
122
  - `continue_call` (callId, message)
119
123
  - `speak_to_user` (callId, message)
package/index.ts CHANGED
@@ -1,12 +1,12 @@
1
1
  import { Type } from "@sinclair/typebox";
2
2
  import type { CoreConfig } from "./src/core-bridge.js";
3
+ import { registerVoiceCallCli } from "./src/cli.js";
3
4
  import {
4
5
  VoiceCallConfigSchema,
5
6
  resolveVoiceCallConfig,
6
7
  validateProviderConfig,
7
8
  type VoiceCallConfig,
8
9
  } from "./src/config.js";
9
- import { registerVoiceCallCli } from "./src/cli.js";
10
10
  import { createVoiceCallRuntime, type VoiceCallRuntime } from "./src/runtime.js";
11
11
 
12
12
  const voiceCallConfigSchema = {
@@ -145,23 +145,17 @@ const voiceCallPlugin = {
145
145
  description: "Voice-call plugin with Telnyx/Twilio/Plivo providers",
146
146
  configSchema: voiceCallConfigSchema,
147
147
  register(api) {
148
- const config = resolveVoiceCallConfig(
149
- voiceCallConfigSchema.parse(api.pluginConfig),
150
- );
148
+ const config = resolveVoiceCallConfig(voiceCallConfigSchema.parse(api.pluginConfig));
151
149
  const validation = validateProviderConfig(config);
152
150
 
153
151
  if (api.pluginConfig && typeof api.pluginConfig === "object") {
154
152
  const raw = api.pluginConfig as Record<string, unknown>;
155
153
  const twilio = raw.twilio as Record<string, unknown> | undefined;
156
154
  if (raw.provider === "log") {
157
- api.logger.warn(
158
- "[voice-call] provider \"log\" is deprecated; use \"mock\" instead",
159
- );
155
+ api.logger.warn('[voice-call] provider "log" is deprecated; use "mock" instead');
160
156
  }
161
157
  if (typeof twilio?.from === "string") {
162
- api.logger.warn(
163
- "[voice-call] twilio.from is deprecated; use fromNumber instead",
164
- );
158
+ api.logger.warn("[voice-call] twilio.from is deprecated; use fromNumber instead");
165
159
  }
166
160
  }
167
161
 
@@ -175,7 +169,9 @@ const voiceCallPlugin = {
175
169
  if (!validation.valid) {
176
170
  throw new Error(validation.errors.join("; "));
177
171
  }
178
- if (runtime) return runtime;
172
+ if (runtime) {
173
+ return runtime;
174
+ }
179
175
  if (!runtimePromise) {
180
176
  runtimePromise = createVoiceCallRuntime({
181
177
  config,
@@ -194,8 +190,7 @@ const voiceCallPlugin = {
194
190
 
195
191
  api.registerGatewayMethod("voicecall.initiate", async ({ params, respond }) => {
196
192
  try {
197
- const message =
198
- typeof params?.message === "string" ? params.message.trim() : "";
193
+ const message = typeof params?.message === "string" ? params.message.trim() : "";
199
194
  if (!message) {
200
195
  respond(false, { error: "message required" });
201
196
  return;
@@ -210,9 +205,7 @@ const voiceCallPlugin = {
210
205
  return;
211
206
  }
212
207
  const mode =
213
- params?.mode === "notify" || params?.mode === "conversation"
214
- ? params.mode
215
- : undefined;
208
+ params?.mode === "notify" || params?.mode === "conversation" ? params.mode : undefined;
216
209
  const result = await rt.manager.initiateCall(to, undefined, {
217
210
  message,
218
211
  mode,
@@ -229,10 +222,8 @@ const voiceCallPlugin = {
229
222
 
230
223
  api.registerGatewayMethod("voicecall.continue", async ({ params, respond }) => {
231
224
  try {
232
- const callId =
233
- typeof params?.callId === "string" ? params.callId.trim() : "";
234
- const message =
235
- typeof params?.message === "string" ? params.message.trim() : "";
225
+ const callId = typeof params?.callId === "string" ? params.callId.trim() : "";
226
+ const message = typeof params?.message === "string" ? params.message.trim() : "";
236
227
  if (!callId || !message) {
237
228
  respond(false, { error: "callId and message required" });
238
229
  return;
@@ -251,10 +242,8 @@ const voiceCallPlugin = {
251
242
 
252
243
  api.registerGatewayMethod("voicecall.speak", async ({ params, respond }) => {
253
244
  try {
254
- const callId =
255
- typeof params?.callId === "string" ? params.callId.trim() : "";
256
- const message =
257
- typeof params?.message === "string" ? params.message.trim() : "";
245
+ const callId = typeof params?.callId === "string" ? params.callId.trim() : "";
246
+ const message = typeof params?.message === "string" ? params.message.trim() : "";
258
247
  if (!callId || !message) {
259
248
  respond(false, { error: "callId and message required" });
260
249
  return;
@@ -273,8 +262,7 @@ const voiceCallPlugin = {
273
262
 
274
263
  api.registerGatewayMethod("voicecall.end", async ({ params, respond }) => {
275
264
  try {
276
- const callId =
277
- typeof params?.callId === "string" ? params.callId.trim() : "";
265
+ const callId = typeof params?.callId === "string" ? params.callId.trim() : "";
278
266
  if (!callId) {
279
267
  respond(false, { error: "callId required" });
280
268
  return;
@@ -304,8 +292,7 @@ const voiceCallPlugin = {
304
292
  return;
305
293
  }
306
294
  const rt = await ensureRuntime();
307
- const call =
308
- rt.manager.getCall(raw) || rt.manager.getCallByProviderCallId(raw);
295
+ const call = rt.manager.getCall(raw) || rt.manager.getCallByProviderCallId(raw);
309
296
  if (!call) {
310
297
  respond(true, { found: false });
311
298
  return;
@@ -319,8 +306,7 @@ const voiceCallPlugin = {
319
306
  api.registerGatewayMethod("voicecall.start", async ({ params, respond }) => {
320
307
  try {
321
308
  const to = typeof params?.to === "string" ? params.to.trim() : "";
322
- const message =
323
- typeof params?.message === "string" ? params.message.trim() : "";
309
+ const message = typeof params?.message === "string" ? params.message.trim() : "";
324
310
  if (!to) {
325
311
  respond(false, { error: "to required" });
326
312
  return;
@@ -342,14 +328,11 @@ const voiceCallPlugin = {
342
328
  api.registerTool({
343
329
  name: "voice_call",
344
330
  label: "Voice Call",
345
- description:
346
- "Make phone calls and have voice conversations via the voice-call plugin.",
331
+ description: "Make phone calls and have voice conversations via the voice-call plugin.",
347
332
  parameters: VoiceCallToolSchema,
348
333
  async execute(_toolCallId, params) {
349
334
  const json = (payload: unknown) => ({
350
- content: [
351
- { type: "text", text: JSON.stringify(payload, null, 2) },
352
- ],
335
+ content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
353
336
  details: payload,
354
337
  });
355
338
 
@@ -360,12 +343,16 @@ const voiceCallPlugin = {
360
343
  switch (params.action) {
361
344
  case "initiate_call": {
362
345
  const message = String(params.message || "").trim();
363
- if (!message) throw new Error("message required");
346
+ if (!message) {
347
+ throw new Error("message required");
348
+ }
364
349
  const to =
365
350
  typeof params.to === "string" && params.to.trim()
366
351
  ? params.to.trim()
367
352
  : rt.config.toNumber;
368
- if (!to) throw new Error("to required");
353
+ if (!to) {
354
+ throw new Error("to required");
355
+ }
369
356
  const result = await rt.manager.initiateCall(to, undefined, {
370
357
  message,
371
358
  mode:
@@ -404,7 +391,9 @@ const voiceCallPlugin = {
404
391
  }
405
392
  case "end_call": {
406
393
  const callId = String(params.callId || "").trim();
407
- if (!callId) throw new Error("callId required");
394
+ if (!callId) {
395
+ throw new Error("callId required");
396
+ }
408
397
  const result = await rt.manager.endCall(callId);
409
398
  if (!result.success) {
410
399
  throw new Error(result.error || "end failed");
@@ -413,10 +402,11 @@ const voiceCallPlugin = {
413
402
  }
414
403
  case "get_status": {
415
404
  const callId = String(params.callId || "").trim();
416
- if (!callId) throw new Error("callId required");
405
+ if (!callId) {
406
+ throw new Error("callId required");
407
+ }
417
408
  const call =
418
- rt.manager.getCall(callId) ||
419
- rt.manager.getCallByProviderCallId(callId);
409
+ rt.manager.getCall(callId) || rt.manager.getCallByProviderCallId(callId);
420
410
  return json(call ? { found: true, call } : { found: false });
421
411
  }
422
412
  }
@@ -424,11 +414,11 @@ const voiceCallPlugin = {
424
414
 
425
415
  const mode = params?.mode ?? "call";
426
416
  if (mode === "status") {
427
- const sid =
428
- typeof params.sid === "string" ? params.sid.trim() : "";
429
- if (!sid) throw new Error("sid required for status");
430
- const call =
431
- rt.manager.getCall(sid) || rt.manager.getCallByProviderCallId(sid);
417
+ const sid = typeof params.sid === "string" ? params.sid.trim() : "";
418
+ if (!sid) {
419
+ throw new Error("sid required for status");
420
+ }
421
+ const call = rt.manager.getCall(sid) || rt.manager.getCallByProviderCallId(sid);
432
422
  return json(call ? { found: true, call } : { found: false });
433
423
  }
434
424
 
@@ -436,7 +426,9 @@ const voiceCallPlugin = {
436
426
  typeof params.to === "string" && params.to.trim()
437
427
  ? params.to.trim()
438
428
  : rt.config.toNumber;
439
- if (!to) throw new Error("to required for call");
429
+ if (!to) {
430
+ throw new Error("to required for call");
431
+ }
440
432
  const result = await rt.manager.initiateCall(to, undefined, {
441
433
  message:
442
434
  typeof params.message === "string" && params.message.trim()
@@ -469,7 +461,9 @@ const voiceCallPlugin = {
469
461
  api.registerService({
470
462
  id: "voicecall",
471
463
  start: async () => {
472
- if (!config.enabled) return;
464
+ if (!config.enabled) {
465
+ return;
466
+ }
473
467
  try {
474
468
  await ensureRuntime();
475
469
  } catch (err) {
@@ -481,7 +475,9 @@ const voiceCallPlugin = {
481
475
  }
482
476
  },
483
477
  stop: async () => {
484
- if (!runtimePromise) return;
478
+ if (!runtimePromise) {
479
+ return;
480
+ }
485
481
  try {
486
482
  const rt = await runtimePromise;
487
483
  await rt.stop();
@@ -168,12 +168,7 @@
168
168
  },
169
169
  "provider": {
170
170
  "type": "string",
171
- "enum": [
172
- "telnyx",
173
- "twilio",
174
- "plivo",
175
- "mock"
176
- ]
171
+ "enum": ["telnyx", "twilio", "plivo", "mock"]
177
172
  },
178
173
  "telnyx": {
179
174
  "type": "object",
@@ -224,12 +219,7 @@
224
219
  },
225
220
  "inboundPolicy": {
226
221
  "type": "string",
227
- "enum": [
228
- "disabled",
229
- "allowlist",
230
- "pairing",
231
- "open"
232
- ]
222
+ "enum": ["disabled", "allowlist", "pairing", "open"]
233
223
  },
234
224
  "allowFrom": {
235
225
  "type": "array",
@@ -247,10 +237,7 @@
247
237
  "properties": {
248
238
  "defaultMode": {
249
239
  "type": "string",
250
- "enum": [
251
- "notify",
252
- "conversation"
253
- ]
240
+ "enum": ["notify", "conversation"]
254
241
  },
255
242
  "notifyHangupDelaySec": {
256
243
  "type": "integer",
@@ -300,11 +287,7 @@
300
287
  "properties": {
301
288
  "mode": {
302
289
  "type": "string",
303
- "enum": [
304
- "off",
305
- "serve",
306
- "funnel"
307
- ]
290
+ "enum": ["off", "serve", "funnel"]
308
291
  },
309
292
  "path": {
310
293
  "type": "string"
@@ -317,12 +300,7 @@
317
300
  "properties": {
318
301
  "provider": {
319
302
  "type": "string",
320
- "enum": [
321
- "none",
322
- "ngrok",
323
- "tailscale-serve",
324
- "tailscale-funnel"
325
- ]
303
+ "enum": ["none", "ngrok", "tailscale-serve", "tailscale-funnel"]
326
304
  },
327
305
  "ngrokAuthToken": {
328
306
  "type": "string"
@@ -344,9 +322,7 @@
344
322
  },
345
323
  "sttProvider": {
346
324
  "type": "string",
347
- "enum": [
348
- "openai-realtime"
349
- ]
325
+ "enum": ["openai-realtime"]
350
326
  },
351
327
  "openaiApiKey": {
352
328
  "type": "string"
@@ -380,9 +356,7 @@
380
356
  "properties": {
381
357
  "provider": {
382
358
  "type": "string",
383
- "enum": [
384
- "openai"
385
- ]
359
+ "enum": ["openai"]
386
360
  },
387
361
  "model": {
388
362
  "type": "string"
@@ -395,30 +369,18 @@
395
369
  "properties": {
396
370
  "auto": {
397
371
  "type": "string",
398
- "enum": [
399
- "off",
400
- "always",
401
- "inbound",
402
- "tagged"
403
- ]
372
+ "enum": ["off", "always", "inbound", "tagged"]
404
373
  },
405
374
  "enabled": {
406
375
  "type": "boolean"
407
376
  },
408
377
  "mode": {
409
378
  "type": "string",
410
- "enum": [
411
- "final",
412
- "all"
413
- ]
379
+ "enum": ["final", "all"]
414
380
  },
415
381
  "provider": {
416
382
  "type": "string",
417
- "enum": [
418
- "openai",
419
- "elevenlabs",
420
- "edge"
421
- ]
383
+ "enum": ["openai", "elevenlabs", "edge"]
422
384
  },
423
385
  "summaryModel": {
424
386
  "type": "string"
@@ -476,11 +438,7 @@
476
438
  },
477
439
  "applyTextNormalization": {
478
440
  "type": "string",
479
- "enum": [
480
- "auto",
481
- "on",
482
- "off"
483
- ]
441
+ "enum": ["auto", "on", "off"]
484
442
  },
485
443
  "languageCode": {
486
444
  "type": "string"
package/package.json CHANGED
@@ -1,13 +1,16 @@
1
1
  {
2
2
  "name": "@openclaw/voice-call",
3
- "version": "2026.1.29",
4
- "type": "module",
3
+ "version": "2026.2.1",
5
4
  "description": "OpenClaw voice-call plugin",
5
+ "type": "module",
6
6
  "dependencies": {
7
- "@sinclair/typebox": "0.34.47",
7
+ "@sinclair/typebox": "0.34.48",
8
8
  "ws": "^8.19.0",
9
9
  "zod": "^4.3.6"
10
10
  },
11
+ "devDependencies": {
12
+ "openclaw": "workspace:*"
13
+ },
11
14
  "openclaw": {
12
15
  "extensions": [
13
16
  "./index.ts"