Pular para o conteúdo

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 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.

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 * 4
loss.bookings === Math.max(1, Math.floor(loss.missed * data.closeRate))
loss.money === loss.bookings * data.jobValue

Divergir é 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.

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 rotaPara quêCorpo → resposta
POST /talk/snapshotcaptura progressivaTalkSnapshot202 { ok, sessionId }
POST /talk/contactliga a sessão a um contato{ sessionId, displayName, email?, phone?, consent, turnstileToken? }200 { contactId, reachable }
POST /talk/breakdown-emaila saída de baixo compromissoTalkSnapshot + { email, consent }202
POST /talk/voice-sessionsessão da Taly (Vapi Web){ sessionId, turnstileToken? }200 { assistant, publicKey, sessionId }

GET /talk/availability e POST /talk/book foram 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 respondia 501. 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.

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).

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.

Dois endpoints recebem e-mail e consentimento, e só um grava. A tabela é o mapa:

EndpointRecebeGrava onde
POST /talk/snapshotas 4 respostas, sem PIInada (stub responde 202; live é 501)
POST /talk/contactnome, e-mail, telefone, consentimento, respostascontacts, channel_identities (email/sms), contact_profile_facts (4 chaves lead.*) — via persist-talk-lead.ts
POST /talk/breakdown-emaile-mail, consentimento, snapshotnada

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.

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:

  • 503 com o nome da peça, nunca 501 nem 500. 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 a VAPI_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_KEY de servidor no env.d.ts de 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 no metadata da 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 webGreeting do tenant cobre isso e quem não define cai na saudação única.
  • O serverUrl é o mesmo /webhooks/vapi do 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 em wrangler.toml para 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.

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.