@openclaw/voice-call 2026.10.1-beta.2 → 2026.10.5-beta.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.
package/README.md CHANGED
@@ -102,12 +102,125 @@ Notes:
102
102
  - Twilio defaults to US1. For a non-US Region, set `twilio.region` to `ie1` or `au1` and use credentials created in that Region; see [Twilio's regional REST API guide](https://www.twilio.com/docs/global-infrastructure/using-the-twilio-rest-api-in-a-non-us-region).
103
103
  - `mock` is a local dev provider (no network calls).
104
104
  - Telnyx requires `telnyx.publicKey` (or `TELNYX_PUBLIC_KEY`) unless `skipSignatureVerification` is true.
105
- - Runtime accepts canonical config only. If older configs still use `provider: "log"`, `twilio.from`, or legacy `streaming.*` OpenAI keys, run `openclaw doctor --fix` to rewrite them.
105
+ - Runtime accepts canonical config only. Doctor retains migrations for `provider: "log"`, `twilio.from`, legacy `streaming.*` OpenAI keys, and `realtime.agentContext.includeSystemPrompt` because supported releases can still preserve these settings. Run `openclaw doctor --fix` to normalize them; existing canonical values are preserved.
106
106
  - advanced webhook, streaming, and tunnel notes: `https://docs.openclaw.ai/plugins/voice-call`
107
107
  - `responseModel` is optional. When unset, voice responses use the runtime default model.
108
108
  - `sessionScope` defaults to `per-phone`, preserving caller memory across calls. Use `per-call` for reception, booking, IVR, and bridge flows where each carrier call should start fresh. Use `main` to share the configured agent's main session (`agent:<agentId>:main`, or `global` when core `session.scope` is `"global"`). Custom core `session.mainKey` values are ignored.
109
109
  - `realtime.consultThinkingLevel` is optional. When set, it overrides the thinking level used by the model behind realtime `openclaw_agent_consult` calls.
110
110
  - `realtime.consultFastMode` is optional. When set, it toggles fast mode for realtime `openclaw_agent_consult` calls.
111
+ - `realtime.idleHangupMs` is optional. When set to a positive integer, an active realtime call ends after neither side has spoken for that many milliseconds; in-flight agent consults pause the timer. Unset keeps the current no-speech-idle-limit behavior.
112
+
113
+ ## Per-call briefs and errands
114
+
115
+ `initiate_call` and `voicecall.initiate` accept an optional `brief`. The opening
116
+ `message` remains the first verbatim line; the brief guides the conversation
117
+ after it. The realtime voice and its consult agent receive the same brief.
118
+
119
+ All brief fields are optional:
120
+
121
+ | Field | Value |
122
+ | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
123
+ | `task` | Plain text describing what to achieve, up to 2000 characters. |
124
+ | `context` | Facts the voice may use, up to 4000 characters. |
125
+ | `language` | Language name or BCP-47 tag, up to 100 characters. |
126
+ | `identity` | Introduction text, or `{ introduction, disclose: "volunteer" \| "when-asked" }`; up to 500 characters. Defaults to introducing only when asked. |
127
+ | `disclosures` | Up to 20 strings listing permitted personal details, each up to 500 characters. Defaults to none beyond the permitted identity. |
128
+ | `approvals` | What the voice may agree to, up to 2000 characters. Defaults to no spending or commitments beyond the task. |
129
+ | `voicemailMessage` | Message to leave on a machine, up to 1000 characters. Defaults to the supplied introduction or a neutral message, followed by a promise to try again later; excludes task details. |
130
+ | `successCriteria` | What counts as done, up to 1000 characters. |
131
+ | `maxDurationSeconds` | Positive integer that shortens this call's duration limit; values above the configured cap are capped. |
132
+
133
+ Briefs have an overall limit of 8000 JSON characters. Unknown fields are rejected.
134
+ For a plumber visit:
135
+
136
+ ```json
137
+ {
138
+ "action": "initiate_call",
139
+ "to": "+15550005678",
140
+ "mode": "conversation",
141
+ "message": "Hello, can I arrange a plumber visit?",
142
+ "brief": {
143
+ "task": "Book a plumber to repair a leaking kitchen tap",
144
+ "context": "The owner is available Tuesday or Thursday, 9 AM to noon. Ask for the callout fee before agreeing.",
145
+ "language": "en",
146
+ "identity": {
147
+ "introduction": "I am calling on behalf of Alex",
148
+ "disclose": "volunteer"
149
+ },
150
+ "disclosures": ["The owner's first name is Alex"],
151
+ "approvals": "A visit on Tuesday or Thursday morning with a callout fee up to 20 EUR. No repair charges are approved.",
152
+ "successCriteria": "Confirm the day, arrival window, callout fee and booking reference",
153
+ "voicemailMessage": "Hello, I am calling to ask about a plumber visit for a leaking tap. The owner will follow up.",
154
+ "maxDurationSeconds": 180
155
+ }
156
+ }
157
+ ```
158
+
159
+ CLI callers can pass `--brief 'Ask whether a plumber is available Tuesday'`,
160
+ `--brief '<JSON>'`, or `--brief-file ./plumber-brief.json`. The two brief flags
161
+ are mutually exclusive. Use `--mode conversation` for a conversation.
162
+
163
+ ## Reports, live transcript, callbacks and voicemail
164
+
165
+ These features are disabled by default. Configure them under
166
+ `plugins.entries.voice-call.config`:
167
+
168
+ ```json5
169
+ {
170
+ reports: {
171
+ enabled: true,
172
+ includeTranscript: true, // default true
173
+ // summaryModel: "<provider>/<model>",
174
+ // inboundSessionKey: "agent:reception:main",
175
+ },
176
+ live: { transcript: true, minIntervalMs: 5000 },
177
+ inboundPolicy: "allowlist",
178
+ callbacks: {
179
+ enabled: true,
180
+ windowMinutes: 60,
181
+ // greeting: "Hello, I can take a message for the owner.",
182
+ // brief: { task: "Take a message for the owner" },
183
+ },
184
+ voicemail: { detection: "twilio", onMachine: "leave-message" },
185
+ }
186
+ ```
187
+
188
+ - Reports go to the session that requested an outbound call. They include its
189
+ outcome, important facts, duration, end reason and, by default, transcript.
190
+ `reports.inboundSessionKey` selects the destination for ordinary inbound calls.
191
+ A Gateway restart marks an interrupted pending report or live-transcript delivery
192
+ as failed. OpenClaw does not resend it because the earlier process may already
193
+ have reached the channel.
194
+ - Live transcript sends both sides' final lines to the requester in batches,
195
+ at most once per `live.minIntervalMs` (default 5000).
196
+ Each batch's position is saved before it is sent, so a call that survives a
197
+ restart continues with new lines only and never repeats an earlier batch.
198
+ - With realtime voice enabled and `inboundPolicy: "allowlist"`, callbacks from a
199
+ number called within `callbacks.windowMinutes` (default 60) are also accepted.
200
+ They are linked to the original outbound call and take a message for the owner
201
+ without sharing details. The classic STT/TTS path never admits callbacks. Other
202
+ numbers follow the existing inbound policy. `callbacks.greeting` and
203
+ `callbacks.brief` can customize that conversation.
204
+ - With Twilio machine detection, outbound conversation calls hold realtime input
205
+ and the opening until a human or unknown result, or 30 seconds after the bridge
206
+ is ready. A machine result blocks realtime speech even after that cap.
207
+ - Twilio machine detection leaves the brief's voicemail message once, waits for
208
+ realtime voice playback to finish, and then hangs up. When realtime playback
209
+ is unavailable, it retires the realtime stream and uses carrier text-to-speech
210
+ followed by hang-up. With `onMachine: "hang-up"`, it ends the call immediately.
211
+ Notify calls wait for detection before playing the appropriate message.
212
+ The detected answer type is recorded. The mock provider can simulate this; Telnyx and Plivo behavior
213
+ is unchanged. `voicemail.detection` defaults to `"off"`.
214
+
215
+ During a call, `steer_call` or `voicecall.steer` accepts `{ callId, message, mode }`.
216
+ `mode: "guidance"` (the default) changes the voice's guidance and subsequent consults;
217
+ `mode: "say"` makes the voice say the message verbatim now. Messages are limited
218
+ to 500 characters. Only the requesting session or an operator may steer that call.
219
+
220
+ ```bash
221
+ openclaw voicecall steer --call-id <id> --message "Ask for the callout fee" --mode guidance
222
+ openclaw voicecall steer --call-id <id> --message "The owner can also do Thursday morning." --mode say
223
+ ```
111
224
 
112
225
  ## Stale call reaper
113
226
 
@@ -139,7 +252,8 @@ Tool name: `voice_call`
139
252
 
140
253
  Actions:
141
254
 
142
- - `initiate_call` (message, to?, mode?)
255
+ - `initiate_call` (message, to?, mode?, brief?)
256
+ - `steer_call` (callId, message, mode?: guidance | say)
143
257
  - `continue_call` (callId, message)
144
258
  - `speak_to_user` (callId, message)
145
259
  - `end_call` (callId)
@@ -147,7 +261,8 @@ Actions:
147
261
 
148
262
  ## Gateway RPC
149
263
 
150
- - `voicecall.initiate` (to?, message, mode?)
264
+ - `voicecall.initiate` (to?, message, mode?, brief?)
265
+ - `voicecall.steer` (callId, message, mode?: guidance | say)
151
266
  - `voicecall.continue` (callId, message)
152
267
  - `voicecall.speak` (callId, message)
153
268
  - `voicecall.end` (callId)
@@ -1,5 +1,5 @@
1
1
  import { fetchWithSsrFGuard } from "../api.js";
2
- import { n as normalizeProxyIp, s as getHeader } from "./runtime-entry-CbSKxSKk.mjs";
2
+ import { c as getHeader, r as normalizeProxyIp } from "./runtime-entry-STjFmi_B.mjs";
3
3
  import { n as reserveWebhookReplay, t as createWebhookReplayCache } from "./webhook-replay-B7RJNWNG.mjs";
4
4
  import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
5
5
  import { isLoopbackHost } from "openclaw/plugin-sdk/gateway-runtime";
@@ -181,19 +181,10 @@ function redactTwilioVerificationUrlForDiagnostics(url) {
181
181
  return "<invalid verification URL>";
182
182
  }
183
183
  }
184
- function stripPortFromUrl(url) {
185
- try {
186
- const parsed = new URL(url);
187
- if (!parsed.port) return url;
188
- parsed.port = "";
189
- return parsed.toString();
190
- } catch {
191
- return url;
192
- }
193
- }
194
184
  function setPortOnUrl(url, port) {
195
185
  try {
196
186
  const parsed = new URL(url);
187
+ if (!port && !parsed.port) return url;
197
188
  parsed.port = port;
198
189
  return parsed.toString();
199
190
  } catch {
@@ -297,21 +288,24 @@ function verifyTwilioWebhook(ctx, authToken, options) {
297
288
  remoteIP: options?.remoteIP
298
289
  });
299
290
  const params = new URLSearchParams(ctx.rawBody);
300
- if (validateTwilioSignature(authToken, signature, verificationUrl, params)) {
291
+ const verifyUrl = (candidateUrl) => {
292
+ if (!validateTwilioSignature(authToken, signature, candidateUrl, params)) return;
301
293
  const replayKey = createTwilioReplayKey({
302
- verificationUrl,
294
+ verificationUrl: candidateUrl,
303
295
  signature,
304
296
  requestParams: params
305
297
  });
306
298
  return {
307
299
  ok: true,
308
- verificationUrl,
300
+ verificationUrl: candidateUrl,
309
301
  ...reserveWebhookReplay(twilioReplayCache, replayKey)
310
302
  };
311
- }
303
+ };
304
+ const verified = verifyUrl(verificationUrl);
305
+ if (verified) return verified;
312
306
  const variants = /* @__PURE__ */ new Set();
313
307
  variants.add(verificationUrl);
314
- variants.add(stripPortFromUrl(verificationUrl));
308
+ variants.add(setPortOnUrl(verificationUrl, ""));
315
309
  if (options?.publicUrl) try {
316
310
  const publicPort = new URL(options.publicUrl).port;
317
311
  if (publicPort) variants.add(setPortOnUrl(verificationUrl, publicPort));
@@ -320,17 +314,8 @@ function verifyTwilioWebhook(ctx, authToken, options) {
320
314
  if (hostHeaderPort) variants.add(setPortOnUrl(verificationUrl, hostHeaderPort));
321
315
  for (const candidateUrl of variants) {
322
316
  if (candidateUrl === verificationUrl) continue;
323
- if (!validateTwilioSignature(authToken, signature, candidateUrl, params)) continue;
324
- const replayKey = createTwilioReplayKey({
325
- verificationUrl: candidateUrl,
326
- signature,
327
- requestParams: params
328
- });
329
- return {
330
- ok: true,
331
- verificationUrl: candidateUrl,
332
- ...reserveWebhookReplay(twilioReplayCache, replayKey)
333
- };
317
+ const result = verifyUrl(candidateUrl);
318
+ if (result) return result;
334
319
  }
335
320
  const isNgrokFreeTier = verificationUrl.includes(".ngrok-free.app") || verificationUrl.includes(".ngrok.io");
336
321
  return {
@@ -346,15 +331,6 @@ function getBaseUrlNoQuery(url) {
346
331
  const u = new URL(url);
347
332
  return `${u.protocol}//${u.host}${u.pathname}`;
348
333
  }
349
- function createPlivoV2ReplayKey(url, nonce) {
350
- return `plivo:v2:${sha256Hex(`${getBaseUrlNoQuery(url)}\n${nonce}`)}`;
351
- }
352
- function validatePlivoV2Signature(params) {
353
- const baseUrl = getBaseUrlNoQuery(params.url);
354
- const expected = normalizeSignatureBase64(crypto.createHmac("sha256", params.authToken).update(baseUrl + params.nonce).digest("base64"));
355
- const provided = normalizeSignatureBase64(params.signature);
356
- return safeEqualSecret(expected, provided);
357
- }
358
334
  function sortedPlivoParams(params, format) {
359
335
  return [...params].toSorted(([leftKey, leftValue], [rightKey, rightValue]) => {
360
336
  const left = leftKey === rightKey ? leftValue : leftKey;
@@ -373,10 +349,24 @@ function constructPlivoV3BaseUrl(params) {
373
349
  if (params.method === "GET") return baseUrl;
374
350
  return baseUrl + sortedPlivoParams(params.postParams, "body");
375
351
  }
376
- function validatePlivoV3Signature(params) {
377
- const hmacBase = `${params.baseUrl}.${params.nonce}`;
352
+ function verifyPlivoSignature(params) {
353
+ const hmacBase = `${params.baseUrl}${params.version === "v3" ? "." : ""}${params.nonce}`;
378
354
  const expected = normalizeSignatureBase64(crypto.createHmac("sha256", params.authToken).update(hmacBase).digest("base64"));
379
- return normalizeStringEntries(params.signatureHeader.split(",")).map((s) => normalizeSignatureBase64(s)).some((signature) => safeEqualSecret(expected, signature));
355
+ const ok = params.signatures.map(normalizeSignatureBase64).some((signature) => safeEqualSecret(expected, signature));
356
+ const result = {
357
+ ok,
358
+ version: params.version,
359
+ verificationUrl: params.verificationUrl
360
+ };
361
+ if (!ok) return {
362
+ ...result,
363
+ reason: `Invalid Plivo ${params.version.toUpperCase()} signature`
364
+ };
365
+ const replayKey = `plivo:${params.version}:${sha256Hex(`${params.baseUrl}\n${params.nonce}`)}`;
366
+ return {
367
+ ...result,
368
+ ...reserveWebhookReplay(plivoReplayCache, replayKey)
369
+ };
380
370
  }
381
371
  /**
382
372
  * Verify Plivo webhooks using V3 signature if present; fall back to V2.
@@ -420,45 +410,23 @@ function verifyPlivoWebhook(ctx, authToken, options) {
420
410
  url: verificationUrl,
421
411
  postParams
422
412
  });
423
- if (!validatePlivoV3Signature({
413
+ return verifyPlivoSignature({
424
414
  authToken,
425
- signatureHeader: signatureV3,
426
- nonce: nonceV3,
427
- baseUrl
428
- })) return {
429
- ok: false,
430
415
  version: "v3",
431
416
  verificationUrl,
432
- reason: "Invalid Plivo V3 signature"
433
- };
434
- const replayKey = `plivo:v3:${sha256Hex(`${baseUrl}\n${nonceV3}`)}`;
435
- return {
436
- ok: true,
437
- version: "v3",
438
- verificationUrl,
439
- ...reserveWebhookReplay(plivoReplayCache, replayKey)
440
- };
441
- }
442
- if (signatureV2 && nonceV2) {
443
- if (!validatePlivoV2Signature({
444
- authToken,
445
- signature: signatureV2,
446
- nonce: nonceV2,
447
- url: verificationUrl
448
- })) return {
449
- ok: false,
450
- version: "v2",
451
- verificationUrl,
452
- reason: "Invalid Plivo V2 signature"
453
- };
454
- const replayKey = createPlivoV2ReplayKey(verificationUrl, nonceV2);
455
- return {
456
- ok: true,
457
- version: "v2",
458
- verificationUrl,
459
- ...reserveWebhookReplay(plivoReplayCache, replayKey)
460
- };
417
+ signatures: normalizeStringEntries(signatureV3.split(",")),
418
+ nonce: nonceV3,
419
+ baseUrl
420
+ });
461
421
  }
422
+ if (signatureV2 && nonceV2) return verifyPlivoSignature({
423
+ authToken,
424
+ version: "v2",
425
+ verificationUrl,
426
+ signatures: [signatureV2],
427
+ nonce: nonceV2,
428
+ baseUrl: getBaseUrlNoQuery(verificationUrl)
429
+ });
462
430
  return {
463
431
  ok: false,
464
432
  reason: "Missing Plivo signature headers (V3 or V2)",
@@ -506,21 +474,57 @@ async function guardedJsonApiRequest(params) {
506
474
  await release();
507
475
  }
508
476
  }
509
- /** Failed carrier probes keep calls alive; an empty or missing response is terminal. */
510
- async function readProviderCallStatus(request, describe) {
511
- try {
512
- const data = await request();
513
- return data ? describe(data) : {
514
- status: "not-found",
515
- isTerminal: true
516
- };
517
- } catch {
518
- return {
519
- status: "error",
520
- isTerminal: false,
521
- isUnknown: true
522
- };
523
- }
477
+ //#endregion
478
+ //#region extensions/voice-call/src/providers/shared/carrier-api.ts
479
+ /** Keep call controls and recovery probes on the same carrier endpoint and credentials. */
480
+ function createCarrierApi(name, baseUrl, authorization, options = {}) {
481
+ const provider = name.toLowerCase();
482
+ const headers = { Authorization: authorization };
483
+ const allowedHostnames = [new URL(baseUrl).hostname];
484
+ const request = (endpoint, body, params) => guardedJsonApiRequest({
485
+ method: params?.method ?? "POST",
486
+ allowNotFound: params?.allowNotFound,
487
+ body,
488
+ url: `${baseUrl}${endpoint}`,
489
+ headers: {
490
+ ...headers,
491
+ "Content-Type": options.contentType ?? "application/json"
492
+ },
493
+ allowedHostnames,
494
+ auditContext: `voice-call.${provider}.api`,
495
+ errorPrefix: `${name} API error`,
496
+ createError: options.createError,
497
+ malformedJsonMessage: options.malformedJsonMessage
498
+ });
499
+ return {
500
+ request,
501
+ async getCallStatus(endpoint, describe) {
502
+ try {
503
+ const data = await guardedJsonApiRequest({
504
+ url: `${baseUrl}${endpoint}`,
505
+ method: "GET",
506
+ allowNotFound: true,
507
+ headers: options.statusContentType ? {
508
+ ...headers,
509
+ "Content-Type": options.statusContentType
510
+ } : headers,
511
+ allowedHostnames,
512
+ auditContext: `${provider}-get-call-status`,
513
+ errorPrefix: `${name} get call status error`
514
+ });
515
+ return data ? describe(data) : {
516
+ status: "not-found",
517
+ isTerminal: true
518
+ };
519
+ } catch {
520
+ return {
521
+ status: "error",
522
+ isTerminal: false,
523
+ isUnknown: true
524
+ };
525
+ }
526
+ }
527
+ };
524
528
  }
525
529
  //#endregion
526
- export { verifyTelnyxWebhook as a, verifyPlivoWebhook as i, readProviderCallStatus as n, verifyTwilioWebhook as o, reconstructWebhookUrl as r, guardedJsonApiRequest as t };
530
+ export { verifyTwilioWebhook as a, verifyTelnyxWebhook as i, reconstructWebhookUrl as n, verifyPlivoWebhook as r, createCarrierApi as t };
@@ -1,6 +1,6 @@
1
1
  import { sleep } from "../api.js";
2
- import { l as resolveVoiceCallStreamExposurePaths, u as validateProviderConfig } from "./config-CX9J-053.mjs";
3
- import { _ as MAX_CALL_RECORD_EVENTS, b as loadActiveCallsFromStore, c as resolveWebhookExposureStatus, d as setupTailscaleExposureRoutes, h as resolveVoiceCallAgentId, l as cleanupTailscaleExposureRoute, u as getTailscaleSelfInfo, v as findCallInStore, x as setVoiceCallStateRuntime, y as getCallHistoryFromStore } from "./runtime-entry-CbSKxSKk.mjs";
2
+ import { g as CallBriefSchema, l as resolveVoiceCallStreamExposurePaths, u as validateProviderConfig } from "./config-CsrGuamE.mjs";
3
+ import { S as resolveVoiceCallAgentId, _ as findCallInStore, b as setVoiceCallStateRuntime, d as getTailscaleSelfInfo, f as setupTailscaleExposureRoutes, g as MAX_CALL_RECORD_EVENTS, l as resolveWebhookExposureStatus, u as cleanupTailscaleExposureRoute, v as getCallHistoryFromStore, y as loadActiveCallsFromStore } from "./runtime-entry-STjFmi_B.mjs";
4
4
  import { r as resolveUserPath, t as resolveDefaultVoiceCallStoreDir } from "./store-path-Cfwff6j7.mjs";
5
5
  import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
6
6
  import { callGatewayFromCli, isGatewayClientRequestError, isGatewayTransportError, redactSensitiveUrlLikeString } from "openclaw/plugin-sdk/gateway-runtime";
@@ -8,6 +8,7 @@ import { isRecord, normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/
8
8
  import { MAX_TCP_PORT, MAX_TIMER_TIMEOUT_MS, addTimerTimeoutGraceMs, clampTimerTimeoutMs, parseStrictNonNegativeInteger } from "openclaw/plugin-sdk/number-runtime";
9
9
  import fs from "node:fs";
10
10
  import path from "node:path";
11
+ import { readFile } from "node:fs/promises";
11
12
  import { createDeferred } from "openclaw/plugin-sdk/concurrency-runtime";
12
13
  import { once } from "node:events";
13
14
  import { format } from "node:util";
@@ -232,43 +233,23 @@ function resolveOperationTimeout(config) {
232
233
  function resolveContinueTimeout(config) {
233
234
  return clampTimerTimeoutMs(config.transcriptTimeoutMs + VOICE_CALL_GATEWAY_OPERATION_TIMEOUT_MS + VOICE_CALL_GATEWAY_TRANSCRIPT_BUFFER_MS) ?? 1;
234
235
  }
235
- function resolveVoiceCallDeadlineMs(timeoutMs, nowMs = Date.now()) {
236
- return nowMs + (clampTimerTimeoutMs(timeoutMs) ?? MAX_TIMER_TIMEOUT_MS);
237
- }
238
- function readGatewayPollTimeoutMs(payload, fallbackTimeoutMs) {
239
- if (isRecord(payload) && typeof payload.pollTimeoutMs === "number") return clampTimerTimeoutMs(payload.pollTimeoutMs) ?? fallbackTimeoutMs;
240
- return fallbackTimeoutMs;
241
- }
242
- function readCompletedContinueResult(payload) {
243
- if (!isRecord(payload)) throw new Error("voicecall gateway response missing operation status");
244
- if (payload.status === "pending") return { status: "pending" };
245
- if (payload.status === "failed") return {
246
- status: "failed",
247
- error: typeof payload.error === "string" ? payload.error : "continue failed"
248
- };
249
- if (payload.status === "completed") return {
250
- status: "completed",
251
- result: payload.result
252
- };
253
- throw new Error("voicecall gateway response has unknown operation status");
254
- }
255
236
  async function pollContinueGateway(payload, fallbackTimeoutMs) {
256
237
  if (!isRecord(payload) || typeof payload.operationId !== "string") return payload;
257
238
  if (!payload.operationId) throw new Error("voicecall gateway response missing operationId");
258
- const params = {
259
- operationId: payload.operationId,
260
- timeoutMs: readGatewayPollTimeoutMs(payload, fallbackTimeoutMs)
261
- };
262
- const deadlineMs = resolveVoiceCallDeadlineMs(params.timeoutMs);
239
+ const operationId = payload.operationId;
240
+ const timeoutMs = typeof payload.pollTimeoutMs === "number" ? clampTimerTimeoutMs(payload.pollTimeoutMs) ?? fallbackTimeoutMs : fallbackTimeoutMs;
241
+ const deadlineMs = performance.now() + (clampTimerTimeoutMs(timeoutMs) ?? MAX_TIMER_TIMEOUT_MS);
263
242
  for (;;) {
264
- const remainingMs = deadlineMs - Date.now();
243
+ const remainingMs = deadlineMs - performance.now();
265
244
  if (remainingMs <= 0) break;
266
- const gateway = await callVoiceCallGateway("voicecall.continue.result", { operationId: params.operationId }, { timeoutMs: Math.min(VOICE_CALL_GATEWAY_DEFAULT_TIMEOUT_MS, remainingMs) });
245
+ const gateway = await callVoiceCallGateway("voicecall.continue.result", { operationId }, { timeoutMs: Math.min(VOICE_CALL_GATEWAY_DEFAULT_TIMEOUT_MS, remainingMs) });
267
246
  if (!gateway.ok) throw new Error(`gateway unavailable while waiting for voicecall continue result: ${formatErrorMessage(gateway.error)}`);
268
- const result = readCompletedContinueResult(gateway.payload);
247
+ const result = gateway.payload;
248
+ if (!isRecord(result)) throw new Error("voicecall gateway response missing operation status");
269
249
  if (result.status === "completed") return result.result;
270
- if (result.status === "failed") throw new Error(result.error);
271
- const sleepMs = Math.min(VOICE_CALL_GATEWAY_POLL_INTERVAL_MS, deadlineMs - Date.now());
250
+ if (result.status === "failed") throw new Error(typeof result.error === "string" ? result.error : "continue failed");
251
+ if (result.status !== "pending") throw new Error("voicecall gateway response has unknown operation status");
252
+ const sleepMs = Math.min(VOICE_CALL_GATEWAY_POLL_INTERVAL_MS, deadlineMs - performance.now());
272
253
  if (sleepMs <= 0) break;
273
254
  await sleep(sleepMs);
274
255
  }
@@ -306,7 +287,8 @@ async function initiateVoiceCall(params) {
306
287
  const gateway = await callVoiceCallGateway(params.method, {
307
288
  ...params.to ? { to: params.to } : {},
308
289
  ...params.message ? { message: params.message } : {},
309
- ...mode ? { mode } : {}
290
+ ...mode ? { mode } : {},
291
+ ...params.brief ? { brief: params.brief } : {}
310
292
  }, { timeoutMs: resolveOperationTimeout(params.config) });
311
293
  if (gateway.ok) return readGatewayCallId(gateway.payload, params.failureMessage);
312
294
  const runtime = await ensureStandaloneRuntime(params);
@@ -314,6 +296,7 @@ async function initiateVoiceCall(params) {
314
296
  if (!to) throw new Error("Missing --to and no toNumber configured");
315
297
  const result = await runtime.manager.initiateCall(to, void 0, {
316
298
  message: params.message,
299
+ brief: params.brief,
317
300
  mode
318
301
  });
319
302
  if (!result.success) throw new Error(result.error || params.failureMessage || "initiate failed");
@@ -322,6 +305,14 @@ async function initiateVoiceCall(params) {
322
305
  }
323
306
  //#endregion
324
307
  //#region extensions/voice-call/src/cli.ts
308
+ async function readCliBrief(options) {
309
+ if (options.brief && options.briefFile) throw new Error("Use either --brief or --brief-file");
310
+ const raw = options.briefFile ? await readFile(resolveUserPath(options.briefFile), "utf8") : options.brief;
311
+ if (raw === void 0) return;
312
+ if (raw.length > 16e3) throw new Error("Brief input is too large");
313
+ const value = options.briefFile || raw.trimStart().startsWith("{") || raw.trimStart().startsWith("[") ? JSON.parse(raw) : { task: raw };
314
+ return CallBriefSchema.parse(value);
315
+ }
325
316
  function resolveMode(input) {
326
317
  const raw = normalizeOptionalLowercaseString(input) ?? "";
327
318
  if (raw === "serve" || raw === "off") return raw;
@@ -422,6 +413,11 @@ function registerVoiceCallCli(params) {
422
413
  if (stateRuntime) setVoiceCallStateRuntime({ state: stateRuntime });
423
414
  };
424
415
  const root = program.command("voicecall").description("Voice call utilities").addHelpText("after", () => `\nDocs: https://docs.openclaw.ai/cli/voicecall\n`);
416
+ const managerAction = (command) => runWithStandaloneRuntime(createRuntime, (ensureRuntime) => runGatewayManagerCommand({
417
+ config,
418
+ ensureRuntime,
419
+ ...command
420
+ }));
425
421
  root.command("setup").description("Show Voice Call provider and webhook setup status").option("--json", "Print machine-readable JSON").action((options) => {
426
422
  const status = buildSetupStatus(config, coreConfig);
427
423
  if (options.json) {
@@ -441,28 +437,17 @@ function registerVoiceCallCli(params) {
441
437
  process.exitCode = 1;
442
438
  return;
443
439
  }
444
- if (!options.to) {
445
- if (options.json) writeCliJson({
446
- ok: true,
447
- setup,
448
- liveCall: false
449
- });
450
- else {
451
- writeSetupStatus(setup);
452
- writeCliLine("live-call: skipped (pass --to and --yes to place one)");
453
- }
454
- return;
455
- }
456
- if (!options.yes) {
440
+ if (!options.to || !options.yes) {
457
441
  if (options.json) writeCliJson({
458
442
  ok: true,
459
443
  setup,
460
444
  liveCall: false,
461
- wouldCall: options.to
445
+ ...options.to ? { wouldCall: options.to } : {}
462
446
  });
463
447
  else {
464
448
  writeSetupStatus(setup);
465
- writeCliLine("live-call: dry run for %s (add --yes to place it)", options.to);
449
+ if (options.to) writeCliLine("live-call: dry run for %s (add --yes to place it)", options.to);
450
+ else writeCliLine("live-call: skipped (pass --to and --yes to place one)");
466
451
  }
467
452
  return;
468
453
  }
@@ -493,22 +478,21 @@ function registerVoiceCallCli(params) {
493
478
  ensureRuntime,
494
479
  config,
495
480
  method,
481
+ brief: await readCliBrief(options),
496
482
  to: options.to,
497
483
  message: options.message,
498
484
  mode: options.mode
499
485
  }) });
500
486
  });
501
- root.command("call").description("Initiate an outbound voice call").requiredOption("-m, --message <text>", "Message to speak when call connects").option("-t, --to <phone>", "Phone number to call (E.164 format, uses config toNumber if not set)").option("--mode <mode>", "Call mode: notify (hangup after message) or conversation (stay open)", "conversation").action(callAction("voicecall.initiate"));
502
- root.command("start").description("Alias for voicecall call").requiredOption("--to <phone>", "Phone number to call").option("--message <text>", "Message to speak when call connects").option("--mode <mode>", "Call mode: notify (hangup after message) or conversation (stay open)", "conversation").action(callAction("voicecall.start"));
503
- root.command("continue").description("Speak a message and wait for a response").requiredOption("--call-id <id>", "Call ID").requiredOption("--message <text>", "Message to speak").action(async (options) => runWithStandaloneRuntime(createRuntime, async (ensureRuntime) => {
487
+ root.command("call").description("Initiate an outbound voice call").requiredOption("-m, --message <text>", "Message to speak when call connects").option("-t, --to <phone>", "Phone number to call (E.164 format, uses config toNumber if not set)").option("--mode <mode>", "Call mode: notify (hangup after message) or conversation (stay open)", "conversation").option("--brief <text-or-json>", "Per-call task or structured JSON brief").option("--brief-file <path>", "Read a structured JSON brief from a file").action(callAction("voicecall.initiate"));
488
+ root.command("start").description("Alias for voicecall call").requiredOption("--to <phone>", "Phone number to call").option("--message <text>", "Message to speak when call connects").option("--mode <mode>", "Call mode: notify (hangup after message) or conversation (stay open)", "conversation").option("--brief <text-or-json>", "Per-call task or structured JSON brief").option("--brief-file <path>", "Read a structured JSON brief from a file").action(callAction("voicecall.start"));
489
+ root.command("continue").description("Speak a message and wait for a response").requiredOption("--call-id <id>", "Call ID").requiredOption("--message <text>", "Message to speak").action((options) => {
504
490
  const gatewayParams = {
505
491
  callId: options.callId,
506
492
  message: options.message
507
493
  };
508
494
  const continueTimeoutMs = resolveContinueTimeout(config);
509
- await runGatewayManagerCommand({
510
- config,
511
- ensureRuntime,
495
+ return managerAction({
512
496
  gatewayCall: async () => {
513
497
  try {
514
498
  return await callVoiceCallGateway("voicecall.continue.start", gatewayParams, { timeoutMs: resolveOperationTimeout(config) });
@@ -521,39 +505,37 @@ function registerVoiceCallCli(params) {
521
505
  managerFallback: (manager) => manager.continueCall(options.callId, options.message),
522
506
  failureLabel: "continue"
523
507
  });
508
+ });
509
+ root.command("speak").description("Speak a message without waiting for response").requiredOption("--call-id <id>", "Call ID").requiredOption("--message <text>", "Message to speak").action((options) => managerAction({
510
+ gatewayCall: () => callVoiceCallGateway("voicecall.speak", {
511
+ callId: options.callId,
512
+ message: options.message
513
+ }),
514
+ managerFallback: (manager) => manager.speak(options.callId, options.message),
515
+ failureLabel: "speak"
524
516
  }));
525
- root.command("speak").description("Speak a message without waiting for response").requiredOption("--call-id <id>", "Call ID").requiredOption("--message <text>", "Message to speak").action(async (options) => runWithStandaloneRuntime(createRuntime, async (ensureRuntime) => {
526
- await runGatewayManagerCommand({
527
- config,
528
- ensureRuntime,
529
- gatewayCall: () => callVoiceCallGateway("voicecall.speak", {
530
- callId: options.callId,
531
- message: options.message
532
- }),
533
- managerFallback: (manager) => manager.speak(options.callId, options.message),
534
- failureLabel: "speak"
535
- });
536
- }));
537
- root.command("dtmf").description("Send DTMF digits to an active call").requiredOption("--call-id <id>", "Call ID").requiredOption("--digits <digits>", "DTMF digits").action(async (options) => runWithStandaloneRuntime(createRuntime, async (ensureRuntime) => {
538
- await runGatewayManagerCommand({
539
- config,
540
- ensureRuntime,
541
- gatewayCall: () => callVoiceCallGateway("voicecall.dtmf", {
542
- callId: options.callId,
543
- digits: options.digits
544
- }),
545
- managerFallback: (manager) => manager.sendDtmf(options.callId, options.digits),
546
- failureLabel: "dtmf"
517
+ root.command("steer").description("Steer an active realtime call through its owning Gateway").requiredOption("--call-id <id>", "Call ID").requiredOption("--message <text>", "Owner instruction").option("--mode <mode>", "guidance or say", "guidance").action(async (options) => {
518
+ if (options.mode !== "say" && options.mode !== "guidance") throw new Error("mode must be say or guidance");
519
+ const gateway = await callVoiceCallGateway("voicecall.steer", {
520
+ callId: options.callId,
521
+ message: options.message,
522
+ mode: options.mode
547
523
  });
524
+ if (!gateway.ok) throw new Error("Steering requires the running Gateway that owns the active call");
525
+ writeCliJson(gateway.payload);
526
+ });
527
+ root.command("dtmf").description("Send DTMF digits to an active call").requiredOption("--call-id <id>", "Call ID").requiredOption("--digits <digits>", "DTMF digits").action((options) => managerAction({
528
+ gatewayCall: () => callVoiceCallGateway("voicecall.dtmf", {
529
+ callId: options.callId,
530
+ digits: options.digits
531
+ }),
532
+ managerFallback: (manager) => manager.sendDtmf(options.callId, options.digits),
533
+ failureLabel: "dtmf"
548
534
  }));
549
- root.command("end").description("Hang up an active call").requiredOption("--call-id <id>", "Call ID").action(async (options) => runWithStandaloneRuntime(createRuntime, async (ensureRuntime) => {
550
- await runGatewayManagerCommand({
551
- config,
552
- ensureRuntime,
553
- gatewayCall: () => callVoiceCallGateway("voicecall.end", { callId: options.callId }),
554
- managerFallback: (manager) => manager.endCall(options.callId),
555
- failureLabel: "end"
556
- });
535
+ root.command("end").description("Hang up an active call").requiredOption("--call-id <id>", "Call ID").action((options) => managerAction({
536
+ gatewayCall: () => callVoiceCallGateway("voicecall.end", { callId: options.callId }),
537
+ managerFallback: (manager) => manager.endCall(options.callId),
538
+ failureLabel: "end"
557
539
  }));
558
540
  root.command("status").description("Show call status").option("--call-id <id>", "Call ID").option("--json", "Print machine-readable JSON").action(async (options) => {
559
541
  const gateway = await callVoiceCallGateway("voicecall.status", options.callId ? { callId: options.callId } : void 0);