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.
1. Contexto: quem usa e com quem falamos
Seção intitulada “1. Contexto: quem usa e com quem falamos”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.
2. Contêineres: as peças que rodam
Seção intitulada “2. Contêineres: as peças que rodam”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ça | O que é | Onde roda |
|---|---|---|
apps/api-worker | O sistema. Rotas, agente, agendamento, cobrança, notificação. | Cloudflare Worker |
apps/marketing-web | Landing e funil. | Cloudflare Pages |
apps/booking-web | Interface de agendamento; o build sai em api-worker/public/ui. | Servido pelo Worker |
apps/llm-service | Serviço de LLM em Python. Desligado desde 11/08/2026 — ver ADR 0005. | — |
apps/llm-edge-worker | Mesmo serviço em Cloudflare Containers. É por aqui que ele volta, se voltar. | — |
⚠️ A voz não passa pelo nosso LLM
Seção intitulada “⚠️ A voz não passa pelo nosso LLM”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 Workerchat/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ídochat.tactflow.io /book/*, /ui/*, /lab, /embed.jstactflow.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.
4. O caminho de uma ligação
Seção intitulada “4. O caminho de uma ligaçã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.
5. O caminho do funil
Seção intitulada “5. O caminho do funil”O site de marketing é outro projeto, em outro host, e chama o Worker por CORS. O contrato está em talk-contract.
| Endereço | Estado 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.
6. Onde o estado vive
Seção intitulada “6. Onde o estado vive”| Estado | Onde | Observação |
|---|---|---|
| Tenants, contatos, agendamentos, pagamentos | Neon Postgres | Tabelas de evento são particionadas por mês |
| Conversa em andamento | ConversationRuntimeDO | Durable Object com SQLite, um por conversa |
| Interface de agendamento | public/ui | Build do booking-web, é gitignored |
| Segredos | wrangler secret | Nunca 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.
Onde continuar
Seção intitulada “Onde continuar”- Estado real de deploy e runbook: produção
- O canal de voz por dentro: mapa do runtime
- Contrato do funil: talk-contract
- Por que monorepo: ADR 0003