A palavra mais difícil de ensinar pro meu assistente de WhatsApp foi "comprei"
Há alguns meses eu tô construindo o HeyPlugi, um assistente que roda dentro do WhatsApp: você manda um áudio ou texto e ele cria lembrete, anota gasto, monta lista de compras e guarda notas pra você achar depois. Sem app novo, sem planilha.
A stack é bem "indie":
- WhatsApp Cloud API recebendo as mensagens
- n8n self-hosted fazendo toda a orquestração dos agentes de IA
- Supabase (Postgres + Edge Functions) como backend
- Lovable no front (site e painel)
- gpt-4.1-mini em todos os agentes
Quero contar menos "como é lindo" e mais as coisas que me fizeram bater a cabeça. Talvez poupe tempo de alguém.
Como a mensagem anda
Toda mensagem cai num workflow que eu chamo de Orquestrador. Ele faz uma coisa só: classifica a intenção (lembrete, finanças, lista de compras, nota, amigo, dúvida) e despacha pro agente especializado. Começou com um agente que fazia tudo; hoje são seis, cada um com prompt e saída estruturada próprios.
Separar ajudou muito. Mas o problema virou outro: o classificador precisa acertar a porta.
1. "Comprei" tem três significados
- "comprei pão, 12 reais" → finanças
- "comprei o leite da lista" → lista de compras (dar baixa no item)
- "me lembra de comprar pão amanhã às 8h" → lembrete
Na primeira versão, "comprei o leite da lista" caía em Finanças, e o Plugi perguntava quanto custou o leite. O problema era a ordem das regras no prompt: a regra "comprei + item sem valor = finanças" vinha antes da regra "menção à lista = compras".
O que resolveu:
- regra de desempate explícita: valor em dinheiro é finanças; data ou hora é lembrete; "lista", "só faltou" ou "acabou" é compras
- uma bateria de 31 frases rodando contra o classificador num workflow separado. Só publico mudança no prompt se der 31/31.
Se você tem um classificador de intenção em produção e não tem uma bateria dessas, recomendo muito. Mudar uma vírgula no prompt quebra coisa que funcionava.
2. O IF do n8n que sempre dava true
Teve uma semana em que toda mensagem virava continuação de lembrete. O usuário mandava "2329,00 escola" e o Plugi respondia como se fosse a hora de um lembrete pendente.
Era um nó IF checando {{ $json.id }} com o operador "exists". Só que, no n8n, uma expressão sobre uma chave que não existe vira string vazia '', e '' "existe". Então a condição era sempre verdadeira.
E não era só isso. Eram três bugs em cadeia:
- o IF com "exists" (troquei por
Object.keys($json).length > 0) - o nó de lookup anterior substituía o item inteiro por
{}quando não achava nada, e o classificador perdia o texto original (passei a referenciar o nó de origem em vez de$json) - um filtro de data table com
{ }simples em vez de{{ }}, comparando com o texto literal da expressão
Lição: em fluxo visual, quando um nó troca o item, todo $json depois dele mente pra você.
3. O nono dígito
Tem uma função de "Amigos": você cadastra alguém e o Plugi manda lembrete pro WhatsApp dessa pessoa. Ela recebe um convite e responde "aceito".
Uma amiga aceitou o convite e… nada aconteceu.
O cadastro normalizava o telefone sempre com 13 dígitos (55 + DDD + 9 + 8 dígitos). Mas o webhook do WhatsApp entregou o número sem o nono dígito, com 12. A busca fazia .eq("phone_whatsapp", phone) sem normalizar, não achava ninguém e tratava como usuário desconhecido.
Se você trabalha com WhatsApp no Brasil: normalize o telefone na entrada E na busca, ou procure pelas duas variantes.
4. Meu site tinha 29 páginas e o Google ignorava 18
Essa foi hoje. O site é SPA feito no Lovable, com um plugin no Vite que pré-renderizava o <head> de cada rota (title, description, canonical). Achei que tava resolvido.
O Search Console mostrava 18 de 29 páginas como "Detectada, mas não indexada". Fui olhar o HTML que o servidor entrega: tinha título, uma frase e o menu. O texto dos artigos só aparecia depois do JavaScript. Pro Google, eram 29 páginas quase iguais e rasas.
A correção foi SSR no build: dentro do próprio plugin do Vite (no closeBundle), um build SSR programático de um entry-server.tsx, renderToPipeableStream em cada rota pública com StaticRouter, e hydrateRoot no cliente só quando o HTML pré-renderizado bate com a URL. O client do Supabase vira um stub só no build SSR.
Detalhe que custou um tempo: importar o bundle SSR com ?t=${Date.now()} (pra furar cache) criava uma segunda instância do módulo. O AuthContext do entry e o da página lazy viravam objetos diferentes e dava "useAuth must be used within AuthProvider" em só uma rota. Tirando a query, resolveu.
Ainda não sei se o Google vai indexar. Volto aqui contando.
Por que gpt-4.1-mini e não o modelo mais forte?
Porque a tarefa de cada agente é estreita (classificar, extrair data/valor, montar JSON) e o volume é de mensagem de WhatsApp. Pra esse tipo de tarefa, com prompt bem fechado, saída estruturada e bateria de teste, o modelo pequeno dá conta, e o custo por mensagem cabe no preço do produto. Troco quando a bateria mostrar que precisa, não quando sair modelo novo.
O HeyPlugi faz, num papo de WhatsApp, por texto ou áudio:
- lembretes (pontuais e recorrentes, inclusive pra família e amigos)
- controle de gastos e receitas, com parcelas e fluxo de caixa
- lista de compras compartilhada com quem mora com você
- "Segunda Mente": guardar notas e links e achar depois perguntando do seu jeito
Tem 7 dias grátis: https://heyplugi.com.br/?utm_source=tabnews&utm_medium=referral&utm_campaign=pitch
Mas o que eu mais queria era feedback de quem já colocou LLM em produção: como vocês fazem regressão de prompt? A bateria de frases tá funcionando, mas sinto que tô reinventando a roda.