[object Object]

← back to Butlr

snapshot: 5 file(s) changed, +2 new, ~3 modified

fde778ef8196acf2290ebaa29e4ac8932978b0f8 · 2026-05-13 08:57:54 -0700 · Steve

Files touched

Diff

commit fde778ef8196acf2290ebaa29e4ac8932978b0f8
Author: Steve <steve@designerwallcoverings.com>
Date:   Wed May 13 08:57:54 2026 -0700

    snapshot: 5 file(s) changed, +2 new, ~3 modified
---
 lib/twilio.js           |  22 ++++++-
 lib/vapi.js             | 152 ++++++++++++++++++++++++++++++++++++++++++++++++
 routes/listen.js        |  45 ++++++++++++--
 routes/vapi-webhooks.js |  88 ++++++++++++++++++++++++++++
 server.js               |   3 +
 5 files changed, 303 insertions(+), 7 deletions(-)

diff --git a/lib/twilio.js b/lib/twilio.js
index c6b7b43..d5f2f03 100644
--- a/lib/twilio.js
+++ b/lib/twilio.js
@@ -229,7 +229,27 @@ async function tick() {
       if (!full) continue;
 
       if (full.status === 'queued') {
-        await dialCall(full);
+        // Backend dispatch — Vapi is the production conversational stack;
+        // Twilio remains as the fallback for legacy Twilio-only flows.
+        const backend = (process.env.BUTLR_BACKEND || 'vapi').toLowerCase();
+        if (backend === 'vapi') {
+          try {
+            const vapi = require('./vapi');
+            const r = await vapi.dialCall(full);
+            if (r.ok) {
+              data.updateStatus(full.id, 'dialing');
+              if (data.patchRow) data.patchRow(full.id, { vapi_call_id: r.vapi_call_id });
+            } else {
+              log('vapi dial failed, falling back to Twilio:', r.error, r.detail);
+              await dialCall(full);
+            }
+          } catch (e) {
+            log('vapi dispatch exception, falling back to Twilio:', e.message);
+            await dialCall(full);
+          }
+        } else {
+          await dialCall(full);
+        }
       } else if (DRY_RUN && ['dialing','on_hold','connected'].includes(full.status)) {
         await advanceDryRun(full);
       }
diff --git a/lib/vapi.js b/lib/vapi.js
new file mode 100644
index 0000000..df398b3
--- /dev/null
+++ b/lib/vapi.js
@@ -0,0 +1,152 @@
+// Vapi outbound dialer — alternative backend to lib/twilio.js.
+//
+// Flow:
+//   1) Butlr's wizard adds a call row via data.addCall(body, userId)
+//   2) Worker tick (in lib/twilio.js or this module) picks up queued rows
+//   3) dialCall(row) POSTs to https://api.vapi.ai/call/phone with assistantId
+//      + phoneNumberId + customer.number + assistantOverrides.firstMessage +
+//      assistantOverrides.model.messages (system) injecting the per-call goal
+//   4) Vapi handles the actual outbound dial, speech-to-speech, IVR navigation,
+//      and emits events to the serverUrl configured on the assistant
+//      (currently https://butlr.agentabrams.com/vapi/webhook)
+//
+// All env vars come from the secrets-manager fan-out:
+//   VAPI_API_KEY            — server-side (Bearer)
+//   VAPI_ASSISTANT_ID       — the Butlr v1 assistant
+//   VAPI_PHONE_NUMBER_ID    — Steve's Twilio +1-606-713-0489 imported to Vapi
+//   VAPI_VOICE_ID           — Steve's ElevenLabs IVC clone (Xa9qV4wNbvSkdUWsYLzq)
+
+const VAPI_BASE = 'https://api.vapi.ai';
+
+function log(...args) { console.log('[vapi]', new Date().toISOString(), ...args); }
+
+function authHeader() {
+  const key = process.env.VAPI_API_KEY;
+  if (!key) throw new Error('VAPI_API_KEY missing');
+  return { Authorization: 'Bearer ' + key, 'Content-Type': 'application/json' };
+}
+
+// Build the per-call goal override that Vapi appends on top of the assistant's
+// baked-in system prompt. Keeps the assistant generic; each call gets a goal.
+function goalOverride(call) {
+  const callbackName = call.callback_name || 'a customer';
+  const callbackPhone = call.callback_phone || 'unknown';
+  const acctLast4 = (call.account_number || '').slice(-4);
+  return {
+    firstMessage: `Hi, this is an automated assistant calling on behalf of ${callbackName}. This call may be recorded for quality.`,
+    model: {
+      // Provider/model REQUIRED by Vapi assistantOverrides — even though we just
+      // want to append a system message, the override replaces the whole model
+      // block. So we re-state the assistant's defaults here.
+      provider: 'openai',
+      model: 'gpt-4o-mini',
+      temperature: 0.3,
+      messages: [
+        {
+          role: 'system',
+          content: [
+            `GOAL FOR THIS CALL: ${call.goal}`,
+            ``,
+            `Calling on behalf of: ${callbackName}`,
+            `Callback phone (if rep asks): ${callbackPhone}`,
+            `Account last-4 (if rep asks AND only the last 4): ${acctLast4 || 'not provided'}`,
+            ``,
+            `As soon as the goal is satisfied (rep gives the answer, IVR confirms it, etc.) call the endCall tool with the answer in the reason field. Don't keep talking after you've got what you came for.`,
+          ].join('\n'),
+        },
+      ],
+      tools: [
+        { type: 'dtmf' },
+        { type: 'endCall' },
+      ],
+    },
+  };
+}
+
+// Place an outbound call via Vapi. Returns { ok, vapi_call_id, customer_number }
+// or { ok: false, error, status, detail }.
+async function dialCall(call) {
+  const assistantId = process.env.VAPI_ASSISTANT_ID;
+  const phoneNumberId = process.env.VAPI_PHONE_NUMBER_ID;
+  if (!assistantId || !phoneNumberId) {
+    return { ok: false, error: 'missing_assistant_or_phone_id' };
+  }
+  if (!call.business_phone) return { ok: false, error: 'no_business_phone' };
+
+  const body = {
+    assistantId,
+    phoneNumberId,
+    customer: { number: call.business_phone, name: call.business_name || '' },
+    assistantOverrides: goalOverride(call),
+    metadata: {
+      butlr_call_id: call.id,
+      butlr_business_name: call.business_name,
+      butlr_user_id: call.user_id || null,
+    },
+  };
+
+  try {
+    const r = await fetch(VAPI_BASE + '/call/phone', {
+      method: 'POST',
+      headers: authHeader(),
+      body: JSON.stringify(body),
+    });
+    if (!r.ok) {
+      const text = await r.text().catch(() => '');
+      log('LIVE dial FAIL', { call_id: call.id, status: r.status, body: text.slice(0, 300) });
+      return { ok: false, error: 'vapi_http_' + r.status, detail: text.slice(0, 300) };
+    }
+    const j = await r.json();
+    log('LIVE dial placed', { call_id: call.id, vapi_id: j.id, to: call.business_phone });
+    return { ok: true, vapi_call_id: j.id, customer_number: call.business_phone };
+  } catch (e) {
+    log('LIVE dial exception', { call_id: call.id, error: e.message });
+    return { ok: false, error: 'vapi_exception', detail: e.message };
+  }
+}
+
+// Live-takeover — push a one-shot say into an active Vapi call. Uses Vapi's
+// POST /call/{id}/control endpoint (control verb: 'say').
+async function sayOnCall(vapiCallId, text) {
+  if (!vapiCallId || !text) return { ok: false, error: 'missing_args' };
+  try {
+    const r = await fetch(VAPI_BASE + '/call/' + vapiCallId + '/control', {
+      method: 'POST',
+      headers: authHeader(),
+      body: JSON.stringify({ type: 'say', message: String(text).slice(0, 500), endCallAfterSpoken: false }),
+    });
+    return { ok: r.ok, status: r.status };
+  } catch (e) {
+    return { ok: false, error: 'control_exception', detail: e.message };
+  }
+}
+
+// Live-takeover hangup — end the active Vapi call now.
+async function endCall(vapiCallId, reason = 'owner ended call from listen page') {
+  if (!vapiCallId) return { ok: false, error: 'missing_call_id' };
+  try {
+    const r = await fetch(VAPI_BASE + '/call/' + vapiCallId + '/control', {
+      method: 'POST',
+      headers: authHeader(),
+      body: JSON.stringify({ type: 'end-call', reason }),
+    });
+    return { ok: r.ok, status: r.status };
+  } catch (e) {
+    return { ok: false, error: 'control_exception', detail: e.message };
+  }
+}
+
+// Fetch the recording URL for a completed Vapi call. Used by listen page
+// archive-section when the call.status flips to done/failed and Vapi has
+// finished post-call processing (~30s after hangup).
+async function getRecording(vapiCallId) {
+  if (!vapiCallId) return null;
+  try {
+    const r = await fetch(VAPI_BASE + '/call/' + vapiCallId, { headers: authHeader() });
+    if (!r.ok) return null;
+    const j = await r.json();
+    return j.recordingUrl || (j.artifact && j.artifact.recordingUrl) || null;
+  } catch { return null; }
+}
+
+module.exports = { dialCall, sayOnCall, endCall, getRecording };
diff --git a/routes/listen.js b/routes/listen.js
index f267c2a..5bed050 100644
--- a/routes/listen.js
+++ b/routes/listen.js
@@ -41,16 +41,39 @@ router.get('/api/calls/:call_id/transcript', (req, res) => {
   res.json({ ok: true, transcript: t });
 });
 
-router.get('/api/calls/:call_id/recording', (req, res) => {
+router.get('/api/calls/:call_id/recording', async (req, res) => {
   const callId = req.params.call_id;
   if (!/^[A-Za-z0-9_-]{6,16}$/.test(callId)) return res.status(404).end();
-  const call = data.getCall(callId, req.user && req.user.id, false);
+  const call = data.getCall(callId, req.user && req.user.id, true);
   if (!call) return res.status(404).end();
+  // Local Twilio-archive path
   const fp = path.join(transcribe.RECORDINGS_DIR, `${callId}.mp3`);
-  if (!fs.existsSync(fp)) return res.status(404).end();
-  res.type('audio/mpeg');
-  res.setHeader('Content-Disposition', `inline; filename="${callId}.mp3"`);
-  fs.createReadStream(fp).pipe(res);
+  if (fs.existsSync(fp)) {
+    res.type('audio/mpeg');
+    res.setHeader('Content-Disposition', `inline; filename="${callId}.mp3"`);
+    return fs.createReadStream(fp).pipe(res);
+  }
+  // Vapi-backed call — fetch recording URL from Vapi and stream it through
+  if (call.vapi_call_id) {
+    try {
+      const recordingUrl = await require('../lib/vapi').getRecording(call.vapi_call_id);
+      if (recordingUrl) {
+        const r = await fetch(recordingUrl);
+        if (r.ok) {
+          res.type(r.headers.get('content-type') || 'audio/mpeg');
+          res.setHeader('Content-Disposition', `inline; filename="${callId}.mp3"`);
+          const reader = r.body.getReader();
+          while (true) {
+            const { done, value } = await reader.read();
+            if (done) break;
+            res.write(value);
+          }
+          return res.end();
+        }
+      }
+    } catch (e) { console.error('[recording] vapi fetch', e.message); }
+  }
+  return res.status(404).end();
 });
 
 // Cheap existence probes for /calls list inline chips. Return
@@ -91,6 +114,12 @@ router.post('/api/calls/:call_id/say', express.json(), (req, res) => {
   if (!text) return res.status(400).json({ ok: false, error: 'no_text' });
   callOverrides.set(callId, { sayText: text });
   console.log(`[takeover] say queued for ${callId}: "${text.slice(0, 80)}"`);
+  // If this call was placed via Vapi, push the say directly to the live call.
+  // Falls through silently if no vapi_call_id (Twilio-backend call).
+  if (call.vapi_call_id) {
+    try { require('../lib/vapi').sayOnCall(call.vapi_call_id, text); }
+    catch (e) { console.error('[takeover] vapi sayOnCall:', e.message); }
+  }
   res.json({ ok: true });
 });
 
@@ -102,6 +131,10 @@ router.post('/api/calls/:call_id/hangup', (req, res) => {
   if (!call) return res.status(404).json({ ok: false });
   callOverrides.set(callId, { hangup: true });
   console.log(`[takeover] hangup queued for ${callId}`);
+  if (call.vapi_call_id) {
+    try { require('../lib/vapi').endCall(call.vapi_call_id, 'owner ended call'); }
+    catch (e) { console.error('[takeover] vapi endCall:', e.message); }
+  }
   res.json({ ok: true });
 });
 
diff --git a/routes/vapi-webhooks.js b/routes/vapi-webhooks.js
new file mode 100644
index 0000000..c8313f2
--- /dev/null
+++ b/routes/vapi-webhooks.js
@@ -0,0 +1,88 @@
+// Vapi webhook receiver — Vapi POSTs all call events here when the assistant's
+// serverUrl is set to https://butlr.agentabrams.com/vapi/webhook.
+//
+// Events we care about:
+//   - status-update      → maps to Butlr call.status (dialing/connected/done/failed)
+//   - speech-update      → live transcript turn (rep + AI), pushed to agent-log
+//   - tool-call          → AI invoked dtmf or endCall — log it
+//   - end-of-call-report → final summary with recordingUrl, transcript, summary
+//   - hang               → quick hangup signal
+//
+// Vapi metadata.butlr_call_id is the bridge back to our internal call row.
+
+const express = require('express');
+const fs = require('fs');
+const path = require('path');
+const data = require('../lib/data');
+
+const router = express.Router();
+
+const AGENT_LOG_DIR = path.join(__dirname, '..', 'data', 'agent-log');
+fs.mkdirSync(AGENT_LOG_DIR, { recursive: true });
+
+function appendAgentLog(callId, turn) {
+  if (!callId) return;
+  const fp = path.join(AGENT_LOG_DIR, `${callId}.json`);
+  let arr = [];
+  try { arr = JSON.parse(fs.readFileSync(fp, 'utf8')); } catch {}
+  arr.push({ ...turn, at: new Date().toISOString() });
+  try { fs.writeFileSync(fp, JSON.stringify(arr, null, 0)); } catch (e) { console.error('[vapi-wh] log write', e.message); }
+}
+
+router.post('/webhook', express.json({ limit: '256kb' }), (req, res) => {
+  // Vapi wraps each event in {message: {...}}. We just unpack.
+  const msg = (req.body && req.body.message) || req.body || {};
+  const type = msg.type;
+  const callObj = msg.call || {};
+  const vapiCallId = callObj.id;
+  const butlrCallId = (callObj.metadata && callObj.metadata.butlr_call_id) || null;
+
+  if (type === 'status-update') {
+    const s = msg.status || msg.callStatus;
+    let internal = null;
+    if (s === 'queued' || s === 'ringing') internal = 'dialing';
+    else if (s === 'in-progress') internal = 'connected';
+    else if (s === 'ended' || s === 'completed') internal = 'done';
+    else if (['busy', 'no-answer', 'failed'].includes(s)) internal = 'failed';
+    if (butlrCallId && internal) {
+      try { data.updateStatus(butlrCallId, internal); } catch (e) { console.error('[vapi-wh] updateStatus', e.message); }
+    }
+    console.log(`[vapi-wh] status=${s} → ${internal || 'noop'} call=${butlrCallId} vapi=${vapiCallId}`);
+  }
+
+  else if (type === 'transcript' || type === 'speech-update') {
+    const role = msg.role || msg.transcriptType || 'unknown';
+    const text = msg.transcript || msg.transcriptDelta || '';
+    const isFinal = msg.transcriptType === 'final' || msg.isFinal;
+    if (text && isFinal && butlrCallId) {
+      appendAgentLog(butlrCallId, { kind: role === 'assistant' ? 'said' : 'heard', text, role });
+      console.log(`[vapi-wh] transcript role=${role} call=${butlrCallId}: "${text.slice(0, 80)}"`);
+    }
+  }
+
+  else if (type === 'tool-calls' || type === 'function-call') {
+    const tools = msg.toolCallList || msg.functionCall ? [msg.functionCall].filter(Boolean) : [];
+    for (const t of (msg.toolCallList || [])) {
+      console.log(`[vapi-wh] tool-call call=${butlrCallId} ${t.function && t.function.name}(${JSON.stringify(t.function && t.function.arguments)})`);
+      if (butlrCallId) appendAgentLog(butlrCallId, { kind: 'tool', tool: t.function && t.function.name, args: t.function && t.function.arguments });
+    }
+  }
+
+  else if (type === 'end-of-call-report') {
+    const recordingUrl = msg.recordingUrl || (msg.artifact && msg.artifact.recordingUrl);
+    const summary = msg.summary || '';
+    const transcript = msg.transcript || '';
+    if (butlrCallId) {
+      try {
+        if (data.setRecordingMeta && recordingUrl) data.setRecordingMeta(butlrCallId, { recording_url: recordingUrl, recording_sid: null });
+        if (data.setTranscriptMeta && transcript) data.setTranscriptMeta(butlrCallId, { transcript_path: `vapi:${vapiCallId}` });
+        appendAgentLog(butlrCallId, { kind: 'end-of-call', summary, recordingUrl: recordingUrl || null });
+      } catch (e) { console.error('[vapi-wh] eoc save', e.message); }
+    }
+    console.log(`[vapi-wh] end-of-call call=${butlrCallId} duration=${msg.durationSeconds || msg.duration}s rec=${recordingUrl ? 'yes' : 'no'}`);
+  }
+
+  res.json({ ok: true });
+});
+
+module.exports = router;
diff --git a/server.js b/server.js
index 3beda4d..5e7e8dd 100644
--- a/server.js
+++ b/server.js
@@ -11,6 +11,7 @@ const publicRoutes = require('./routes/public');
 const listenRoutes = require('./routes/listen');
 const agentLogRoutes = require('./routes/agent-log');
 const twilioWebhooks = require('./routes/twilio-webhooks');
+const vapiWebhooks = require('./routes/vapi-webhooks');
 const adminRoutes = require('./routes/admin');
 const { attachListenBridge } = require('./lib/listen-bridge');
 const { requireOwner, softAuth, mountLogin } = require('./lib/owner-auth');
@@ -117,6 +118,8 @@ app.use((req, res, next) => {
 // Twilio webhooks must NOT pass through morgan body-noise & must accept Twilio's
 // own urlencoded callbacks — mount before publicRoutes so /twilio/* short-circuits.
 app.use('/twilio', twilioWebhooks);
+// Vapi webhooks — assistant serverUrl is https://butlr.agentabrams.com/vapi/webhook.
+app.use('/vapi', vapiWebhooks);
 
 // Owner-auth: /login + /logout (always public). Then hard-gate every route
 // that exposes call data so no future route can leak by accident.

← bf42377 call-quality-report: one-shot 7-check report card for any ca  ·  back to Butlr  ·  test: 11/11 unit test for routes/vapi-webhooks.js f05df40 →