← back to Butlr
lib/vapi.js
224 lines
// 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', // upgraded from gpt-4o-mini 2026-05-21 — mini ignores anti-endCall rules
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'}`,
``,
`═══════════════════ HARD RULES ═══════════════════`,
``,
`1. AD / PROMO / MARKETING AUDIO → PRESS 0 IMMEDIATELY via the dtmf tool.`,
` These are NEVER an answer to the goal — they're commercials. The MOMENT you recognize`,
` the audio is promotional, call the dtmf tool with digit "0" to escape to a human.`,
` Examples of promo audio to press 0 on instantly:`,
` - "try our new ___", "zest sauce", "loaded tots", "choose 2 for $6.99"`,
` - "place your order online at ___.com"`,
` - "have you tried our ___", "thanks for choosing ___" + a sales pitch`,
` - any marketing-voice narration that isn't a direct question to you`,
` Do NOT wait. Do NOT endCall. Do NOT stay silent. Press 0 fast.`,
``,
`2. HOLD = STAY ON THE LINE. Period. No exceptions.`,
` ANY of these phrases means HOLD, not goodbye:`,
` - "Can you hold please?" / "Could you hold?" / "Hold on a moment" / "One sec"`,
` - "Please hold" / "Thank you for holding"`,
` - "We are with another customer" / "All reps are busy" / "Helping other customers"`,
` - Hold music, silence, beeps, ringing-back sounds`,
` - "Thank you for calling [business]" (this is the greeting, NOT the answer to your goal)`,
` When you hear any of the above, say simply "Sure, I'll hold" (or nothing) and then`,
` WAIT SILENTLY for up to 3 minutes. DO NOT call endCall. DO NOT say "Got it. Goodbye".`,
` The call is NOT over just because someone asked you to hold — they are about to come back.`,
``,
`3. ORDER-BOTS that ask "is this pickup or delivery?" or "are you placing an order?":`,
` DO NOT repeat the goal question over and over — that's a useless loop. Instead say`,
` ONCE, clearly: "Neither. I just have a quick question — can I speak to a person please?"`,
` Then go silent and wait. If the bot loops again, press 0 via the dtmf tool to escape`,
` to a human agent.`,
``,
`4. IVR menus ("press 1 for English", "press 2 for hours", etc.): use the dtmf tool to`,
` press the digit that gets you closest to a human or to the answer. If the menu`,
` explicitly offers store hours, press THAT. Otherwise press 0 to reach an operator.`,
``,
`5. endCall CRITERIA — only end the call when EITHER:`,
` (a) A real human gave an EXPLICIT on-topic answer to the goal (e.g. "we close at 11pm",`,
` "we're open until midnight tonight"). Repeat the answer back briefly to confirm,`,
` thank them, then endCall with the answer in the reason field.`,
` (b) You've waited silently for 3+ minutes with no human and only hold/promo audio.`,
` endCall with reason "no human reached after 3 minutes hold".`,
` NEVER endCall just because audio is playing. Audio ≠ answer.`,
``,
`6. NEVER say "Got it. Goodbye" unless a real human just told you the actual answer.`,
``,
`7. MISSING INFO that the rep demands (account number, SSN, DOB, full card #, password, member ID):`,
` Do NOT get stuck saying "I don't have that handy." Instead — pick whichever is better`,
` given the situation:`,
` (a) Offer a best-guess placeholder (e.g. last-4 "1234" or a plausible account # format).`,
` The rep will either accept it or ask for the real one — either way the call moves.`,
` (b) Say: "I don't have that on me right now — Steve will call back with that info`,
` within the hour. Anything else you'd need from him when he calls?"`,
` Then thank the rep and endCall with reason: "need-callback: rep required <field-name>".`,
` Pick (a) when the question is recoverable and (b) when it would block all progress.`,
` Either is acceptable — DO NOT just keep apologizing for missing info.`,
``,
`═════════════════════════════════════════════════`,
].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; }
}
async function transferCall(vapiCallId, destinationNumber) {
if (!vapiCallId || !destinationNumber) return { ok: false, error: 'missing_args' };
try {
const r = await fetch(VAPI_BASE + '/call/' + vapiCallId + '/control', {
method: 'POST',
headers: authHeader(),
body: JSON.stringify({
type: 'transfer',
destination: { type: 'number', number: destinationNumber },
}),
});
return { ok: r.ok, status: r.status };
} catch (e) {
return { ok: false, error: 'control_exception', detail: e.message };
}
}
module.exports = { dialCall, sayOnCall, endCall, transferCall, getRecording };