From 0752ae6d6d823b55893d01e60c57fb62982383e4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 24 Jan 2026 23:20:52 +0000 Subject: [PATCH] fix: return TwiML for outbound conversation calls --- CHANGELOG.md | 1 + .../voice-call/src/providers/twilio.test.ts | 64 +++++++++++++++++++ extensions/voice-call/src/providers/twilio.ts | 9 +++ 3 files changed, 74 insertions(+) create mode 100644 extensions/voice-call/src/providers/twilio.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b23632639..cd285db1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ Docs: https://docs.clawd.bot - Agents: use the active auth profile for auto-compaction recovery. - Gateway: reduce log noise for late invokes + remote node probes; debounce skills refresh. (#1607) Thanks @petter-b. - macOS: default direct-transport `ws://` URLs to port 18789; document `gateway.remote.transport`. (#1603) Thanks @ngutman. +- Voice Call: return stream TwiML for outbound conversation calls on initial Twilio webhook. (#1634) ## 2026.1.23-1 diff --git a/extensions/voice-call/src/providers/twilio.test.ts b/extensions/voice-call/src/providers/twilio.test.ts new file mode 100644 index 000000000..8fb275f51 --- /dev/null +++ b/extensions/voice-call/src/providers/twilio.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; + +import type { WebhookContext } from "../types.js"; +import { TwilioProvider } from "./twilio.js"; + +const STREAM_URL = "wss://example.ngrok.app/voice/stream"; + +function createProvider(): TwilioProvider { + return new TwilioProvider( + { accountSid: "AC123", authToken: "secret" }, + { publicUrl: "https://example.ngrok.app", streamPath: "/voice/stream" }, + ); +} + +function createContext( + rawBody: string, + query?: WebhookContext["query"], +): WebhookContext { + return { + headers: {}, + rawBody, + url: "https://example.ngrok.app/voice/twilio", + method: "POST", + query, + }; +} + +describe("TwilioProvider", () => { + it("returns streaming TwiML for outbound conversation calls before in-progress", () => { + const provider = createProvider(); + const ctx = createContext("CallStatus=initiated&Direction=outbound-api", { + callId: "call-1", + }); + + const result = provider.parseWebhookEvent(ctx); + + expect(result.providerResponseBody).toContain(STREAM_URL); + expect(result.providerResponseBody).toContain(""); + }); + + it("returns empty TwiML for status callbacks", () => { + const provider = createProvider(); + const ctx = createContext("CallStatus=ringing&Direction=outbound-api", { + callId: "call-1", + type: "status", + }); + + const result = provider.parseWebhookEvent(ctx); + + expect(result.providerResponseBody).toBe( + '', + ); + }); + + it("returns streaming TwiML for inbound calls", () => { + const provider = createProvider(); + const ctx = createContext("CallStatus=ringing&Direction=inbound"); + + const result = provider.parseWebhookEvent(ctx); + + expect(result.providerResponseBody).toContain(STREAM_URL); + expect(result.providerResponseBody).toContain(""); + }); +}); diff --git a/extensions/voice-call/src/providers/twilio.ts b/extensions/voice-call/src/providers/twilio.ts index 06115d662..17102b732 100644 --- a/extensions/voice-call/src/providers/twilio.ts +++ b/extensions/voice-call/src/providers/twilio.ts @@ -294,6 +294,7 @@ export class TwilioProvider implements VoiceCallProvider { const isStatusCallback = type === "status"; const callStatus = params.get("CallStatus"); const direction = params.get("Direction"); + const isOutbound = direction?.startsWith("outbound") ?? false; const callIdFromQuery = typeof ctx.query?.callId === "string" && ctx.query.callId.trim() ? ctx.query.callId.trim() @@ -313,6 +314,14 @@ export class TwilioProvider implements VoiceCallProvider { if (this.notifyCalls.has(callIdFromQuery)) { return TwilioProvider.EMPTY_TWIML; } + + // Conversation mode: return streaming TwiML immediately for outbound calls. + if (isOutbound) { + const streamUrl = this.getStreamUrl(); + return streamUrl + ? this.getStreamConnectXml(streamUrl) + : TwilioProvider.PAUSE_TWIML; + } } // Status callbacks should not receive TwiML.