Contrato do funil /talk
Status: canônico · Escopo: Costura HTTP entre o site (tactflow.io) e o Worker — endpoints, schema compartilhado e o stub que destrava o front-end · Atualizado em: 2026-08-05
✅ CANÔNICO — esta é a única superfície pela qual o site fala com o runtime.
Fonte de verdade do schema: a máquina de estados em
apps/marketing-web/src/scripts/funnel.ts. Este doc espelha; não inventa.Desde 10/08/2026 as duas pontas vivem no mesmo repositório, então uma mudança no schema e o ajuste do consumidor cabem num PR só — ver ADR 0003.
O que este doc responde: “o site precisa de um endpoint — qual é a forma exata, e como o front-end constrói contra ele antes do backend existir?”
Por que existe um contrato antes da implementação
Seção intitulada “Por que existe um contrato antes da implementação”A trilha do site tinha quatro tarefas paradas esperando endpoints que não existiam: horários vindos do backend, persistir contato, persistir o snapshot parcial e disparar o e-mail do breakdown. Enquanto o contrato não existia, cada passo do front-end dependia de uma janela de tempo do fundador.
O contrato mais o stub invertem isso. O front-end implementa contra uma origem real, com CORS real e formas de erro reais; as implementações de verdade entram depois, sem ninguém parar.
O schema é um só
Seção intitulada “O schema é um só”O funil coleta quatro respostas e deriva três números. A Taly, quando existir, qualifica o mesmo lead — então os dois gravam nos mesmos campos. Dois schemas para o mesmo lead divergem em duas semanas.
type TalkSector = 'home' | 'clinic' | 'studio' | 'pro' | 'other';
type TalkFunnelData = { sector: TalkSector | null; missedPerWeek: number | null; // por SEMANA (a tela exibe × 4) jobValue: number | null; // dólares closeRate: number; // 0..1, escolhido pelo lead};
type TalkSnapshot = { sessionId: string; // token opaco por aba, gerado no cliente step: 'intro' | 'sector' | 'missed' | 'value' | 'loss' | 'contact' | 'schedule'; data: TalkFunnelData; loss: { missed: number; bookings: number; money: number; jobValue: number } | null;};sector não é ofício. A máquina usa um conjunto deliberadamente mais largo que home services — a landing já mostra clínica e estúdio, então uma lista só de HVAC contradiria a própria página. Ofício mais fino (“HVAC”, “encanamento”) é fato de conversa, nunca este campo.
Tipos e validação: apps/api-worker/src/api/talk/contract.ts. O site vendoriza esse arquivo ou copia os tipos — não é o packages/contracts, que é gerado do Pydantic do serviço LLM e seria sobrescrito por pnpm contracts:gen.
As três identidades
Seção intitulada “As três identidades”O cliente manda as respostas cruas e o loss derivado. Não é redundância: o e-mail do breakdown tem de reproduzir os números que o lead viu na tela, e recalcular no servidor divergiria silenciosamente no primeiro drift entre os dois repos. Então o cliente manda o que exibiu, o servidor deriva de novo e compara:
loss.missed === data.missedPerWeek * 4loss.bookings === Math.max(1, Math.floor(loss.missed * data.closeRate))loss.money === loss.bookings * data.jobValueDivergir é 422. O drift aparece aqui, e não na caixa de entrada de um lead.
O Math.max(1, …) não é arredondamento defensivo: é o único canto da grade em que o número exibido arredonda para cima (3 chamadas/semana a 5% dá 0.6, e “0 jobs” seria beco sem saída). Reproduzir isso importa — sem o max, aquele canto reprovaria um snapshot legítimo.
Os endpoints
Seção intitulada “Os endpoints”Todos em api.tactflow.io (chat.tactflow.io responde junto enquanto sai). Roteamento em api/router.ts, handlers em api/handlers/talk.ts.
| Método e rota | Para quê | Corpo → resposta |
|---|---|---|
POST /talk/snapshot | captura progressiva | TalkSnapshot → 202 { ok, sessionId } |
POST /talk/contact | liga a sessão a um contato | { sessionId, displayName, email?, phone?, consent, turnstileToken? } → 200 { contactId, reachable } |
POST /talk/breakdown-email | a saída de baixo compromisso | TalkSnapshot + { email, consent } → 202 |
POST /talk/voice-session | sessão da Taly (Vapi Web) | { sessionId, turnstileToken? } → 200 { assistant, publicKey, sessionId } |
GET /talk/availabilityePOST /talk/bookforam removidos em 11/08/2026. Nasceram para um agendamento dentro da página que nunca foi construído: o site nunca os chamou, o stub devolvia horários falsos e o live respondia501. Quem passa a agendar ali mesmo é a Taly, pela tool de booking do agente — ver ADR 0004. Contrato órfão é pior que ausência: alguém constrói contra ele achando que existe.
Erro tem forma, sempre
Seção intitulada “Erro tem forma, sempre”type TalkErrorBody = { error: string; message: string; fields?: Array<{ field: string; code: string; message: string }>;};Vale para o stub também, inclusive nos 4xx e 5xx. Um stub que só sabe responder sucesso produz uma UI sem estado de erro — que é exatamente o defeito registrado no funil hoje, onde o submit do e-mail sempre confirma.
Códigos que o front-end precisa tratar: validation_failed (400), identity_mismatch (422), turnstile_missing / turnstile_failed (403), voice_not_configured (503), not_implemented (501).
Consentimento e dado de lead
Seção intitulada “Consentimento e dado de lead”O POST /talk/snapshot é anônimo: setor, chamadas perdidas, valor e taxa não identificam ninguém, e ficam chaveados só pelo sessionId. Dado pessoal só entra no POST /talk/contact, que exige consent: true explícito. É o que responde a preocupação de guardar lead antes de consentimento: antes do passo de contato não existe PII armazenada.
Onde cada dado é gravado
Seção intitulada “Onde cada dado é gravado”Dois endpoints recebem e-mail e consentimento, e só um grava. A tabela é o mapa:
| Endpoint | Recebe | Grava onde |
|---|---|---|
POST /talk/snapshot | as 4 respostas, sem PII | nada (stub responde 202; live é 501) |
POST /talk/contact | nome, e-mail, telefone, consentimento, respostas | contacts, channel_identities (email/sms), contact_profile_facts (4 chaves lead.*) — via persist-talk-lead.ts |
POST /talk/breakdown-email | e-mail, consentimento, snapshot | nada |
O e-mail do breakdown é montado, entregue ao Resend e esquecido: o handler não abre conexão com o banco. Depois do 202, o endereço existe no painel do Resend e — só em caso de falha — num log que carrega o sessionId, não o endereço. Procurar o lead que “pediu o breakdown” no banco não acha nada.
Não é esquecimento, é dívida registrada: a caixa que o site exige nesse ponto promete “occasional Tactflow updates” e descadastro em um clique. Sem gravação não existe registro de consentimento (consent_events existe e esse caminho não usa), o endereço não entra em lista nenhuma, e nenhum e-mail sai com link de descadastro. Hoje a promessa não é quebrada porque nenhum update é enviado. Quem for enviar o primeiro precisa fechar isso antes: contato + identidade como no /talk/contact, consent_events para o registro e outbound_deliveries para o histórico de envio.
Telefone e e-mail são sempre digitados, nunca ditados. O STT erra exatamente nos caracteres que precisam estar exatos — soletração e domínio. Mesmo no fluxo com a Taly, ela pede e a tela abre o campo.
Nada mais no Worker precisava de CORS: o widget de booking é servido da mesma origem que ele chama. O site não é — tactflow.io está no Cloudflare Pages, outra origem. Implementação em api/cors.ts.
Allowlist, nunca *. Estes endpoints gravam dado de lead e criam sessões de voz que custam dinheiro; um curinga deixaria qualquer página da internet dirigi-los do browser da vítima. Origem fora da lista recebe resposta sem o header de permissão — o browser se recusa a ler, que é a falha correta.
Padrão: tactflow.io, www.tactflow.io e localhost:4321 (servidor de dev do Astro). Sobrescrever com TALK_ALLOWED_ORIGINS.
TALK_BACKEND=stub serve o backend de fixture — na mesma origem e no mesmo caminho do real. Host de mock separado deixaria CORS, latência e tratamento de erro sem teste até o último dia.
O stub valida a entrada igual ao real: recusa snapshot malformado, recusa identidade que não fecha, recusa contato sem consentimento. O que ele não faz é escrever no banco.
/talk/voice-session no stub responde 503 voice_not_configured de propósito — devolver uma config de assistente falsa faria o front-end construir contra algo que nunca poderia funcionar. O caminho de erro (microfone negado, WebRTC bloqueado por rede corporativa) é um que o site precisa implementar de qualquer forma.
A sessão da Taly, no live
Seção intitulada “A sessão da Taly, no live”O endpoint monta o assistente a partir do tenant tactflow — a Tactflow é tenant dela mesma — e devolve a config para o SDK Web subir a chamada no browser. Detalhes que importam:
503com o nome da peça, nunca501nem500. As duas respostas dizem coisas diferentes para quem lê:501é “não construído, espere release”;503 voice_not_configuredé “construído, falta credencial nossa”. Falta hoje aVAPI_PUBLIC_KEY(#119) e o calendário do tenant (#118).- A chave que vai ao browser é a pública, e ela é separada da
VAPI_API_KEYde servidor noenv.d.tsde propósito: trocar uma pela outra vaza a chave de servidor para qualquer visitante. - Teto de duração só no transporte
web(TETO_SESSAO_WEB_SEGUNDOS, 10 min). Ligação de telefone é o produto e não leva teto — cortar quem está pedindo serviço é o defeito, não a proteção. O transporte viaja nometadatada chamada, para minuto de demonstração e minuto de cliente não somarem no mesmo balde (#89). - A saudação do browser é outra. A do telefone abre com “thanks for calling”, que sai errado de um widget onde a pessoa clicou; o
webGreetingdo tenant cobre isso e quem não define cai na saudação única. - O
serverUrlé o mesmo/webhooks/vapido telefone, de propósito: a Taly agenda pelas mesmas tools já validadas em ligação real, em vez de um caminho paralelo só dela.
Falta ainda, e está na #120: teto por IP e por dia. O Turnstile já protege o endpoint, mas chave pública é usável de qualquer origem.
Sem TALK_BACKEND=stub, as rotas respondem 501 not_implemented com a mensagem apontando onde o trabalho vive. Isso é deliberado: melhor uma falha real e explícita do que dado falso passando por verdadeiro.
⚠️
TALK_BACKEND = "stub"está ligado emwrangler.tomlpara o site poder ser construído. Remover essa linha quando os backends reais entrarem — com ela ligada, produção responde ao site com fixture.
Turnstile é verificado quando TURNSTILE_SECRET_KEY existe; sem o segredo a checagem é pulada (dev e stub, onde o site ainda não tem site key). Com o segredo configurado a checagem é obrigatória e fail-closed.
Falta ainda, e entra com os backends reais: rate limit por IP, cap de bookings por dia e por telefone, e cache curto no availability. Agenda real exposta na internet aberta é a superfície mais sensível dos seis endpoints.
O conflito do goTo
Seção intitulada “O conflito do goTo”A máquina recusa pular à frente de passo não respondido, e checa todos os anteriores.
Hoje isso não morde ninguém: não existe passo schedule (a ordem é intro · sector · missed · value · loss · contact) e goTo não tem chamador no site. A armadilha fica registrada porque volta no dia em que existir um passo depois do loss que alguém queira alcançar direto — quem chega por link não respondeu sector, missedPerWeek nem jobValue, então o goTo devolve false e o link não faz nada, em silêncio.
Quando isso acontecer, são duas saídas: ou cada passo declara suas próprias dependências de dados em vez de herdar a ordem do array, ou o passo novo não fica depois do resultado. A primeira preserva a intenção da guarda.