[object Object]

← back to Butlr

butlr: add voicemail-IVR language guard to auto-hangup when AMD false-positives a voicemail tree as human

40322db1bb2750beb72247fdff6ee87c632c78c9 · 2026-05-26 10:06:31 -0700 · SteveStudio2

Twilio AMD mislabels chatty voicemail systems as AnsweredBy=human, so the
existing machine-hangup in /twilio/amd never fires and the AI agent navigates
the voicemail menu (call Tjz8cfFd burned turns inside Civil Coffee's mailbox).
Adds a deterministic looksLikeVoicemail() guard in both /twilio/gather (silent
Hangup TwiML, pre-LLM) and /twilio/partial (earliest call.update Status=completed
fast-bail) as belt-and-suspenders. Voicemail = wasted money.

Files touched

Diff

commit 40322db1bb2750beb72247fdff6ee87c632c78c9
Author: SteveStudio2 <steve@designerwallcoverings.com>
Date:   Tue May 26 10:06:31 2026 -0700

    butlr: add voicemail-IVR language guard to auto-hangup when AMD false-positives a voicemail tree as human
    
    Twilio AMD mislabels chatty voicemail systems as AnsweredBy=human, so the
    existing machine-hangup in /twilio/amd never fires and the AI agent navigates
    the voicemail menu (call Tjz8cfFd burned turns inside Civil Coffee's mailbox).
    Adds a deterministic looksLikeVoicemail() guard in both /twilio/gather (silent
    Hangup TwiML, pre-LLM) and /twilio/partial (earliest call.update Status=completed
    fast-bail) as belt-and-suspenders. Voicemail = wasted money.
---
 routes/twilio-webhooks.js | 90 +++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 90 insertions(+)

diff --git a/routes/twilio-webhooks.js b/routes/twilio-webhooks.js
index 1a7b861..f948103 100644
--- a/routes/twilio-webhooks.js
+++ b/routes/twilio-webhooks.js
@@ -164,6 +164,21 @@ router.post('/gather/:call_id', express.urlencoded({ extended: true }), async (r
   if (incoming) turns.push({ role: 'user', content: incoming });
   console.log(`[ai-agent] call=${callId} heard: "${incoming.slice(0,80)}"  turn=${turns.length}`);
 
+  // ── Voicemail-IVR guard (Steve's HARD RULE — 2026-05-26) ────────────────
+  // Twilio AMD false-positives chatty voicemail systems as AnsweredBy=human /
+  // unknown, so the machine-hangup in /twilio/amd never fires and the AI
+  // agent then happily navigates the voicemail menu (call Tjz8cfFd burned
+  // turns pressing 0/1 inside Civil Coffee's voicemail tree). This is a
+  // deterministic belt-and-suspenders bail: if what we just heard matches
+  // voicemail-tree language, hang up IMMEDIATELY — no LLM round-trip.
+  // Voicemail = wasted money. See feedback_butlr_amd_auto_hangup.md.
+  if (looksLikeVoicemail(incoming)) {
+    console.log(`[ai-agent] call=${callId} VOICEMAIL-GUARD hangup — heard: "${incoming.slice(0,80)}"`);
+    try { data.patchRow && data.patchRow(callId, { notes: ((call.notes || '') + ' [auto-hangup: voicemail-IVR language guard]') }); } catch (e) {}
+    data.updateStatus(callId, 'done');
+    return res.type('text/xml').send('<?xml version="1.0" encoding="UTF-8"?>\n<Response><Hangup/></Response>');
+  }
+
   // ── Live takeover check — skip LLM entirely if owner queued an action ────
   // Owner can POST to /api/calls/:id/say or /hangup from the listen page;
   // we consume that here and short-circuit the LLM round-trip.
@@ -447,6 +462,42 @@ router.post('/partial/:call_id', express.urlencoded({ extended: true }), async (
   if (!partial || partial.length < 30) return;
   const call = data.getCallUnscoped(callId, true);
   if (!call) return;
+
+  // ── Voicemail fast-bail (Steve's HARD RULE — 2026-05-26) ────────────────
+  // If the interim transcript already reads like a voicemail tree, hang up
+  // NOW via call.update — earliest possible exit, before the gather even
+  // completes. SILENT hangup (no goodbye) so we never leave a partial
+  // recorded message on someone's machine. Voicemail = wasted money.
+  if (looksLikeVoicemail(partial)) {
+    if (PARTIAL_DECIDED.has(callId)) return;
+    PARTIAL_DECIDED.add(callId);
+    console.log(`[partial] call=${callId} VOICEMAIL-GUARD fast-hangup — heard: "${partial.slice(0,100)}"`);
+    try {
+      const sid = call.twilio_call_sid;
+      if (!sid) { PARTIAL_DECIDED.delete(callId); return; }
+      const AC = process.env.TWILIO_ACCOUNT_SID;
+      const TOK = process.env.TWILIO_AUTH_TOKEN;
+      const auth = 'Basic ' + Buffer.from(`${AC}:${TOK}`).toString('base64');
+      const r = await fetch(`https://api.twilio.com/2010-04-01/Accounts/${AC}/Calls/${sid}.json`, {
+        method: 'POST',
+        headers: { Authorization: auth, 'Content-Type': 'application/x-www-form-urlencoded' },
+        body: new URLSearchParams({ Status: 'completed' }),
+      });
+      if (r.ok) {
+        console.log(`[partial] call=${callId} VOICEMAIL-GUARD hangup ok`);
+        try { data.patchRow && data.patchRow(callId, { notes: ((call.notes || '') + ' [auto-hangup: voicemail-IVR language guard (partial)]') }); } catch (e) {}
+        data.updateStatus(callId, 'done');
+      } else {
+        console.error(`[partial] call=${callId} voicemail hangup failed: ${r.status}`);
+        PARTIAL_DECIDED.delete(callId);
+      }
+    } catch (e) {
+      console.error(`[partial] call=${callId} voicemail fast-hangup error:`, e.message);
+      PARTIAL_DECIDED.delete(callId);
+    }
+    return;
+  }
+
   // Cheap regex pre-filter — only hit the LLM when partial looks goal-relevant.
   const lower = partial.toLowerCase();
   const looksGoaly = /\b(\d{1,2}[:.]?\d{0,2}\s*(am|pm)|open\s+(at|from|monday|until)|closed|address is|located at|membership (is\s+)?(active|inactive|expired|current)|account is\s+(active|closed|past due)|at the tone|coordinated universal time|hours .{1,30} minutes)\b/.test(lower);
@@ -746,6 +797,45 @@ function escapeXml(s) {
   return String(s || '').replace(/[<>&"']/g, c => ({'<':'&lt;','>':'&gt;','&':'&amp;','"':'&quot;',"'":'&apos;'}[c]));
 }
 
+// ── Voicemail-tree language detector ──────────────────────────────────
+// Returns true when a transcribed utterance clearly belongs to a voicemail
+// / answering-machine system rather than a live human or a normal business
+// IVR. Twilio AMD frequently mislabels these as AnsweredBy=human, so this
+// is the second line of defense (the first being /twilio/amd machine-hangup).
+//
+// Phrases are intentionally voicemail-SPECIFIC. We do NOT match generic
+// "press N for X" menu language (that's a legit business IVR we want to
+// navigate). We match the give-aways of a personal/business mailbox:
+//   - "review the voicemail" / "to send the message" (mailbox edit menu)
+//   - "leave a message" / "after the tone" / "at the beep"
+//   - "the person you are trying to reach" / "is not available"
+//   - "your call has been forwarded to voicemail"
+//   - "mailbox is full" / "the voicemail box"
+//   - "record your message" / "to re-record"
+const VOICEMAIL_PATTERNS = [
+  /\bleave (?:a|your) message\b/,
+  /\bafter the (?:tone|beep)\b/,
+  /\bat the (?:tone|beep)\b/,
+  /\brecord your message\b/,
+  /\bto re-?record\b/,
+  /\breview (?:the|your) (?:voicemail|message)\b/,
+  /\bto send the message\b/,
+  /\bthe person you(?:'re| are)? (?:trying|attempting) to (?:reach|call)\b/,
+  /\bis not available\b.*\bvoicemail\b/,
+  /\bhas been forwarded to (?:voicemail|an automated voice messaging)\b/,
+  /\bplease leave (?:a|your)\b/,
+  /\bvoicemail (?:box|system|inbox)\b/,
+  /\bmailbox (?:is full|belonging to)\b/,
+  /\byour message will be (?:recorded|sent)\b/,
+  /\bto leave a (?:call ?back|message)\b/,
+  /\bpress (?:1|one) to (?:review|send|listen to) (?:the |your )?(?:voicemail|message|recording)\b/,
+];
+function looksLikeVoicemail(text) {
+  const t = String(text || '').toLowerCase().trim();
+  if (t.length < 6) return false;
+  return VOICEMAIL_PATTERNS.some(re => re.test(t));
+}
+
 // ── Speak via ElevenLabs (cloned voice) with Polly fallback ───────────
 // Steve's standing rule: never piper/say/coqui as primary — always EL.
 // Cached MP3s land in data/audio-cache/<hash>.mp3 and are served by

← 5ed99e3 butlr: live-listen now broadcasts BOTH call tracks (inbound  ·  back to Butlr  ·  live page: hide + reap stale zombie calls (age-based filter 7b0d62c →