Como criar um agente de IA no WhatsApp pela API oficial
Um agente de IA no WhatsApp oficial é um laço curto: a mensagem do cliente chega por webhook, um modelo de linguagem escreve a resposta e a API envia. O difícil não é a chamada ao modelo — é o que fica em volta dela: responder dentro do tempo do webhook, lembrar a conversa, saber a hora de chamar uma pessoa e não brigar com quem já está atendendo pelo celular. Este guia mostra um agente completo e o que costuma quebrar em produção.
O que a Meta permite
Bot que atende os clientes da própria empresa — tirar dúvida, acompanhar pedido, agendar, qualificar e passar para a equipe — é o uso para o qual a plataforma existe. O que os termos da Meta para a WhatsApp Business Platform restringem, na seção de provedores de IA, é usar a plataforma para oferecer ou vender a própria tecnologia de IA como produto principal — o assistente de uso geral, do tipo “converse com um modelo pelo WhatsApp”. Se o seu agente responde sobre o seu negócio, ele está do lado permitido.
A arquitetura
- Webhook. O CRPRO Hub entrega
message.received. O seu endpoint verifica a assinatura, deduplica peloiddo evento, coloca numa fila e responde2xx— em até 10 segundos. Um modelo pode levar mais que isso para responder; por isso a chamada a ele não fica no caminho do webhook. Veja receber mensagens. - Worker. Tira o evento da fila, monta o histórico daquela conversa, chama o modelo e envia a resposta pela API do Hub.
- Estado. Duas coisas por conversa: o histórico e se uma pessoa já assumiu. Num banco, não em memória, para sobreviver a um deploy.
O código
O exemplo usa a API da Anthropic; qualquer modelo com API de chat entra no mesmo lugar. Ele é a função do worker — recebe o evento já verificado e deduplicado:
npm install @anthropic-ai/sdk // agente.js import Anthropic from '@anthropic-ai/sdk' const claude = new Anthropic() // le ANTHROPIC_API_KEY do ambiente
const MODELO = 'claude-sonnet-5-5'
const SISTEMA = `Você é o atendente virtual da Loja Exemplo no WhatsApp.
Responda em português, em mensagens curtas, e use só as informações abaixo.
Se não souber a resposta, se o cliente pedir uma pessoa, ou se o assunto for
reclamação, cancelamento ou pagamento, responda exatamente [[HUMANO]].
Horário de atendimento: segunda a sexta, das 9h às 18h.
Prazo de entrega: até 3 dias úteis.`
const historicos = new Map() // canal:contato -> mensagens; use um banco
const comHumano = new Set() // conversas que uma pessoa assumiu
const respostas = new Map() // message_id recebido -> texto ja gerado
async function aoReceber(evento) {
// Em coexistencia, o que a equipe digita no celular chega como
// message.echo: alguem assumiu, e o bot sai da conversa.
if (evento.event === 'message.echo') {
comHumano.add(`${evento.channel_id}:${evento.data.to}`)
return
}
if (evento.event !== 'message.received') return
const conversa = `${evento.channel_id}:${evento.data.from}`
if (comHumano.has(conversa)) return
if (!evento.data.text) {
return enviar(evento, 'Por enquanto eu só leio texto. Pode escrever a sua dúvida?')
}
const historico = [...(historicos.get(conversa) ?? []), { role: 'user', content: evento.data.text }]
// Reprocessar o mesmo evento reaproveita o texto ja gerado: o corpo do envio
// fica identico e a API devolve o envio original. Chamar o modelo de novo
// geraria outro texto, e a mesma Idempotency-Key com outro corpo da 409.
let texto = respostas.get(evento.data.message_id)
if (texto === undefined) {
const resposta = await claude.messages.create({
model: MODELO,
max_tokens: 500,
system: SISTEMA,
messages: aparar(historico),
})
texto = resposta.content
.filter((bloco) => bloco.type === 'text')
.map((bloco) => bloco.text)
.join('\n')
.trim()
respostas.set(evento.data.message_id, texto)
}
if (!texto || texto.includes('[[HUMANO]]')) {
comHumano.add(conversa)
await avisarEquipe(evento) // e-mail, Slack, CRM: o que a sua equipe usa
return enviar(evento, 'Vou chamar uma pessoa da equipe para continuar com você.')
}
const envio = await enviar(evento, texto)
// So entra no historico o que o cliente de fato recebeu.
if (envio.ok) historicos.set(conversa, aparar([...historico, { role: 'assistant', content: texto }]))
return envio
}
// As ultimas 20 mensagens, sempre comecando por uma do usuario.
function aparar(historico) {
const recente = historico.slice(-20)
while (recente.length && recente[0].role !== 'user') recente.shift()
return recente
}
function enviar(evento, texto) {
return fetch(`https://crprohub.com/api/v1/channels/${evento.channel_id}/messages`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.CRPRO_API_KEY}`,
'Content-Type': 'application/json',
// Uma resposta por mensagem recebida, mesmo se o evento for reprocessado.
'Idempotency-Key': `ia-${evento.data.message_id}`,
},
body: JSON.stringify({ to: evento.data.from, type: 'text', text: { body: texto.slice(0, 4096) } }),
})
}Quatro decisões nesse código são de propósito:
- O prompt de sistema limita o assunto. O agente responde com as informações que você deu e, fora delas, chama uma pessoa. Um agente que inventa prazo de entrega gera mais atendimento do que economiza.
- O histórico é curto. As últimas 20 mensagens, sempre começando por uma do cliente. Conversa longa custa mais a cada resposta e quase nunca precisa do começo.
- A chave de idempotência sai da mensagem recebida. Se o mesmo evento for processado duas vezes, o worker reaproveita o texto que já gerou, o corpo do envio sai idêntico e a API devolve o envio original em vez de mandar outra resposta. Chamar o modelo de novo geraria outro texto, e a mesma chave com outro corpo volta
409 IDEMPOTENCY_KEY_REUSED. E só entra no histórico o que o cliente de fato recebeu. - Mídia recebe um aviso, não um palpite. Áudio e imagem chegam sem texto; responder sem entender o conteúdo é pior do que pedir que a pessoa escreva.
O código é executado nos testes do CRPRO Hub com o modelo simulado: a passagem para humano, o eco, o histórico e o corpo de envio são conferidos contra a validação real da API.
Passagem para humano
Todo agente precisa de uma saída. No exemplo, o modelo responde um marcador quando não sabe ou quando o assunto pede uma pessoa; o worker então avisa a equipe, manda uma frase ao cliente e deixa de responder aquela conversa.
Em coexistência, há um sinal ainda melhor: quando alguém da equipe responde pelo celular, o Hub entrega message.echo. O worker trata esse evento como “uma pessoa assumiu” e sai da conversa sozinho — o bot não atropela o atendente. Para isso, o endpoint precisa assinar também message.echo. Devolver a conversa ao bot é decisão sua: por tempo sem mensagens, por um comando da equipe ou quando o atendimento é encerrado no seu sistema.
O que costuma quebrar
- Chamar o modelo dentro do webhook. Passou de 10 segundos, a entrega volta, o modelo é chamado de novo e o endpoint caminha para a pausa. Fila primeiro, sempre.
- Responder ao próprio envio. Endpoint que assina todos os eventos recebe
message.sentemessage.statusdas respostas do bot. Filtre pormessage.received. - Mensagens em rajada. O cliente manda três mensagens seguidas e o worker responde três vezes, fora de ordem. Processe uma conversa por vez, ou espere alguns segundos e junte as mensagens antes de chamar o modelo.
- Resposta atrasada. Resposta logo depois da mensagem do cliente está dentro da janela de 24 horas. Um follow-up do bot no dia seguinte não está, e volta com o erro 131047: fora da janela, só template.
- Bot conversando com bot. Dois sistemas automáticos respondendo um ao outro esbarram no limite de mensagens para o mesmo contato (erro 131056). Ponha um teto de respostas por conversa por minuto.
Quanto custa
São duas contas. A do modelo, por volume de texto processado — o histórico curto e o prompt objetivo são o que a mantém baixa. E a da Meta: resposta dentro da janela de atendimento é mensagem de serviço, que passou a ser cobrada em outubro de 2026 depois de uma franquia mensal por número. Os números e a calculadora estão em preços.