← back to Butlr
cost-tracker: log Vapi voice_min + Twilio voice_min + SMS to ~/.claude/cost-ledger.jsonl
eb1df5e1255bfd32e64abf241b09dbd54707c5b2 · 2026-05-13 10:40:34 -0700 · SteveStudio2
Per Steve's standing rule — every paid API call appends to the ledger.
- ~/.claude/skills/cost-tracker/pricing.json: new vapi_voice entry at
$0.10/min conservative midpoint (tune from real invoice). Twilio
rates (sms $0.0083 / voice_min $0.014) already present.
- routes/vapi-webhooks.js: on end-of-call-report, spawn cost-tracker
log.js detached with durationSeconds/60 voice_min units. Best-
effort — log failure doesn't affect call data.
- routes/twilio-webhooks.js: on POST /status/<id> with CallStatus=
completed, log the CallDuration as voice_min via twilio.logCost().
- lib/twilio.js: new logCost(api, qty, unit, note) helper. Spawns
detached + unref so SMS send / call hangup return immediately.
sendSms now logs 1 sms unit on successful send.
Smoke-tested: log.js --api vapi_voice --units 1:voice_min logs
$0.100000 and writes the jsonl entry as expected.
Files touched
M lib/twilio.jsA lib/vapi-listen.jsM routes/twilio-webhooks.jsM routes/vapi-webhooks.jsM views/public/listen.ejs
Diff
commit eb1df5e1255bfd32e64abf241b09dbd54707c5b2
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date: Wed May 13 10:40:34 2026 -0700
cost-tracker: log Vapi voice_min + Twilio voice_min + SMS to ~/.claude/cost-ledger.jsonl
Per Steve's standing rule — every paid API call appends to the ledger.
- ~/.claude/skills/cost-tracker/pricing.json: new vapi_voice entry at
$0.10/min conservative midpoint (tune from real invoice). Twilio
rates (sms $0.0083 / voice_min $0.014) already present.
- routes/vapi-webhooks.js: on end-of-call-report, spawn cost-tracker
log.js detached with durationSeconds/60 voice_min units. Best-
effort — log failure doesn't affect call data.
- routes/twilio-webhooks.js: on POST /status/<id> with CallStatus=
completed, log the CallDuration as voice_min via twilio.logCost().
- lib/twilio.js: new logCost(api, qty, unit, note) helper. Spawns
detached + unref so SMS send / call hangup return immediately.
sendSms now logs 1 sms unit on successful send.
Smoke-tested: log.js --api vapi_voice --units 1:voice_min logs
$0.100000 and writes the jsonl entry as expected.
---
lib/twilio.js | 20 ++++++++++++-
lib/vapi-listen.js | 71 +++++++++++++++++++++++++++++++++++++++++++++++
routes/twilio-webhooks.js | 10 +++++++
routes/vapi-webhooks.js | 39 ++++++++++++++++++++++++--
views/public/listen.ejs | 5 +++-
5 files changed, 141 insertions(+), 4 deletions(-)
diff --git a/lib/twilio.js b/lib/twilio.js
index 41e192c..dab1c9d 100644
--- a/lib/twilio.js
+++ b/lib/twilio.js
@@ -135,6 +135,8 @@ async function sendSms({ to, body }) {
log('LIVE SMS failed:', resp.status, json && json.message);
return { ok: false, error: 'sms_failed', status: resp.status, detail: json };
}
+ // Cost ledger — 1 outbound SMS (US rate $0.0083). Per Steve's standing rule.
+ logCost('twilio_sms_us', 1, 'sms', `to=${String(to).slice(-4)}`);
return { ok: true, sid: json.sid, mode: auth.mode };
} catch (e) {
log('LIVE SMS exception:', e.message);
@@ -142,6 +144,22 @@ async function sendSms({ to, body }) {
}
}
+// Append-only cost-ledger emit. Best-effort — never blocks the call path.
+function logCost(api, qty, unit, note) {
+ try {
+ const { spawn } = require('child_process');
+ const homeDir = process.env.HOME || '/root';
+ const child = spawn('node', [
+ `${homeDir}/.claude/skills/cost-tracker/scripts/log.js`,
+ '--api', api,
+ '--units', `${qty}:${unit}`,
+ '--app', 'butlr',
+ '--note', note || '',
+ ], { detached: true, stdio: 'ignore' });
+ child.unref();
+ } catch (e) { log('cost-log spawn:', e.message); }
+}
+
// Send the right SMS for each status transition (only when notify_sms is on).
async function notifyOnStageChange(call, fromStatus, toStatus) {
if (!call.notify_sms || !call.callback_phone) return;
@@ -322,4 +340,4 @@ function start({ intervalMs = 5000 } = {}) {
setTimeout(tick, 500);
}
-module.exports = { start, tick, dialCall, sendSms, DRY_RUN, buildAnnouncement, announceTwiml };
+module.exports = { start, tick, dialCall, sendSms, logCost, DRY_RUN, buildAnnouncement, announceTwiml };
diff --git a/lib/vapi-listen.js b/lib/vapi-listen.js
new file mode 100644
index 0000000..1d6b22d
--- /dev/null
+++ b/lib/vapi-listen.js
@@ -0,0 +1,71 @@
+// Bridge: Vapi per-call listenUrl WebSocket → Butlr listen-bridge.
+//
+// When a Vapi call enters 'in-progress' status, the webhook payload includes
+// call.monitor.listenUrl — a WebSocket that streams mixed-track raw audio for
+// passive monitoring. Vapi default format: PCM 16-bit little-endian at 16 kHz
+// mono. We open a WS to that URL, forward each binary frame to the existing
+// listen-bridge broadcastPcm() so any browser already subscribed to
+// /listen-ws/<butlrCallId> receives the audio identically to the Twilio path.
+//
+// Closes automatically when the call ends (call.ended webhook → detach).
+
+const WebSocket = require('ws');
+const listenBridge = require('./listen-bridge');
+
+const ACTIVE = new Map(); // butlrCallId → { ws, listenUrl, attachedAt }
+
+function attach(callId, listenUrl) {
+ if (!callId || !listenUrl) return { ok: false, error: 'missing_args' };
+ if (ACTIVE.has(callId)) return { ok: true, already_attached: true };
+
+ try {
+ const ws = new WebSocket(listenUrl);
+ ws.binaryType = 'arraybuffer';
+
+ ws.on('open', () => {
+ console.log(`[vapi-listen] WS open call=${callId}`);
+ });
+
+ ws.on('message', (data, isBinary) => {
+ if (!isBinary) return; // Vapi may send JSON control frames; we ignore
+ try {
+ const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
+ listenBridge.broadcastPcm(callId, buf);
+ } catch (e) {
+ // Drop silently — single bad frame shouldn't kill the bridge.
+ }
+ });
+
+ ws.on('close', (code, reason) => {
+ console.log(`[vapi-listen] WS closed call=${callId} code=${code} reason=${reason && reason.toString().slice(0, 80)}`);
+ ACTIVE.delete(callId);
+ });
+
+ ws.on('error', (e) => {
+ console.error(`[vapi-listen] WS error call=${callId}:`, e.message);
+ });
+
+ ACTIVE.set(callId, { ws, listenUrl, attachedAt: Date.now() });
+ return { ok: true };
+ } catch (e) {
+ console.error(`[vapi-listen] attach failed call=${callId}:`, e.message);
+ return { ok: false, error: e.message };
+ }
+}
+
+function detach(callId) {
+ const entry = ACTIVE.get(callId);
+ if (!entry) return { ok: true, was_attached: false };
+ try { entry.ws.close(); } catch {}
+ ACTIVE.delete(callId);
+ return { ok: true, was_attached: true };
+}
+
+function list() {
+ return Array.from(ACTIVE.entries()).map(([callId, e]) => ({
+ callId,
+ age_ms: Date.now() - e.attachedAt,
+ }));
+}
+
+module.exports = { attach, detach, list };
diff --git a/routes/twilio-webhooks.js b/routes/twilio-webhooks.js
index 33707de..bb41028 100644
--- a/routes/twilio-webhooks.js
+++ b/routes/twilio-webhooks.js
@@ -452,6 +452,16 @@ router.post('/status/:call_id', (req, res) => {
// when we switched to the no-Redirect inline-Gather pattern.)
if (cs === 'completed' || ['busy','failed','no-answer','canceled'].includes(cs)) {
CONV.delete(callId);
+ // Cost ledger — Twilio bills per-minute on completed calls. CallDuration
+ // is in seconds. Best-effort log (never blocks the response).
+ const durSec = Number(req.body.CallDuration || 0);
+ if (durSec > 0) {
+ try {
+ const twilio = require('../lib/twilio');
+ const minutes = (durSec / 60).toFixed(3);
+ if (twilio.logCost) twilio.logCost('twilio_voice_us', minutes, 'voice_min', `call=${callId} dur=${durSec}s`);
+ } catch (e) { console.error('[status-cost]', e.message); }
+ }
}
res.type('text/xml').send('<Response/>');
});
diff --git a/routes/vapi-webhooks.js b/routes/vapi-webhooks.js
index 15e3a46..45f2aa4 100644
--- a/routes/vapi-webhooks.js
+++ b/routes/vapi-webhooks.js
@@ -14,6 +14,7 @@ const express = require('express');
const fs = require('fs');
const path = require('path');
const data = require('../lib/data');
+const vapiListen = require('../lib/vapi-listen');
// Owner user_id for autonomously-queued sub-calls. Spawned calls inherit the
// originating user's ownership so the listen page + recording proxy work.
@@ -91,6 +92,19 @@ router.post('/webhook', express.json({ limit: '256kb' }), (req, res) => {
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}`);
+
+ // On call connect, open Vapi's listen-WS bridge so the browser's /listen-ws
+ // for this call_id starts receiving 16 kHz PCM audio frames. On call end,
+ // tear it down. The listenUrl comes from Vapi's webhook payload.
+ if (s === 'in-progress' && butlrCallId) {
+ const listenUrl = (callObj.monitor && callObj.monitor.listenUrl) || msg.listenUrl;
+ if (listenUrl) {
+ const r = vapiListen.attach(butlrCallId, listenUrl);
+ console.log(`[vapi-wh] listen bridge attach call=${butlrCallId}: ${JSON.stringify(r)}`);
+ }
+ } else if ((s === 'ended' || internal === 'done' || internal === 'failed') && butlrCallId) {
+ vapiListen.detach(butlrCallId);
+ }
}
else if (type === 'transcript' || type === 'speech-update') {
@@ -125,14 +139,35 @@ router.post('/webhook', express.json({ limit: '256kb' }), (req, res) => {
const recordingUrl = msg.recordingUrl || (msg.artifact && msg.artifact.recordingUrl);
const summary = msg.summary || '';
const transcript = msg.transcript || '';
+ const durSec = Number(msg.durationSeconds || msg.duration || 0);
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 });
+ appendAgentLog(butlrCallId, { kind: 'end-of-call', summary, recordingUrl: recordingUrl || null, duration_seconds: durSec });
} 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'}`);
+ // ── cost ledger ─────────────────────────────────────────────────────
+ // Append the per-call Vapi voice-min usage to ~/.claude/cost-ledger.jsonl
+ // via the cost-tracker skill (Steve's standing rule). Best-effort —
+ // log failures don't affect call data.
+ if (durSec > 0) {
+ const minutes = (durSec / 60).toFixed(3);
+ const { spawn } = require('child_process');
+ const homeDir = process.env.HOME || '/root';
+ const logScript = `${homeDir}/.claude/skills/cost-tracker/scripts/log.js`;
+ try {
+ const child = spawn('node', [
+ logScript,
+ '--api', 'vapi_voice',
+ '--units', `${minutes}:voice_min`,
+ '--app', 'butlr',
+ '--note', `call=${butlrCallId || vapiCallId} dur=${durSec}s`,
+ ], { detached: true, stdio: 'ignore' });
+ child.unref();
+ } catch (e) { console.error('[vapi-wh] cost-log spawn:', e.message); }
+ }
+ console.log(`[vapi-wh] end-of-call call=${butlrCallId} duration=${durSec}s rec=${recordingUrl ? 'yes' : 'no'}`);
}
res.json({ ok: true });
diff --git a/views/public/listen.ejs b/views/public/listen.ejs
index 0396d1d..faf6b41 100644
--- a/views/public/listen.ejs
+++ b/views/public/listen.ejs
@@ -96,7 +96,10 @@ transcript: data/transcripts/<%= call.id %>.json (post-call)</pre>
<script>
(function() {
const callId = <%- JSON.stringify(call.id) %>;
- const SAMPLE_RATE = 8000;
+ // Twilio Media Streams = 8 kHz mulaw → decoded to PCM16 at 8 kHz.
+ // Vapi listen WS = raw PCM 16-bit at 16 kHz (default Vapi format).
+ // We pick the right AudioContext sample rate per-call so audio doesn't sound chipmunked or sludgey.
+ const SAMPLE_RATE = <%- call.vapi_call_id ? '16000' : '8000' %>;
const btn = document.getElementById('connect-btn');
const stateEl = document.getElementById('ws-state');
const countEl = document.getElementById('frame-count');
← 0dd1395 wizard step 2: DNC pre-check on phone field + directory pres
·
back to Butlr
·
admin/uploads: image + small-file upload UI with public URL f69ec60 →