VocalizeCloud Operator Runbook

Open the console →

Internal reference for running the AI voice receptionist. Covers onboarding a number, the transfer policy, delivery contracts, billing, and what to do when a call goes wrong.

1 · How it works

A caller dials a client's business number. Twilio hits POST /voice, which returns TwiML that opens a bidirectional Media Stream to /media-stream. The engine bridges that stream to the OpenAI Realtime API — the caller talks to the AI live. Both legs are G.711 μ-law @ 8 kHz, so audio passes through with no resampling.

The AI hears the caller, speaks back, and can call two tools: capture_lead (record name/number/reason) and transfer_to_human (request a live handoff — allowed or denied by your policy, not the model). On hangup the call's capture + transcript are delivered, and usage is metered.

Brand-neutral by design. The caller only ever hears the client's business name — never "VocalizeCloud", "Twilio", or "OpenAI". The product name lives here in the operator tooling, not on the phone.
PieceWhat
HostRailway — service vc, vc-production-517e.up.railway.app
Consoleconsole.worldvc.business (and /console)
TelephonyTwilio (YoreVox account), Media Streams
AIOpenAI Realtime API
StorePostgres (Railway) — agent_config, call_capture, call_usage

2 · Onboard a number

Everything is per-number and hot-swappable — a config change takes effect on the next call to that number, no redeploy.

  1. In Twilio, point the number's Voice webhook at https://vc-production-517e.up.railway.app/voice (HTTP POST).
  2. Open the console, paste the ADMIN_TOKEN, click Connect.
  3. + New, then fill the number's card:
FieldNotes
NumberE.164, e.g. +19205330466. This is the primary key and the billable client.
Business nameWhat the caller hears. "Northside Dental", not a product name.
VoiceOpenAI voice — sage (default), alloy, echo, shimmer, coral, ballad, verse.
GreetingThe exact first line the AI speaks. Blank = a sensible default.
InstructionsPersona & behavior. Blank = default that tells the AI to capture leads. Add domain rules here (hours, services, what to never promise).
Handoff SMSE.164 that receives a text when a lead is captured. Blank = off.
Transfer numberThe human's phone. Presence of this is what offers transfer to the AI at all.

Click Save. Call the number to verify.

3 · Transfer policy

The AI can request a transfer; the engine decides whether to allow it via evaluateTransfer(). You control this per number — the model never gets the final say.

ModeBehavior
alwaysAny transfer request connects the caller to the human number.
neverAI only. Requests are denied; the AI takes a message instead.
rulesAllowed only when the rules below pass. This is the nuanced mode.
(blank)No explicit policy — falls back to rules defaults.

Rules-mode fields

  • Business hours — timezone (e.g. Asia/Manila), start/end (09:0018:00), and days. Outside hours → denied, message taken. Hours are evaluated in the given timezone via Intl.DateTimeFormat.
  • Caller asks for a person — allow when the trigger is a direct request.
  • Urgent matters — allow when urgency is high.
  • Trigger keywords — comma-separated (emergency, complaint). If the trigger matches, allow.
When a transfer is allowed, the AI tells the caller it's connecting them, then after ~3.5 s the live call is redirected (Twilio call-update to <Dial>). The capture collected so far is delivered before the handoff, so nothing is lost.
If the human doesn't answer (timeout="20"), Twilio falls to /voice/transfer-fallback: the caller hears an apology and can leave a recorded voicemail. So "transfer" degrades gracefully to "message" — it never dead-ends.

4 · Capture delivery

On hangup, deliverCapture() fires all three channels in parallel (Promise.allSettled — one failing never blocks the others):

ChannelDetail
Postgrescall_capture row — structured lead + full transcript. Always on (when a DB is configured). This backs the Captures tab.
SMSSent from the client's own Twilio number to the Handoff SMS number. Blank = off.
WebhookSigned POST to the configured URL (Nexus/workspace ingest). Blank = off.

Webhook contract

POST JSON: { callSid, to, from, lead, transcript }. If a webhook secret is set, the request carries x-vc-signature: sha256=<HMAC-SHA256 of the raw body>. Verify it before trusting the payload.

signature = "sha256=" + hmac_sha256(secret, rawRequestBody)
// constant-time compare against the x-vc-signature header

5 · Usage & billing

Every call writes a call_usage row attributed to the called number (= the client). It records call seconds and the AI input_tokens/output_tokens summed from each response.done.

The Usage tab (and GET /admin/usage) rolls this up per number for the current month, or ?month=YYYY-MM. Minutes are rounded up per call (billing convention); tokens are raw sums.

Local metering is live now. Forwarding these rows to a control-plane /usage endpoint (so metered spend lands on a tenant invoice) is the marked TODO in usage.ts — wire it when the control plane runs and numbers map to tenants.

6 · Call forwarding (GSM)

To route an existing mobile line into a VocalizeCloud number, use the carrier's call-forwarding codes from the handset:

CodeAction
**21*<number>#Forward all calls unconditionally.
*#21#Check unconditional-forwarding status.
##21#Cancel unconditional forwarding.
**61*<number>#Forward on no-answer.
Gotcha seen in testing: on an unregistered PH SIM, **21*…# can return "Setting Registration Failed" and international forwarding may be blocked carrier-side (Globe). That's a carrier limitation, not an engine fault — register the SIM or forward to a domestic number first.

7 · Environment (Railway)

VarPurpose
OPENAI_API_KEYRealtime API. Without it the media socket closes immediately.
DATABASE_URLPostgres. Unset = env-only mode (no store, no console data).
ADMIN_TOKENBearer token for /admin/* and the console login. Timing-safe compared.
TWILIO_ACCOUNT_SIDFor outbound SMS + call-update (transfer).
TWILIO_AUTH_TOKENTwilio auth + inbound signature validation. Never paste in chat — set it directly in Railway.
PUBLIC_BASE_URLPublic https base; used to build the wss:// stream URL and callback URLs.
OPENAI_REALTIME_MODELModel id. preview in the name switches to the preview API shape.
TWILIO_VALIDATESet false to disable signature checks (debug only). Absent + auth token present = validation on.

Health: GET /healthz{ ok, realtime, configStore, admin }. All-green means the key, Postgres, and admin token are all set.

8 · Troubleshooting

SymptomLikely cause → fix
Call connects then drops/healthz shows missing OPENAI_API_KEY → set the key in Railway.
Connected but silentHistorically two causes, both fixed: greeting sent before the session's audio format applied, and a GA vs preview event-name mismatch. If it recurs, check logs for [openai] receiving audio — absence means the model side never produced audio.
403 on /voiceTwilio signature mismatch. Confirm PUBLIC_BASE_URL matches the exact webhook URL Twilio calls (scheme + host), or the proxied host headers line up.
No SMS deliveredCheck the client number can send SMS in Twilio; PH delivery can be finicky. The DB capture still lands — SMS is best-effort.
Transfer never ringsConfirm a transfer number is set and the policy allows it at call time (mode/hours). Logs show [transfer] … ALLOW/DENY with the reason.
Console shows no dataconfigStore is env-only → no DATABASE_URL. The console reads the store; without a DB there's nothing to list.

9 · Admin API

All routes take Authorization: Bearer <ADMIN_TOKEN>. The console is a UI over exactly these.

RouteDoes
GET /admin/agentsList every configured number.
PUT /admin/agents/:numberCreate or update a number's config (E.164). Takes effect next call.
DELETE /admin/agents/:numberRemove a number's config.
GET /admin/captures[?to=]Last 50 captures, optionally filtered to one number.
GET /admin/usage[?month=YYYY-MM]Per-number monthly rollup (calls, minutes, tokens).
curl -sX PUT https://vc-production-517e.up.railway.app/admin/agents/+19205330466 \
  -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
  -d '{"businessName":"Northside Dental","greeting":"Thanks for calling Northside Dental!",
       "transferNumber":"+63917...","notifySms":"+63917...",
       "transferPolicy":{"mode":"always"}}'