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:

Técnica

1. O que você vai montar

Um processo Node, ligado 24 horas no seu Mac, que:

  1. Conecta no WhatsApp como dispositivo vinculado, igual ao WhatsApp Web, sem navegador.
  2. Recebe a sua mensagem e passa pro Claude Agent SDK, o mesmo motor do Claude Code.
  3. Roda o agente com a pasta de trabalho apontada pros seus arquivos, então ele lê e escreve de verdade.
  4. Devolve a resposta no chat.
seu celular → WhatsApp → [Mac: Baileys] → [Claude Agent SDK, cwd = suas notas] → resposta

São três peças:

PeçaO que fazPor que essa
@whiskeysockets/baileysCliente de WhatsApp em Node, sem navegadorFala o protocolo do WhatsApp Web direto, sem a API oficial de Business, que é cara e burocrática
@anthropic-ai/claude-agent-sdkO cérebroRoda o agente do Claude Code com ferramentas de arquivo e lê o CLAUDE.md da pasta
launchdO supervisor do macOSSobe 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 minha ANTHROPIC_API_KEY ficou 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.

SinalLeitura
Só creds.json no wa-auth/O pareamento parou no 515 e ninguém reabriu o socket
registered: false no creds.jsonFoi pareado por QR e vai cair em cerca de 1 minuto
Timeout in AwaitingInitialSync no logO celular não mandou o histórico. A queda vem a seguir
conflict / device_removedDispositivo 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:

  1. Gateway e pareamento. Rode o bin/parear.ts e 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.
  2. Eco. Faça o bot responder repetindo o que você mandou. Confirma que o envio funciona.
  3. 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.
  4. Portão e escrita, com DRY_RUN=true. Uma semana vendo o que ele faria.
  5. DRY_RUN=false. Agora vale.
  6. launchd. Deixa de ser experimento.

O passo 3 é onde o retorno aparece. Se você parar ali, ainda vale a pena.

10. Quando quebrar

SintomaOnde olhar
Log diz “conectado” mas nada chegaProcesso 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 0Veja o wa.error.log
Sessão cai em cerca de 1 minutoFoi pareado por QR. Pareie de novo por código
Bot mudo com processo vivoErro no tratamento que engoliu a exceção. Mantenha o .catch em volta de cada mensagem
Sessão some depois de umas duas semanasRegra 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:

  1. Número dedicado, nunca o seu. O Baileys não é oficial e o WhatsApp pode banir.
  2. Pareie por código, nunca por QR.
  3. Reconecte depois do 515. O pareamento só completa na segunda conexão.
  4. Nunca dois processos ao mesmo tempo. Isso revoga o dispositivo.
  5. settingSources: ["project"], sem user e sem local.
  6. Tire Bash, Task e Monitor da lista de ferramentas.
  7. USER e HOME no plist, porque o launchd não herda o seu ambiente.
  8. Segredo fora do repositório de notas, e nunca >> no .env.
  9. DRY_RUN=true na primeira semana.
  10. Use o celular do bot a cada duas semanas, ou o WhatsApp desconecta o dispositivo.

Fontes