diff --git a/CHANGELOG.md b/CHANGELOG.md
index e68511a6ce4..f0c1c993105 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -30,6 +30,7 @@ Docs: https://docs.clawd.bot
- macOS: avoid touching launchd in Remote over SSH so quitting the app no longer disables the remote gateway. (#1105)
- Memory: index atomically so failed reindex preserves the previous memory database. (#1151)
- Memory: avoid sqlite-vec unique constraint failures when reindexing duplicate chunk ids. (#1151)
+- Voice call: separate TwiML fetches from status callbacks to avoid stuck calls. (#1180) — thanks @andrew-kurin.
## 2026.1.18-5
@@ -47,6 +48,7 @@ Docs: https://docs.clawd.bot
- macOS: avoid touching launchd in Remote over SSH so quitting the app no longer disables the remote gateway. (#1105)
- Memory: index atomically so failed reindex preserves the previous memory database. (#1151)
- Memory: avoid sqlite-vec unique constraint failures when reindexing duplicate chunk ids. (#1151)
+- Voice call: separate TwiML fetches from status callbacks to avoid stuck calls. (#1180) — thanks @andrew-kurin.
## 2026.1.18-5
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 00000000000..8374be4127f
--- /dev/null
+++ b/extensions/voice-call/src/providers/twilio.test.ts
@@ -0,0 +1,92 @@
+import { describe, expect, it } from "vitest";
+
+import { TwilioProvider } from "./twilio.js";
+
+const mockTwilioConfig = {
+ accountSid: "AC00000000000000000000000000000000",
+ authToken: "test-token",
+};
+
+const callId = "internal-call-id";
+const twimlPayload =
+ "Hi";
+
+describe("TwilioProvider", () => {
+ it("serves stored TwiML on twiml requests without emitting events", async () => {
+ const provider = new TwilioProvider(mockTwilioConfig);
+ (provider as unknown as { apiRequest: (endpoint: string, params: Record) => Promise }).apiRequest =
+ async () => ({
+ sid: "CA00000000000000000000000000000000",
+ status: "queued",
+ direction: "outbound-api",
+ from: "+15550000000",
+ to: "+15550000001",
+ uri: "/Calls/CA00000000000000000000000000000000.json",
+ });
+
+ await provider.initiateCall({
+ callId,
+ to: "+15550000000",
+ from: "+15550000001",
+ webhookUrl: "https://example.com/voice/webhook?provider=twilio",
+ inlineTwiml: twimlPayload,
+ });
+
+ const result = provider.parseWebhookEvent({
+ headers: { host: "example.com" },
+ rawBody:
+ "CallSid=CA00000000000000000000000000000000&CallStatus=initiated&Direction=outbound-api",
+ url: `https://example.com/voice/webhook?provider=twilio&callId=${callId}&type=twiml`,
+ method: "POST",
+ query: { provider: "twilio", callId, type: "twiml" },
+ });
+
+ expect(result.events).toHaveLength(0);
+ expect(result.providerResponseBody).toBe(twimlPayload);
+ });
+
+ it("does not consume stored TwiML on status callbacks", async () => {
+ const provider = new TwilioProvider(mockTwilioConfig);
+ (provider as unknown as { apiRequest: (endpoint: string, params: Record) => Promise }).apiRequest =
+ async () => ({
+ sid: "CA00000000000000000000000000000000",
+ status: "queued",
+ direction: "outbound-api",
+ from: "+15550000000",
+ to: "+15550000001",
+ uri: "/Calls/CA00000000000000000000000000000000.json",
+ });
+
+ await provider.initiateCall({
+ callId,
+ to: "+15550000000",
+ from: "+15550000001",
+ webhookUrl: "https://example.com/voice/webhook?provider=twilio",
+ inlineTwiml: twimlPayload,
+ });
+
+ const statusResult = provider.parseWebhookEvent({
+ headers: { host: "example.com" },
+ rawBody:
+ "CallSid=CA00000000000000000000000000000000&CallStatus=initiated&Direction=outbound-api&From=%2B15550000000&To=%2B15550000001",
+ url: `https://example.com/voice/webhook?provider=twilio&callId=${callId}&type=status`,
+ method: "POST",
+ query: { provider: "twilio", callId, type: "status" },
+ });
+
+ expect(statusResult.events).toHaveLength(1);
+ expect(statusResult.events[0]?.type).toBe("call.initiated");
+ expect(statusResult.providerResponseBody).not.toBe(twimlPayload);
+
+ const twimlResult = provider.parseWebhookEvent({
+ headers: { host: "example.com" },
+ rawBody:
+ "CallSid=CA00000000000000000000000000000000&CallStatus=initiated&Direction=outbound-api",
+ url: `https://example.com/voice/webhook?provider=twilio&callId=${callId}&type=twiml`,
+ method: "POST",
+ query: { provider: "twilio", callId, type: "twiml" },
+ });
+
+ expect(twimlResult.providerResponseBody).toBe(twimlPayload);
+ });
+});
diff --git a/extensions/voice-call/src/providers/twilio.ts b/extensions/voice-call/src/providers/twilio.ts
index 62a65eb4c1e..e5a3a88b55d 100644
--- a/extensions/voice-call/src/providers/twilio.ts
+++ b/extensions/voice-call/src/providers/twilio.ts
@@ -84,10 +84,13 @@ export class TwilioProvider implements VoiceCallProvider {
const webhookUrl = this.callWebhookUrls.get(providerCallId);
if (!webhookUrl) return;
- const callIdMatch = webhookUrl.match(/callId=([^&]+)/);
- if (!callIdMatch) return;
-
- this.deleteStoredTwiml(callIdMatch[1]);
+ try {
+ const callId = new URL(webhookUrl).searchParams.get("callId");
+ if (!callId) return;
+ this.deleteStoredTwiml(callId);
+ } catch {
+ // Ignore malformed URLs; best-effort cleanup only.
+ }
}
constructor(config: TwilioConfig, options: TwilioProviderOptions = {}) {
@@ -177,7 +180,14 @@ export class TwilioProvider implements VoiceCallProvider {
typeof ctx.query?.callId === "string" && ctx.query.callId.trim()
? ctx.query.callId.trim()
: undefined;
- const event = this.normalizeEvent(params, callIdFromQuery);
+ const requestType =
+ typeof ctx.query?.type === "string" && ctx.query.type.trim()
+ ? ctx.query.type.trim()
+ : undefined;
+ const isTwimlRequest = requestType === "twiml";
+ const event = isTwimlRequest
+ ? null
+ : this.normalizeEvent(params, callIdFromQuery);
// For Twilio, we must return TwiML. Most actions are driven by Calls API updates,
// so the webhook response is typically a pause to keep the call alive.
@@ -292,12 +302,16 @@ export class TwilioProvider implements VoiceCallProvider {
typeof ctx.query?.callId === "string" && ctx.query.callId.trim()
? ctx.query.callId.trim()
: undefined;
+ const requestType =
+ typeof ctx.query?.type === "string" && ctx.query.type.trim()
+ ? ctx.query.type.trim()
+ : undefined;
// Avoid logging webhook params/TwiML (may contain PII).
// Handle initial TwiML request (when Twilio first initiates the call)
// Check if we have stored TwiML for this call (notify mode)
- if (callIdFromQuery) {
+ if (callIdFromQuery && requestType !== "status") {
const storedTwiml = this.twimlStorage.get(callIdFromQuery);
if (storedTwiml) {
// Clean up after serving (one-time use)
@@ -373,12 +387,14 @@ export class TwilioProvider implements VoiceCallProvider {
* Otherwise, uses webhook URL for dynamic TwiML.
*/
async initiateCall(input: InitiateCallInput): Promise {
- const url = new URL(input.webhookUrl);
- url.searchParams.set("callId", input.callId);
+ const webhookUrl = new URL(input.webhookUrl);
+ webhookUrl.searchParams.set("callId", input.callId);
+
+ const twimlUrl = new URL(webhookUrl);
+ twimlUrl.searchParams.set("type", "twiml");
// Create separate URL for status callbacks (required by Twilio)
- const statusUrl = new URL(input.webhookUrl);
- statusUrl.searchParams.set("callId", input.callId);
+ const statusUrl = new URL(webhookUrl);
statusUrl.searchParams.set("type", "status"); // Differentiate from TwiML requests
// Store TwiML content if provided (for notify mode)
@@ -392,7 +408,7 @@ export class TwilioProvider implements VoiceCallProvider {
const params: Record = {
To: input.to,
From: input.from,
- Url: url.toString(), // TwiML serving endpoint
+ Url: twimlUrl.toString(), // TwiML serving endpoint
StatusCallback: statusUrl.toString(), // Separate status callback endpoint
StatusCallbackEvent: "initiated ringing answered completed",
Timeout: "30",
@@ -403,7 +419,7 @@ export class TwilioProvider implements VoiceCallProvider {
params,
);
- this.callWebhookUrls.set(result.sid, url.toString());
+ this.callWebhookUrls.set(result.sid, webhookUrl.toString());
return {
providerCallId: result.sid,
diff --git a/src/cli/exec-approvals-cli.ts b/src/cli/exec-approvals-cli.ts
index 64e932969e6..b25e80d6a3e 100644
--- a/src/cli/exec-approvals-cli.ts
+++ b/src/cli/exec-approvals-cli.ts
@@ -185,7 +185,7 @@ export function registerExecApprovalsCli(program: Command) {
}
allowlistEntries.push({ pattern: trimmed, lastUsedAt: Date.now() });
agent.allowlist = allowlistEntries;
- file.agents = { ...(file.agents ?? {}), [agentKey]: agent };
+ file.agents = { ...file.agents, [agentKey]: agent };
const next = await saveSnapshot(opts, nodeId, file, snapshot.hash);
const payload = opts.json ? JSON.stringify(next) : JSON.stringify(next, null, 2);
defaultRuntime.log(payload);
@@ -229,11 +229,11 @@ export function registerExecApprovalsCli(program: Command) {
agent.allowlist = nextEntries;
}
if (isEmptyAgent(agent)) {
- const agents = { ...(file.agents ?? {}) };
+ const agents = { ...file.agents };
delete agents[agentKey];
file.agents = Object.keys(agents).length > 0 ? agents : undefined;
} else {
- file.agents = { ...(file.agents ?? {}), [agentKey]: agent };
+ file.agents = { ...file.agents, [agentKey]: agent };
}
const next = await saveSnapshot(opts, nodeId, file, snapshot.hash);
const payload = opts.json ? JSON.stringify(next) : JSON.stringify(next, null, 2);
diff --git a/src/infra/exec-approvals.ts b/src/infra/exec-approvals.ts
index 213ce5a7c4c..3a7d8989054 100644
--- a/src/infra/exec-approvals.ts
+++ b/src/infra/exec-approvals.ts
@@ -242,7 +242,7 @@ function parseFirstToken(command: string): string | null {
if (end > 1) return trimmed.slice(1, end);
return trimmed.slice(1);
}
- const match = /^[^\\s]+/.exec(trimmed);
+ const match = /^\S+/.exec(trimmed);
return match ? match[0] : null;
}
diff --git a/src/infra/skills-remote.ts b/src/infra/skills-remote.ts
index cf81b765769..0f0c923f7f0 100644
--- a/src/infra/skills-remote.ts
+++ b/src/infra/skills-remote.ts
@@ -36,7 +36,11 @@ function extractErrorMessage(err: unknown): string | undefined {
if (typeof err === "object" && "message" in err && typeof err.message === "string") {
return err.message;
}
- return String(err);
+ try {
+ return JSON.stringify(err);
+ } catch {
+ return undefined;
+ }
}
function logRemoteBinProbeFailure(nodeId: string, err: unknown) {