Técnica
Como conectar o Claude ao WhatsApp e ter um agente pessoal no dia a dia
whatsappclaude-agent-sdkagentessetuplaunchd
Problema
Muita gente me perguntou como eu conectei o Claude ao meu WhatsApp. A resposta curta é que o Claude que roda no meu computador, com acesso à minha pasta de notas, ganhou uma segunda porta: além do terminal, agora ele atende por mensagem. Eu mando um texto do celular, ele lê os arquivos certos, responde, e quando preciso guarda algo no lugar certo. (Este guia trata só mensagem de texto. Áudio e imagem pedem mais peças, e ficam pra depois.)
O Alfredo, o assistente que cuida da rotina lá de casa pelo WhatsApp, usa essa mesma montagem. O que vem abaixo é a parte que serve pra qualquer pessoa: o canal. O que o agente faz com ele (compras, agenda, lembretes) é decisão sua, e começa pequeno.
Uma coisa antes de qualquer código: um agente com acesso aos seus arquivos, acionado por mensagem, precisa de portão de permissão. Esse é o passo 6, e eu não pularia.
Antes de começar: três avisos
1. O Baileys não é oficial. A biblioteca que fala com o WhatsApp aqui (@whiskeysockets/baileys) se conecta como dispositivo vinculado, mas não é um produto da Meta. O WhatsApp diz, na página Sobre apps não oficiais, que vincular a sua conta a versões não oficiais do WhatsApp viola os Termos de Serviço e pode levar a banimento temporário ou permanente. Eu uso há dois meses sem problema, mas isso é experiência minha, não garantia. Por isso o próximo aviso importa.
2. Use um número dedicado, nunca o seu. Eu comprei um chip pré-pago e botei num celular velho que fica em casa, no wi-fi. Custa uns 15 reais por mês de recarga. Se o WhatsApp banir o número, você perde um chip e não a sua conversa com a família. E nos primeiros dias você vai testar coisa esquisita, melhor num número descartável.
3. O celular do bot precisa ser usado de vez em quando. Segundo o Help Center do WhatsApp, dispositivos vinculados funcionam sem o telefone online, mas são desconectados se o telefone ficar mais de 14 dias sem uso. Bota um lembrete quinzenal: desbloqueia a tela do celular do bot e abre o WhatsApp.
Você também vai precisar de:
- Um Mac (ou Linux) que fique ligado. O processo roda ali, não na nuvem.
- Node 22 ou mais novo.
- Uma chave da API da Anthropic. A documentação do Agent SDK diz que, sem aprovação prévia, a Anthropic não permite que desenvolvedores terceiros ofereçam login claude.ai ou limites de uso da assinatura nos seus produtos, e manda usar a autenticação por chave de API. Aqui o agente é seu e atende só você, mas a regra escrita aponta pra chave, então é o caminho que este guia segue. Você paga por uso, e uso pessoal costuma ser barato.
Técnica
1. O que você vai montar
Um processo Node, ligado 24 horas no seu Mac, que:
- Conecta no WhatsApp como dispositivo vinculado, igual ao WhatsApp Web, sem navegador.
- Recebe a sua mensagem e passa pro Claude Agent SDK, o mesmo motor do Claude Code.
- Roda o agente com a pasta de trabalho apontada pros seus arquivos, então ele lê e escreve de verdade.
- Devolve a resposta no chat.
seu celular → WhatsApp → [Mac: Baileys] → [Claude Agent SDK, cwd = suas notas] → resposta
São três peças:
| Peça | O que faz | Por que essa |
|---|---|---|
@whiskeysockets/baileys | Cliente de WhatsApp em Node, sem navegador | Fala o protocolo do WhatsApp Web direto, sem a API oficial de Business, que é cara e burocrática |
@anthropic-ai/claude-agent-sdk | O cérebro | Roda o agente do Claude Code com ferramentas de arquivo e lê o CLAUDE.md da pasta |
launchd | O supervisor do macOS | Sobe no boot e religa se cair |
Se você ainda não tem um CLAUDE.md na pasta de notas, esse é um bom motivo pra ter: as regras que você escreve pro Claude no computador passam a valer no WhatsApp sem duplicar nada.
2. Esqueleto do projeto
O código mora fora da sua pasta de notas, porque ele não deve ficar dentro do que o agente edita.
mkdir -p ~/Projects/claude-whatsapp && cd ~/Projects/claude-whatsapp
npm init -y
npm pkg set type=module
npm i @whiskeysockets/baileys @anthropic-ai/claude-agent-sdk
npm i -D tsx typescript @types/node
mkdir -p src bin
mkdir -p ~/.claude-whatsapp # estado e segredos, fora do git
O tsx roda TypeScript direto, sem passo de build. Pra um processo pessoal isso vale mais que a pureza de compilar.
O estado fica em ~/.claude-whatsapp/, nunca no repositório. Ali vão a sessão do WhatsApp, o .env e os logs. Se a sua pasta de notas é um repositório com backup, segredo lá dentro vaza pro backup.
Crie o ~/.claude-whatsapp/.env num editor de texto:
ANTHROPIC_API_KEY=sk-ant-...
MEU_NUMERO=5511999999999
VAULT=/Users/seunome/caminho/pra/suas/notas
DRY_RUN=true
MEU_NUMERO é o seu número pessoal (quem vai mandar mensagem pro bot), só dígitos, com o 55 do país. Comece com DRY_RUN=true: na primeira semana você quer ver o que o agente faria antes de deixar ele escrever.
Armadilha que já me custou uma noite: nunca acrescente linha nesse arquivo com
>>no terminal. O arquivo costuma não terminar em quebra de linha, e a variável nova gruda na anterior. A minhaANTHROPIC_API_KEYficou inválida em silêncio por causa disso.
Crie também src/env.ts. Ele carrega o .env antes de qualquer outro arquivo ler variável:
import { join } from "node:path";
import { homedir } from "node:os";
process.loadEnvFile(join(homedir(), ".claude-whatsapp", ".env"));
3. O gateway: falar com o WhatsApp
Este é o único arquivo que conversa com o Baileys. O resto do código não deveria saber que o WhatsApp existe.
src/gateway.ts:
import makeWASocket, {
useMultiFileAuthState,
fetchLatestBaileysVersion,
} from "@whiskeysockets/baileys";
import { join } from "node:path";
import { homedir } from "node:os";
const AUTH_DIR = join(homedir(), ".claude-whatsapp", "wa-auth");
export type Mensagem = {
chatId: string;
autorJid: string;
texto: string;
doBot: boolean;
};
export async function conectar(
h: {
onMensagem: (m: Mensagem) => void;
onConectado: (jid: string) => void;
onDesconectado: (motivo: string, permanente: boolean) => void;
},
opts: { pareamentoPorCodigo?: string } = {},
) {
const { state, saveCreds } = await useMultiFileAuthState(AUTH_DIR);
const { version } = await fetchLatestBaileysVersion();
const sock = makeWASocket({ auth: state, version });
sock.ev.on("creds.update", saveCreds);
let codigoPedido = false;
sock.ev.on("connection.update", (u) => {
// O evento `qr` é o sinal de que o servidor está pronto pra parear.
// Pedir o código antes disso corre o risco de sair antes do handshake.
if (u.qr && opts.pareamentoPorCodigo && !state.creds.registered && !codigoPedido) {
codigoPedido = true;
sock.requestPairingCode(opts.pareamentoPorCodigo).then((codigo) => {
console.log(`\n CÓDIGO DE PAREAMENTO: ${codigo}\n`);
console.log(" No celular do bot: Configurações > Dispositivos conectados >");
console.log(" Conectar dispositivo > Conectar com número de telefone.\n");
});
}
if (u.connection === "open") {
console.log(`[gateway] conectado como ${sock.user?.id}`);
h.onConectado(sock.user?.id ?? "?");
}
if (u.connection === "close") {
const err = u.lastDisconnect?.error as any;
const code = err?.output?.statusCode;
const detalhe = err?.output?.payload?.message ?? err?.message ?? "";
// 401 e 403 são kick de verdade (logout, banimento, sessão substituída).
// O resto é queda de rede e pede reconexão.
const permanente = code === 401 || code === 403;
h.onDesconectado(`código ${code}, ${detalhe}`, permanente);
}
});
sock.ev.on("messages.upsert", async ({ messages }) => {
for (const raw of messages) {
const texto =
raw.message?.conversation ??
raw.message?.extendedTextMessage?.text ??
"";
if (!texto) continue;
h.onMensagem({
chatId: raw.key.remoteJid ?? "",
autorJid: raw.key.participant ?? raw.key.remoteJid ?? "",
texto,
doBot: !!raw.key.fromMe,
});
}
});
return {
enviar: (chatId: string, texto: string) => sock.sendMessage(chatId, { text: texto }),
encerrar: () => sock.end(undefined),
};
}
Duas escolhas que valem explicar.
Config de fábrica no makeWASocket. Tem muito tutorial na internet mandando ajustar browser, syncFullHistory e markOnlineOnConnect. Eu tentei os três num dia de debug e a sessão foi expulsa igual. Não acrescente variável a um problema que você ainda não tem.
O gateway não decide nada. Ele recebe, normaliza e entrega. Quem pode falar, o que vira resposta e o que é ignorado mora em outro arquivo. Você vai agradecer quando quiser testar a lógica sem abrir conexão.
4. O pareamento, onde quase todo mundo quebra
Pareie por código digitado, nunca por QR code. É a lição mais cara do projeto inteiro, e contradiz quase toda documentação que você vai achar.
O que acontece com o QR: ele conecta, parece funcionar, e o WhatsApp revoga o dispositivo uns 60 segundos depois. Na minha montagem o QR deixou o registro pela metade (creds.registered ficou false), o celular nunca mandou o histórico, e veio um conflict / device_removed. Aconteceu duas vezes, em configurações diferentes. O requestPairingCode foi o único caminho que fechou o registro.
bin/parear.ts:
import "../src/env.ts";
import { conectar } from "../src/gateway.ts";
const i = process.argv.indexOf("--codigo");
const numero = i !== -1 ? process.argv[i + 1] : undefined;
if (!/^\d{12,15}$/.test(numero ?? "")) {
console.error("uso: npx tsx bin/parear.ts --codigo 5511999999999");
process.exit(1);
}
// Logo depois do pareamento o WhatsApp fecha a conexão com o código 515
// (restartRequired) e exige um socket novo: as chaves só sobem na segunda
// conexão. Sem reconectar, o pareamento para aqui com apenas o creds.json
// no disco e o processo vivo sem sessão nenhuma.
async function abrir(): Promise<void> {
await conectar(
{
onMensagem: (m) => console.log(`[msg] chat=${m.chatId} autor=${m.autorJid}\n "${m.texto}"`),
onConectado: (jid) => console.log(`[conectado] ${jid}, a sessão está de pé.`),
onDesconectado: (motivo, permanente) => {
console.log(`[desconectado] ${motivo} permanente=${permanente}`);
if (permanente) process.exit(1);
console.log("[reconectando] abrindo socket novo...");
setTimeout(() => abrir().catch(() => process.exit(1)), 2000);
},
},
{ pareamentoPorCodigo: numero },
);
}
await abrir();
console.log("Aguardando... (Ctrl+C depois de conectar; a sessão fica salva)");
setInterval(() => {}, 60_000);
O número que você passa em --codigo é o do chip do bot, não o seu:
cd ~/Projects/claude-whatsapp
npx tsx bin/parear.ts --codigo 5511988887777
O código de 8 caracteres aparece no terminal e expira rápido, então fique com o celular do bot na mão. No celular: Configurações > Dispositivos conectados > Conectar dispositivo > Conectar com número de telefone. Espere o [conectado] aparecer antes de dar Ctrl+C. Esse script também imprime o identificador de cada chat que chega, que você vai querer mais pra frente.
Como saber se deu certo: olhe ~/.claude-whatsapp/wa-auth/. Sessão saudável tem centenas de arquivos. Se tiver só o creds.json, o pareamento parou no meio e vai cair.
| Sinal | Leitura |
|---|---|
Só creds.json no wa-auth/ | O pareamento parou no 515 e ninguém reabriu o socket |
registered: false no creds.json | Foi pareado por QR e vai cair em cerca de 1 minuto |
Timeout in AwaitingInitialSync no log | O celular não mandou o histórico. A queda vem a seguir |
conflict / device_removed | Dispositivo revogado. Pareie de novo por código |
5. O cérebro: chamar o Claude com acesso aos seus arquivos
Este é o ponto em que a coisa deixa de ser um chatbot e passa a ser um agente: ele enxerga a sua pasta.
src/agente.ts:
import { query } from "@anthropic-ai/claude-agent-sdk";
import { classificarFerramenta } from "./permissao.ts";
const SYSTEM_APPEND = `
Você está respondendo por WhatsApp, no celular do usuário.
- Curto é regra dura, não estilo: mire até 600 caracteres, uma tela de celular.
- Uma pergunta por vez, nunca três.
- Ao capturar algo, responda em uma linha: o que entendeu e onde guardou.
`;
const ESCRITA = new Set(["Write", "Edit", "MultiEdit"]);
export async function responder(
prompt: string,
sessionId: string | null,
opts: { liberado?: boolean } = {},
): Promise<{ texto: string; sessionId: string | null }> {
const pedacos: string[] = [];
let sid = sessionId;
const q = query({
prompt,
options: {
cwd: process.env.VAULT!,
systemPrompt: { type: "preset", preset: "claude_code", append: SYSTEM_APPEND },
permissionMode: "default",
settingSources: ["project"],
tools: ["Read", "Grep", "Glob", "Write", "Edit", "WebSearch", "WebFetch"],
resume: sessionId ?? undefined,
canUseTool: async (toolName, input) => {
const { verdict, motivo } = classificarFerramenta(toolName, input ?? {});
if (verdict === "proibido") {
return { behavior: "deny", message: `bloqueado: ${motivo}` };
}
if (verdict === "aprovacao" && !opts.liberado) {
return {
behavior: "deny",
message: `precisa de aprovação: ${motivo}. Diga ao usuário o que você queria fazer e peça que responda "aplica".`,
};
}
if (process.env.DRY_RUN === "true" && ESCRITA.has(toolName)) {
return { behavior: "deny", message: "modo dry-run: nada é escrito" };
}
return { behavior: "allow", updatedInput: input };
},
},
});
for await (const m of q) {
if (m.type === "assistant" && m.message?.content) {
for (const b of m.message.content) {
if ("text" in b && typeof b.text === "string") pedacos.push(b.text);
}
}
if (m.type === "system" && "session_id" in m) sid = String((m as any).session_id);
}
return { texto: pedacos.join("\n").trim() || "(sem resposta)", sessionId: sid };
}
Quatro detalhes que valem mais do que parecem:
cwd. É isso que faz o Claude ver os seus arquivos. Com settingSources: ["project"] ele também carrega o CLAUDE.md da raiz dessa pasta.
settingSources: ["project"], e só ele. Não coloque "user" nem "local". O seu ~/.claude/settings.json pode ter permissões que aprovam ferramenta automaticamente antes dela chegar no seu portão. Herdar isso abre a porta pelo lado de fora, e você nem vê.
A lista tools define o que existe pro agente. O que não está ali simplesmente não existe. Repare no que ficou de fora: Bash, Task (subagente, que teria ferramentas próprias) e Monitor. Qualquer um dos três consegue escrever arquivo por fora da sua regra. Não adianta ter portão na porta com a janela aberta.
resume. É o que dá continuidade de conversa: guarde o session_id que volta e mande de novo no turno seguinte. Vale ter um comando /novo que zera isso, porque uma hora a sessão fica presa num assunto velho.
6. O portão de permissão (não pule)
Você está dando a um processo automático a capacidade de escrever nos seus arquivos, acionado por mensagem, revisando num celular. Pense um segundo no que isso significa.
A regra que eu uso é assimétrica. Livre pra escrever rascunho, captura e arquivo de projeto. Aprovação obrigatória pra escrever nos arquivos que governam o sistema (MEMORY.md, INDEX.md, qualquer CLAUDE.md). Nunca deletar arquivo nem mover pasta.
src/permissao.ts:
import { resolve, relative, basename } from "node:path";
const CANONICOS = new Set(["MEMORY.md", "INDEX.md", "ARCHIVE.md", "CLAUDE.md"]);
export type Verdict = "livre" | "aprovacao" | "proibido";
export function classificarCaminho(path: string): Verdict {
const vault = process.env.VAULT!;
const abs = resolve(vault, path);
const rel = relative(vault, abs);
// fora do vault, ou o vault inteiro: proibido
if (rel.startsWith("..") || rel === "" || rel.startsWith("/")) return "proibido";
if (CANONICOS.has(basename(abs))) return "aprovacao";
return "livre";
}
const LEITURA = new Set(["Read", "Grep", "Glob", "WebSearch", "WebFetch"]);
const ESCRITA = new Set(["Write", "Edit", "MultiEdit"]);
export function classificarFerramenta(
toolName: string,
input: Record<string, unknown>,
): { verdict: Verdict; motivo: string } {
if (LEITURA.has(toolName)) return { verdict: "livre", motivo: "leitura" };
if (toolName === "Bash") return { verdict: "proibido", motivo: "shell não passa" };
if (ESCRITA.has(toolName)) {
const alvo = String(input.file_path ?? input.path ?? "");
return { verdict: classificarCaminho(alvo), motivo: alvo };
}
return { verdict: "proibido", motivo: `ferramenta desconhecida: ${toolName}` };
}
O Bash é bloqueado aqui mesmo já estando fora da lista de tools, de propósito. Se um dia você acrescentar a ferramenta sem lembrar da regra, o portão segura. É redundância barata em cima de coisa que não dá pra desfazer.
O fluxo de aprovação na prática: o agente tenta a escrita, o portão bloqueia, e o agente te avisa no chat o que queria fazer. Você responde “aplica” e ele aplica. A liberação vale um turno só. Isso é melhor que pedir licença em texto antes de tentar, porque o pedido fica registrado e você diz sim uma vez, em vez de duas.
Nunca use bypassPermissions. Do celular você não consegue revisar um diff de verdade, e a única coisa que te protege é o portão.
7. Juntando tudo: o index.ts
Aqui entram a lista de quem pode falar, a trava de tamanho e a regra dos dois processos.
Quem pode falar. O número do bot vai receber mensagem de gente aleatória e spam. Só responda se o autor for você, e ignore em silêncio quem não for. Uma resposta automática confirma pro desconhecido que ali tem um bot. Uma pegadinha que só aparece com grupo: dentro de grupo o autor não chega como telefone, chega como identificador @lid, e você precisaria resolver isso antes de comparar. Enquanto for só conversa 1:1, o telefone basta.
Trava de tamanho. Parede de texto no WhatsApp faz você desengajar do próprio sistema. O prompt já pede resposta curta, mas prompt é pedido e trava é garantia. A versão simples parte a mensagem em blocos de 900 caracteres.
src/tamanho.ts:
export const LIMIAR = 900;
export function partirEmBlocos(texto: string, limite = LIMIAR): string[] {
const blocos: string[] = [];
let atual = "";
for (const paragrafo of texto.split("\n\n")) {
if ((atual + "\n\n" + paragrafo).length <= limite) {
atual = atual ? `${atual}\n\n${paragrafo}` : paragrafo;
continue;
}
if (atual) blocos.push(atual);
let resto = paragrafo;
while (resto.length > limite) {
blocos.push(resto.slice(0, limite));
resto = resto.slice(limite);
}
atual = resto;
}
if (atual) blocos.push(atual);
return blocos;
}
Nunca dois processos ao mesmo tempo. Duas conexões com a mesma credencial dão conflict, e o WhatsApp revoga o dispositivo. Isso me custou dois pareamentos num dia só. Antes de rodar qualquer coisa à mão, desligue o serviço do launchd (passo 8). Isso também mata a ideia de escrever um scriptzinho separado pra mandar mensagem: ele abriria uma conexão própria. O envio sai sempre de dentro do processo que já está rodando. Um lockfile barato ajuda.
src/index.ts:
import "./env.ts";
import { existsSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";
import { conectar } from "./gateway.ts";
import { responder } from "./agente.ts";
import { partirEmBlocos } from "./tamanho.ts";
const LOCK = join(homedir(), ".claude-whatsapp", "wa.lock");
if (existsSync(LOCK)) {
const pid = Number(readFileSync(LOCK, "utf8"));
try {
process.kill(pid, 0);
console.error(`Já tem uma instância rodando (pid ${pid}). Saindo.`);
// exit 0 de propósito: com SuccessfulExit=false no launchd, sair "com sucesso"
// é o que impede de religar contra uma instância que já existe
process.exit(0);
} catch {
// processo morto, lock órfão: segue e sobrescreve
}
}
writeFileSync(LOCK, String(process.pid));
process.on("exit", () => {
try {
unlinkSync(LOCK);
} catch {}
});
const MEU_JID = `${process.env.MEU_NUMERO}@s.whatsapp.net`;
const sessoes = new Map<string, string | null>();
let enviar: (chatId: string, texto: string) => Promise<unknown>;
async function tratar(chatId: string, autorJid: string, texto: string, doBot: boolean) {
if (doBot || autorJid !== MEU_JID) return; // silêncio, de propósito
// log só com metadado, nunca o texto da conversa
console.log(`[msg] ${new Date().toISOString()} ${texto.length} caracteres`);
if (texto.trim() === "/novo") {
sessoes.delete(chatId);
await enviar(chatId, "Conversa zerada.");
return;
}
const liberado = texto.trim().toLowerCase() === "aplica";
const prompt = liberado ? "Aplique a alteração que você tentou fazer e foi bloqueada." : texto;
const r = await responder(prompt, sessoes.get(chatId) ?? null, { liberado });
sessoes.set(chatId, r.sessionId);
for (const bloco of partirEmBlocos(r.texto)) await enviar(chatId, bloco);
}
async function abrir(): Promise<void> {
const canal = await conectar({
onMensagem: (m) => {
// try/catch em volta de cada mensagem: erro engolido deixa o bot mudo
tratar(m.chatId, m.autorJid, m.texto, m.doBot).catch((e) =>
console.error("[erro]", e instanceof Error ? e.message : e),
);
},
onConectado: (jid) => console.log(`[conectado] ${jid}`),
onDesconectado: (motivo, permanente) => {
console.log(`[desconectado] ${motivo} permanente=${permanente}`);
// Saída deliberada (exit 0) pra o launchd não ficar religando um logout
// que só um humano resolve, digitando o código no celular.
if (permanente) process.exit(0);
setTimeout(() => abrir().catch(() => process.exit(1)), 2000);
},
});
enviar = canal.enviar;
}
await abrir();
8. Deixar ligado de verdade (launchd)
Rodar no terminal serve pra testar. Pra valer, o launchd sobe o processo no boot e religa se ele cair.
~/Library/LaunchAgents/com.seunome.claude-whatsapp.plist:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.seunome.claude-whatsapp</string>
<key>ProgramArguments</key>
<array>
<string>/usr/local/bin/node</string>
<string>--import</string>
<string>tsx</string>
<string>/Users/seunome/Projects/claude-whatsapp/src/index.ts</string>
</array>
<key>WorkingDirectory</key>
<string>/Users/seunome/Projects/claude-whatsapp</string>
<key>EnvironmentVariables</key>
<dict>
<key>USER</key>
<string>seunome</string>
<key>HOME</key>
<string>/Users/seunome</string>
<key>PATH</key>
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
</dict>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<dict>
<key>SuccessfulExit</key>
<false/>
</dict>
<key>ThrottleInterval</key>
<integer>30</integer>
<key>StandardOutPath</key>
<string>/Users/seunome/.claude-whatsapp/wa.log</string>
<key>StandardErrorPath</key>
<string>/Users/seunome/.claude-whatsapp/wa.error.log</string>
</dict>
</plist>
Troque seunome pelo seu usuário e confira o caminho do node com which node (no Homebrew de Mac com chip Apple costuma ser /opt/homebrew/bin/node).
Duas coisas que parecem detalhe e não são:
O launchd não herda o ambiente do seu terminal. Tudo que o processo precisa tem que estar declarado no plist, incluindo USER e HOME. Na minha montagem, sem USER o SDK respondia “Not logged in”.
KeepAlive com SuccessfulExit: false, e não true. Assim uma queda religa sozinha, mas uma saída deliberada não. Isso importa porque logout do WhatsApp exige um humano digitando código no celular, e religar sozinho nesse caso vira loop infinito consumindo bateria e log.
launchctl load ~/Library/LaunchAgents/com.seunome.claude-whatsapp.plist # ligar
launchctl unload ~/Library/LaunchAgents/com.seunome.claude-whatsapp.plist # desligar
launchctl list | grep claude-whatsapp # status 0 = ok
tail -f ~/.claude-whatsapp/wa.log # log
9. A ordem que eu sugiro
Não construa tudo de uma vez. Cada passo termina em algo que funciona:
- Gateway e pareamento. Rode o
bin/parear.tse veja as mensagens aparecendo no terminal. Só isso, um dia inteiro se precisar. Se o pareamento estiver frágil, tudo em cima vai parecer bug de outra coisa. - Eco. Faça o bot responder repetindo o que você mandou. Confirma que o envio funciona.
- Claude só lendo. Ligue o Agent SDK com
tools: ["Read", "Grep", "Glob"]. Ele lê os seus arquivos e responde. Já é útil, e é o que eu deixaria rodando por alguns dias. - Portão e escrita, com
DRY_RUN=true. Uma semana vendo o que ele faria. DRY_RUN=false. Agora vale.launchd. Deixa de ser experimento.
O passo 3 é onde o retorno aparece. Se você parar ali, ainda vale a pena.
10. Quando quebrar
| Sintoma | Onde olhar |
|---|---|
| Log diz “conectado” mas nada chega | Processo vivo com conexão morta. Mate o processo e deixe o launchd religar |
| Agente responde “Not logged in” | Falta USER e HOME no plist, ou a ANTHROPIC_API_KEY está inválida no .env |
launchctl list com status diferente de 0 | Veja o wa.error.log |
| Sessão cai em cerca de 1 minuto | Foi pareado por QR. Pareie de novo por código |
| Bot mudo com processo vivo | Erro no tratamento que engoliu a exceção. Mantenha o .catch em volta de cada mensagem |
| Sessão some depois de umas duas semanas | Regra dos 14 dias. Use o celular do bot e pareie de novo se precisar |
Sobre logs: registre metadado (quando, quantos caracteres) e nunca o conteúdo das conversas. Você vai precisar dos logs pra debugar, e não vai querer um arquivo com meses de conversa em texto puro no disco.
O princípio que eu mais repito sobre esse canal: a mensagem chega, a resposta sai, ninguém fica mudo. Silêncio explicável é regra. Silêncio inexplicável é o pior erro possível aqui, porque você para de confiar no sistema e volta a fazer tudo na mão.
Resumo das armadilhas
Se você ler só uma parte, leia esta:
- Número dedicado, nunca o seu. O Baileys não é oficial e o WhatsApp pode banir.
- Pareie por código, nunca por QR.
- Reconecte depois do 515. O pareamento só completa na segunda conexão.
- Nunca dois processos ao mesmo tempo. Isso revoga o dispositivo.
settingSources: ["project"], semusere semlocal.- Tire
Bash,TaskeMonitorda lista de ferramentas. USEReHOMEno plist, porque olaunchdnão herda o seu ambiente.- Segredo fora do repositório de notas, e nunca
>>no.env. DRY_RUN=truena primeira semana.- Use o celular do bot a cada duas semanas, ou o WhatsApp desconecta o dispositivo.
Fontes
- WhatsApp, Sobre apps não oficiais
- WhatsApp, Sobre dispositivos vinculados
- Anthropic, Visão geral do Agent SDK
- Repositório do Baileys