Pular para o conteúdo

Arquitetura

Status: canônico · Escopo: Como o sistema é montado — peças, fronteiras e os dois caminhos que importam · Atualizado em: 2026-08-11

Este documento existe para responder uma pergunta: quem fala com quem, e por quê. Detalhe de implementação mora no código; decisão mora aqui.

⚠️ Estado real, com data. Onde algo está construído mas não entrega, está escrito — documento de arquitetura que só descreve a intenção é pior que nenhum, porque parece confiável.


Três pessoas diferentes tocam o sistema, e elas quase nunca se cruzam.

flowchart TB
PROSPECT["Dono de HVAC avaliando<br/>(prospect)"]
DONO["Dono de HVAC cliente<br/>(paga a mensalidade)"]
FINAL["Cliente do nosso cliente<br/>(liga precisando de conserto)"]
TF{{"Tactflow"}}
PROSPECT -->|"responde o funil, pede a conta"| TF
FINAL -->|"liga para o número do negócio"| TF
TF -->|"e-mail: agendamento novo"| DONO
TF -->|"grava na agenda"| GCAL["Google Calendar<br/>(agenda do cliente)"]
TF --> VAPI["Vapi<br/>voz + telefonia"]
TF --> RESEND["Resend<br/>e-mail transacional"]
TF --> STRIPE["Stripe<br/>depósito"]
TF -.->|"não configurado"| TWILIO["Twilio<br/>SMS"]

A assimetria que define o produto: quem paga (o dono) é quem menos usa o sistema. Ele não abre painel; ele recebe um e-mail. Quem interage de verdade é alguém que nunca ouviu falar da Tactflow e só quer consertar o ar-condicionado.

Por isso o aviso ao dono é a peça mais importante do produto, e não um detalhe de notificação — ver padrão de e-mail.


flowchart LR
subgraph CF["Cloudflare"]
PAGES["marketing-web<br/>Astro · Pages<br/>tactflow.io"]
W["api-worker<br/>Worker · TypeScript<br/>api + chat.tactflow.io"]
DO[("ConversationRuntimeDO<br/>Durable Object")]
ASSETS["booking-web<br/>build estático em public/ui"]
end
RENDER["llm-service<br/>Python · DESLIGADO"]
NEON[("Neon Postgres<br/>branches production e development")]
VAPI["Vapi"]
OPENAI["OpenAI gpt-4o"]
PAGES -->|"/talk/* (CORS)"| W
W --> DO
W --> ASSETS
W --> NEON
W -->|"só chat e /lab"| RENDER
RENDER --> NEON
VAPI -->|"webhook + tool-calls"| W
VAPI -->|"o modelo, direto"| OPENAI
PeçaO que éOnde roda
apps/api-workerO sistema. Rotas, agente, agendamento, cobrança, notificação.Cloudflare Worker
apps/marketing-webLanding e funil.Cloudflare Pages
apps/booking-webInterface de agendamento; o build sai em api-worker/public/ui.Servido pelo Worker
apps/llm-serviceServiço de LLM em Python. Desligado desde 11/08/2026 — ver ADR 0005.
apps/llm-edge-workerMesmo serviço em Cloudflare Containers. É por aqui que ele volta, se voltar.

O engano mais fácil desta arquitetura. O llm-service nunca participou de ligação, e foi por isso que desligá-lo não custou nada ao produto:

ligação → Vapi → gpt-4o (a Vapi chama a OpenAI) → tool-call → nosso Worker
chat/lab → nosso Worker → llm-service (DESLIGADO: responde 503 com motivo)

buildVapiAssistantResponse devolve provider: 'openai', model: 'gpt-4o', e a Vapi conversa com a OpenAI sozinha. Nosso Worker entra só quando o agente precisa fazer alguma coisa — consultar horário, agendar, avisar o dono.

A consequência prática: com o serviço desligado o chat hospedado e o /lab não respondem, e ligação nenhuma é afetada. Ver mapa do runtime de voz.


3. Fronteira de host: api é máquina, chat é gente

Seção intitulada “3. Fronteira de host: api é máquina, chat é gente”
api.tactflow.io allowlist: /talk/*, /webhooks/*, /internal/*, /health
todo o resto → 404, arquivo estático incluído
chat.tactflow.io /book/*, /ui/*, /lab, /embed.js
tactflow.io landing (Pages, outro projeto)

A regra é recusa, não redirecionamento: enquanto api atende caminho humano — mesmo só devolvendo — continua sendo possível montar link com a origem errada e nada quebra. Recusando, quem errar descobre no primeiro teste em vez de num e-mail já enviado a cliente.

Isso já custou caro: o retorno do checkout do Stripe apontava para api/book/:slug, e só não aterrissava em erro porque havia um redirect no meio. Código em site-host.ts, operação em deploy de produção.


sequenceDiagram
participant C as Cliente final
participant V as Vapi
participant W as api-worker
participant N as Neon
participant D as Dono
C->>V: liga para o número do negócio
V->>W: assistant-request (webhook)
W->>N: resolve o tenant pelo número
W-->>V: config do assistente + serverUrl
Note over V: a Vapi conversa com gpt-4o sozinha
V->>W: tool-call: horários livres
W->>N: consulta agenda
V->>W: tool-call: agendar
W->>N: grava o agendamento
W->>D: e-mail "agendamento novo"
W-->>V: confirmado

⚠️ PUBLIC_BASE_URL é a armadilha nº 1 do canal. O serverUrl que devolvemos diz à Vapi para onde mandar as tool-calls. Se ele não bater com o host onde o Worker responde, o assistente atende, conversa — e não consegue agendar nada. Falha que só aparece em ligação real.


O site de marketing é outro projeto, em outro host, e chama o Worker por CORS. O contrato está em talk-contract.

EndereçoEstado em 11/08/2026
POST /talk/contact✅ grava o lead e manda o e-mail de horários
POST /talk/breakdown-email✅ manda a conta que a tela mostrou
POST /talk/snapshot❌ 501 — resposta parcial não é gravada
GET /talk/availability · POST /talk/book🗑️ removidos em 11/08/2026 — o site nunca chamou; a Taly passa a agendar pela tool do agente
POST /talk/voice-session❌ 501 — falar com a Taly no site não funciona

O cliente manda as respostas cruas e a conta derivada; o servidor recalcula e compara. Não é redundância: o e-mail precisa reproduzir os números que a pessoa viu, e recalcular sozinho divergiria em silêncio no primeiro drift entre os dois repositórios.


EstadoOndeObservação
Tenants, contatos, agendamentos, pagamentosNeon PostgresTabelas de evento são particionadas por mês
Conversa em andamentoConversationRuntimeDODurable Object com SQLite, um por conversa
Interface de agendamentopublic/uiBuild do booking-web, é gitignored
Segredoswrangler secretNunca em wrangler.toml

⚠️ Duas branches do Neon, e confundi-las já aconteceu. DATABASE_URL aponta para development; produção usa DATABASE_URL_PRODUCTION. São endpoints diferentes, e escrever na errada achando que era produção é um erro silencioso — o comando funciona.

Detalhe das tabelas em modelo de dados.