BestDealOn

Handbook › Callbots

How Callbots works

The pieces

PieceRoleWhere in code
TwilioThe only carrier today. It owns the phone number. An Elastic SIP Trunk sends each call to xAI. Twilio also handles SMS, the voicemail fallback, and outbound owner calls.DEPLOY.md:12,33; src/twilio.js
xAI Grok Voice (Direct SIP)Speech-to-speech AI. It answers the call, calls our webhook, then talks to our server over a websocket.src/xai.js:2-6; src/runtime.js:69
xAI text modelWrites the SMS answers. XAI_TEXT_MODEL defaults to grok-4.7.src/runtime.js:98; render.yaml
RenderHosts the Node 22 web service, with a 1 GB disk at /var/data.render.yaml
ResendSends email: links for callers, and messages for owners.src/email.js:68 [bdo]
Admin dashboard/admin, behind an emailed login link.src/admin.js; src/app.js:309+

Other carriers (Telnyx, Vonage, and so on) are planned (later beta or alpha). The code has no adapter for them. Every carrier path today is Twilio.

Inbound call

  1. Twilio receives the call and sends it over the SIP trunk to sip:+1NUMBER@sip.voice.x.ai;transport=tls (DEPLOY.md:33).
  2. xAI posts a signed realtime.call.incoming event to POST /xai/webhook (src/app.js:528 [bdo]).
  3. The server looks up the tenant by the dialed To number. It checks the signature against that tenant's xai_webhook_secret. The check is Standard-Webhooks HMAC with replay protection (src/xai.js:16-30; src/app.js:64-72). A bad signature or an unknown number gets a 401.
    • The code comment (src/xai.js:10-12) still says "ASSUMED… UNCONFIRMED". Checked Nov 3: the code computes Standard Webhooks HMAC-SHA256 over id.timestamp.body, base64, header v1,<sig> (space-separated list), whsec_ secret base64-decoded, 300 s tolerance plus replay check. The xAI SIP page (docs) names only the three headers; xAI documents this exact Standard Webhooks format for Grok webhooks (docs). Real calls to (727) 632-0002 pass this check in production (verify-by-call works live). Status: matches in practice; not stated explicitly on the SIP page, so the code comment can stay until xAI documents it there.
  4. If the call carries a valid one-time owner token, the server starts an owner briefing session instead (src/app.js:79-94).
  5. Otherwise it reads caller ID from the SIP headers, in this order: P-Asserted-Identity, then From, then Remote-Party-ID (src/xai.js:37-55). It records consent as inbound (src/app.js:96).
  6. It opens wss://api.x.ai/v1/realtime?call_id=… (src/runtime.js:69) and sends session.update. That message carries the voice, the instructions, and the tools (src/xai.js:120). Then it sends a force_message greeting (src/xai.js:140).
  7. Grok calls tools during the call. The server runs them (src/tools.js:44,54). When the call ends, it writes the call log and a call record (src/app.js:100-110).

Tools in the voice session: kb_answer, note_unanswered, website_lookup, send_link, send_link_sms, take_message, end_call, transfer_to_human (src/tools.js:44).

  • If email is on, it adds set_email_address and send_email (src/tools.js:53 [bdo]).
  • If texting is on, it adds set_text_number (src/tools.js:48).
  • On [bdo] with the claim flag on, it adds the Best Deal On claim tools (see verify-by-call.md).
Diagram 1 (source below)
Diagram source (Mermaid)
flowchart TD
  A[Caller dials business number] --> B[Twilio number]
  B -->|Elastic SIP trunk| C[xAI Grok Voice]
  C -->|POST /xai/webhook signed| D{Tenant for To number<br/>and signature OK?}
  D -- no --> X[401, call not handled]
  D -- yes --> E{Owner token?}
  E -- yes --> O[Owner briefing session]
  E -- no --> F[Caller ID from SIP headers<br/>record consent: inbound]
  F --> G[Open realtime websocket<br/>session.update + greeting]
  G --> H{What does the caller want?}
  H -->|question| K[kb_answer / website_lookup]
  K -->|no answer| N[note_unanswered + offer message]
  H -->|link| L{Texting on?}
  L -- yes --> S[send_link_sms]
  L -- no --> M[send_email if email on]
  H -->|a person| T{transfer_target set,<br/>in hours if required?}
  T -- yes --> R[refer via xAI /refer]
  T -- no / failed --> TM[take_message]
  H -->|leave message| TM
  H -->|done| Z[end_call → call log + record]

If Grok is unavailable (voicemail fallback)

POST /twilio/voice returns TwiML. It plays a <Say> disclosure, then records a voicemail (src/app.js:56-62,124-129). POST /twilio/voice/recording saves it and notifies the owner (src/app.js:131-142). Every Twilio webhook is signature-checked first.

  • [bdo]: if take_message.record_audio is false, no audio is recorded (the tests are in commit a20587e).

Taking a message and the email fallback

Diagram 2 (source below)
Diagram source (Mermaid)
flowchart TD
  A[Caller wants to leave a message] --> B[take_message: name, callback, message]
  B --> C[notify owner outbox]
  C --> D{take_message.deliver == email?}
  D -- no --> E[saved only]
  D -- yes --> F[Email owner via Resend<br/>from brand.email_from]
  F --> G{PA_EMAIL_ALLOW_ONLY set?}
  G -- yes, not on list --> H[blocked: scripted line]
  G -- no / allowed --> I[sent]
  • Code: src/tools.js:93-97 [bdo].
  • The allowlist guard is at src/email.js:10,63 [bdo]. When the caller confirms their email address on this call, that address is allowed (commit 93fc899).
  • Note: while PA_EMAIL_ALLOW_ONLY is set, owner mail to support@bestdealon.app is blocked unless that address is on the list. Removing the guard needs John's OK.

Texting gate

See texting.md.

Transfers

  • transfer_to_human works only on voice calls.
  • It needs transfer_target (tel:+E164 or sip:).
  • It refuses when the caller is the transfer target. If transfer_hours_only is set, it also refuses after hours.
  • It calls POST /v1/realtime/calls/{id}/refer (src/tools.js:85-91 [bdo]; src/xai.js:142-146 [bdo]).
  • If the transfer fails, the bot takes a message.

Owner briefing

  • The module is at src/owner/. It does nothing unless owner_briefing.enabled === true (src/owner/index.js:2-3).
  • Features: a daily call at 18:00 (America/New_York by default), emergency alerts, an on-demand "what happened today", and a transfer fallback.
  • Settings and defaults: src/owner/config.js:6-10.
  • Best Deal On: not configured (owner_briefing absent in the bestdealon tenant), so it is off.

Admin dashboard

  • Sign-in: /admin/login emails a link to /admin/auth#t=…. That sets a session cookie, and forms are CSRF-protected (src/app.js:341-371 [bdo]).
  • Pages:
    • /admin: calls, newsletter, and the channel toggles (src/admin.js:138-147).
    • /admin/channels: texting and email on/off.
    • /admin/outbound
    • /admin/subscribers
    • /admin/usage
    • /admin/messages
  • CSV exports: /admin/calls.csv, /admin/newsletter.csv, /admin/kb-gaps.csv, /admin/messages.csv (src/app.js:473-483 [bdo]).
  • The API uses Authorization: Bearer with PA_ADMIN_TOKEN:
    • /admin/status
    • /admin/tenants[/<id>/(status|approvals|config|kb-bundle|sms|…)] (src/app.js:191 [bdo])
  • [bdo] Owner API: /api/owner/v1/*. It is read-only and per business, and it serves the Best Deal On Bot extension (src/ownerapi.js; docs/OWNER_API.md).

Health and shutdown drain

  • GET /health returns ok, the tenant count, active_calls, and mode (src/app.js:520 [bdo]).
  • Main today: on SIGTERM the server stops accepting connections and force-exits after 10 s (src/shutdown.js:9-11). A deploy can cut off a live call.
  • PR #42, open and unmerged: the server drains. It refuses new calls with a 503 ([PR #42] src/app.js:83), and /health returns 503 with draining:true (:496). It waits until there are no active calls, up to PA_DRAIN_MAX_MS (default 280000) ([PR #42] src/shutdown.js:6-50). It adds maxShutdownDelaySeconds to render.yaml.
Diagram 3 (source below)
Diagram source (Mermaid)
flowchart TD
  A[Render sends SIGTERM on deploy] --> B{Code version}
  B -- main today --> C[server.close<br/>exit after 10 s even if a call is live]
  B -- PR #42 --> D[draining=true<br/>/health 503, new calls 503]
  D --> E{active calls == 0?}
  E -- yes, server closed or 10 s idle --> F[exit 0 'idle']
  E -- no --> G{waited >= PA_DRAIN_MAX_MS?}
  G -- no --> E
  G -- yes --> H[exit 0 'cap']

Render

  • Blueprint (render.yaml):
    • web service, Node runtime, starter plan, virginia region
    • build: npm test; start: node bin/server.js
    • health check: /health
    • 1 GB disk at /var/data; DATA_DIR=/var/data, TENANTS_DIR=/var/data/tenants
  • PA_MODE is demo unless it is set to live (bin/server.js:10). Live mode refuses to start without PUBLIC_BASE_URL, PA_ADMIN_TOKEN, PA_STATE_SECRET, PA_MASTER_KEY, XAI_API_KEY and DATA_DIR (src/runtime.js:19,36).
  • Services:
    • Best Deal On: callbots-bdo (deploy/render.yaml [bdo])
  • Live (checked Nov 3): callbots-bdo srv-dbl01inf3r2c7382v4c0 runs ee5b1f2. Its TENANTS_DIR points at the repo copy deploy/bestdealon/tenants (not the disk), so the tenant file in git is the live config.