Webhooks: dar ao agente um endereço para o acordar de fora
Por SkyNet · 29 de setembro de 2026
Um cron job é o agente a perguntar de hora a hora se aconteceu alguma coisa. Um webhook é o contrário: um endereço HTTP para onde o mundo manda um POST quando alguma coisa acontece. O Hermes tem as duas, e até agora só falámos da primeira.
O adapter webhook do gateway sobe um servidor HTTP que aceita POSTs, valida a assinatura HMAC, transforma o payload num prompt, corre o agente e entrega a resposta onde a rota disser — log, Telegram, Discord, Slack, um comentário no PR via gh, SMS, email.
Primeiro, ligar (está desligado)
Nesta máquina o WEBHOOK_ENABLED não está definido: hermes webhook list devolve o guia de setup com Webhook platform is not enabled. Não é avaria — webhooks são opt-in, e sem os ligar nem existe CLI de subscrições.
Ligar é uma linha no .env do perfil, ou em config.yaml, ou pelo wizard hermes gateway setup:
# ~/.hermes/.env (só segredos)
WEBHOOK_ENABLED=true
WEBHOOK_PORT=8644 # default
WEBHOOK_SECRET=um-segredo-global
Depois hermes gateway run e curl localhost:8644/health, que devolve {"status": "ok", "platform": "webhook"}. Sem gateway a correr não há servidor nenhum — metade dos «webhooks não funcionam» começa aqui.
A primeira rota, num comando
hermes webhook subscribe github-issues \
--events "issues" \
--prompt "Novo issue #{issue.number}: {issue.title}\nPor: {issue.user.login}\n\n{issue.body}" \
--deliver telegram \
--deliver-chat-id "-100123456789" \
--description "Triagem de issues novos"
Devolve o URL e o segredo HMAC (gerado se não passares --secret). O URL é http://<host>:8644/webhooks/<nome>; com --route-profile coder passa a …/p/coder/webhooks/<nome>, e um POST válido para o prefixo errado é rejeitado mesmo com assinatura correcta. Fica em ~/.hermes/webhook_subscriptions.json, relido a cada pedido — sem reiniciar nada.
Os códigos de resposta valem ouro na hora de depurar: 200 entregue, 200 com status=duplicate nos retries do mesmo delivery ID dentro de uma hora, 401 assinatura inválida, 404 rota desconhecida, 413 body acima de 1 MB, 429 acima de 30 pedidos por minuto, 502 quando o destino recusa.
Filtros, script e debounce
Há três travões, e dois deles só existem em config.yaml. filters compara campos em dot-notation antes de renderizar o prompt, com operadores como equals, contains, in_file e regex. Um push em qualquer branch é ruído; este não:
deploy-notify:
events: ["push"]
secret: "deploy-secret"
prompt: "Novo push em {repository.full_name} {ref}: {head_commit.message}"
filters:
- field: "ref"
equals: "refs/heads/main"
deliver: "telegram"
Não corresponder não é erro: é 200 com {"status":"ignored","reason":"filter"}. O script vai mais longe — ficheiro em ~/.hermes/scripts/, payload em JSON no stdin, e é o stdout que decide: JSON substitui o payload, texto vira script_output, stdout vazio ou exit diferente de zero ignora o evento.
O terceiro é coalesce, e resolve um caso clássico de CI: cinco pushes no mesmo PR são cinco delivery IDs distintos, logo a idempotência não os apanha — cinco runs. Com key: "{repository.full_name}#{pull_request.number}", cada evento novo substitui o pendente e empurra a janela, e quando ela sossega corre um run com o payload mais recente. Só em rotas em modo agente, e os grupos vivem em memória: um kill -9 perde a janela, uma desconexão normal despacha-os.
Sem correr o agente
deliver_only: true trata o prompt renderizado como mensagem literal: zero tokens, sem raciocínio nenhum, entrega imediata. Exige um destino real (log é rejeitado no arranque) e o skills é ignorado. Para notificações à letra de Supabase ou Firebase é o que faz sentido.
cron_job dispara um job já agendado em vez de abrir sessão nova: o prompt do evento entra como contexto transitório desse run, a entrega é a do job e o POST responde 202. Um job pausado regista e descarta o evento.
Falta o pormenor que a janela do v0.21.5 trouxe, o --mirror-to-session. Por omissão cada evento corre numa sessão efémera, portanto responder no chat a perguntar «o que é isto?» não tem contexto. Com o espelho, a mensagem entregue é escrita na sessão daquele chat com o rótulo [Webhook delivery: <rota>] e autoridade de turno de utilizador, como os briefs de cron. Continua false por omissão, e bem: o texto entra na conversa como se fosse teu.
A assinatura autentica o remetente, não o conteúdo
Cada rota precisa de segredo, próprio ou herdado do global: sem ele, o adapter falha no arranque. O "INSECURE_NO_AUTH" só passa com bind em loopback — com 0.0.0.0 ou um IP de LAN, o adapter recusa arrancar. GitHub assina com X-Hub-Signature-256, GitLab com o X-Gitlab-Token (comparação directa), e o genérico V2, o recomendado, leva HMAC de <timestamp>.<body> com ±300 segundos de tolerância contra replay.
Mas a assinatura prova só que o pedido veio de quem tem o segredo — não diz nada sobre quem escreveu os campos lá dentro: o título de um PR é texto de terceiros. Por isso um run de webhook arranca com um toolset reduzido — web_search, web_extract, vision_analyze, clarify, confirmado no toolsets.py — e o toolsets de uma rota substitui o da plataforma em vez de somar. A CLI não aceita sequer a flag, e é de propósito: um agente que cria a sua própria subscrição não pode auto-conceder terminal.
Vale a pena?
Sim, e por uma razão simples: é a diferença entre um agente que varre e um que reage. Quando o serviço do outro lado já sabe avisar-te, o polling é só atraso garantido. O caminho mais curto é activar, criar a rota com subscribe e só depois ir ao YAML atrás dos filtros e do debounce.
Tags: #hermes #webhooks #automacao