Pular para o conteúdo

Mapa técnico do runtime de voz

Status: canônico · Escopo: Caminho de produção do canal de voz — Vapi, tool-calls, actions, calendário e lembretes · Atualizado em: 2026-08-04

✅ CANÔNICO — este é o caminho de produção do canal primário.

Fonte de verdade de escopo: DIRECTION.md · índice: docs/README.md

Última revisão: 2026-07-28

O que este doc responde: “uma ligação chega — o que acontece, arquivo por arquivo, e onde eu mexo para mudar o comportamento?”

⚠️ A voz não passa por apps/llm-service. O pipeline STT→LLM→TTS é hospedado pela Vapi e configurado por nós a cada chamada. Docs de pipeline de texto (pipeline-stages.md, pacing-planner.md, field-dictionary.md) não descrevem a voz.


Telefone do cliente
│ PSTN (8kHz μ-law — banda estreita; isso degrada STT e TTS)
Twilio (número +1 682 622 8174, importado na Vapi)
Vapi (orquestra a chamada: Deepgram STT → GPT-4o → ElevenLabs TTS)
│ ① POST /webhooks/vapi { message.type: "assistant-request" }
│ ← devolvemos a config COMPLETA do assistente
│ ② POST /webhooks/vapi { message.type: "tool-calls" }
│ ← devolvemos { results: [{ toolCallId, result }] }
Cloudflare Worker (apps/api-worker) → Neon Postgres · Durable Object · Google Calendar · Twilio SMS

dois tipos de mensagem são tratados: assistant-request e tool-calls. Qualquer outro tipo recebe { ok: true } — inclusive end-of-call-report, que é por isso que não existe text-back de chamada perdida hoje (ver DIRECTION.md §3, item 1).


PassoArquivo
Rotaapi/router.tsPOST /webhooks/vapi
Handlerchannel/application/handle-vapi-webhook.ts

O handler, em ordem:

  1. AutenticaverifyVapiSecret compara x-vapi-secret (ou Authorization: Bearer) com VAPI_SERVER_URL_SECRET. Fail-closed fora de development: sem secret configurado, produção responde 401. Um webhook de voz sem auth é entrada pública que dispara chamadas e carrega tenants.
  2. Resolve o tenant pelo númeroreadVapiPhoneNumberIdfindVoiceEndpointByVapiPhoneNumberId (storage/identity/channel-endpoints.repo.ts). A associação número↔tenant vive em channel_endpoints (migration 0028_voice_endpoint.sql), não em demo_sites.config. Número desconhecido → 404 + log vapi_unknown_phone.
  3. Avisa de config suspeitawarnOnSuspiciousVoiceConfig emite: vapi_config_placeholder_owner_sms (ownerAlerts.smsTo num 555-01xx reservado), vapi_config_placeholder_owner_email (emailTo num @example.com, reservado pela IANA), vapi_config_sem_aviso_ao_dono (nenhum dos dois preenchido — o dono não fica sabendo de nada) e vapi_config_demo_phone_id para ids com prefixo demo-. ⚠️ Este warning roda em toda requisição, antes do branch por tipo — não use a presença dele para diagnosticar nada.
  4. Deriva o serverUrlresolvePublicBaseUrl(env, request)${base}/webhooks/vapi.

⚠️ A armadilha nº 1 do canal: PUBLIC_BASE_URL

Seção intitulada “⚠️ A armadilha nº 1 do canal: PUBLIC_BASE_URL”

O serverUrl que devolvemos no assistant-request é para onde a Vapi manda as tool-calls. Se ele apontar para uma origem que não está servindo este Worker, toda tool-call volta pro cliente como “No result returned for call_…” — a IA diz “estou com problema no sistema de agendamento” e a ligação morre. O sintoma não aponta pro PUBLIC_BASE_URL; parece bug de tool.

Regra: PUBLIC_BASE_URL em wrangler.toml tem que ser a origem que responde de fato. Trocar domínio e reativar [[routes]] são uma operação, nunca duas.


2. assistant-request — montamos o cérebro a cada chamada

Seção intitulada “2. assistant-request — montamos o cérebro a cada chamada”

Arquivo: channel/vapi/build-assistant-response.ts

Não existe assistente pré-criado na Vapi. O número tem assistantId: null e usa server.url → nós devolvemos a config inteira, montada a partir dos dados do tenant. Vantagem: mudar prompt/voz/tools é deploy, não clique no dashboard.

O que devolvemos:

CampoPapelPor que está assim
nameNome do assistente${businessName} Receptionist
firstMessageSaudaçãoconfig.greeting com {business} interpolado
serverUrlPara onde vão as tool-callsDerivado de PUBLIC_BASE_URL — ver a armadilha nº 1 acima
modelopenai/gpt-4o, temp 0.4, system prompt + toolsPrompt montado por buildSystemPrompt; tools por resolveVapiTools
voice11labs, voiceId fixoSem model nem optimizeStreamingLatency declarados — a Vapi aplica o default dela
metadataslug, tenantId, flag de owner alertsRastreio, e a única via de identificar o tenant numa chamada sem número

O prompt vem do vertical, não do código. As linhas de intake (o que coletar, qual action chamar, quais offering slugs usar) saem de config.voice.rules, populado por config/verticals/*.json. Antes elas eram fixas no buildSystemPrompt e iam para todo tenant — o que mandava o med spa coletar endereço de serviço e usar slugs (emergency, service-call) que não existem no catálogo dele. Tenant sem voice.rules cai no conjunto de home services, então arlington-hvac não mudou.

⚠️ O que este doc afirmava e o código não faz. Até 2026-08-06 a tabela acima listava transcriber (deepgram/nova-2-phonecall + keywords), startSpeakingPlan (smartEndpointingPlan: livekit), stopSpeakingPlan, endCallFunctionEnabled, silenceTimeoutSeconds, maxDurationSeconds, backchannelingEnabled, backgroundDenoisingEnabled e keypadInputPlan — e chamava o smart endpointing de “a correção de naturalidade mais importante”, com 14 abortos de LLM numa única chamada como evidência.

Nenhum desses campos existe em build-assistant-response.ts. grep -rE 'startSpeakingPlan|smartEndpointing|nova-2-phonecall|transcriber|keypadInputPlan' não retorna nada em apps/, e não há chamada a api.vapi.ai que os aplique por fora.

E não há a hipótese de estarem no dashboard: assistente nunca é configurado pela interface da Vapi — é tudo por API (decisão do dono, 2026-08-06). Como a config vem inteira deste webhook, o que não está aqui não é enviado. Então esses campos simplesmente nunca foram para produção, e o doc descrevia intenção como se fosse estado.

Consequência prática: a naturalidade em produção pode estar pior do que estes docs sugerem, e sem endCallFunctionEnabled a linha pode ficar aberta depois do tchau. Ligar esses campos muda comportamento de voz em produção e precisa de validação em ligação real, então virou issue própria em vez de entrar junto de um refactor de prompt.

Onde mexer: comportamento/roteiro → buildSystemPrompt. Naturalidade/turn-taking → os planos acima. Voz → bloco voice.


handle-vapi-webhook (tool-calls)
├─ readVapiToolCalls ..................... channel/vapi/types.ts
│ └─ 0 tools parseadas → log `vapi_tool_calls_unparsed` + { results: [] }
│ (é o que faz a Vapi falar "No result returned" — payload mudou de forma)
├─ createVapiActionContext ............... channel/vapi/create-vapi-action-context.ts
│ └─ sessionId = callerNumber ? `voice:${callerNumber}` : `vapi:${callId}`
│ ↑ CONTINUIDADE: quem liga de novo do mesmo número reentra na MESMA conversa
├─ para cada tool: executeVapiToolCall → agent/actions/dispatch.ts
└─ formatActionResultForVapi ............. channel/vapi/tool-schemas.ts
└─ vira TEXTO FALÁVEL ("Tomorrow at 2:00 PM"), nunca JSON/ISO

A Vapi ignora resposta não-200 por completo → “No result returned”. Por isso cada tool é envolvida em try/catch que devolve { toolCallId, error: "…" } com status 200. Nunca deixe uma exceção escapar deste handler.

Regra de ouro 2: efeitos colaterais não bloqueiam a resposta

Seção intitulada “Regra de ouro 2: efeitos colaterais não bloqueiam a resposta”

Trabalho de DO/SMS roda em executionCtx.waitUntil(...) depois de montar results. Se a gente esperasse, a Vapi dá timeout e fala “No result returned” mesmo com a tool tendo funcionado.

Definidas em channel/vapi/tool-schemas.ts e injetadas em VAPI_BOOKING_TOOLS — sem adição condicional:

ToolO que fazCadeia
record_service_intakeSalva urgência, tipo, endereço+ZIP, problema, nome, callback. Devolve suggested_offering_slug (emergency vs service-call)agent/actions/record-service-intake.ts
check_availabilitySlots reais. Devolve label falável + starts_at ISO pareadobooking/domain/availability.ts + calendar/list-booked-ranges.ts (freebusy do Google quando conectado)
book_appointmentAgenda e remarcaver abaixo

generate_checkout e capture_contact existem no dispatch mas não são expostos na voz — é por isso que a voz não consegue cobrar (DIRECTION.md §4, decisão aberta).

Não existe cancel_appointment. Nem tool, nem case no dispatch, nem o arquivo calendar/cancel-active-appointments.ts que uma versão anterior deste doc citava. O único cancelamento implementado é calendar/cancel-superseded-appointments.ts, que roda dentro de um book_appointment (ver abaixo). Cancelar por voz é trabalho a fazer, não a validar.

book_appointment faz reagendamento sem cancelar antes

Seção intitulada “book_appointment faz reagendamento sem cancelar antes”

agent/actions/book-appointment.ts — agendar um novo horário supersede o antigo via cancelSupersededAppointmentsWithCalendarSync. O prompt instrui explicitamente: “To RESCHEDULE, do NOT cancel first”. Cancelar-e-reagendar abriria janela onde o cliente não tem horário nenhum e o evento do calendário fica órfão.

Também toca: hold-policy, reschedule-policy, offering-payment, release-expired-holds, sync-appointment (best-effort — falha de calendário não derruba o booking).

Por que isso funciona entre ligações diferentes: cancelSupersededForConversation é escopado por conversation_id, e conversations tem unique em (tenant_id, contact_id, endpoint_id)storage/identity/conversations.repo.ts. O mesmo caller que liga de novo reusa a mesma conversa, então o agendamento anterior é encontrado e superseded. Não há tool de remarcação: remarcar é reagendar.


GatilhoO que armaArquivo
intake sem bookingFollow-up rápido de 30min (E35 rapid_checkin) no DOreconcileVoiceIntakeFollowup
booking confirmadoCancela o follow-up + arma confirmação e lembretes T-24h/T-1h (E36)notifications/schedule-appointment-reminders.ts
booking confirmadoAlerta SMS pro dono com contexto do jobnotifications/notify-owner-booking.ts

O disparo relê o booking ativo no momento do envio (conversation/application/execute-appointment-reminder.ts) — então cancelar/remarcar depois é respeitado automaticamente. Respeita quiet-hours e consentimento TCPA/opt-out.

⚠️ Hoje esses SMS não entregam: sem TWILIO_* em produção tudo cai em twilio_sms_skipped (bloqueado em A2P 10DLC — DIRECTION.md §2).

Já causou dois loops de alarme em produção. trace_id é coluna uuid; um slug legível (voice-intake-<conv>) explode o cast ::uuid, a escrita de timeline falha, o handler do alarme quebra e reagenda pra sempre. Guardas: crypto.randomUUID() no traceId, e findPlannedById retorna null para id não-UUID (storage/conversation/response-plans.repo.ts, com teste de regressão).


Logs do Worker (o que nós fizemos):

Terminal window
cd apps/api-worker && npx wrangler tail --format pretty

Eventos que importam: vapi_tool_call (sucesso, com args + result), vapi_tool_call_failed, vapi_tool_calls_unparsed (payload mudou de forma), vapi_unknown_phone, vapi_tool_side_effects_failed, twilio_sms_skipped.

Call-logs da Vapi (o que a Vapi fez — transcript, latência por turno, endpointing, abortos):

Terminal window
curl -L -H "Authorization: Bearer $VAPI_PRIVATE_API_KEY" -o call-logs.jsonl.gz \
https://api.vapi.ai/call/<CALL_ID>/call-logs

JSONL gzip, uma linha por evento (body + attributes). É a ferramenta para qualidade de voz: mede User stopped speakingBot started speaking (latência de turno), conta LLM request aborted before completion (turn-taking ruim) e mostra o transcript real do Deepgram com confidence. Gravações: /mono-recording, /stereo-recording.

Estado de saúde:

Terminal window
pnpm smoke:voice # assistant-request + round-trip de tool-call

Sinais de chamada que não iniciou sessão: cost: 0, startedAt: null, zero mensagens e call-logs file not found → a chamada nem chegou ao assistente (problema de telefonia/conta, não nosso).


ArquivoCobre
channel/vapi/vapi.test.tsConfig do assistente, auth, resolução de tenant
channel/vapi/tool-schemas.test.tsSchemas + formatação falável
agent/actions/voice-booking-loop.test.tsLoop intake → availability → book
agent/actions/dispatch.test.tsRoteamento de actions
agent/actions/record-service-intake.test.tsNormalização de intake
pnpm smoke:voiceRound-trip contra worker rodando

Validado em ligação real: atender · intake · disponibilidade · agendar (call 019fa16f — intake→availability→book→lembretes armados).

Construído, não validado ao vivo: remarcar (via supersede no book_appointment) · continuidade de caller · o pass de naturalidade (smart endpointing + end_call).

Não existe: cancelar por voz · text-back de chamada perdida (end-of-call-report não é tratado) · cobrança na voz · voicemail · transferência para humano.