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.
0. Topologia
Seção intitulada “0. Topologia”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 SMSSó 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).
1. Entrada
Seção intitulada “1. Entrada”| Passo | Arquivo |
|---|---|
| Rota | api/router.ts — POST /webhooks/vapi |
| Handler | channel/application/handle-vapi-webhook.ts |
O handler, em ordem:
- Autentica —
verifyVapiSecretcomparax-vapi-secret(ouAuthorization: Bearer) comVAPI_SERVER_URL_SECRET. Fail-closed fora dedevelopment: sem secret configurado, produção responde 401. Um webhook de voz sem auth é entrada pública que dispara chamadas e carrega tenants. - Resolve o tenant pelo número —
readVapiPhoneNumberId→findVoiceEndpointByVapiPhoneNumberId(storage/identity/channel-endpoints.repo.ts). A associação número↔tenant vive emchannel_endpoints(migration0028_voice_endpoint.sql), não emdemo_sites.config. Número desconhecido → 404 + logvapi_unknown_phone. - Avisa de config suspeita —
warnOnSuspiciousVoiceConfigemite:vapi_config_placeholder_owner_sms(ownerAlerts.smsTonum555-01xxreservado),vapi_config_placeholder_owner_email(emailTonum@example.com, reservado pela IANA),vapi_config_sem_aviso_ao_dono(nenhum dos dois preenchido — o dono não fica sabendo de nada) evapi_config_demo_phone_idpara ids com prefixodemo-. ⚠️ Este warning roda em toda requisição, antes do branch por tipo — não use a presença dele para diagnosticar nada. - Deriva o
serverUrl—resolvePublicBaseUrl(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:
| Campo | Papel | Por que está assim |
|---|---|---|
name | Nome do assistente | ${businessName} Receptionist |
firstMessage | Saudação | config.greeting com {business} interpolado |
serverUrl | Para onde vão as tool-calls | Derivado de PUBLIC_BASE_URL — ver a armadilha nº 1 acima |
model | openai/gpt-4o, temp 0.4, system prompt + tools | Prompt montado por buildSystemPrompt; tools por resolveVapiTools |
voice | 11labs, voiceId fixo | Sem model nem optimizeStreamingLatency declarados — a Vapi aplica o default dela |
metadata | slug, tenantId, flag de owner alerts | Rastreio, 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,backgroundDenoisingEnabledekeypadInputPlan— 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 emapps/, e não há chamada aapi.vapi.aique 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
endCallFunctionEnableda 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.
3. tool-calls — o trabalho real
Seção intitulada “3. tool-calls — o trabalho real”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/ISORegra de ouro: sempre HTTP 200
Seção intitulada “Regra de ouro: sempre HTTP 200”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.
As 3 tools expostas
Seção intitulada “As 3 tools expostas”Definidas em channel/vapi/tool-schemas.ts
e injetadas em VAPI_BOOKING_TOOLS — sem adição condicional:
| Tool | O que faz | Cadeia |
|---|---|---|
record_service_intake | Salva urgência, tipo, endereço+ZIP, problema, nome, callback. Devolve suggested_offering_slug (emergency vs service-call) | agent/actions/record-service-intake.ts |
check_availability | Slots reais. Devolve label falável + starts_at ISO pareado | booking/domain/availability.ts + calendar/list-booked-ranges.ts (freebusy do Google quando conectado) |
book_appointment | Agenda e remarca | ver 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, nemcasenodispatch, nem o arquivocalendar/cancel-active-appointments.tsque uma versão anterior deste doc citava. O único cancelamento implementado écalendar/cancel-superseded-appointments.ts, que roda dentro de umbook_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.
4. Efeitos colaterais pós-booking
Seção intitulada “4. Efeitos colaterais pós-booking”| Gatilho | O que arma | Arquivo |
|---|---|---|
| intake sem booking | Follow-up rápido de 30min (E35 rapid_checkin) no DO | reconcileVoiceIntakeFollowup |
| booking confirmado | Cancela o follow-up + arma confirmação e lembretes T-24h/T-1h (E36) | notifications/schedule-appointment-reminders.ts |
| booking confirmado | Alerta SMS pro dono com contexto do job | notifications/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).
trace_id tem que ser UUID
Seção intitulada “trace_id tem que ser UUID”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).
5. Depurar uma ligação
Seção intitulada “5. Depurar uma ligação”Logs do Worker (o que nós fizemos):
cd apps/api-worker && npx wrangler tail --format prettyEventos 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):
curl -L -H "Authorization: Bearer $VAPI_PRIVATE_API_KEY" -o call-logs.jsonl.gz \ https://api.vapi.ai/call/<CALL_ID>/call-logsJSONL gzip, uma linha por evento (body + attributes). É a ferramenta para qualidade de voz:
mede User stopped speaking → Bot 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:
pnpm smoke:voice # assistant-request + round-trip de tool-callSinais 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).
6. Testes
Seção intitulada “6. Testes”| Arquivo | Cobre |
|---|---|
channel/vapi/vapi.test.ts | Config do assistente, auth, resolução de tenant |
channel/vapi/tool-schemas.test.ts | Schemas + formatação falável |
agent/actions/voice-booking-loop.test.ts | Loop intake → availability → book |
agent/actions/dispatch.test.ts | Roteamento de actions |
agent/actions/record-service-intake.test.ts | Normalização de intake |
pnpm smoke:voice | Round-trip contra worker rodando |
7. Estado e lacunas
Seção intitulada “7. Estado e lacunas”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.