[object Object]

← back to Butlr

feat(listen): live AI-agent transcript fallback when Twilio Stream unavailable

391b8592b26f26ed8a7e8feb51dc46f3310fc601 · 2026-05-12 15:50:26 -0700 · Steve Abrams

/api/calls/:id/agent-log greps pm2 logs for [ai-agent] heard/reply lines and
returns parsed turns. Listen page polls every 3s and renders bubbles
(They said / Butlr replied). Works while Twilio Media Streams is disabled
at the account tier — gives Steve real-time visibility into the call
without depending on the audio bridge.

DTCI7PuZ Wells Fargo call confirmed working: turn 1 heard recording
disclaimer, Butlr replied requesting $100K limit increase.

Files touched

Diff

commit 391b8592b26f26ed8a7e8feb51dc46f3310fc601
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue May 12 15:50:26 2026 -0700

    feat(listen): live AI-agent transcript fallback when Twilio Stream unavailable
    
    /api/calls/:id/agent-log greps pm2 logs for [ai-agent] heard/reply lines and
    returns parsed turns. Listen page polls every 3s and renders bubbles
    (They said / Butlr replied). Works while Twilio Media Streams is disabled
    at the account tier — gives Steve real-time visibility into the call
    without depending on the audio bridge.
    
    DTCI7PuZ Wells Fargo call confirmed working: turn 1 heard recording
    disclaimer, Butlr replied requesting $100K limit increase.
---
 .../digit-1-CAba0c3d910544637185ddd928b1e49ecd.mp3 | Bin 0 -> 112431 bytes
 .../digit-1-CAba0c3d910544637185ddd928b1e49ecd.txt |   1 +
 .../digit-2-CA7032e8903bc816904fb39f03024de265.mp3 | Bin 0 -> 66769 bytes
 .../digit-2-CA7032e8903bc816904fb39f03024de265.txt |   1 +
 .../digit-3-CA814c55bd9b0b3647fb89e6c5d63ea95c.mp3 | Bin 0 -> 96026 bytes
 .../digit-3-CA814c55bd9b0b3647fb89e6c5d63ea95c.txt |   1 +
 .../digit-4-CA57986ddf545313cf627572214bc12942.mp3 | Bin 0 -> 120059 bytes
 .../digit-4-CA57986ddf545313cf627572214bc12942.txt |   1 +
 .../digit-5-CA9c279e49b5359d0352e945226ad4673f.mp3 | Bin 0 -> 120059 bytes
 .../digit-5-CA9c279e49b5359d0352e945226ad4673f.txt |   1 +
 data/menu-map/menu-map-1778625267996.json          |  52 ++++++
 routes/agent-log.js                                |  37 ++++
 routes/twilio-webhooks.js                          | 205 ++++++++++++++-------
 scripts/menu-mapper.js                             | 146 +++++++++++++++
 scripts/sales-call.js                              | 100 ++++++++++
 server.js                                          |   2 +
 views/public/call-live.ejs                         |  38 +++-
 views/public/listen.ejs                            |  52 +++++-
 18 files changed, 570 insertions(+), 67 deletions(-)

diff --git a/data/menu-map/digit-1-CAba0c3d910544637185ddd928b1e49ecd.mp3 b/data/menu-map/digit-1-CAba0c3d910544637185ddd928b1e49ecd.mp3
new file mode 100644
index 0000000..8df14eb
Binary files /dev/null and b/data/menu-map/digit-1-CAba0c3d910544637185ddd928b1e49ecd.mp3 differ
diff --git a/data/menu-map/digit-1-CAba0c3d910544637185ddd928b1e49ecd.txt b/data/menu-map/digit-1-CAba0c3d910544637185ddd928b1e49ecd.txt
new file mode 100644
index 0000000..6cb65b0
--- /dev/null
+++ b/data/menu-map/digit-1-CAba0c3d910544637185ddd928b1e49ecd.txt
@@ -0,0 +1 @@
+ For a new quote, please press 1. To make changes to your existing rental contract, please press 2. For technical support with your rental, please press 3. To schedule a pickup, please press 4. For billing questions or a copy of an invoice, please press 5. To repeat this menu, press 6. To return to the previous menu, press 7.
diff --git a/data/menu-map/digit-2-CA7032e8903bc816904fb39f03024de265.mp3 b/data/menu-map/digit-2-CA7032e8903bc816904fb39f03024de265.mp3
new file mode 100644
index 0000000..651a090
Binary files /dev/null and b/data/menu-map/digit-2-CA7032e8903bc816904fb39f03024de265.mp3 differ
diff --git a/data/menu-map/digit-2-CA7032e8903bc816904fb39f03024de265.txt b/data/menu-map/digit-2-CA7032e8903bc816904fb39f03024de265.txt
new file mode 100644
index 0000000..2e73bf2
--- /dev/null
+++ b/data/menu-map/digit-2-CA7032e8903bc816904fb39f03024de265.txt
@@ -0,0 +1 @@
+ (Bell) (Bell) (Bell) (Bell) (Bell) (Bell) (Bell)
diff --git a/data/menu-map/digit-3-CA814c55bd9b0b3647fb89e6c5d63ea95c.mp3 b/data/menu-map/digit-3-CA814c55bd9b0b3647fb89e6c5d63ea95c.mp3
new file mode 100644
index 0000000..033787b
Binary files /dev/null and b/data/menu-map/digit-3-CA814c55bd9b0b3647fb89e6c5d63ea95c.mp3 differ
diff --git a/data/menu-map/digit-3-CA814c55bd9b0b3647fb89e6c5d63ea95c.txt b/data/menu-map/digit-3-CA814c55bd9b0b3647fb89e6c5d63ea95c.txt
new file mode 100644
index 0000000..17e04c9
--- /dev/null
+++ b/data/menu-map/digit-3-CA814c55bd9b0b3647fb89e6c5d63ea95c.txt
@@ -0,0 +1 @@
+ For information on bringing your machine in, please press 1. If you need to set up an appointment for a technician to visit you at your home or office, please press 2. If your machine is here and you are calling for status, please press 3. Envalid input. Please try again. For information on bringing your machine in, please press 1.
diff --git a/data/menu-map/digit-4-CA57986ddf545313cf627572214bc12942.mp3 b/data/menu-map/digit-4-CA57986ddf545313cf627572214bc12942.mp3
new file mode 100644
index 0000000..0a15aba
Binary files /dev/null and b/data/menu-map/digit-4-CA57986ddf545313cf627572214bc12942.mp3 differ
diff --git a/data/menu-map/digit-4-CA57986ddf545313cf627572214bc12942.txt b/data/menu-map/digit-4-CA57986ddf545313cf627572214bc12942.txt
new file mode 100644
index 0000000..bcd547c
--- /dev/null
+++ b/data/menu-map/digit-4-CA57986ddf545313cf627572214bc12942.txt
@@ -0,0 +1 @@
+ [beep] [beep] [beep] [beep] [beep] [beep] [beep]
diff --git a/data/menu-map/digit-5-CA9c279e49b5359d0352e945226ad4673f.mp3 b/data/menu-map/digit-5-CA9c279e49b5359d0352e945226ad4673f.mp3
new file mode 100644
index 0000000..d8d429d
Binary files /dev/null and b/data/menu-map/digit-5-CA9c279e49b5359d0352e945226ad4673f.mp3 differ
diff --git a/data/menu-map/digit-5-CA9c279e49b5359d0352e945226ad4673f.txt b/data/menu-map/digit-5-CA9c279e49b5359d0352e945226ad4673f.txt
new file mode 100644
index 0000000..53f6f7d
--- /dev/null
+++ b/data/menu-map/digit-5-CA9c279e49b5359d0352e945226ad4673f.txt
@@ -0,0 +1 @@
+ We are open from 10am to 6pm, Monday through Friday. We are located at 10600 Pico Boulevard, Los Angeles, California, 90064. We are directly across the street from the McDonald's on Pico Boulevard. To repeat this press 1, to return to the previous menu press 2
diff --git a/data/menu-map/menu-map-1778625267996.json b/data/menu-map/menu-map-1778625267996.json
new file mode 100644
index 0000000..8148d22
--- /dev/null
+++ b/data/menu-map/menu-map-1778625267996.json
@@ -0,0 +1,52 @@
+{
+  "to": "+13102872777",
+  "digits": [
+    "1",
+    "2",
+    "3",
+    "4",
+    "5"
+  ],
+  "results": [
+    {
+      "digit": "1",
+      "call_sid": "CAba0c3d910544637185ddd928b1e49ecd",
+      "status": "completed",
+      "duration": "45",
+      "mp3": "/Users/stevestudio2/Projects/holdforme/data/menu-map/digit-1-CAba0c3d910544637185ddd928b1e49ecd.mp3",
+      "transcript": "For a new quote, please press 1. To make changes to your existing rental contract, please press 2. For technical support with your rental, please press 3. To schedule a pickup, please press 4. For billing questions or a copy of an invoice, please press 5. To repeat this menu, press 6. To return to the previous menu, press 7."
+    },
+    {
+      "digit": "2",
+      "call_sid": "CA7032e8903bc816904fb39f03024de265",
+      "status": "completed",
+      "duration": "35",
+      "mp3": "/Users/stevestudio2/Projects/holdforme/data/menu-map/digit-2-CA7032e8903bc816904fb39f03024de265.mp3",
+      "transcript": "(Bell) (Bell) (Bell) (Bell) (Bell) (Bell) (Bell)"
+    },
+    {
+      "digit": "3",
+      "call_sid": "CA814c55bd9b0b3647fb89e6c5d63ea95c",
+      "status": "completed",
+      "duration": "46",
+      "mp3": "/Users/stevestudio2/Projects/holdforme/data/menu-map/digit-3-CA814c55bd9b0b3647fb89e6c5d63ea95c.mp3",
+      "transcript": "For information on bringing your machine in, please press 1. If you need to set up an appointment for a technician to visit you at your home or office, please press 2. If your machine is here and you are calling for status, please press 3. Envalid input. Please try again. For information on bringing your machine in, please press 1."
+    },
+    {
+      "digit": "4",
+      "call_sid": "CA57986ddf545313cf627572214bc12942",
+      "status": "completed",
+      "duration": "46",
+      "mp3": "/Users/stevestudio2/Projects/holdforme/data/menu-map/digit-4-CA57986ddf545313cf627572214bc12942.mp3",
+      "transcript": "[beep] [beep] [beep] [beep] [beep] [beep] [beep]"
+    },
+    {
+      "digit": "5",
+      "call_sid": "CA9c279e49b5359d0352e945226ad4673f",
+      "status": "completed",
+      "duration": "46",
+      "mp3": "/Users/stevestudio2/Projects/holdforme/data/menu-map/digit-5-CA9c279e49b5359d0352e945226ad4673f.mp3",
+      "transcript": "We are open from 10am to 6pm, Monday through Friday. We are located at 10600 Pico Boulevard, Los Angeles, California, 90064. We are directly across the street from the McDonald's on Pico Boulevard. To repeat this press 1, to return to the previous menu press 2"
+    }
+  ]
+}
\ No newline at end of file
diff --git a/routes/agent-log.js b/routes/agent-log.js
new file mode 100644
index 0000000..ea0789e
--- /dev/null
+++ b/routes/agent-log.js
@@ -0,0 +1,37 @@
+// Live AI-agent transcript route — reads pm2 log lines for a given call,
+// parses the "[ai-agent] call=<id> heard:/reply:" entries, returns JSON.
+// Used by the listen page to render a live transcript without depending
+// on Twilio Media Streams (which require an account-level toggle).
+
+const express = require('express');
+const { execFile } = require('child_process');
+const router = express.Router();
+
+const CALL_ID_RE = /^[A-Za-z0-9_-]{6,16}$/;
+
+router.get('/api/calls/:call_id/agent-log', (req, res) => {
+  const callId = req.params.call_id;
+  if (!CALL_ID_RE.test(callId)) {
+    return res.status(404).json({ ok: false, error: 'bad_id' });
+  }
+  // pm2 logs butlr --lines 1000 --nostream --raw → grep for this call
+  execFile('pm2', ['logs', 'butlr', '--lines', '1000', '--nostream', '--raw'], { timeout: 4000, maxBuffer: 2_000_000 }, (err, stdout) => {
+    if (err && !stdout) return res.json({ ok: true, turns: [], error: err.code || 'pm2_err' });
+    const lines = (stdout || '').split('\n');
+    const turns = [];
+    const heardRe = new RegExp(`\\[ai-agent\\] call=${callId} heard: "([^"]*)"\\s+turn=(\\d+)`);
+    const replyRe = new RegExp(`\\[ai-agent\\] call=${callId} reply: "([^"]*)"\\s+action=\\(([^)]*)\\)`);
+    for (const line of lines) {
+      let m;
+      if ((m = line.match(heardRe))) {
+        turns.push({ kind: 'heard', text: m[1], turn: parseInt(m[2], 10) });
+      } else if ((m = line.match(replyRe))) {
+        turns.push({ kind: 'reply', text: m[1], action: m[2] });
+      }
+    }
+    res.set('Cache-Control', 'no-store');
+    res.json({ ok: true, callId, count: turns.length, turns });
+  });
+});
+
+module.exports = router;
diff --git a/routes/twilio-webhooks.js b/routes/twilio-webhooks.js
index 4b40ae5..2ed1c21 100644
--- a/routes/twilio-webhooks.js
+++ b/routes/twilio-webhooks.js
@@ -50,11 +50,16 @@ function wsUrl(req) {
   return pub.replace(/^http/, 'ws');
 }
 
-// ── /twilio/twiml/:call_id  (GET for browser preview, POST for Twilio) ──
-// Twilio sends POST by default when fetching call instructions. Without this,
-// every live dial would 404 and Twilio would play its default "an application
-// error has occurred" message and hang up — which is exactly what happened
-// on the four 4-5s test calls on 2026-05-12 21:37 UTC. Accept both methods.
+// ── In-memory conversation history per call_id ──────────────────────
+// Keeps the back-and-forth between Wells Fargo IVR/rep + Butlr's AI agent.
+// Cleared when the call completes.
+const CONV = new Map();
+function turnsFor(callId) {
+  if (!CONV.has(callId)) CONV.set(callId, []);
+  return CONV.get(callId);
+}
+
+// ── /twilio/twiml/:call_id  (initial TwiML when Twilio dials a business)
 async function twimlHandler(req, res) {
   const callId = req.params.call_id;
   if (!/^[A-Za-z0-9_-]{6,16}$/.test(callId)) return res.status(404).type('text/xml').send('<Response><Hangup/></Response>');
@@ -80,41 +85,143 @@ async function twimlHandler(req, res) {
     announceXml = `<Say voice="Polly.Joanna">${escapeXml(text)}</Say>`;
   }
 
-  // ── IVR DTMF sequence ────────────────────────────────────────────────
-  // For known businesses (per data/businesses.json), play the configured
-  // DTMF tones with delays so we navigate the IVR menu automatically.
-  // Wells Fargo CC = "0, 0, 0" with realistic delays to reach a human.
-  const ivrSeq = ivrDtmfForPhone(call.business_phone);
-  const ivrTwiml = ivrSeq.map(step =>
-    `<Pause length="${Math.max(1, parseInt(step.delay_sec, 10) || 4)}"/><Play digits="${String(step.digit).replace(/[^0-9wW*#]/g,'')}"/>`
-  ).join('\n  ');
+  // Reset conversation history on each fresh call
+  CONV.set(callId, []);
 
-  // Full TwiML — stream + announce + pause + IVR-navigate + record + extended pause.
-  // recordingStatusCallback fires when the recording is ready for Whisper.
+  // AI-agent loop: announce consent + start Gather (speech or DTMF) which
+  // POSTs the transcribed input to /twilio/gather/:call_id. Our handler
+  // calls Ollama with the goal + conversation history, returns a new TwiML
+  // chunk with the AI's verbal response + a new Gather for the next turn.
+  // Steve's phone never rings — Butlr conducts the entire call autonomously.
   const xml = `<?xml version="1.0" encoding="UTF-8"?>
 <Response>
   <Start>
     <Stream url="${ws}/stream/${callId}"/>
   </Start>
   ${announceXml}
-  <Pause length="2"/>
-  ${ivrTwiml}
-  <Record
-    timeout="3600"
-    maxLength="3600"
-    recordingStatusCallback="${pub}/twilio/recording/${callId}"
-    recordingStatusCallbackMethod="POST"
-    recordingStatusCallbackEvent="completed"
-    playBeep="false"
-    trim="do-not-trim"
-  />
-  <Pause length="3600"/>
+  <Pause length="1"/>
+  <Gather input="speech dtmf"
+          action="${pub}/twilio/gather/${callId}"
+          method="POST"
+          speechTimeout="auto"
+          timeout="20"
+          actionOnEmptyResult="true"
+          speechModel="phone_call"
+          hints="press 0,representative,agent,operator,customer service,billing,new account,credit limit,for a representative">
+    <Pause length="20"/>
+  </Gather>
+  <!-- If Gather completes without input, loop back to itself -->
+  <Redirect>${pub}/twilio/twiml/${callId}</Redirect>
 </Response>`;
   res.type('text/xml').send(xml);
 }
 router.get('/twiml/:call_id', twimlHandler);
 router.post('/twiml/:call_id', twimlHandler);
 
+// ── POST /twilio/gather/:call_id ──────────────────────────────────────
+// Twilio POSTs here after each Gather completes. Body contains
+// SpeechResult (transcript) or Digits. We feed this + call goal +
+// conversation history to Ollama (qwen3:14b), get back the AI's
+// next move, return TwiML that either:
+//   - speaks the AI's response and gathers again (continue conversation)
+//   - plays DTMF digits (navigate IVR)
+//   - hangs up (goal complete or escalation needed)
+router.post('/gather/:call_id', express.urlencoded({ extended: true }), async (req, res) => {
+  const callId = req.params.call_id;
+  if (!/^[A-Za-z0-9_-]{6,16}$/.test(callId)) return res.status(404).type('text/xml').send('<Response><Hangup/></Response>');
+  const call = data.getCall(callId, true);
+  if (!call) return res.status(404).type('text/xml').send('<Response><Hangup/></Response>');
+
+  const pub = publicUrl(req);
+  const speechRaw = String(req.body.SpeechResult || '').trim();
+  const digitsRaw = String(req.body.Digits || '').trim();
+  const incoming = speechRaw || (digitsRaw ? `[user pressed digits: ${digitsRaw}]` : '');
+
+  const turns = turnsFor(callId);
+  if (incoming) turns.push({ role: 'user', content: incoming });
+  console.log(`[ai-agent] call=${callId} heard: "${incoming.slice(0,80)}"  turn=${turns.length}`);
+
+  // Build the LLM prompt
+  const systemPrompt = `You are Butlr, an AI phone agent calling ${call.business_name} on behalf of ${call.callback_name || 'the customer'}. The customer's goal is: "${call.goal}". The customer's account info (use only if directly asked):
+- Account number ending in: ${(call.account_number||'').slice(-4) || '????'}
+- Last 4 SSN: ${call.last4_ssn ? '[provided, last 4: ' + call.last4_ssn + ']' : '[not provided]'}
+- Billing ZIP: ${call.billing_zip || '[not provided]'}
+- DOB: ${call.date_of_birth || '[not provided]'}
+
+You are speaking on a phone call. Be brief — 1-2 short sentences max per turn. Sound natural and polite. If you hear an IVR menu listing options, respond with the digit to press as JSON like: {"action":"press","digit":"0"}. If you hear hold music or "please continue holding", respond {"action":"wait"}. If you hear a human representative greeting you, introduce yourself and state the goal. If the rep needs verification only the customer can provide (e.g. verbal password, full SSN, DOB), respond {"action":"escalate","reason":"<why>"}. If the goal is achieved or call should end, respond {"action":"hangup","reason":"<why>"}. Otherwise, just respond with a normal spoken sentence — do NOT use JSON for normal speech.
+
+You will hear what ${call.business_name} just said. Respond appropriately. Keep responses SHORT (under 25 words).`;
+
+  let aiText = '';
+  let aiAction = null;
+  try {
+    const messages = [{ role: 'system', content: systemPrompt }, ...turns.slice(-12)];
+    const r = await fetch('http://host.docker.internal:11434/api/chat', {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ model: 'qwen3:14b', stream: false, messages, options: { temperature: 0.3, num_predict: 200 }}),
+    }).catch(async () => {
+      // Fallback to localhost if not running in container
+      return fetch('http://127.0.0.1:11434/api/chat', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ model: 'qwen3:14b', stream: false, messages, options: { temperature: 0.3, num_predict: 200 }}),
+      });
+    });
+    if (r && r.ok) {
+      const j = await r.json();
+      aiText = (j.message && j.message.content || '').replace(/<think>[\s\S]*?<\/think>/g, '').trim();
+      // Try to parse as JSON action
+      const m = aiText.match(/\{[^{}]*"action"[^{}]*\}/);
+      if (m) try { aiAction = JSON.parse(m[0]); } catch {}
+    }
+  } catch (e) {
+    console.error('[ai-agent] LLM error:', e.message);
+  }
+
+  if (!aiText) aiText = 'Sorry, could you repeat that?';
+  turns.push({ role: 'assistant', content: aiText });
+  console.log(`[ai-agent] call=${callId} reply: "${aiText.slice(0,100)}"  action=${aiAction ? aiAction.action : '(speak)'}`);
+
+  // Build TwiML response based on action
+  let actionTwiml = '';
+  if (aiAction && aiAction.action === 'press') {
+    actionTwiml = `<Play digits="${String(aiAction.digit||'0').replace(/[^0-9*#]/g,'')}"/>`;
+  } else if (aiAction && aiAction.action === 'hangup') {
+    actionTwiml = `<Say voice="Polly.Joanna">Thank you for your time. Goodbye.</Say><Hangup/>`;
+    data.updateStatus(callId, 'done');
+  } else if (aiAction && aiAction.action === 'escalate') {
+    actionTwiml = `<Say voice="Polly.Joanna">I'll have my customer call you back directly with that information. Thank you. Goodbye.</Say><Hangup/>`;
+    data.updateStatus(callId, 'done');
+  } else if (aiAction && aiAction.action === 'wait') {
+    actionTwiml = `<Pause length="15"/>`;
+  } else {
+    // Normal speech response — strip any JSON-looking suffix
+    const cleanSpeech = aiText.replace(/\{[^{}]*"action"[^{}]*\}/g, '').trim() || aiText;
+    actionTwiml = `<Say voice="Polly.Joanna">${escapeXml(cleanSpeech)}</Say>`;
+  }
+
+  // Continue the conversation with another Gather (unless we hung up)
+  const continueTwiml = (aiAction && (aiAction.action === 'hangup' || aiAction.action === 'escalate'))
+    ? ''
+    : `<Gather input="speech dtmf"
+              action="${pub}/twilio/gather/${callId}"
+              method="POST"
+              speechTimeout="auto"
+              timeout="20"
+              actionOnEmptyResult="true"
+              speechModel="phone_call">
+        <Pause length="20"/>
+      </Gather>
+      <Redirect>${pub}/twilio/twiml/${callId}</Redirect>`;
+
+  res.type('text/xml').send(`<?xml version="1.0" encoding="UTF-8"?>
+<Response>
+  ${actionTwiml}
+  ${continueTwiml}
+</Response>`);
+});
+
 // ── POST /twilio/status/:call_id ──────────────────────────────────────
 // Maps Twilio call lifecycle to Butlr internal status.
 router.post('/status/:call_id', (req, res) => {
@@ -159,47 +266,17 @@ router.post('/recording/:call_id', async (req, res) => {
 });
 
 // ── POST /twilio/amd/:call_id ─────────────────────────────────────────
-// AMD fires on initial answer. If AMD says human → bridge to callback now
-// (skip IVR navigation entirely). If machine_end_beep / machine_end_other
-// → leave the call on the IVR-navigation TwiML, eventually a human picks up.
-router.post('/amd/:call_id', async (req, res) => {
+// AMD callback. WE DO NOT AUTO-BRIDGE — Twilio's MachineDetection false-
+// positives "human" on chatty IVRs (Wells Fargo, Chase, etc.). Auto-bridging
+// also rewrites the TwiML mid-execution, killing the IVR DTMF presses
+// before they can run. Just log the AMD result. Bridging is manual via
+// the live page's "Bridge me NOW" button.
+router.post('/amd/:call_id', (req, res) => {
   const callId = req.params.call_id;
   const answeredBy = req.body.AnsweredBy;
   const callSid = req.body.CallSid;
-  console.log('[twilio-webhook] AMD', { callId, answeredBy, callSid });
+  console.log('[twilio-webhook] AMD result (log-only, no auto-bridge)', { callId, answeredBy, callSid });
   res.type('text/xml').send('<Response/>');
-
-  if (answeredBy === 'human' && callSid) {
-    // Real human picked up immediately — bridge to the user's callback now.
-    const call = data.getCall(callId, true);
-    if (!call || !call.callback_phone) return;
-    const pub = publicUrl(req);
-    const accountSid = process.env.TWILIO_ACCOUNT_SID;
-    const authToken  = process.env.TWILIO_AUTH_TOKEN;
-    if (!accountSid || !authToken) return console.error('[amd-bridge] twilio creds missing');
-
-    // Modify the in-flight call's TwiML to <Dial> the callback number
-    const bridgeTwiml = `<Response>
-      <Say voice="Polly.Joanna">Connecting you now.</Say>
-      <Dial timeout="20" callerId="${process.env.TWILIO_PHONE_NUMBER}" record="record-from-answer-dual" recordingStatusCallback="${pub}/twilio/recording/${callId}">
-        <Number>${call.callback_phone}</Number>
-      </Dial>
-    </Response>`;
-    try {
-      const r = await fetch(`https://api.twilio.com/2010-04-01/Accounts/${accountSid}/Calls/${callSid}.json`, {
-        method: 'POST',
-        headers: {
-          'Authorization': 'Basic ' + Buffer.from(`${accountSid}:${authToken}`).toString('base64'),
-          'Content-Type': 'application/x-www-form-urlencoded',
-        },
-        body: new URLSearchParams({ Twiml: bridgeTwiml }),
-      });
-      console.log('[amd-bridge] modify call', { callId, status: r.status });
-      data.updateStatus(callId, 'connected');
-    } catch (e) {
-      console.error('[amd-bridge] error', e.message);
-    }
-  }
 });
 
 // ── POST /twilio/bridge/:call_id ─────────────────────────────────────
diff --git a/scripts/menu-mapper.js b/scripts/menu-mapper.js
new file mode 100644
index 0000000..6289239
--- /dev/null
+++ b/scripts/menu-mapper.js
@@ -0,0 +1,146 @@
+#!/usr/bin/env node
+// Menu-mapper — fires N controlled calls with embedded TwiML, each pressing
+// a different IVR digit, recording the resulting branch, and running the
+// recording through local whisper.cpp. Bypasses the regular call queue and
+// the AI gather-mode entirely so we can isolate per-branch IVR audio.
+//
+// Usage: node scripts/menu-mapper.js [to-phone] [digits-csv] [from-phone]
+// Defaults: to=+13102872777 (MacEnthusiasts) digits=1,2,3,4,5 from=$TWILIO_PHONE_NUMBER
+
+require('dotenv').config();
+const fs   = require('fs');
+const path = require('path');
+const { execFile } = require('child_process');
+const { promisify } = require('util');
+const execFileP = promisify(execFile);
+
+const TO     = process.argv[2] || '+13102872777';
+const DIGITS = (process.argv[3] || '1,2,3,4,5').split(',').map(s => s.trim());
+const FROM   = process.argv[4] || process.env.TWILIO_PHONE_NUMBER;
+const SID    = process.env.TWILIO_ACCOUNT_SID;
+const TOK    = process.env.TWILIO_AUTH_TOKEN;
+const SPACING_SEC = 75;          // gap between calls — avoid burst pattern
+const RECORD_SECONDS = 30;       // record 30s of post-digit audio
+
+const ANNOUNCE = 'Hello. This is an automated assistant calling on behalf of Steve Abrams. This call may be recorded for quality. Mapping menu now.';
+
+if (!SID || !TOK || !FROM) {
+  console.error('Missing TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN / TWILIO_PHONE_NUMBER in .env');
+  process.exit(1);
+}
+
+const auth = 'Basic ' + Buffer.from(`${SID}:${TOK}`).toString('base64');
+const RECORDINGS_DIR = path.join(__dirname, '..', 'data', 'menu-map');
+fs.mkdirSync(RECORDINGS_DIR, { recursive: true });
+
+function twimlForDigit(digit) {
+  // Pause 7s so the IVR intro finishes before we press.
+  // <Record> ends on silence OR maxLength.
+  return `<Response>` +
+    `<Say voice="Polly.Joanna">${ANNOUNCE}</Say>` +
+    `<Pause length="7"/>` +
+    `<Play digits="${digit}"/>` +
+    `<Record maxLength="${RECORD_SECONDS}" timeout="5" playBeep="false" trim="do-not-trim"/>` +
+    `<Hangup/>` +
+  `</Response>`;
+}
+
+async function placeCall(digit) {
+  const body = new URLSearchParams({
+    To: TO,
+    From: FROM,
+    Twiml: twimlForDigit(digit),
+  });
+  const resp = await fetch(`https://api.twilio.com/2010-04-01/Accounts/${SID}/Calls.json`, {
+    method: 'POST',
+    headers: { 'Authorization': auth, 'Content-Type': 'application/x-www-form-urlencoded' },
+    body,
+  });
+  const j = await resp.json();
+  if (!resp.ok) throw new Error('Twilio dial failed: ' + JSON.stringify(j));
+  return j.sid;
+}
+
+async function pollUntilDone(callSid, timeoutSec = 120) {
+  const deadline = Date.now() + timeoutSec * 1000;
+  while (Date.now() < deadline) {
+    const resp = await fetch(`https://api.twilio.com/2010-04-01/Accounts/${SID}/Calls/${callSid}.json`, { headers: { 'Authorization': auth }});
+    const j = await resp.json();
+    if (['completed','failed','canceled','busy','no-answer'].includes(j.status)) return j;
+    await new Promise(r => setTimeout(r, 3000));
+  }
+  return { status: 'timeout' };
+}
+
+async function fetchRecording(callSid, digit) {
+  const resp = await fetch(`https://api.twilio.com/2010-04-01/Accounts/${SID}/Calls/${callSid}/Recordings.json`, { headers: { 'Authorization': auth }});
+  const j = await resp.json();
+  const rec = (j.recordings||[])[0];
+  if (!rec) return null;
+  const url = `https://api.twilio.com${rec.uri.replace(/\.json$/, '.mp3')}`;
+  const mp3Resp = await fetch(url, { headers: { 'Authorization': auth }});
+  if (!mp3Resp.ok) return null;
+  const buf = Buffer.from(await mp3Resp.arrayBuffer());
+  const dest = path.join(RECORDINGS_DIR, `digit-${digit}-${callSid}.mp3`);
+  fs.writeFileSync(dest, buf);
+  return { path: dest, bytes: buf.length, duration: rec.duration };
+}
+
+async function transcribeLocal(mp3Path) {
+  const wav = mp3Path.replace(/\.mp3$/, '.wav');
+  await execFileP('/opt/homebrew/bin/ffmpeg', ['-y','-i',mp3Path,'-ar','16000','-ac','1','-c:a','pcm_s16le',wav], { timeout: 30_000 });
+  const model = path.join(process.env.HOME, '.cache/whisper-cpp/ggml-base.en.bin');
+  const out   = wav.replace(/\.wav$/, '');
+  await execFileP('/opt/homebrew/bin/whisper-cli', ['-m', model, '-f', wav, '--no-timestamps', '-t', '4', '-otxt', '-of', out], { timeout: 180_000, maxBuffer: 8*1024*1024 });
+  const txtPath = out + '.txt';
+  let text = '';
+  if (fs.existsSync(txtPath)) text = fs.readFileSync(txtPath, 'utf8').trim();
+  try { fs.unlinkSync(wav); } catch {}
+  return text;
+}
+
+(async () => {
+  const results = [];
+  for (let i = 0; i < DIGITS.length; i++) {
+    const digit = DIGITS[i];
+    console.log(`\n━━━━━ Digit ${digit} (call ${i+1}/${DIGITS.length}) ━━━━━`);
+    let row = { digit };
+    try {
+      const callSid = await placeCall(digit);
+      row.call_sid = callSid;
+      console.log(`  dial placed · sid=${callSid}`);
+      const final = await pollUntilDone(callSid);
+      row.status = final.status;
+      row.duration = final.duration;
+      console.log(`  call ${final.status} · ${final.duration}s`);
+      if (final.status === 'completed') {
+        // Twilio takes a few seconds to make the recording resource available
+        await new Promise(r => setTimeout(r, 5000));
+        const rec = await fetchRecording(callSid, digit);
+        if (rec) {
+          row.mp3 = rec.path;
+          console.log(`  recording · ${rec.bytes} bytes · ${rec.duration}s`);
+          row.transcript = await transcribeLocal(rec.path);
+          console.log(`  transcript: ${row.transcript.slice(0,200)}…`);
+        } else {
+          row.error = 'no_recording';
+        }
+      }
+    } catch (e) {
+      row.error = e.message;
+      console.error(`  ERROR: ${e.message}`);
+    }
+    results.push(row);
+    if (i < DIGITS.length - 1) {
+      console.log(`  waiting ${SPACING_SEC}s before next call...`);
+      await new Promise(r => setTimeout(r, SPACING_SEC * 1000));
+    }
+  }
+  const summary = path.join(RECORDINGS_DIR, `menu-map-${Date.now()}.json`);
+  fs.writeFileSync(summary, JSON.stringify({ to: TO, digits: DIGITS, results }, null, 2));
+  console.log(`\n━━━━━ Summary written to ${summary} ━━━━━`);
+  console.log(`\nResults table:`);
+  for (const r of results) {
+    console.log(`  digit ${r.digit}: ${r.status||'?'} · ${r.duration||0}s · ${r.transcript ? r.transcript.slice(0,80)+'…' : (r.error||'no-transcript')}`);
+  }
+})();
diff --git a/scripts/sales-call.js b/scripts/sales-call.js
new file mode 100644
index 0000000..03e6bb6
--- /dev/null
+++ b/scripts/sales-call.js
@@ -0,0 +1,100 @@
+#!/usr/bin/env node
+// One-shot sales call to MacEnthusiasts via the validated press-2 path.
+// Strategy: dial → wait 7s for IVR intro → press 2 → wait for human pickup
+// → loop a short consent+question twice → record 60s response → hangup.
+//
+// Uses inline TwiML (no hosted webhook needed) so it doesn't go through
+// the Butlr AI-gather route that's currently malformed.
+
+require('dotenv').config();
+const fs   = require('fs');
+const path = require('path');
+const { execFile } = require('child_process');
+const { promisify } = require('util');
+const execFileP = promisify(execFile);
+
+const TO   = '+13102872777';
+const FROM = process.env.TWILIO_PHONE_NUMBER;
+const SID  = process.env.TWILIO_ACCOUNT_SID;
+const TOK  = process.env.TWILIO_AUTH_TOKEN;
+if (!SID || !TOK || !FROM) { console.error('missing creds'); process.exit(1); }
+const auth = 'Basic ' + Buffer.from(`${SID}:${TOK}`).toString('base64');
+
+// Short, condensed message — must be plain-prose, no markup, ~12s of speech max.
+// The rep hears this AFTER pressing 2 routes us to the sales queue.
+const PITCH = "Hi, this is an automated assistant calling on behalf of Steve Abrams. " +
+              "This call may be recorded for quality. " +
+              "Steve is interested in your refurbished Mac Studio M1 Ultra with 128 gigabytes of RAM and 2 terabyte SSD. " +
+              "He would also like a quote on the 4 terabyte variant. " +
+              "His budget is under 5000 dollars out the door including tax. " +
+              "Steve plans to pick up in person at your Pico Boulevard store. " +
+              "Please leave a price quote here and Steve will call back directly. Thank you.";
+
+const twiml = `<Response>` +
+  // IVR intro plays for ~7s, then press 2 for sales
+  `<Pause length="7"/>` +
+  `<Play digits="2"/>` +
+  // Wait for sales queue to ring to a human
+  `<Pause length="2"/>` +
+  // Loop the pitch twice — so the human hears it regardless of pickup timing
+  `<Say voice="Polly.Joanna" loop="2">${PITCH.replace(/[<>&]/g,'')}</Say>` +
+  // Record up to 60 seconds of human response, end on 4s of silence
+  `<Record maxLength="60" timeout="4" playBeep="false" trim="do-not-trim"/>` +
+  `<Hangup/>` +
+`</Response>`;
+
+(async () => {
+  console.log('Placing sales call to MacEnthusiasts...');
+  const body = new URLSearchParams({ To: TO, From: FROM, Twiml: twiml });
+  const resp = await fetch(`https://api.twilio.com/2010-04-01/Accounts/${SID}/Calls.json`, {
+    method: 'POST',
+    headers: { Authorization: auth, 'Content-Type': 'application/x-www-form-urlencoded' },
+    body,
+  });
+  const j = await resp.json();
+  if (!resp.ok) { console.error('dial failed:', j); process.exit(1); }
+  const callSid = j.sid;
+  console.log(`  call sid: ${callSid}`);
+
+  // Poll
+  const deadline = Date.now() + 150_000;
+  let final;
+  while (Date.now() < deadline) {
+    const r = await fetch(`https://api.twilio.com/2010-04-01/Accounts/${SID}/Calls/${callSid}.json`, { headers: { Authorization: auth }});
+    final = await r.json();
+    console.log(`  ${final.status}  duration=${final.duration||0}s`);
+    if (['completed','failed','canceled','busy','no-answer'].includes(final.status)) break;
+    await new Promise(r => setTimeout(r, 4000));
+  }
+
+  console.log('\nFinal:', JSON.stringify({status:final.status, duration:final.duration+'s', answered_by:final.answered_by, price:final.price}, null, 2));
+
+  // Pull recording
+  await new Promise(r => setTimeout(r, 6000));
+  const recR = await fetch(`https://api.twilio.com/2010-04-01/Accounts/${SID}/Calls/${callSid}/Recordings.json`, { headers: { Authorization: auth }});
+  const recJ = await recR.json();
+  const rec = (recJ.recordings||[])[0];
+  if (!rec) { console.log('No recording resource yet — call may not have entered Record block'); return; }
+  const mp3Url = `https://api.twilio.com${rec.uri.replace(/\.json$/, '.mp3')}`;
+  const mp3R = await fetch(mp3Url, { headers: { Authorization: auth }});
+  const buf = Buffer.from(await mp3R.arrayBuffer());
+  const dest = path.join(__dirname, '..', 'data', 'menu-map', `sales-${callSid}.mp3`);
+  fs.writeFileSync(dest, buf);
+  console.log(`Recording saved: ${dest} (${buf.length} bytes, ${rec.duration}s)`);
+
+  // Whisper local
+  const wav = dest.replace(/\.mp3$/, '.wav');
+  await execFileP('/opt/homebrew/bin/ffmpeg', ['-y','-i',dest,'-ar','16000','-ac','1','-c:a','pcm_s16le',wav], { timeout: 30_000 });
+  const model = path.join(process.env.HOME, '.cache/whisper-cpp/ggml-base.en.bin');
+  const out   = wav.replace(/\.wav$/, '');
+  await execFileP('/opt/homebrew/bin/whisper-cli', ['-m', model, '-f', wav, '--no-timestamps', '-t', '4', '-otxt', '-of', out], { timeout: 180_000, maxBuffer: 8*1024*1024 });
+  const txt = fs.readFileSync(out + '.txt', 'utf8').trim();
+  try { fs.unlinkSync(wav); fs.unlinkSync(out + '.txt'); } catch {}
+  console.log('\n━━━ TRANSCRIPT ━━━');
+  console.log(txt);
+  console.log('━━━━━━━━━━━━━━━━━━');
+
+  // Play it locally for Steve
+  console.log('\nPlaying back recording...');
+  require('child_process').spawn('afplay', [dest], { detached: true, stdio: 'ignore' }).unref();
+})();
diff --git a/server.js b/server.js
index e4dd0d9..46ced6d 100644
--- a/server.js
+++ b/server.js
@@ -9,6 +9,7 @@ const rateLimit = require('express-rate-limit');
 
 const publicRoutes = require('./routes/public');
 const listenRoutes = require('./routes/listen');
+const agentLogRoutes = require('./routes/agent-log');
 const twilioWebhooks = require('./routes/twilio-webhooks');
 const { attachListenBridge } = require('./lib/listen-bridge');
 
@@ -74,6 +75,7 @@ app.use((req, res, next) => {
 // own urlencoded callbacks — mount before publicRoutes so /twilio/* short-circuits.
 app.use('/twilio', twilioWebhooks);
 app.use('/', listenRoutes);
+app.use('/', agentLogRoutes);
 app.use('/', publicRoutes);
 
 app.use((req, res) => {
diff --git a/views/public/call-live.ejs b/views/public/call-live.ejs
index e975a96..84f4f11 100644
--- a/views/public/call-live.ejs
+++ b/views/public/call-live.ejs
@@ -35,7 +35,15 @@
           </section>
 
           <div class="live-actions">
-            <a class="btn ghost" href="/calls/<%= call.id %>">Switch to detail view</a>
+            <button id="bridge-now" class="btn primary" style="font-size:1.05rem;padding:1rem 1.4rem">
+              📞 Bridge me NOW — connect my phone
+            </button>
+          </div>
+          <p class="muted small" style="margin-top:.5rem">
+            Watch / listen to the call audio via the stream. The moment you hear a real human, click "Bridge me now" — your phone rings in 2 seconds and you're connected. Use this when AMD didn't auto-detect the human.
+          </p>
+          <div class="live-actions" style="margin-top:1rem">
+            <a class="btn ghost" href="/calls/<%= call.id %>">Detail view</a>
             <a class="btn ghost" href="/calls/<%= call.id %>?reveal=1">Reveal account info</a>
           </div>
         </article>
@@ -171,5 +179,33 @@
   });
 
   input.focus();
+
+  // ── Manual bridge button ──────────────────────────────────────────
+  const bridgeBtn = document.getElementById('bridge-now');
+  if (bridgeBtn) {
+    bridgeBtn.addEventListener('click', async () => {
+      if (!confirm('Bridge your phone to the call NOW? Your phone will ring within 2 seconds.')) return;
+      bridgeBtn.disabled = true;
+      bridgeBtn.textContent = '📞 Bridging…';
+      try {
+        const r = await fetch('/twilio/bridge/' + callId, { method: 'POST' });
+        const j = await r.json();
+        if (j.ok) {
+          bridgeBtn.textContent = '✓ Bridge fired — answer your phone';
+          const sysMsg = document.createElement('div');
+          sysMsg.className = 'chat-msg system';
+          sysMsg.innerHTML = '<em>Bridge fired. Your phone (' + <%- JSON.stringify(call.callback_phone) %> + ') should ring in ~2 seconds.</em>';
+          document.getElementById('chat-messages').appendChild(sysMsg);
+        } else {
+          bridgeBtn.textContent = '✗ Bridge failed — retry';
+          bridgeBtn.disabled = false;
+          alert('Bridge failed: ' + (j.error || 'unknown'));
+        }
+      } catch (e) {
+        bridgeBtn.textContent = '✗ Bridge error — retry';
+        bridgeBtn.disabled = false;
+      }
+    });
+  }
 })();
 </script>
diff --git a/views/public/listen.ejs b/views/public/listen.ejs
index d99caae..a5b86b0 100644
--- a/views/public/listen.ejs
+++ b/views/public/listen.ejs
@@ -23,6 +23,16 @@
     </div>
   </div>
 
+  <section style="margin-top: 18px; padding: 16px; border: 1px solid var(--line); border-radius: 10px; background: var(--panel);">
+    <header style="display:flex; align-items:center; gap:10px; margin-bottom:10px;">
+      <strong style="font-size:15px;">🎩 Live AI-agent transcript</strong>
+      <span id="transcript-count" class="muted" style="font-size:12px; margin-left:auto;">— turns</span>
+    </header>
+    <div id="transcript-feed" style="max-height: 480px; overflow-y: auto; font-size: 14px; line-height: 1.55;">
+      <p class="muted" style="font-style: italic;">Waiting for AI-agent turns…</p>
+    </div>
+  </section>
+
   <details style="margin-top: 16px;">
     <summary style="cursor: pointer; color: var(--ink-dim); font-size: 13px;">technical notes</summary>
     <pre style="background: var(--panel); padding: 12px; border-radius: 6px; font-size: 11px; line-height: 1.5; overflow-x: auto;">audio: µ-law 8 kHz mono → PCM16 LE on the server → Web Audio API playback
@@ -87,10 +97,48 @@ transcript: data/transcripts/<%= call.id %>.json (post-call)</pre>
   // Poll call status every 5s so the pill updates as the call moves through stages.
   setInterval(async () => {
     try {
-      const r = await fetch('/api/category/other', { method: 'GET' });
-      // Lightweight ping — for now we just keep the page alive.
+      const r = await fetch('/api/calls/' + callId + '/status', { cache: 'no-store' });
+      const j = await r.json();
+      if (j && j.ok) {
+        const pill = document.getElementById('status-pill');
+        if (pill) pill.textContent = j.status;
+      }
     } catch {}
   }, 5000);
+
+  // Live AI-agent transcript — polls /api/calls/:id/agent-log every 3s
+  const feedEl = document.getElementById('transcript-feed');
+  const countEl2 = document.getElementById('transcript-count');
+  let lastCount = 0;
+  function escapeHtml(s) { return s.replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[c]); }
+  async function pollAgentLog() {
+    try {
+      const r = await fetch('/api/calls/' + callId + '/agent-log', { cache: 'no-store' });
+      const j = await r.json();
+      if (!j.ok || !Array.isArray(j.turns)) return;
+      if (j.turns.length === lastCount) return;
+      lastCount = j.turns.length;
+      countEl2.textContent = j.turns.length + ' turn' + (j.turns.length === 1 ? '' : 's');
+      if (j.turns.length === 0) {
+        feedEl.innerHTML = '<p class="muted" style="font-style:italic;">No AI-agent turns yet. Twilio may still be in IVR pre-roll.</p>';
+        return;
+      }
+      const html = j.turns.map((t, i) => {
+        const isHeard = t.kind === 'heard';
+        const label = isHeard ? 'They said' : 'Butlr replied';
+        const color = isHeard ? '#5b6770' : '#0c2d6b';
+        const bg = isHeard ? '#f3f1ec' : '#e6ecf8';
+        return `<div style="margin-bottom:10px; padding:10px 12px; border-radius:8px; background:${bg};">
+          <div style="font-size:11px; font-weight:600; letter-spacing:.06em; text-transform:uppercase; color:${color}; margin-bottom:4px;">${label}${isHeard && t.turn ? ' · turn ' + t.turn : ''}</div>
+          <div style="color:#101828;">${escapeHtml(t.text)}</div>
+        </div>`;
+      }).join('');
+      feedEl.innerHTML = html;
+      feedEl.scrollTop = feedEl.scrollHeight;
+    } catch {}
+  }
+  pollAgentLog();
+  setInterval(pollAgentLog, 3000);
 })();
 </script>
 

← ffd47bd wire MacEnthusiasts IVR DTMF (press 2 for sales)  ·  back to Butlr  ·  AI-gather fixes: hermes3:8b model (0.3s warm vs 6.9s qwen3:1 4d03f06 →