[object Object]

← 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

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 →