Documentação oficial desta página · v0.20.6
Hooks de eventos
O Hermes tem quatro sistemas de hook que rodam código próprio em pontos-chave do ciclo de vida:
| Sistema | Registrado por | Roda em | Para que serve |
|---|---|---|---|
| Hooks do gateway | HOOK.yaml + handler.py em ~/.hermes/hooks/ | Só no gateway | Registro em log, alertas, webhooks |
| Hooks de plugin | ctx.register_hook() num plugin | CLI + gateway | Interceptação de ferramentas, métricas, salvaguardas |
| Hooks de shell | bloco hooks: em ~/.hermes/config.yaml apontando para scripts de shell | CLI + gateway | Scripts prontos para bloquear, formatar automaticamente, injetar contexto |
| Webhooks de saída | lista hooks.outbound: em ~/.hermes/config.yaml | CLI + gateway | Empurrar eventos de ciclo de vida assinados para endpoints HTTP externos — CI, painéis, outros agentes |
Erros em callbacks de hook são isolados e registrados em log, em vez de derrubar o agente. Nem todos os hooks são passivos: hooks de diretiva/controle podem mudar o fluxo, transformações podem substituir conteúdo, e um hook de shell em pre_tool_call pode bloquear ou falhar fechado.
Hooks de eventos do gateway
Os hooks do gateway disparam automaticamente durante a operação do gateway (Telegram, Discord, Slack, WhatsApp, Teams), sem travar o pipeline principal do agente.
Criar um hook
Cada hook é um diretório em ~/.hermes/hooks/ com dois arquivos:
~/.hermes/hooks/
└── meu-hook/
├── HOOK.yaml # Declara em quais eventos escutar
└── handler.py # Função Python que trata o evento
HOOK.yaml
name: meu-hook
description: Registra em arquivo toda a atividade do agente
events:
- agent:start
- agent:end
- agent:step
A lista events determina quais eventos acionam o seu handler. Você pode assinar qualquer combinação de eventos, inclusive curingas como command:*.
handler.py
import json
from datetime import datetime
from pathlib import Path
LOG_FILE = Path.home() / ".hermes" / "hooks" / "meu-hook" / "activity.log"
async def handle(event_type: str, context: dict):
"""Chamada a cada evento assinado. Precisa se chamar 'handle'."""
entry = {
"timestamp": datetime.now().isoformat(),
"event": event_type,
**context,
}
with open(LOG_FILE, "a") as f:
f.write(json.dumps(entry) + "\n")
Regras do handler:
- Precisa se chamar
handle - Recebe
event_type(string) econtext(dicionário) - Pode ser
async defoudefnormal — os dois funcionam - Erros são capturados e registrados, e nunca derrubam o agente
Eventos disponíveis
| Evento | Quando dispara | Chaves do contexto |
|---|---|---|
gateway:startup | O processo do gateway inicia | platforms (lista com os nomes das plataformas ativas) |
session:start | Nova sessão de mensagens criada | platform, user_id, session_id, session_key |
session:end | Sessão encerrada (antes do reset) | platform, user_id, session_key |
session:reset | O usuário rodou /new ou /reset | platform, user_id, session_key |
session:compress | A compressão de contexto de uma sessão terminou | platform, session_id, old_session_id (vazio quando a compactação foi feita no lugar), in_place (booleano — true = transcrição compactada no mesmo id, false = rotacionada a partir de old_session_id), compression_count |
agent:start | O agente começa a processar uma mensagem | platform, user_id, chat_id, thread_id (id do tópico de fórum / da raiz da thread; vazio fora de uma thread), chat_type ("dm" | "group" | "forum"; vazio se desconhecido), session_id, message (truncada em 500 caracteres) |
agent:step | Cada iteração do laço de chamada de ferramentas | platform, user_id, session_id, iteration, tool_names |
agent:end | O agente termina o processamento | as mesmas chaves de agent:start, mais response (truncada em 500 caracteres) |
reaction:added | Uma reação de emoji foi adicionada a uma mensagem que o bot enxerga (hoje, no adaptador do Slack). Exige o escopo reactions:read e a assinatura do evento de bot reaction_added; o bot precisa ser membro do canal. | platform, reaction, user_id, item_user_id, item_type, channel_id, message_ts, team_id, event_ts, raw_event |
reaction:removed | Uma reação de emoji foi removida de uma mensagem que o bot enxerga. Exige a assinatura do evento de bot reaction_removed. | mesmo formato de reaction:added |
command:* | Qualquer comando de barra executado | platform, user_id, command, args |
Correspondência por curinga
Handlers registrados em command:* disparam para qualquer evento command: (command:model, command:reset etc.). Monitore todos os comandos de barra com uma única assinatura.
Um handler que publica uma mensagem de acompanhamento no mesmo tópico de fórum do Telegram deve incluir message_thread_id=int(thread_id) quando chat_type == "forum" e thread_id não estiver vazio.
Exemplos
Alerta no Telegram em tarefas longas
Mande uma mensagem para você mesmo quando o agente passar de 10 passos:
# ~/.hermes/hooks/long-task-alert/HOOK.yaml
name: long-task-alert
description: Avisa quando o agente está dando muitos passos
events:
- agent:step
# ~/.hermes/hooks/long-task-alert/handler.py
import os
import httpx
THRESHOLD = 10
BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN")
CHAT_ID = os.getenv("TELEGRAM_HOME_CHANNEL")
async def handle(event_type: str, context: dict):
iteration = context.get("iteration", 0)
if iteration == THRESHOLD and BOT_TOKEN and CHAT_ID:
tools = ", ".join(context.get("tool_names", []))
text = f"⚠️ O agente já está rodando há {iteration} passos. Últimas ferramentas: {tools}"
async with httpx.AsyncClient() as client:
await client.post(
f"https://api.telegram.org/bot{BOT_TOKEN}/sendMessage",
json={"chat_id": CHAT_ID, "text": text},
)
Registro de uso de comandos
Acompanhe quais comandos de barra são usados:
# ~/.hermes/hooks/command-logger/HOOK.yaml
name: command-logger
description: Registra o uso de comandos de barra
events:
- command:*
# ~/.hermes/hooks/command-logger/handler.py
import json
from datetime import datetime
from pathlib import Path
LOG = Path.home() / ".hermes" / "logs" / "command_usage.jsonl"
def handle(event_type: str, context: dict):
LOG.parent.mkdir(parents=True, exist_ok=True)
entry = {
"ts": datetime.now().isoformat(),
"command": context.get("command"),
"args": context.get("args"),
"platform": context.get("platform"),
"user": context.get("user_id"),
}
with open(LOG, "a") as f:
f.write(json.dumps(entry) + "\n")
Webhook no início de sessão
Faça um POST para um serviço externo quando surgir uma sessão nova:
# ~/.hermes/hooks/session-webhook/HOOK.yaml
name: session-webhook
description: Avisa um serviço externo quando há sessão nova
events:
- session:start
- session:reset
# ~/.hermes/hooks/session-webhook/handler.py
import httpx
WEBHOOK_URL = "https://seu-servico.example.com/hermes-events"
async def handle(event_type: str, context: dict):
async with httpx.AsyncClient() as client:
await client.post(WEBHOOK_URL, json={
"event": event_type,
**context,
}, timeout=5)
Tutorial: BOOT.md — rode uma checklist de partida a cada boot do gateway
Um padrão popular na comunidade: deixe uma checklist em Markdown em ~/.hermes/BOOT.md e faça o agente executá-la uma vez a cada vez que o gateway inicia. Serve para coisas como "a cada boot, verifique falhas de cron durante a noite e me avise no Discord se algo falhou" ou "resuma as últimas 24h do deploy.log e publique no canal #ops do Slack".
Este tutorial mostra como montar isso você mesmo, como um hook definido por você. O Hermes não traz um hook BOOT.md embutido — você liga exatamente o comportamento que quer.
O que vamos construir
- Um arquivo em
~/.hermes/BOOT.mdcom instruções de partida em linguagem natural. - Um hook de gateway que dispara em
gateway:startup, cria um agente de uso único com o modelo e as credenciais já resolvidos do seu gateway, e executa as instruções do BOOT.md. - Uma convenção
[SILENT]para o agente poder optar por não mandar mensagem quando não há nada a relatar.
Passo 1: escreva a sua checklist
Crie o ~/.hermes/BOOT.md. Escreva como se estivesse instruindo um assistente humano:
# Checklist de partida
1. Rode `hermes cron list` e verifique se alguma tarefa agendada falhou durante a noite.
2. Se alguma falhou, resuma para o canal #ops do Discord (o hook entrega a sua resposta final ao destino configurado).
3. Verifique se `/opt/app/deploy.log` tem linhas com ERROR nas últimas 24 horas. Se tiver, resuma e inclua no mesmo relatório.
4. Se nada deu errado, responda apenas `[SILENT]` para que nenhuma mensagem seja enviada.
O agente vê isso como parte do prompt dele, então vale tudo que você consegue descrever em linguagem simples — chamadas de ferramenta, comandos de shell, envio de mensagens, resumo de arquivos.
Passo 2: crie o hook
~/.hermes/hooks/boot-md/
├── HOOK.yaml
└── handler.py
~/.hermes/hooks/boot-md/HOOK.yaml
name: boot-md
description: Roda o ~/.hermes/BOOT.md na partida do gateway
events:
- gateway:startup
~/.hermes/hooks/boot-md/handler.py
"""Roda o ~/.hermes/BOOT.md a cada partida do gateway."""
import logging
import threading
from pathlib import Path
logger = logging.getLogger("hooks.boot-md")
BOOT_FILE = Path.home() / ".hermes" / "BOOT.md"
def _build_prompt(content: str) -> str:
return (
"You are running a startup boot checklist. Follow the instructions "
"below exactly.\n\n"
"---\n"
f"{content}\n"
"---\n\n"
"Execute each instruction. Put any user-facing summary in your "
"final response — the hook delivers it to the configured channel "
"(e.g. Discord or Slack); you do not send messages yourself.\n"
"If nothing needs attention and there is nothing to report, reply "
"with ONLY: [SILENT]"
)
def _run_boot_agent(content: str) -> None:
"""Cria um agente de uso único e executa a checklist.
Usa o modelo já resolvido do gateway e as credenciais de execução, para que
isso funcione igual com endpoints personalizados, agregadores e provedores
baseados em OAuth.
"""
try:
from gateway.run import _resolve_gateway_model, _resolve_runtime_agent_kwargs
from run_agent import AIAgent
agent = AIAgent(
model=_resolve_gateway_model(),
**_resolve_runtime_agent_kwargs(),
platform="gateway",
quiet_mode=True,
skip_context_files=True,
skip_memory=True,
max_iterations=20,
)
result = agent.run_conversation(_build_prompt(content))
response = (result.get("final_response", "") or "").strip()
if response.upper() not in {"[SILENT]", "SILENT", "NO_REPLY", "NO REPLY"}:
logger.info("boot-md completed: %s", response[:200])
else:
logger.info("boot-md completed (nothing to report)")
except Exception as e:
logger.error("boot-md agent failed: %s", e)
async def handle(event_type: str, context: dict) -> None:
if not BOOT_FILE.exists():
return
content = BOOT_FILE.read_text(encoding="utf-8").strip()
if not content:
return
logger.info("Running BOOT.md (%d chars)", len(content))
# Thread em segundo plano, para a partida do gateway não ficar presa a um turno inteiro do agente.
thread = threading.Thread(
target=_run_boot_agent,
args=(content,),
name="boot-md",
daemon=True,
)
thread.start()
As duas linhas mais importantes:
_resolve_gateway_model()lê o modelo configurado no gateway naquele momento._resolve_runtime_agent_kwargs()resolve as credenciais do provedor do mesmo jeito que um turno normal do gateway — incluindo chaves de API, URLs-base, tokens OAuth e pools de credenciais.
Sem isso, um AIAgent() pelado cai nos padrões embutidos e vai tomar 401 em qualquer endpoint que não seja o padrão.
Passo 3: teste
Reinicie o gateway:
hermes gateway restart
Acompanhe os logs:
hermes logs --follow --level INFO | grep boot-md
Você deve ver Running BOOT.md (N chars) seguido de boot-md completed: ... (resumo do que o agente fez) ou de boot-md completed (nothing to report), quando o agente respondeu com um token de silêncio exato como [SILENT].
Apague o ~/.hermes/BOOT.md para desativar a checklist — o hook continua carregado, mas passa batido em silêncio quando o arquivo não está lá.
Estender o padrão
- Checklists que sabem o dia: use
datetime.now().weekday()dentro das instruções do BOOT.md ("se for segunda-feira, verifique também o log semanal de deploy"). As instruções são texto livre, então vale qualquer coisa sobre a qual o agente consiga raciocinar. - Várias checklists: aponte o hook para outro arquivo (
STARTUP.md,MORNING.mdetc.) e registre um diretório de hook separado para cada um. - Variante sem agente: se você não precisa de um laço completo de agente, dispense o
AIAgente faça o handler publicar direto uma notificação fixa viahttpx. Mais barato, mais rápido, e sem depender de provedor.
Por que isso não é embutido
Uma versão antiga do Hermes trazia isso como hook embutido e criava, em silêncio, um agente com os padrões pelados a cada boot do gateway. Isso pegou de surpresa quem usava endpoints personalizados e deixou o recurso invisível para quem nem sabia que ele estava rodando. Manter isso como um padrão documentado — montado por você, no seu diretório de hooks — significa que você vê exatamente o que ele faz e adere escrevendo os arquivos.
Como funciona
- Na partida do gateway, o
HookRegistry.discover_and_load()varre o~/.hermes/hooks/ - Cada subdiretório com
HOOK.yaml+handler.pyé carregado dinamicamente - Os handlers são registrados nos eventos que declararam
- Em cada ponto do ciclo de vida, o
hooks.emit()dispara todos os handlers correspondentes - Erros em qualquer handler são capturados e registrados — um hook quebrado nunca derruba o agente
Hooks de gateway só disparam no gateway (Telegram, Discord, Slack, WhatsApp, Teams). A CLI não carrega hooks de gateway. Para hooks que funcionam em todo lugar, use hooks de plugin.
Hooks de plugin
Plugins podem registrar hooks que disparam em sessões tanto da CLI quanto do gateway. Eles são registrados por código, com ctx.register_hook(), dentro da função register() do seu plugin.
Para detalhes de empacotamento e registro de plugins, veja o guia de plugins.
def register(ctx):
ctx.register_hook("pre_tool_call", my_tool_observer)
ctx.register_hook("post_tool_call", my_tool_logger)
ctx.register_hook("pre_llm_call", my_memory_callback)
ctx.register_hook("post_llm_call", my_sync_callback)
ctx.register_hook("on_session_start", my_init_callback)
ctx.register_hook("on_session_end", my_cleanup_callback)
# Ciclo de vida do quadro kanban (a espera por dependência pode disparar dentro da transação dela):
ctx.register_hook("kanban_task_claimed", my_claim_callback) # processo despachante
ctx.register_hook("kanban_task_completed", my_done_callback) # processo trabalhador
ctx.register_hook("kanban_task_blocked", my_blocked_callback) # processo trabalhador
Regras gerais para todos os hooks:
- Os callbacks recebem argumentos nomeados. Aceite sempre
**kwargs, por compatibilidade futura. - Exceções em callback são registradas e o callback é pulado; os callbacks seguintes continuam.
- Se um callback Python de plugin num hook com limite de tempo (observadores de caminho quente, como
post_tool_call/pre_llm_call, mais o hook de políticapre_tool_call) travar por mais queplugins.hook_callback_timeout(padrão 30s,0desativa, máximo 600), ele é abandonado sem esperar o worker, para o laço do agente continuar. Callbacks depre_tool_callque estouraram o tempo ou ainda estão rodando falham fechado (bloqueiam a ferramenta); os outros hooks com limite falham abertos (são pulados). Hooks com contrato documentado de thread chamadora (subagent_stop) nunca são movidos para um worker com limite de tempo. Hooks de shell mantêm otimeoutpróprio de cada entrada. - O catálogo abaixo é descritivo: observadores ignoram o retorno, transformações aceitam a primeira substituição válida em string, e hooks de diretiva/controle consomem formatos de retorno documentados. Middleware de plugin é um registro e uma superfície à parte, não mais uma categoria de hook.
- Campos de correlação como
turn_id,api_request_id,task_id,session_ideapi_call_countsão específicos de cada hook e podem não existir. Trate os IDs como opacos. - A validade dos nomes de evento em tempo de execução vem de
hermes_cli.plugins.VALID_HOOKS. Ohermes hooks listlista os hooks de shell e de saída configurados, não todo evento disponível; ohermes hooks test <event>só informa o conjunto válido quando você passa um evento inválido.
Seções de prompt do sistema seguras para cache
Plugins que precisam de orientação durável e sempre ativa podem registrar uma seção
limitada do prompt do sistema, em vez de injetar o mesmo texto pelo pre_llm_call a
cada turno:
def board_rules(session_info):
return f"Apply the worker rules for profile {session_info['profile_name']}."
def register(ctx):
ctx.register_system_prompt_section(
"kanban-advanced.worker-rules",
board_rules, # uma string também é aceita
position="after_memory",
max_chars=4000,
)
O contrato é propositalmente estreito:
- Os IDs são globais, estáveis, identificadores minúsculos de 1 a 128 caracteres, usando só
letras, números,
.,_e-. IDs duplicados são rejeitados. after_memoryé a única âncora de posicionamento. As seções são ordenadas por ID, renderizadas depois do contexto de memória/perfil e antes dos metadados de sessão; plugins não conseguem reordenar nem substituir o conteúdo central do prompt.- Um callable recebe um mapa somente leitura com
session_id,model,provider,platform,profile_nameecwd. Ele roda uma vez por sessão nova. Os bytes renderizados dele são congelados na compressão e recuperados do prompt do sistema completo já persistido depois de um reinício/retomada do processo; o estado do plugin não é lido de novo numa sessão existente. - O
max_charsé limitado a 4.000 caracteres. Todas as seções de plugin juntas, incluindo os cabeçalhos de auditoria delas, são limitadas a 8.000 caracteres e 32 seções. Seções vazias, que não sejam string, grandes demais, que estourem o orçamento agregado ou que levantem exceção são puladas com um aviso; a construção do prompt continua. - Toda seção aceita é nomeada no prompt e registrada em log no início da sessão, com o plugin, a posição e a contagem de caracteres.
Use o pre_llm_call para contexto realmente dinâmico, turno a turno. De propósito, não
existe neste contrato um hook de plugin para dicas de ambiente: mudar o cwd, o branch ou
outros dados de ambiente não pode mutar em silêncio o prompt em cache de uma sessão.
Um hook desses precisa de um consumidor concreto e das mesmas semânticas de congelamento
e retomada segura antes de poder existir.
Catálogo dos hooks de plugin entregues
Os campos de payload abaixo são exatamente os campos específicos de evento fornecidos por cada ponto de chamada. Por compatibilidade retroativa, o PluginManager também acrescenta telemetry_schema_version="hermes.observer.v1" a todo callback de hook de plugin. Esse marcador de envelope legado não significa que todos os payloads de hook compartilham um esquema semântico; contratos novos versionados pertencem ao evento concreto ou à família de capacidade deles.
| Hook | Categoria | Momento exato e comportamento de retorno | Campos explícitos do payload | Privacidade / sensibilidade |
|---|---|---|---|---|
pre_tool_call | Diretiva/controle | Uma vez antes da execução; vence a primeira diretiva block ou approve válida, e retornos modify são mesclados de forma rasa nos argumentos da ferramenta. | tool_name, args, task_id, session_id, tool_call_id, turn_id, api_request_id, middleware_trace | Os argumentos crus podem conter conteúdo do usuário, caminhos, comandos ou segredos. |
post_tool_call | Observador | Depois de resultado bloqueado, com erro ou bem-sucedido; o retorno é ignorado. | tool_name, args, result, task_id, session_id, tool_call_id, turn_id, api_request_id, duration_ms, status, error_type, error_message, middleware_trace | O texto de resultado/erro pode conter conteúdo arbitrário de ferramenta ou do usuário, e segredos. |
transform_tool_result | Transformação | Depois do post_tool_call, antes de anexar à conversa; a primeira string substitui o resultado. | tool_name, args, result, task_id, session_id, tool_call_id, turn_id, api_request_id, duration_ms, status, error_type, error_message | Expõe o resultado completo destinado ao modelo e os argumentos. |
transform_terminal_output | Transformação | Depois da captura limitada do processo em primeiro plano, antes da limitação final da saída; a primeira string substitui a saída. | command, output, returncode, task_id, env_type | Comando e saída podem conter credenciais. |
pre_transcription | Transformação | Disparado pelo despachante de STT depois de resolver o provedor e antes de invocar qualquer backend (embutido, do tipo comando ou registrado por plugin); resultados em dicionário são aplicados na ordem de registro, valendo quem escreve por último em cada campo (prompt, language, model; file_path é somente leitura). | file_path, provider, model, language, prompt, source | O prompt final é enviado ao provedor de STT configurado junto com o áudio — não devolva segredos no hook. |
pre_llm_call | Diretiva/controle | Uma vez por turno, antes do laço; todos os retornos válidos em string ou {"context": ...} são juntados e injetados na mensagem do usuário. | session_id, task_id, turn_id, user_message, conversation_history, is_first_turn, model, platform, parent_session_id, sender_id | Mensagem do usuário e histórico da conversa completos. |
post_llm_call | Observador | Finalização de um turno bem-sucedido e não interrompido; o retorno é ignorado. | session_id, task_id, turn_id, user_message, assistant_response, conversation_history, model, platform | Prompt, resposta e histórico completos. |
transform_llm_output | Transformação | Antes do post_llm_call e da entrega final; a primeira string não vazia substitui a resposta. | response_text, session_id, model, platform | Texto final completo do assistente. |
pre_verify | Diretiva/controle | No portão limitado de verificação de código editado; a primeira diretiva válida de continuar/bloquear-a-parada mantém o turno andando. | session_id, platform, model, coding, attempt, final_response, changed_paths | Rascunho da resposta e caminhos alterados. |
pre_api_request | Observador | A cada tentativa de provedor, imediatamente antes da requisição; o retorno é ignorado. | task_id, turn_id, api_request_id, session_id, user_message, conversation_history, platform, model, provider, base_url, api_mode, api_call_count, retry_count, request_messages, message_count, tool_count, approx_input_tokens, request_char_count, max_tokens, started_at, middleware_trace, request | Sensibilidade alta: os campos legados user_message, conversation_history e request_messages são crus de propósito; prefira o request já saneado. |
post_api_request | Observador | Depois do sucesso normalizado do provedor; o retorno é ignorado. | task_id, turn_id, api_request_id, session_id, platform, model, provider, base_url, api_mode, api_call_count, api_duration, started_at, ended_at, finish_reason, message_count, response_model, response, usage, assistant_message, assistant_content_chars, assistant_tool_call_count | O response saneado está disponível, mas o assistant_message normalizado cru pode conter conteúdo do modelo ou do usuário; usage é dado contábil. |
api_request_error | Observador | A cada tentativa de provedor que falha; o retorno é ignorado. | task_id, turn_id, api_request_id, session_id, platform, model, provider, base_url, api_mode, api_call_count, api_duration, started_at, ended_at, status_code, retry_count, max_retries, retryable, reason, error, request | O texto de erro pode conter dados do provedor ou do usuário; o request é feito para vir saneado. |
on_stream_start | Observador | Despachado quando começa uma resposta de LLM em streaming; entregue fora do caminho dos tokens, por uma fila limitada do host com um worker por callback; o retorno é ignorado. | turn_id, iteration, session_id, model, provider, surface | Só identificadores e metadados de roteamento. |
on_stream_delta | Observador | Despachado a cada delta de texto normalizado do streaming, pela fila limitada de observadores; um callback travado descarta apenas os eventos mais antigos dele; o retorno é ignorado. | delta, kind (text ou reasoning), turn_id, iteration, session_id, model, provider, surface | O texto do delta é saída crua do modelo; deltas de raciocínio exigem a adesão explícita em plugins.stream_reasoning_deltas. |
on_stream_end | Observador | Despachado quando uma resposta em streaming termina ou dá erro, depois que o fluxo fecha; o retorno é ignorado. | final_text, finished, error, turn_id, iteration, session_id, model, provider, surface | Texto completo da resposta montada; o texto de erro pode incluir dados do provedor. |
on_interim_message | Observador | Despachado quando uma mensagem do assistente no meio do laço aparece antes da resposta final (com ou sem streaming); o retorno é ignorado. | text, already_streamed, turn_id, iteration, session_id, model, provider, surface | Texto intermediário completo do assistente. |
transform_api_error_classification | Transformação | A cada tentativa de provedor que falha, no topo do classificador embutido; todos os callbacks rodam, e vence o primeiro dicionário com um reason válido (roda-todos-depois-escolhe-o-primeiro); resultados válidos descartados geram um aviso em tempo de execução. Só plugins Python. | provider, model, status_code, error_type, error_code, error_message, error_body, error, approx_tokens, context_length, num_messages | error_message e error_body podem conter dados crus do provedor ou do usuário. |
on_session_start | Observador | Primeiro turno de uma sessão nova; o retorno é ignorado. | session_id, model, platform | Só identificadores e metadados de roteamento. |
on_session_end | Observador | Canonicamente, a cada finalização de turno; saídas da CLI/TUI têm formatos legados reduzidos adicionais. O retorno é ignorado. | Canônico: session_id, task_id, turn_id, completed, failed, interrupted, turn_exit_reason, model, platform; caminhos de saída podem acrescentar reason/api_request_id e omitir campos. | IDs, modelo/plataforma e desfecho; o payload canônico não traz corpo de mensagem. |
on_session_finalize | Observador | Desmontagem de CLI/TUI/gateway pelo finalize_session; o desligamento ou a expiração do gateway podem finalizar sem um reset. O retorno é ignorado. | Depende da superfície: session_id, platform, e opcionalmente reason, old_session_id, new_session_id | Identificadores de sessão e de roteamento. |
on_session_reset | Observador | Fronteira de sessão na CLI/TUI e, no gateway, depois que a sessão substituta existe; o retorno é ignorado. | CLI: session_id, platform, reason; TUI: session_id, platform; gateway: esses mais reason, old_session_id, new_session_id | Identificadores de sessão e de roteamento. |
on_skill_lifecycle | Observador | Depois de uma mudança autoritativa de estado de uso de skill; o retorno é ignorado. | action, skill_name, provenance, task_id, session_id, use_count, reused, reuse_after_patch | Expõe o nome local da skill e a procedência. |
subagent_start | Observador | Filho construído e prestes a rodar; o retorno é ignorado. | parent_session_id, parent_turn_id, parent_subagent_id, child_session_id, child_subagent_id, child_role, child_goal | O objetivo do filho pode conter conteúdo do usuário ou do projeto. |
subagent_stop | Observador | Saída do filho; o retorno é ignorado. | parent_session_id, parent_turn_id, child_session_id, child_role, child_summary, child_status, tool_call_history, duration_ms | O resumo e os metadados de histórico de ferramentas já redigidos podem revelar a estrutura do projeto. |
pre_gateway_dispatch | Diretiva/controle | Mensagem recebida não interna, antes de autenticação/pareamento/despacho; o primeiro skip, rewrite ou allow válido controla o fluxo. | event, gateway, session_store | Objetos em processo extremamente privilegiados, que expõem dados de entrada do usuário e de roteamento, além de handles do host. |
gateway_platform_event | Observador | Depois que a autorização do gateway com escopo de perfil passa, quando um evento nativo de plataforma suportado é normalizado na fronteira do gateway (Telegram: reações, edições de mensagem; Discord: edições/exclusões de mensagem, thread criada/renomeada); o retorno é ignorado. | platform, event_type, payload (dicionário específico do tipo de evento — veja os contratos por evento mais abaixo) | Só um envelope normalizado de dicionário simples; objetos crus do SDK, handles de adaptador e clientes de bot nunca são expostos. |
pre_command | Observador | Comando de barra reconhecido prestes a ser despachado, antes de o handler rodar, no despacho de caminho frio da CLI e do gateway; o retorno é ignorado na v1 (dicionários em formato de diretiva são registrados em nível debug). Os comandos de interceptação de agente em execução no gateway (/stop, /approve durante uma execução ativa) foram deliberadamente excluídos — as saídas de emergência do plano de controle precisam ficar fora do alcance de plugins. | surface ("cli" | "gateway"), command (nome canônico), alias_used, args_raw, session_key, platform | args_raw pode conter conteúdo do usuário ou segredos digitados depois do comando. |
pre_approval_request | Observador | Antes da aprovação pedida ou inteligente; o retorno é ignorado. | command, description, pattern_key, pattern_keys, session_key, surface, turn_id, tool_call_id | O comando pode conter segredos; a preparação do observador inteligente força a redação, mas nem todas as superfícies têm redação idêntica. |
post_approval_response | Observador | Depois de uma decisão, de um tempo esgotado ou de uma falha de notificação do gateway; o retorno é ignorado. | command, description, pattern_key, pattern_keys, session_key, surface, turn_id, tool_call_id, choice; o caminho inteligente pode acrescentar decided_by | A mesma sensibilidade do comando, mais os metadados da decisão. |
kanban_task_claimed | Observador | Depois do commit da reivindicação, no processo despachante, antes de criar o trabalhador; o retorno é ignorado. | task_id, profile_name, board, assignee, run_id | Identificadores de quadro/tarefa/perfil/responsável. |
kanban_task_completed | Observador | Depois da conclusão e da limpeza, em geral no processo trabalhador; o retorno é ignorado. | task_id, profile_name, board, assignee, run_id, summary | O resumo pode conter conteúdo do projeto ou do usuário. |
kanban_task_blocked | Observador | Depois de uma transição para bloqueado; o caminho de espera por dependência dispara antes de a transação dele sair. O retorno é ignorado. | task_id, profile_name, board, assignee, run_id, reason | O motivo pode conter conteúdo do projeto ou do usuário. |
on_kanban_worker_spawned | Observador | Depois que o spawn_fn retorna e o PID do trabalhador é persistido; roda dentro do lock de despacho, mantenha os callbacks rápidos. O retorno é ignorado. | task_id, profile_name, board, assignee, run_id, worker_pid, workspace_path | workspace_path é um caminho de sistema de arquivos e pode revelar a organização do projeto ou nomes de usuário. |
on_kanban_worker_exited | Observador | Derivado do tique: depois que o detect_crashed_workers recupera uma tarefa de PID morto e a recuperação é commitada. O retorno é ignorado. | task_id, profile_name, board, assignee, run_id, worker_pid, exit_kind, exit_code, outcome, retry_status | Só identificadores e metadados de saída. |
on_kanban_worker_stale_claim | Observador | Depois que uma reivindicação com TTL vencido é recuperada; extensões com PID vivo não disparam. O retorno é ignorado. | task_id, profile_name, board, assignee, run_id, worker_pid, heartbeat_stale, retry_status | Só identificadores e metadados de reivindicação. |
on_kanban_task_updated | Observador | Depois de uma escrita commitada em campo de tarefa fora do ciclo de reivindicar/concluir/bloquear (atribuição, sobrescritas, editores do painel). O retorno é ignorado. | task_id, profile_name, board, assignee, run_id, changed_fields | changed_fields traz só nomes de campo, nunca valores; os valores de título/corpo nomeados no banco do quadro podem conter conteúdo do usuário ou do projeto. |
on_kanban_dispatch_tick | Observador | Uma vez por tique do despachante, estritamente depois de o lock de despacho ser liberado; tiques ociosos e disputados também disparam. O retorno é ignorado. | board, profile_name, dry_run, outcome, result | result é o DispatchResult do tique e carrega ids de tarefa, responsáveis e caminhos de espaço de trabalho. |
Hooks de saída em streaming
Estes hooks, só de observação, deixam plugins consumirem a saída em streaming do LLM para telemetria, painéis ao vivo ou pipelines de TTS, sem alterar a resposta. Eles são entregues por filas limitadas do host, com um worker em segundo plano por callback registrado, então callbacks de plugin nunca rodam em linha no caminho dos tokens. Se um callback trava, só a fila daquele callback pode encher e descartar o evento de observador pendente mais antigo dele; os outros observadores continuam recebendo eventos normalmente.
Registre-os como qualquer outro hook de plugin:
def on_delta(delta, kind, model, provider, **kwargs):
if kind == "text":
print(delta, end="", flush=True)
def register(ctx):
ctx.register_hook("on_stream_delta", on_delta)
Campos comuns aos quatro hooks:
| Parâmetro | Tipo | Descrição |
|---|---|---|
turn_id | str | Identificador opaco do turno, quando existe |
iteration | int | Iteração atual da chamada de API / do laço de ferramentas |
session_id | str | Id da sessão atual do Hermes |
model | str | Identificador do modelo ativo |
provider | str | Nome do provedor ativo |
surface | str | Superfície chamadora, por exemplo cli, discord, telegram |
Campos adicionais:
| Hook | Campos extras |
|---|---|
on_stream_start | nenhum |
on_stream_delta | delta: str, `kind: "text" |
on_stream_end | final_text: str, finished: bool, `error: str |
on_interim_message | text: str, already_streamed: bool |
O on_interim_message também pode disparar depois de uma resposta sem streaming, então registrar só esse hook não força uma chamada de provedor para o transporte de streaming.
Deltas de raciocínio não são expostos a plugins por padrão. Adira explicitamente:
plugins:
stream_reasoning_deltas: true
Os valores de retorno são ignorados. Para o fluxo continuar rápido, os callbacks devem enfileirar o próprio trabalho e retornar logo. Exceções são registradas e não param o fluxo.
pre_tool_call
Dispara imediatamente antes de toda execução de ferramenta — tanto ferramentas embutidas quanto ferramentas de plugin.
Assinatura do callback:
def my_callback(tool_name: str, args: dict, task_id: str, **kwargs):
| Parâmetro | Tipo | Descrição |
|---|---|---|
tool_name | str | Nome da ferramenta prestes a executar (por exemplo "terminal", "web_search", "read_file") |
args | dict | Os argumentos que o modelo passou para a ferramenta |
task_id | str | Identificador de sessão/tarefa. String vazia se não estiver definido. |
Dispara: em model_tools.py, dentro de handle_function_call(), antes de o handler da ferramenta rodar. Dispara uma vez por chamada de ferramenta — se o modelo chama 3 ferramentas em paralelo, dispara 3 vezes.
Valor de retorno — bloquear ou exigir aprovação:
return {"action": "block", "message": "Motivo pelo qual a chamada foi bloqueada"}
# ou
return {"action": "approve", "message": "Por que a aprovação é necessária", "rule_key": "optional:scope"}
Vence a primeira diretiva válida (primeiro os plugins Python registrados, depois os hooks de shell). O block exige uma message não vazia e interrompe a ferramenta, devolvendo esse texto ao modelo como erro. O approve eleva a chamada ao portão de aprovação humana que já existe; message e rule_key são opcionais, e negação, tempo esgotado ou erro do portão falham fechado. Os demais valores de retorno são ignorados, então callbacks que só observam continuam funcionando sem mudança.
Valor de retorno — reescrever os argumentos da ferramenta:
return {"action": "modify", "args": {"new_string": "conteúdo corrigido"}}
O dicionário args devolvido é mesclado de forma rasa sobre os argumentos originais da ferramenta antes de ela executar. Vários hooks modify se acumulam — as chaves de cada hook são mescladas num único dicionário acumulado, construído a partir dos argumentos originais, de modo que o hook A mudando path e o hook B mudando content sobrevivem os dois. Se dois hooks alteram a mesma chave, vence o hook mais tardio.
Os hooks de shell também aceitam o formato compatível com o Claude Code:
{"decision": "modify", "tool_input": {"new_string": "fixed content"}}
Os dois formatos são normalizados internamente para {"action": "modify", "args": {...}}.
Se um callback de pre_tool_call passa de plugins.hook_callback_timeout (ou ainda está rodando de um disparo anterior que estourou o tempo), o Hermes falha fechado: a ferramenta é bloqueada com uma mensagem de tempo esgotado, em vez de seguir sem decisão de política.
Para que serve: registro em log, trilhas de auditoria, contadores de chamada de ferramenta, bloqueio de operações perigosas, limitação de taxa, aplicação de política por usuário, saneamento de argumentos, reescrita de caminhos, injeção de parâmetros padrão.
Exemplo — log de auditoria de chamadas de ferramenta:
import json, logging
from datetime import datetime
logger = logging.getLogger(__name__)
def audit_tool_call(tool_name, args, task_id, **kwargs):
logger.info("TOOL_CALL session=%s tool=%s args=%s",
task_id, tool_name, json.dumps(args)[:200])
def register(ctx):
ctx.register_hook("pre_tool_call", audit_tool_call)
Exemplo — avisar em ferramentas perigosas:
DANGEROUS = {"terminal", "write_file", "patch"}
def warn_dangerous(tool_name, **kwargs):
if tool_name in DANGEROUS:
print(f"⚠ Executando ferramenta potencialmente perigosa: {tool_name}")
def register(ctx):
ctx.register_hook("pre_tool_call", warn_dangerous)
post_tool_call
Dispara imediatamente depois que toda execução de ferramenta retorna.
Assinatura do callback:
def my_callback(tool_name: str, args: dict, result: str, task_id: str,
duration_ms: int, **kwargs):
| Parâmetro | Tipo | Descrição |
|---|---|---|
tool_name | str | Nome da ferramenta que acabou de executar |
args | dict | Os argumentos que o modelo passou para a ferramenta |
result | str | O valor de retorno da ferramenta (sempre uma string JSON) |
task_id | str | Identificador de sessão/tarefa. String vazia se não estiver definido. |
duration_ms | int | Quanto tempo levou o despacho da ferramenta, em milissegundos (medido com time.monotonic() em volta de registry.dispatch()). |
Dispara: em model_tools.py, dentro de handle_function_call(), depois que o handler da ferramenta retorna. Dispara uma vez por chamada de ferramenta. Não dispara se a ferramenta levantou uma exceção não tratada (o erro é capturado e devolvido como string JSON de erro, e o post_tool_call dispara com essa string de erro em result).
Valor de retorno: ignorado.
Para que serve: registrar resultados de ferramenta, coletar métricas, acompanhar taxas de sucesso/falha por ferramenta, painéis de latência, alertas de orçamento por ferramenta, mandar notificações quando ferramentas específicas terminam.
Exemplo — acompanhar métricas de uso de ferramentas:
from collections import Counter, defaultdict
import json
_tool_counts = Counter()
_error_counts = Counter()
_latency_ms = defaultdict(list)
def track_metrics(tool_name, result, duration_ms=0, **kwargs):
_tool_counts[tool_name] += 1
_latency_ms[tool_name].append(duration_ms)
try:
parsed = json.loads(result)
if "error" in parsed:
_error_counts[tool_name] += 1
except (json.JSONDecodeError, TypeError):
pass
def register(ctx):
ctx.register_hook("post_tool_call", track_metrics)
pre_llm_call
Dispara uma vez por turno, antes de o laço de chamada de ferramentas começar. Todos os retornos válidos dos callbacks são agregados na ordem dos plugins e injetados na mensagem do usuário do turno atual.
Assinatura do callback:
def my_callback(session_id: str, user_message: str, conversation_history: list,
is_first_turn: bool, model: str, platform: str, **kwargs):
| Parâmetro | Tipo | Descrição |
|---|---|---|
session_id | str | Identificador único da sessão atual |
user_message | str | A mensagem original do usuário neste turno (antes de qualquer injeção de skill) |
conversation_history | list | Cópia da lista completa de mensagens (formato OpenAI: [{"role": "user", "content": "..."}]) |
is_first_turn | bool | True se este é o primeiro turno de uma sessão nova, False nos turnos seguintes |
model | str | O identificador do modelo (por exemplo "anthropic/claude-sonnet-4.6") |
platform | str | Onde a sessão está rodando: "cli", "telegram", "discord" etc. |
Dispara: em run_agent.py, dentro de run_conversation(), depois da compressão de contexto, mas antes do laço while principal. Dispara uma vez por chamada de run_conversation() (ou seja, uma vez por turno do usuário), não uma vez por chamada de API dentro do laço de ferramentas.
Valor de retorno: se o callback devolve um dicionário com a chave "context", ou uma string simples não vazia, o texto é anexado à mensagem do usuário do turno atual. Devolva None para não injetar nada.
# Injeta contexto
return {"context": "Memórias recuperadas:\n- O usuário gosta de Python\n- Está trabalhando no hermes-agent"}
# String simples (equivalente)
return "Memórias recuperadas:\n- O usuário gosta de Python"
# Sem injeção
return None
Onde o contexto é injetado: sempre na mensagem do usuário, nunca no prompt do sistema. Isso preserva o cache de prompt — o prompt do sistema continua idêntico entre turnos, então os tokens em cache são reaproveitados. O prompt do sistema é território do Hermes (orientação ao modelo, imposição de ferramentas, personalidade, skills). Os plugins contribuem com contexto ao lado da entrada do usuário.
O content limpo da mensagem do usuário continua inalterado. Para reprodução e estabilidade do cache de prompt, o Hermes pode persistir a mensagem exata enviada à API, incluindo o contexto injetado pelo plugin, no campo lateral api_content da linha.
Quando vários plugins devolvem contexto, as saídas deles são unidas por linhas em branco duplas na ordem de descoberta dos plugins (alfabética pelo nome do diretório).
Para que serve: recuperação de memória, injeção de contexto RAG, salvaguardas, análise por turno.
Exemplo — recuperação de memória:
import httpx
MEMORY_API = "https://sua-api-de-memoria.example.com"
def recall(session_id, user_message, is_first_turn, **kwargs):
try:
resp = httpx.post(f"{MEMORY_API}/recall", json={
"session_id": session_id,
"query": user_message,
}, timeout=3)
memories = resp.json().get("results", [])
if not memories:
return None
text = "Contexto recuperado:\n" + "\n".join(f"- {m['text']}" for m in memories)
return {"context": text}
except Exception:
return None
def register(ctx):
ctx.register_hook("pre_llm_call", recall)
Exemplo — salvaguardas:
POLICY = "Nunca execute comandos que apaguem arquivos sem confirmação explícita do usuário."
def guardrails(**kwargs):
return {"context": POLICY}
def register(ctx):
ctx.register_hook("pre_llm_call", guardrails)
post_llm_call
Dispara uma vez por turno, depois que o laço de chamada de ferramentas termina e o agente produziu uma resposta final. Só dispara em turnos bem-sucedidos — não dispara se o turno foi interrompido.
Assinatura do callback:
def my_callback(session_id: str, user_message: str, assistant_response: str,
conversation_history: list, model: str, platform: str, **kwargs):
| Parâmetro | Tipo | Descrição |
|---|---|---|
session_id | str | Identificador único da sessão atual |
user_message | str | A mensagem original do usuário neste turno |
assistant_response | str | A resposta final em texto do agente neste turno |
conversation_history | list | Cópia da lista completa de mensagens depois que o turno terminou |
model | str | O identificador do modelo |
platform | str | Onde a sessão está rodando |
Dispara: em run_agent.py, dentro de run_conversation(), depois que o laço de ferramentas sai com uma resposta final. Protegido por if final_response and not interrupted — ou seja, não dispara quando o usuário interrompe no meio do turno ou quando o agente bate no limite de iterações sem produzir resposta.
Valor de retorno: ignorado.
Para que serve: sincronizar dados da conversa com um sistema de memória externo, calcular métricas de qualidade de resposta, registrar resumos de turno, disparar ações de acompanhamento.
Exemplo — sincronizar com memória externa:
import httpx
MEMORY_API = "https://sua-api-de-memoria.example.com"
def sync_memory(session_id, user_message, assistant_response, **kwargs):
try:
httpx.post(f"{MEMORY_API}/store", json={
"session_id": session_id,
"user": user_message,
"assistant": assistant_response,
}, timeout=5)
except Exception:
pass # melhor esforço
def register(ctx):
ctx.register_hook("post_llm_call", sync_memory)
Exemplo — acompanhar o tamanho das respostas:
import logging
logger = logging.getLogger(__name__)
def log_response_length(session_id, assistant_response, model, **kwargs):
logger.info("RESPONSE session=%s model=%s chars=%d",
session_id, model, len(assistant_response or ""))
def register(ctx):
ctx.register_hook("post_llm_call", log_response_length)
pre_verify
Dispara uma vez por turno em que o agente editou código, pouco antes de ele terminar (depois da proteção embutida de verificar-ao-parar). Este é um portão de política do usuário ou do plugin: um callback pode manter o agente andando — rodar uma checagem, adiá-la, arrumar o diff — em vez de deixar ele parar.
A orientação de verificação que o Hermes traz não é um hook pre_verify padrão. Ela é acrescentada ao empurrãozinho de verificar-ao-parar baseado em evidências quando o código editado não tem evidência recente de verificação, então não cria um segundo caminho padrão de continuação. Defina agent.verify_guidance: false para deixar esse empurrãozinho embutido bem curto.
Assinatura do callback:
def my_callback(session_id: str, platform: str, model: str, coding: bool,
attempt: int, final_response: str, changed_paths: list, **kwargs):
| Parâmetro | Tipo | Descrição |
|---|---|---|
session_id | str | Identificador único da sessão atual |
platform | str | Onde a sessão está rodando ("cli", "telegram", …) |
coding | bool | Se o turno está na postura de programação (num espaço de trabalho de código) — use isso para limitar o escopo do seu hook |
model | str | O identificador do modelo |
attempt | int | Quantas vezes este turno já levou empurrãozinho (0 na primeira) — use isso para se autolimitar |
final_response | str | A resposta que o agente está prestes a entregar |
changed_paths | list | Arquivos que o agente editou neste turno (ordenados; aqui, nunca vazio) |
Restrinja um hook ao contexto de programação checando coding, e faça-o de disparo único com attempt (hooks de shell leem os dois em .extra), do mesmo jeito que um hook pre_tool_call se restringe por tool_name — assim você pode registrar vários hooks pre_verify, cada um disparando só onde deve.
Dispara: em agent/conversation_loop.py, no ponto em que o agente aceitaria uma resposta final, logo depois da checagem de verificar-ao-parar — mas só quando o agente editou código naquele turno e há pelo menos um hook pre_verify registrado.
Valor de retorno — manter o agente andando:
return {"action": "continue", "message": "Rode o formatador nas suas mudanças e então termine."}
A message é anexada como um turno de usuário sintético e o laço roda de novo. O formato Stop do Claude Code ({"decision": "block", "reason": "..."}, em que bloquear a parada significa continuar) também é aceito. Uma diretiva sem mensagem — ou qualquer outro retorno — deixa o turno terminar.
Com limite: diretivas de continuar consecutivas num mesmo turno são limitadas por agent.max_verify_nudges (padrão 3), então um hook que sempre manda continuar nunca consegue prender o laço. A resposta tentada fica no histórico, mas não é mostrada ao usuário enquanto o agente está levando empurrãozinho.
Faça o hook ser idempotente: ele dispara de novo depois de cada empurrãozinho, então filtre por attempt (if attempt: return None) — senão ele só fica empurrando até bater no limite.
Para que serve: adiar testes/lints durante iteração criativa, exigir checagens verdes para certos caminhos, bloquear o "pronto" até existir uma entrada no changelog, rodar uma checklist de verificação específica do projeto.
Exemplo — adiar checagens em trabalho criativo de UI, com escopo e disparo único:
UI = (".tsx", ".jsx", ".css", ".scss")
def defer_ui_checks(coding, attempt, changed_paths, **kwargs):
if attempt or not coding:
return None # disparo único, só em programação
if not all(p.endswith(UI) for p in changed_paths):
return None # só edições puramente de UI
return {
"action": "continue",
"message": "Isto é trabalho de UI — não rode testes/lints ainda; peça ao usuário "
"para dar uma olhada primeiro, e limpe o diff antes de qualquer commit.",
}
def register(ctx):
ctx.register_hook("pre_verify", defer_ui_checks)
Para orientação permanente que deve moldar o empurrãozinho embutido de falta de evidência, use agent.verify_guidance. Para regras mais amplas de postura de programação, que não precisam barrar a verificação, prefira agent.coding_instructions no config.yaml — ela pega carona no briefing de programação e não custa um turno a mais.
transform_api_error_classification
Dispara uma vez por chamada de API que falha, no topo de agent/error_classifier.classify_api_error(), antes do pipeline embutido. Plugins de provedor usam esse hook para assumir as manias de erro do próprio provedor sem remendar o núcleo. Ele muda comportamento (família de transformação): a classificação devolvida governa nova tentativa, compressão, rotação de credenciais e roteamento de reserva.
Os callbacks recebem o contexto de erro já interpretado como argumentos nomeados — provider (restrinja o escopo por ele), model, status_code, error_type, error_code, error_message, error_body, error, approx_tokens, context_length, num_messages. Devolva None para recusar, ou um dicionário para assumir o erro:
return {"reason": "model_not_found", # obrigatório: um nome de FailoverReason
"retryable": False, "should_fallback": True} # sobrescritas opcionais de dica de recuperação
O despacho é roda-todos-depois-escolhe-o-primeiro: todo callback roda, as falhas são isoladas, e vence o primeiro resultado válido na ordem de registro (resultados válidos que perderam geram um aviso em tempo de execução). Dicionários inválidos e motivos desconhecidos são pulados, então um plugin quebrado nunca consegue quebrar a classificação.
Privacidade: error_message e error_body podem carregar dados do provedor sem redação. Só plugins Python — registros por shell são recusados na leitura da configuração, com um aviso.
on_session_start
Dispara uma vez quando uma sessão totalmente nova é criada. Não dispara na continuação de sessão (quando o usuário manda uma segunda mensagem numa sessão que já existe).
Assinatura do callback:
def my_callback(session_id: str, model: str, platform: str, **kwargs):
| Parâmetro | Tipo | Descrição |
|---|---|---|
session_id | str | Identificador único da sessão nova |
model | str | O identificador do modelo |
platform | str | Onde a sessão está rodando |
Dispara: em run_agent.py, dentro de run_conversation(), durante o primeiro turno de uma sessão nova — especificamente depois de o prompt do sistema ser montado, mas antes de o laço de ferramentas começar. A checagem é if not conversation_history (sem mensagens anteriores = sessão nova).
Valor de retorno: ignorado.
Para que serve: inicializar estado com escopo de sessão, aquecer caches, registrar a sessão num serviço externo, registrar em log os inícios de sessão.
Exemplo — inicializar um cache de sessão:
_session_caches = {}
def init_session(session_id, model, platform, **kwargs):
_session_caches[session_id] = {
"model": model,
"platform": platform,
"tool_calls": 0,
"started": __import__("datetime").datetime.now().isoformat(),
}
def register(ctx):
ctx.register_hook("on_session_start", init_session)
on_session_end
Dispara bem no fim de toda chamada de run_conversation(), qualquer que seja o desfecho. Também dispara pelo tratador de saída da CLI, se o agente estava no meio de um turno quando o usuário saiu.
Assinatura do callback:
def my_callback(session_id: str, completed: bool, interrupted: bool,
model: str, platform: str, **kwargs):
| Parâmetro | Tipo | Descrição |
|---|---|---|
session_id | str | Identificador único da sessão |
completed | bool | True se o agente produziu uma resposta final, False caso contrário |
interrupted | bool | True se o turno foi interrompido (usuário mandou mensagem nova, /stop, ou saiu) |
model | str | O identificador do modelo |
platform | str | Onde a sessão está rodando |
Dispara: em dois lugares:
run_agent.py— no fim de toda chamada derun_conversation(), depois de toda a limpeza. Sempre dispara, mesmo se o turno deu erro.cli.py— no tratadoratexitda CLI, mas só se o agente estava no meio de um turno (_agent_running=True) quando a saída aconteceu. Isso pega Ctrl+C e/exitdurante o processamento. Nesse caso,completed=Falseeinterrupted=True.
Valor de retorno: ignorado.
Para que serve: descarregar buffers, fechar conexões, persistir o estado da sessão, registrar a duração da sessão, limpar recursos criados no on_session_start.
Exemplo — descarregar e limpar:
_session_caches = {}
def cleanup_session(session_id, completed, interrupted, **kwargs):
cache = _session_caches.pop(session_id, None)
if cache:
# Descarrega os dados acumulados em disco ou num serviço externo
status = "completed" if completed else ("interrupted" if interrupted else "failed")
print(f"Sessão {session_id} encerrada: {status}, {cache['tool_calls']} chamadas de ferramenta")
def register(ctx):
ctx.register_hook("on_session_end", cleanup_session)
Exemplo — acompanhar a duração da sessão:
import time, logging
logger = logging.getLogger(__name__)
_start_times = {}
def on_start(session_id, **kwargs):
_start_times[session_id] = time.time()
def on_end(session_id, completed, interrupted, **kwargs):
start = _start_times.pop(session_id, None)
if start:
duration = time.time() - start
logger.info("SESSION_DURATION session=%s seconds=%.1f completed=%s interrupted=%s",
session_id, duration, completed, interrupted)
def register(ctx):
ctx.register_hook("on_session_start", on_start)
ctx.register_hook("on_session_end", on_end)
on_session_finalize
Dispara quando a CLI ou o gateway desmonta uma sessão ativa — por exemplo, quando o usuário roda /new, quando o gateway recolhe uma sessão ociosa, ou quando a CLI sai com um agente ativo. Use para descarregar estado atrelado ao ID da sessão que está saindo. No reset do gateway, a sessão substituta já existe antes de este callback rodar.
Assinatura do callback:
def my_callback(session_id: str | None, platform: str, **kwargs):
| Parâmetro | Tipo | Descrição |
|---|---|---|
session_id | str ou None | O ID da sessão que está saindo. Pode ser None se não havia sessão ativa. |
platform | str | "cli" ou o nome da plataforma de mensagens ("telegram", "discord" etc.). |
Dispara: na desmontagem de CLI/TUI e nos caminhos de reset, desligamento ou expiração por ociosidade do gateway. Desligamento e expiração do gateway podem finalizar sem um on_session_reset correspondente.
Valor de retorno: ignorado.
Para que serve: persistir métricas finais da sessão antes de o ID ser descartado, fechar recursos por sessão, emitir um evento final de telemetria, drenar escritas na fila.
on_session_reset
Dispara numa fronteira de sessão da CLI ou da TUI, ou quando o gateway troca por uma chave de sessão nova num chat ativo. Isso deixa os plugins reagirem ao estado de conversa zerado sem esperar o próximo on_session_start.
Assinatura do callback:
def my_callback(session_id: str, platform: str, **kwargs):
| Parâmetro | Tipo | Descrição |
|---|---|---|
session_id | str | O ID da sessão nova (já rotacionado para o valor novo). |
platform | str | "cli", "tui", ou o nome da plataforma de mensagens. |
reason | str, opcional | Presente nos caminhos de reset da CLI e do gateway. |
old_session_id | str, opcional | Só no gateway: o ID da sessão que está saindo. |
new_session_id | str, opcional | Só no gateway: o ID da sessão substituta. |
Dispara: a CLI fornece session_id, platform e reason; a TUI fornece session_id e platform; o gateway acrescenta reason, old_session_id e new_session_id depois de alocar a chave substituta. No reset do gateway, a ordem é: criar e persistir a substituta → on_session_finalize(old_id) → on_session_reset(new_id) → on_session_start(new_id) no primeiro turno de entrada.
Valor de retorno: ignorado.
Para que serve: zerar caches por sessão indexados por session_id, emitir análises de "sessão rotacionada", preparar um balde de estado novo.
Veja o guia Criar um Plugin para o passo a passo completo, incluindo esquemas de ferramenta, handlers e padrões avançados de hook.
subagent_start
Dispara uma vez por agente filho, depois que o delegate_task construiu o AIAgent filho e antes de esse filho rodar. Você delegando uma tarefa só ou um lote de três, este hook dispara uma vez para cada filho.
Este hook é específico do ciclo de vida de delegação/subagente. Ele não é um portão universal de "antes de qualquer invocação de agente" para execuções de agente originadas de gateway, CLI, cron, lote, MoA ou outros executores.
Assinatura do callback:
def my_callback(parent_session_id: str | None,
parent_turn_id: str,
parent_subagent_id: str | None,
child_session_id: str | None,
child_subagent_id: str,
child_role: str,
child_goal: str,
**kwargs):
| Parâmetro | Tipo | Descrição |
|---|---|---|
parent_session_id | str | None | ID de sessão do agente pai que delegou. |
parent_turn_id | str | ID do turno do agente pai que pediu a delegação, se disponível. |
parent_subagent_id | str | None | ID de subagente do pai, quando este filho foi criado por outro subagente; None para agentes pais de topo. |
child_session_id | str | None | ID de sessão alocado para o agente filho. |
child_subagent_id | str | ID estável de subagente usado pela observabilidade e pelos controles de delegação. |
child_role | str | Papel efetivo do filho depois de aplicada a política de delegação, por exemplo "leaf" ou "orchestrator". |
child_goal | str | Objetivo/prompt delegado que o agente filho vai executar. |
Dispara: em tools/delegate_tool.py, dentro de _build_child_agent(), depois que o AIAgent filho foi construído e anotado com os metadados de identidade de subagente, e antes de o _run_single_child() rodar o filho.
Valor de retorno: ignorado. Este é só um hook observador; devolver um valor não bloqueia nem altera a execução do agente filho.
Para que serve: registrar a criação de subagentes, mapear as relações de sessão pai/filho, acompanhar árvores de delegação aninhadas, emitir registros de auditoria antes da execução, pré-alocar recursos de observabilidade por filho.
Exemplo — registrar a criação de subagentes:
import logging
logger = logging.getLogger(__name__)
def log_subagent_start(
parent_session_id,
parent_turn_id,
child_session_id,
child_subagent_id,
child_role,
child_goal,
**kwargs,
):
logger.info(
"SUBAGENT_START parent=%s turn=%s child_session=%s child=%s role=%s goal=%r",
parent_session_id,
parent_turn_id,
child_session_id,
child_subagent_id,
child_role,
child_goal[:200],
)
def register(ctx):
ctx.register_hook("subagent_start", log_subagent_start)
O subagent_start é útil para observabilidade de delegação, mas não é um hook de política bloqueante. Para bloquear a delegação antes de um filho ser construído, use o pre_tool_call para bloquear a chamada da ferramenta delegate_task.
subagent_stop
Dispara uma vez por agente filho, depois que o delegate_task termina. Você tendo delegado uma tarefa só ou um lote de três, este hook dispara uma vez para cada filho. O despacho é serializado na thread do pai depois que os futuros dos filhos drenam, e o corpo de cada callback Python roda nessa mesma thread chamadora (não num worker com limite de tempo).
Assinatura do callback:
def my_callback(parent_session_id: str, child_role: str | None,
child_summary: str | None, child_status: str,
tool_call_history: list[dict], duration_ms: int, **kwargs):
| Parâmetro | Tipo | Descrição |
|---|---|---|
parent_session_id | str | ID de sessão do agente pai que delegou |
child_role | str | None | Etiqueta de papel de orquestrador definida no filho (None se o recurso não está ligado) |
child_summary | str | None | A resposta final que o filho devolveu ao pai |
child_status | str | "completed", "failed", "interrupted" ou "error" |
tool_call_history | list[dict] | Chamadas de ferramenta em ordem, só com metadados: tool_name, tool_input limitado, input_bytes, output_bytes e status; entradas e saídas cruas ficam de fora |
duration_ms | int | Tempo de relógio gasto rodando o filho, em milissegundos |
Dispara: em tools/delegate_tool.py, depois que o ThreadPoolExecutor.as_completed() drena todos os futuros dos filhos. O invoke_hook("subagent_stop", ...) é encaminhado para a thread do pai, para quem escreve o hook não esbarrar em reentrância do pool de filhos, e os callbacks ficam nessa thread chamadora.
Valor de retorno: ignorado.
Para que serve: registrar a atividade de orquestração, acumular durações dos filhos para cobrança, escrever registros de auditoria pós-delegação.
Exemplo — registrar a atividade do orquestrador:
import logging
logger = logging.getLogger(__name__)
def log_subagent(parent_session_id, child_role, child_status, duration_ms, **kwargs):
logger.info(
"SUBAGENT parent=%s role=%s status=%s duration_ms=%d",
parent_session_id, child_role, child_status, duration_ms,
)
def register(ctx):
ctx.register_hook("subagent_stop", log_subagent)
Com delegação pesada (por exemplo, papéis de orquestrador × 5 folhas × profundidade aninhada), o subagent_stop dispara muitas vezes por turno. Mantenha o seu callback rápido; empurre o trabalho caro para uma fila em segundo plano.
pre_gateway_dispatch
Dispara uma vez por MessageEvent recebido no gateway, depois da proteção de eventos internos, mas antes da autenticação/pareamento e do despacho ao agente. Este é o ponto de interceptação para políticas de fluxo de mensagens no nível do gateway (janelas de só escutar, passagem para humano, roteamento por chat etc.) que não cabem direito em nenhum adaptador de plataforma específico.
Assinatura do callback:
def my_callback(event, gateway, session_store, **kwargs):
| Parâmetro | Tipo | Descrição |
|---|---|---|
event | MessageEvent | A mensagem de entrada normalizada (tem .text, .source, .message_id, .internal etc.). |
gateway | GatewayRunner | O executor de gateway ativo, para os plugins poderem chamar gateway.adapters[platform].send(...) e mandar respostas por canal lateral (avisos ao dono etc.). |
session_store | SessionStore | Para ingestão silenciosa na transcrição, via session_store.append_to_transcript(...). |
Dispara: em gateway/run.py, dentro de GatewayRunner._handle_message(), logo depois de o is_internal ser calculado. Eventos internos pulam o hook por completo (são gerados pelo sistema — conclusões de processo em segundo plano etc. — e não podem ser barrados por política voltada ao usuário).
Valor de retorno: None ou um dicionário. Vence o primeiro dicionário de ação reconhecido; os resultados dos demais plugins são ignorados. Exceções nos callbacks de plugin são capturadas e registradas; em caso de erro, o gateway sempre segue para o despacho normal.
| Retorno | Efeito |
|---|---|
{"action": "skip", "reason": "..."} | Descarta a mensagem — sem resposta do agente, sem fluxo de pareamento, sem autenticação. Presume-se que o plugin cuidou dela (por exemplo, ingerindo em silêncio na transcrição). |
{"action": "rewrite", "text": "novo texto"} | Substitui o event.text e segue com o despacho normal, usando o evento modificado. Útil para juntar mensagens ambientes acumuladas num único prompt. |
{"action": "allow"} / None | Despacho normal — roda a cadeia completa de autenticação / pareamento / laço do agente. |
Para que serve: grupos de só escutar (só responder quando marcado; acumular mensagens ambientes no contexto); passagem para humano (ingerir em silêncio as mensagens do cliente enquanto o dono cuida do chat na mão); limitação de taxa por perfil; roteamento guiado por política.
Exemplo — descartar DMs não autorizadas em silêncio, sem acionar o código de pareamento:
def deny_unauthorized_dms(event, **kwargs):
src = event.source
if src.chat_type == "dm" and not _is_approved_user(src.user_id):
return {"action": "skip", "reason": "unauthorized-dm"}
return None
def register(ctx):
ctx.register_hook("pre_gateway_dispatch", deny_unauthorized_dms)
Exemplo — transformar um buffer de mensagens ambientes num único prompt ao ser mencionado:
_buffers = {}
def buffer_or_rewrite(event, **kwargs):
key = (event.source.platform, event.source.chat_id)
buf = _buffers.setdefault(key, [])
if _bot_mentioned(event.text):
combined = "\n".join(buf + [event.text])
buf.clear()
return {"action": "rewrite", "text": combined}
buf.append(event.text)
return {"action": "skip", "reason": "ambient-buffered"}
def register(ctx):
ctx.register_hook("pre_gateway_dispatch", buffer_or_rewrite)
gateway_platform_event
Dispara para eventos nativos de plataforma suportados somente depois que a checagem normal de autorização do gateway, com escopo de perfil, passa. O callback recebe dicionários simples; objetos crus do SDK, handles de adaptador, clientes de bot e contextos de callback nunca fazem parte deste contrato estável.
As reações a mensagens do Telegram foram o primeiro evento suportado; edições e exclusões de mensagem e eventos de ciclo de vida de thread vieram depois:
def on_platform_event(platform, event_type, payload, **kwargs):
if platform == "telegram" and event_type == "reaction":
print(payload["chat_id"], payload["message_id"], payload["emojis"])
elif event_type == "message_edited":
print(platform, payload["chat_id"], payload["message_id"], payload["text"])
def register(ctx):
ctx.register_hook("gateway_platform_event", on_platform_event)
| Parâmetro | Tipo | Descrição |
|---|---|---|
platform | str | Id estável da plataforma ("telegram", "discord"). |
event_type | str | Id de contrato local do evento (veja a tabela abaixo). |
payload | dict | Campos específicos do tipo de evento, documentados por tipo abaixo. |
Todo payload é aditivo e específico do evento; não existe uma versão monolítica de payload de gateway. Todos os ids são strings; campos ausentes ou indisponíveis vêm como None, nunca adivinhados. Eventos malformados e eventos cuja origem não pode ser autorizada são descartados (falha fechada). Uma reconstrução transitória da Application do Telegram registra o observador de novo, junto com os handlers principais.
Contratos de payload por evento (v1, aditivos):
event_type | Plataformas | Campos do payload |
|---|---|---|
reaction | telegram | emojis: list[str], custom_emoji_ids: list[str], chat_id: str, message_id: str, thread_id: str | None (as atualizações de reação do Telegram não trazem id de tópico, então hoje é sempre None). |
message_edited | telegram, discord | chat_id: str, message_id: str, thread_id: str | None, text: str | None (texto ou legenda editados, limitados; None para edições só de mídia ou quando não está em cache), edited_at: str | None (ISO 8601). |
message_deleted | discord | chat_id: str, message_id: str, thread_id: str | None, author_id: str | None. O evento de exclusão do Discord não identifica quem apagou; a origem autorizada é o autor da mensagem apagada, e exclusões fora do cache nunca disparam. |
thread_created | discord | thread_id: str, parent_chat_id: str | None, name: str | None, owner_id: str | None. |
thread_renamed | discord | thread_id: str, parent_chat_id: str | None, old_name: str | None, new_name: str. Disparado só quando o nome mudou de fato; outras atualizações de thread (arquivamento, modo lento, etiquetas) são descartadas. O evento de atualização de thread do Discord não traz o ator, então o dono da thread é a origem autorizada. |
As edições progressivas que o próprio bot faz nas mensagens (streaming) nunca disparam message_edited no Discord — eventos gerados pelo bot são descartados no ponto de disparo.
Este hook é só de observação: ele não dá acesso ao evento cru nem ao adaptador. O acesso ao payload cru do SDK foi deliberadamente deixado de fora — objetos de SDK de adaptador mudam de forma sem aviso e virariam superfície de API impossível de evoluir; onde for realmente necessário, isso exige uma capacidade explícita própria (gateway.raw_events), com o rótulo de "sem garantia de estabilidade" e um projeto próprio (acompanhado em #64228). Para agir numa plataforma (adicionar uma reação, renomear uma thread), use a fachada ctx.platform_actions, protegida por capacidade e documentada no guia de plugins — ela vem desligada por padrão, atrás da capacidade gateway.platform_actions. O PluginContext.dispatch_tool() só consegue chamar ferramentas registradas no registro de ferramentas; o send_message de propósito não está registrado lá (o transporte dele é reservado para os caminhos explícitos de entrega da CLI, do cron, do kanban e do MCP). Um futuro contrato de entrega de saída precisa primeiro fornecer conteúdo e handles de entrega estáveis em todos os adaptadores; esta fatia não pré-registra um hook gateway_message_delivered inerte.
pre_approval_request
Dispara antes de uma decisão de aprovação ser solicitada. Cobre as superfícies com pedido — CLI interativa, TUI Ink, plataformas de gateway e clientes ACP — e as decisões de approvals.mode=smart tomadas sem pedido humano (surface="smart"). No modo inteligente, o hook roda antes de o LLM auxiliar ser chamado.
Este é o lugar certo para ligar um notificador próprio — por exemplo, um app de barra de menu no macOS que abre uma notificação de permitir/negar, ou um log de auditoria que registra cada pedido de aprovação com contexto.
Assinatura do callback:
def my_callback(
command: str,
description: str,
pattern_key: str,
pattern_keys: list[str],
session_key: str,
surface: str,
**kwargs,
):
| Parâmetro | Tipo | Descrição |
|---|---|---|
command | str | Comando de terminal ou script do execute_code sendo avaliado. Payloads inteligentes e de gateway são redigidos antes do despacho ao observador. A redação do observador inteligente é obrigatória mesmo quando security.redact_secrets está desligado; se a redação falha, os hooks inteligentes são pulados. |
description | str | Motivo(s) legível(is) pelos quais o comando foi sinalizado (combinados quando vários padrões batem) |
pattern_key | str | Chave do padrão principal que acionou a aprovação (por exemplo "rm_rf", "sudo") |
pattern_keys | list[str] | Todas as chaves de padrão que bateram |
session_key | str | Identificador de sessão, útil para restringir notificações por chat |
surface | str | "cli" para pedidos interativos na CLI/TUI, "gateway" para aprovações assíncronas de plataforma, ou "smart" para decisões automáticas de aprovar/negar pelo LLM auxiliar |
Valor de retorno: ignorado. Os hooks aqui são só de observação; eles não podem vetar nem antecipar a aprovação. Use o pre_tool_call para bloquear uma ferramenta antes que ela chegue ao sistema de aprovação.
Para que serve: notificações de desktop, alertas push, log de auditoria, webhooks do Slack, roteamento de escalonamento, métricas.
Exemplo — notificação de desktop no macOS:
import subprocess
def notify_approval(command, description, session_key, **kwargs):
title = "O Hermes precisa de aprovação"
body = f"{description}: {command[:80]}"
subprocess.Popen([
"osascript", "-e",
f'display notification "{body}" with title "{title}"',
])
def register(ctx):
ctx.register_hook("pre_approval_request", notify_approval)
post_approval_response
Dispara depois de uma decisão de aprovação pedida ou inteligente, depois que um pedido expira, ou quando o gateway não consegue entregar a notificação de aprovação. A falha de notificação emite choice="notify_failed" antes de existir qualquer decisão de aprovação.
Assinatura do callback:
def my_callback(
command: str,
description: str,
pattern_key: str,
pattern_keys: list[str],
session_key: str,
surface: str,
choice: str,
**kwargs,
):
Os mesmos argumentos nomeados do pre_approval_request, mais:
| Parâmetro | Tipo | Descrição |
|---|---|---|
choice | str | As superfícies com pedido usam "once", "session", "always", "deny", "timeout" ou "notify_failed"; decisões inteligentes usam "smart_approve" ou "smart_deny" |
decided_by | str | "aux_llm" nas decisões inteligentes; ausente nas superfícies com pedido |
Valor de retorno: ignorado.
Para que serve: fechar a notificação de desktop correspondente, registrar a decisão final num log de auditoria, atualizar métricas, avançar um limitador de taxa.
def log_decision(command, choice, session_key, **kwargs):
logger.info("approval %s: %s for session %s", choice, command[:60], session_key)
def register(ctx):
ctx.register_hook("post_approval_response", log_decision)
pre_transcription
Dispara dentro do despachante de STT (tools.transcription_tools.transcribe_audio) depois que o provedor foi resolvido e antes de qualquer backend ser invocado, seja ele embutido, um provedor type: command ou um provedor registrado por plugin. Deixa o plugin conduzir a própria requisição de transcrição, em vez de só observar a transcrição depois.
Assinatura do callback:
def my_callback(
file_path: str,
provider: str,
model: str | None,
language: str | None,
prompt: str | None,
source: str | None,
**kwargs,
) -> dict | None:
| Parâmetro | Tipo | Descrição |
|---|---|---|
file_path | str | Caminho absoluto do arquivo de áudio prestes a ser transcrito. Somente leitura. |
provider | str | Provedor de STT resolvido (local, groq, openai, mistral, xai, elevenlabs, deepinfra, local_command, o nome de um provedor de comando, ou o nome de um provedor de plugin). |
model | str | None | Modelo resolvido até aqui, ou None quando vale o padrão do backend. |
language | str | None | Idioma vindo da seção de configuração do provedor, ou None. |
prompt | str | None | O valor estático de stt.prompt, ou None. |
source | str | None | Rótulo da superfície chamadora (gateway, voice_mode, …). Só para observabilidade, não é usado no despacho. |
Valor de retorno: um dict com qualquer combinação de "prompt", "language" e "model" mapeados para strings, ou None para deixar a requisição intacta. Valores que não sejam string, chaves desconhecidas e file_path são ignorados (tentativas de mexer em file_path são registradas como aviso). Os resultados são aplicados na ordem de registro, valendo quem escreve por último em cada campo, por cima do valor de stt.prompt da configuração. Devolver "" em prompt limpa o prompt configurado para aquela requisição.
Para que serve: injetar uma lista de vocabulário por usuário ou por chat antes de o áudio ser enviado, forçar o language a partir da localidade de quem chama, rebaixar o model em gravações longas, rotear fontes ruidosas para outro modelo.
VOCAB = "Hermes, Teknium, Nous Research, kanban"
def add_vocab(provider, prompt, source, **kwargs):
if source != "gateway":
return None
return {"prompt": f"{prompt}. {VOCAB}" if prompt else VOCAB}
def register(ctx):
ctx.register_hook("pre_transcription", add_vocab)
Nem todo backend aceita um prompt. O local mapeia o prompt para o initial_prompt do faster-whisper; openai, groq, mistral e deepinfra mandam como prompt; xai, elevenlabs, local_command e provedores type: command registram em DEBUG e transcrevem sem ele. Veja a tabela de suporte por provedor para a matriz completa e o limite de privacidade. Erros de encanamento do hook falham abertos: o despacho continua com a requisição sem modificação.
transform_tool_result
Dispara depois que uma ferramenta retorna e antes de o resultado ser anexado à conversa. Deixa um plugin reescrever a string de resultado de QUALQUER ferramenta — não só a saída do terminal — antes de o modelo ver.
Assinatura do callback:
def my_callback(tool_name: str, args: dict, result: str, task_id: str, **kwargs) -> str | None:
O payload completo também inclui session_id, tool_call_id, turn_id, api_request_id, duration_ms, status, error_type e error_message. O result é o resultado final devolvido pelo despacho da ferramenta; ele e o args podem conter conteúdo arbitrário de ferramenta ou do usuário, além de segredos.
Valor de retorno: a primeira str substitui o resultado (inclusive uma string vazia); None deixa como está.
Para que serve: redigir dados pessoais específicos da organização na saída do web_extract, embrulhar respostas JSON longas de ferramenta num cabeçalho de resumo, injetar dicas de recuperação nos resultados do read_file, reescrever relatórios de subagente do delegate_task num esquema específico do projeto.
import re
SECRET = re.compile(r"sk-[A-Za-z0-9]{32,}")
def redact_secrets(tool_name, result, **kwargs):
if SECRET.search(result):
return SECRET.sub("[REDACTED]", result)
return None
def register(ctx):
ctx.register_hook("transform_tool_result", redact_secrets)
Vale para toda ferramenta. Para reescrever só o terminal, veja o transform_terminal_output abaixo — ele é mais estreito, roda antes do transform_tool_result, e a substituição dele ainda está sujeita ao limite final de saída da ferramenta de terminal.
transform_terminal_output
Dispara dentro da ferramenta terminal, depois que a captura do processo em primeiro plano já foi limitada pelo ambiente, e antes do limite final de saída. Deixa os plugins substituírem o stdout/stderr capturado; a substituição continua sujeita ao limite final de saída.
Assinatura do callback:
def my_callback(
command: str,
output: str,
returncode: int,
task_id: str,
env_type: str,
**kwargs,
) -> str | None:
| Parâmetro | Tipo | Descrição |
|---|---|---|
command | str | O comando de shell que produziu a saída. |
output | str | stdout e stderr combinados, depois da captura limitada do processo. |
returncode | int | Código de retorno do processo. |
task_id | str | Identificador efetivo da tarefa, ou uma string vazia. |
env_type | str | Tipo do ambiente de execução. |
Valor de retorno: a primeira str substitui a saída; None deixa como está. Comando e saída podem conter credenciais ou outros dados sensíveis.
def summarize_find(command, output, **kwargs):
if command.startswith("find ") and len(output) > 50_000:
lines = output.count("\n")
head = "\n".join(output.splitlines()[:40])
return f"{head}\n\n[resumo: {lines} caminhos no total, mostrando os 40 primeiros]"
return None
def register(ctx):
ctx.register_hook("transform_terminal_output", summarize_find)
Faz par com o transform_tool_result, que roda depois, para toda ferramenta, inclusive o terminal.
transform_llm_output
Dispara uma vez por turno, depois que o laço de chamada de ferramentas termina e o modelo produziu uma resposta final, antes de essa resposta ser entregue ao usuário (CLI, gateway ou chamador programático). Deixa um plugin reescrever o texto final do assistente com métodos de programação clássica — sem queimar tokens de inferência extras em texto de sabor do SOUL nem numa transformação por skill.
Assinatura do callback:
def my_callback(
response_text: str,
session_id: str,
model: str,
platform: str,
**kwargs,
) -> str | None:
| Parâmetro | Tipo | Descrição |
|---|---|---|
response_text | str | O texto de resposta final do assistente neste turno. |
session_id | str | ID de sessão desta conversa (pode vir vazio em execuções de uso único). |
model | str | Nome do modelo que produziu a resposta (por exemplo anthropic/claude-sonnet-4.6). |
platform | str | Plataforma de entrega (cli, telegram, discord, …; vazio quando não definida). |
Valor de retorno: uma str não vazia para substituir o texto da resposta; None ou string vazia para deixar como está. Vence a primeira string não vazia quando vários plugins se registram. Diferente das transformações de ferramenta e de terminal, uma string vazia não é aceita como substituição.
Para que serve: aplicar uma transformação de personalidade/vocabulário (falar como pirata, como o Bob Esponja), redigir identificadores específicos do usuário no texto final, acrescentar um rodapé de assinatura do projeto, impor um guia de estilo da casa sem queimar tokens em instruções do SOUL.
Quando o streaming da CLI está ligado, uma transformação que só acrescenta texto é impressa depois do corpo transmitido. Uma transformação que substitui a resposta é impressa por inteiro depois do corpo transmitido, marcada como transformação pós-fluxo, para o conteúdo substituto nunca se perder em silêncio.
import os, re
def spongebob(response_text, **kwargs):
if os.environ.get("SPONGEBOB_MODE") != "on":
return None # passa adiante sem mudar
return re.sub(r"!", "!! Molho tártaro!", response_text)
def register(ctx):
ctx.register_hook("transform_llm_output", spongebob)
O hook é protegido por uma resposta não vazia e não interrompida — ele não dispara em interrupções pelo botão de parar nem em turnos vazios. Exceções são registradas como aviso e não quebram a execução do agente.
Hooks observadores de requisição à API
pre_api_request
Dispara a cada tentativa de provedor, imediatamente antes do envio. É só de observação. Os campos legados user_message, conversation_history e request_messages são crus e, de propósito, não saneados, por compatibilidade; consumidores novos devem preferir o envelope request saneado.
post_api_request
Dispara depois que uma resposta do provedor foi normalizada com sucesso. É só de observação. Prefira o response saneado; o assistant_message é a mensagem normalizada crua, e o usage contém dados contábeis.
api_request_error
Dispara numa tentativa de provedor que falhou, com status e tempos de nova tentativa, um objeto error e o request saneado. É só de observação. As mensagens de erro ainda podem conter dados do provedor ou do usuário.
on_skill_lifecycle
Dispara depois de uma mudança autoritativa de estado de uso de skill. É só de observação e expõe o skill_name local, a procedência, os IDs de correlação, a contagem de uso e as marcas de reaproveitamento.
Observadores do ciclo de vida do kanban
kanban_task_claimed
Dispara depois do commit da reivindicação, no processo despachante, imediatamente antes de criar o trabalhador.
kanban_task_completed
Dispara depois da conclusão e da limpeza, em geral no processo trabalhador. O summary dele pode conter conteúdo do projeto ou do usuário.
kanban_task_blocked
Dispara depois de uma transição normal para bloqueado. O caminho de espera por dependência o invoca antes de aquela transação de escrita sair. O reason dele pode conter conteúdo do projeto ou do usuário.
Os três hooks de kanban são só de observação e carregam task_id, profile_name, board, assignee e run_id; o de conclusão acrescenta summary, e o de bloqueio acrescenta reason.
Observadores de ciclo de vida do trabalhador, mutação de tarefa e despacho do kanban
Cinco observadores adicionais (RFC #58548) estendem a família kanban. Todos são só de observação, disparam depois que a transação relevante é commitada, e saem cedo em has_hook — sem assinante, o comportamento do despacho não muda. Hooks com escopo de tarefa carregam os mesmos campos comuns dos hooks acima.
on_kanban_worker_spawned— depois que ospawn_fnretorna e o PID do trabalhador é persistido. Acrescentaworker_pid(pode serNone) eworkspace_path. Roda dentro do lock de despacho; mantenha os callbacks rápidos.on_kanban_worker_exited— derivado do tique, quando odetect_crashed_workersrecupera uma tarefa de PID morto. Acrescentaworker_pid,exit_kind,exit_code,outcome,retry_status.on_kanban_worker_stale_claim— quando uma reivindicação com TTL vencido é recuperada; extensões com PID vivo não disparam. Acrescentaworker_pid,heartbeat_stale,retry_status.on_kanban_task_updated— depois de uma escrita commitada em campo de tarefa fora do ciclo de reivindicar/concluir/bloquear (assign_task, sobrescritas de modelo/raciocínio, editores do painel). Acrescentachanged_fields— só nomes de campo, nunca valores.on_kanban_dispatch_tick— uma vez por tique do despachante, estritamente depois de o lock de despacho ser liberado, incluindo tiques ociosos e com disputa de lock. Payload:board,profile_name,dry_run,outcome,result.
Hooks de shell
Declare hooks em script de shell no seu ~/.hermes/config.yaml e o Hermes vai rodá-los como subprocessos sempre que o evento de hook de plugin correspondente disparar — em sessões tanto da CLI quanto do gateway. Não é preciso escrever plugin em Python.
Use hooks de shell quando você quer um script pronto, de arquivo único (Bash, Python, qualquer coisa com shebang), para:
- Bloquear ou modificar uma chamada de ferramenta — rejeitar comandos perigosos do
terminal, impor políticas por diretório, exigir aprovação para operações destrutivas dewrite_file/patch, ou reescrever argumentos (sanear caminhos, injetar padrões) antes de a ferramenta rodar. - Rodar depois de uma chamada de ferramenta — formatar automaticamente arquivos Python ou TypeScript que o agente acabou de escrever, registrar chamadas de API, disparar um fluxo de CI.
- Injetar contexto no próximo turno do LLM — colocar a saída do
git status, o dia da semana atual ou documentos recuperados antes da mensagem do usuário (vejapre_llm_call). - Observar eventos de ciclo de vida — escrever uma linha de log quando um subagente termina (
subagent_stop) ou quando uma sessão começa (on_session_start).
Os hooks de shell são registrados pela chamada de agent.shell_hooks.register_from_config(cfg), tanto na partida da CLI (hermes_cli/main.py) quanto na partida do gateway (gateway/run.py). Eles se combinam naturalmente com os hooks de plugin em Python — os dois passam pelo mesmo despachante.
Comparação rápida
| Dimensão | Hooks de shell | Hooks de plugin | Hooks de gateway |
|---|---|---|---|
| Declarados em | bloco hooks: em ~/.hermes/config.yaml | register() num plugin com plugin.yaml | diretório com HOOK.yaml + handler.py |
| Ficam em | ~/.hermes/agent-hooks/ (por convenção) | ~/.hermes/plugins/<name>/ | ~/.hermes/hooks/<name>/ |
| Linguagem | Qualquer uma (Bash, Python, binário em Go, …) | Só Python | Só Python |
| Rodam em | CLI + gateway | CLI + gateway | Só no gateway |
| Eventos | VALID_HOOKS (inclusive subagent_stop) | VALID_HOOKS | Ciclo de vida do gateway (gateway:startup, agent:*, command:*) |
| Podem bloquear uma chamada de ferramenta | Sim (pre_tool_call) | Sim (pre_tool_call) | Não |
| Podem injetar contexto no LLM | Sim (pre_llm_call) | Sim (pre_llm_call) | Não |
| Consentimento | Pedido no primeiro uso, por par (event, command) | Implícito (confiança no plugin Python) | Implícito (confiança no diretório) |
| Isolamento entre processos | Sim (subprocesso) | Não (no mesmo processo) | Não (no mesmo processo) |
Esquema de configuração
hooks:
<event_name>: # Precisa estar em VALID_HOOKS
- matcher: "<regex>" # Opcional; usado só em pre/post_tool_call
command: "<comando de shell>" # Obrigatório; roda via shlex.split, shell=False
timeout: <segundos> # Opcional; padrão 60, limitado a 300
fail_closed: <bool> # Opcional; padrão false. Só em pre_tool_call.
# `failClosed` também é aceito (compat. Cursor/Claude Code)
hooks_auto_accept: false # Veja "Modelo de consentimento" abaixo
Os nomes de evento precisam ser um dos eventos de hook de plugin; erros de digitação geram um aviso "Did you mean X?" e são pulados. Chaves desconhecidas dentro de uma entrada são ignoradas; a falta de command é pulada com aviso. timeout > 300 é limitado com aviso. fail_closed: true num evento que não seja pre_tool_call gera aviso e é ignorado (só eventos capazes de bloquear podem falhar fechado).
Protocolo de fio em JSON
A cada disparo do evento, o Hermes cria um subprocesso para cada hook correspondente (respeitando o matcher), manda um payload JSON pelo stdin e lê o stdout de volta como JSON.
stdin — payload que o script recebe:
{
"hook_event_name": "pre_tool_call",
"tool_name": "terminal",
"tool_input": {"command": "rm -rf /"},
"session_id": "sess_abc123",
"cwd": "/home/user/project",
"extra": {"task_id": "...", "tool_call_id": "..."}
}
tool_name e tool_input são null em eventos que não são de ferramenta (pre_llm_call, subagent_stop, ciclo de vida de sessão). O dicionário extra carrega todos os argumentos nomeados específicos do evento (user_message, conversation_history, child_role, duration_ms, …). Valores que não podem ser serializados viram string, em vez de serem omitidos.
stdout — resposta opcional:
// Bloquear um pre_tool_call (os dois formatos são aceitos; normalizados internamente):
{"decision": "block", "reason": "Forbidden: rm -rf"} // estilo Claude Code
{"action": "block", "message": "Forbidden: rm -rf"} // canônico do Hermes
// Modificar um pre_tool_call — reescrever os argumentos da ferramenta antes do despacho:
{"action": "modify", "args": {"new_string": "fixed content"}} // canônico do Hermes
{"decision": "modify", "tool_input": {"new_string": "fixed content"}} // estilo Claude Code
// Injetar contexto no pre_llm_call:
{"context": "Hoje é sexta-feira, 2026-04-17"}
// Manter o agente andando no portão de verificação (pre_verify); os dois formatos são aceitos:
{"action": "continue", "message": "Rode o formatador e então termine."}
{"decision": "block", "reason": "Rode o formatador e então termine."}
// Sem efeito, em silêncio — qualquer saída vazia / que não corresponda serve:
JSON malformado, códigos de saída diferentes de zero e tempos esgotados geram um aviso, mas nunca abortam o laço do agente.
Código de saída 2 = bloqueio (compatível com Claude Code / Cursor)
Um hook de pre_tool_call que sai com o código 2 bloqueia a chamada de ferramenta mesmo quando o stdout dele não traz JSON de bloqueio. A mensagem de bloqueio é resolvida nesta ordem de prioridade:
- o JSON de bloqueio no stdout (
reason/message), quando existe; - os primeiros 400 caracteres do stderr;
- um padrão genérico,
"Blocked by shell hook.".
Então o hook de bloqueio mais simples possível é:
#!/usr/bin/env bash
echo "violação de política: rm -rf não é permitido" >&2
exit 2
Nos eventos em que a diretiva de bloqueio não é honrada (todos menos o pre_tool_call), a saída 2 é tratada como qualquer outra saída diferente de zero: um aviso é registrado e o stdout continua sendo interpretado.
Falhar aberto ou falhar fechado
Por padrão, os hooks de shell falham abertos: um erro ao criar o processo, um tempo esgotado ou um stdout impossível de interpretar geram um aviso e a ação segue. Esse é o padrão certo para hooks de observabilidade — mas é errado para portões de segurança. Um verificador de segredos que quebrou não pode liberar em silêncio a chamada de ferramenta que ele deveria examinar.
Defina fail_closed: true (ou failClosed: true, a grafia do Cursor/Claude Code) numa entrada de pre_tool_call para inverter isso:
hooks:
pre_tool_call:
- matcher: "terminal|write_file|patch"
command: "~/.hermes/agent-hooks/secret-scan.sh"
timeout: 10
fail_closed: true
Com fail_closed: true, cada uma destas situações agora bloqueia a chamada de ferramenta, com hook <command> failed closed: <reason>:
| Falha | Falha aberta (padrão) | fail_closed: true |
|---|---|---|
| Comando não encontrado / não executável | avisa, segue | bloqueia |
| Tempo esgotado | avisa, segue | bloqueia |
| stdout que não é JSON (por exemplo, um stack trace) | avisa, segue | bloqueia |
Saída limpa, JSON válido sem efeito ({}) | segue | segue |
O fail_closed só vale para eventos capazes de bloquear (hoje, o pre_tool_call); defini-lo em qualquer outro evento gera um aviso na leitura da configuração e é ignorado. O hermes hooks test reflete essas semânticas — a linha parsed mostra exatamente o formato de bloqueio que o despachante receberia.
Exemplos práticos
1. Formatar arquivos Python automaticamente depois de cada escrita
# ~/.hermes/config.yaml
hooks:
post_tool_call:
- matcher: "write_file|patch"
command: "~/.hermes/agent-hooks/auto-format.sh"
#!/usr/bin/env bash
# ~/.hermes/agent-hooks/auto-format.sh
payload="$(cat -)"
path=$(echo "$payload" | jq -r '.tool_input.path // empty')
[[ "$path" == *.py ]] && command -v black >/dev/null && black "$path" 2>/dev/null
printf '{}\n'
A visão que o agente tem do arquivo no contexto dele não é relida automaticamente — a reformatação só afeta o arquivo em disco. As próximas chamadas de read_file pegam a versão formatada.
2. Bloquear comandos destrutivos do terminal
hooks:
pre_tool_call:
- matcher: "terminal"
command: "~/.hermes/agent-hooks/block-rm-rf.sh"
timeout: 5
#!/usr/bin/env bash
# ~/.hermes/agent-hooks/block-rm-rf.sh
payload="$(cat -)"
cmd=$(echo "$payload" | jq -r '.tool_input.command // empty')
if echo "$cmd" | grep -qE 'rm[[:space:]]+-rf?[[:space:]]+/'; then
printf '{"decision": "block", "reason": "bloqueado: rm -rf / não é permitido"}\n'
else
printf '{}\n'
fi
3. Injetar o git status em todo turno (equivalente ao UserPromptSubmit do Claude Code)
hooks:
pre_llm_call:
- command: "~/.hermes/agent-hooks/inject-cwd-context.sh"
#!/usr/bin/env bash
# ~/.hermes/agent-hooks/inject-cwd-context.sh
cat - >/dev/null # descarta o payload do stdin
if status=$(git status --porcelain 2>/dev/null) && [[ -n "$status" ]]; then
jq --null-input --arg s "$status" \
'{context: ("Mudanças não commitadas no cwd:\n" + $s)}'
else
printf '{}\n'
fi
O evento UserPromptSubmit do Claude Code de propósito não é um evento separado no Hermes — o pre_llm_call dispara no mesmo lugar e já aceita injeção de contexto. Use ele aqui.
4. Registrar toda conclusão de subagente
hooks:
subagent_stop:
- command: "~/.hermes/agent-hooks/log-orchestration.sh"
#!/usr/bin/env bash
# ~/.hermes/agent-hooks/log-orchestration.sh
log=~/.hermes/logs/orchestration.log
jq -c '{ts: now, parent: .session_id, extra: .extra}' < /dev/stdin >> "$log"
printf '{}\n'
Modelo de consentimento
Cada par (event, command) único pede aprovação ao usuário na primeira vez que o Hermes o vê, e depois persiste a decisão em ~/.hermes/shell-hooks-allowlist.json. As execuções seguintes (CLI ou gateway) pulam o pedido.
Três saídas de emergência contornam o pedido interativo — qualquer uma basta:
- a opção
--accept-hooksna CLI (por exemplo,hermes --accept-hooks chat) - a variável de ambiente
HERMES_ACCEPT_HOOKS=1 hooks_auto_accept: trueno~/.hermes/config.yaml
Execuções sem TTY (gateway, cron, CI) precisam de uma dessas três — senão, qualquer hook recém-adicionado fica sem registro em silêncio e gera um aviso.
Edições no script são confiadas em silêncio. A lista de permissões é indexada pela string exata do comando, não pelo hash do script, então editar o script em disco não invalida o consentimento. O hermes hooks doctor sinaliza mudança de mtime, para você notar as edições e decidir se aprova de novo.
Colocar na lista de permissões manualmente
Colocar na lista manualmente é útil em implantações sem TTY ou com conta de serviço, em que um operador não consegue responder ao pedido de primeiro uso de forma interativa. O arquivo da lista de permissões é o ~/.hermes/shell-hooks-allowlist.json, e o formato esperado é um array approvals. Cada aprovação registra o event do hook e a string exata do command:
{
"approvals": [
{
"event": "post_llm_call",
"command": "/home/hermes/.hermes/hooks/my-hook.py"
}
]
}
A string do comando precisa bater exatamente com o comando configurado no hook. Um objeto indexado por caminho com um campo sha256 não é o formato esperado e não vai aprovar o hook. Confira as entradas manuais com hermes hooks list.
A CLI hermes hooks
| Comando | O que faz |
|---|---|
hermes hooks list | Despeja os hooks configurados com matcher, timeout e situação do consentimento |
hermes hooks test <event> [--for-tool X] [--payload-file F] | Dispara todo hook correspondente contra um payload sintético e imprime a resposta interpretada |
hermes hooks revoke <command> | Remove toda entrada da lista de permissões que bata com <command> (vale a partir do próximo reinício) |
hermes hooks doctor | Para cada hook configurado: verifica o bit de execução, a situação na lista de permissões, a mudança de mtime, a validade da saída JSON e o tempo aproximado de execução |
Segurança
Os hooks de shell rodam com as suas credenciais completas de usuário — o mesmo limite de confiança de uma entrada de cron ou de um alias de shell. Trate o bloco hooks: do config.yaml como configuração privilegiada:
- Só referencie scripts que você escreveu ou revisou por inteiro.
- Mantenha os scripts dentro de
~/.hermes/agent-hooks/, para o caminho ser fácil de auditar. - Rode o
hermes hooks doctorde novo depois de puxar uma configuração compartilhada, para notar hooks recém-adicionados antes de eles se registrarem. - Se o seu config.yaml fica em controle de versão compartilhado com um time, revise os PRs que mexem na seção
hooks:com o mesmo cuidado com que você revisaria configuração de CI.
Ordem e precedência
Tanto os hooks de plugin em Python quanto os hooks de shell passam pelo mesmo despachante invoke_hook(). Os plugins Python são registrados primeiro (discover_and_load()) e os hooks de shell depois (register_from_config()), então as decisões de bloqueio de pre_tool_call em Python têm precedência em caso de empate. Vence o primeiro bloqueio válido — o agregador retorna assim que qualquer callback produz {"action": "block", "message": str} com uma mensagem não vazia.
Webhooks de saída
Os webhooks de saída são o espelho, do lado de empurrar, da plataforma de webhooks de entrada: webhooks de entrada acordam o Hermes quando o mundo muda; webhooks de saída contam ao mundo quando o Hermes faz algo. Configure uma lista de endpoints HTTP e os eventos de ciclo de vida que interessam a cada um, e o Hermes faz um POST de um payload JSON assinado para cada endpoint sempre que um evento correspondente dispara — sem ninguém do lado de lá ficar consultando.
Usos típicos:
- Avisar um sistema de CI ou um painel quando um turno do agente termina (
on_session_end) - Acompanhar conclusões de subagente numa frota inteira (
subagent_stop) - Alimentar monitoramento externo com atividade de ferramentas (
post_tool_callcom ummatcher) - Acordar outra instância do Hermes: aponte a URL para o webhook de entrada daquela instância
Configuração
Acrescente uma lista hooks.outbound: ao ~/.hermes/config.yaml:
hooks:
outbound:
- name: ci-notify # rótulo opcional para os logs
url: https://ci.example.com/hermes-events
events: [on_session_end, subagent_stop]
secret_env: HERMES_OUTBOUND_WEBHOOK_SECRET # variável de ambiente com o segredo HMAC
timeout: 10 # segundos por tentativa (1–60)
- name: tool-monitor
url: https://metrics.example.com/hooks/hermes
events: [post_tool_call]
matcher: "terminal|delegate_task" # regex, só em eventos com escopo de ferramenta
Qualquer evento do conjunto de hooks de plugin é válido (pre_tool_call, post_tool_call, pre_llm_call, post_llm_call, on_session_start, on_session_end, subagent_start, subagent_stop, ...). Entradas malformadas geram aviso e são puladas — um webhook quebrado nunca derruba o agente. As mudanças passam a valer na próxima sessão da CLI ou no próximo reinício do gateway.
Segredos: prefira secret_env (o nome de uma variável de ambiente, em geral definida em ~/.hermes/.env) a um literal secret: embutido, para o arquivo de configuração ficar livre de credenciais. Entradas sem segredo são entregues sem assinatura (marcadas como UNSIGNED pelo hermes hooks list).
Formato de fio
Cada disparo faz um POST com um corpo JSON de mesmo formato de primeiro nível do stdin dos hooks de shell, mais metadados de entrega:
{
"hook_event_name": "on_session_end",
"tool_name": null,
"tool_input": null,
"session_id": "sess_abc123",
"cwd": "/home/user/project",
"extra": {"completed": true, "interrupted": false, "model": "...", "platform": "cli"},
"delivery_id": "3f2c9a...",
"timestamp": "2026-07-22T14:00:00Z"
}
Cabeçalhos:
| Cabeçalho | Valor |
|---|---|
Content-Type | application/json |
X-Hermes-Event | O nome do evento de hook |
X-Hermes-Delivery | Id único por entrega — o mesmo valor de delivery_id no corpo |
X-Hermes-Signature-256 | sha256=<hex> — HMAC-SHA256 do corpo cru, no estilo do GitHub; só aparece quando há um segredo configurado |
Verifique a assinatura exatamente como você faria com um webhook do GitHub:
import hashlib, hmac
def verify(body: bytes, header: str, secret: str) -> bool:
expected = "sha256=" + hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, header)
Como delivery_id e timestamp ficam dentro do corpo assinado, um receptor que verifica a assinatura também ganha proteção contra repetição de graça:
- Deduplique pelo
delivery_id(ou pelo cabeçalhoX-Hermes-Deliverycorrespondente) — guarde os ids vistos recentemente e pule os repetidos. O Hermes tenta reenviar entregas falhas uma vez, então o mesmo id pode chegar duas vezes de forma legítima. - Rejeite eventos velhos comparando o
timestampcom o seu relógio, com uma janela de tolerância (5 minutos é o padrão comum). Um atacante que repita uma requisição capturada não consegue forjar um timestamp novo sem o segredo.
Semântica de entrega
- Dispara e esquece, fora do caminho quente. Os eventos são serializados e enfileirados na hora; uma única thread em segundo plano faz os POSTs HTTP. Um endpoint lento ou morto nunca consegue travar uma chamada de ferramenta ou um turno do agente.
- Só notificação. Diferente dos hooks de shell, os webhooks de saída não conseguem bloquear chamadas de ferramenta nem injetar contexto — o corpo da resposta é ignorado. Eles observam, nunca conduzem.
- Novas tentativas limitadas. Erros de conexão e respostas 5xx são tentados de novo uma vez, com espera progressiva; respostas 4xx não são repetidas (o receptor disse que a requisição em si está errada). As falhas são registradas e descartadas — a entrega é de melhor esforço, não garantida.
- Redirecionamentos nunca são seguidos. Uma resposta 3xx é tratada como erro de configuração e registrada — seguir um POST redirecionado descartaria o payload assinado em silêncio. Aponte a
urlpara o endpoint final. - Fila limitada. Se a fila acumula (endpoint morto, enxurrada de eventos), os eventos novos são descartados com um aviso, em vez de consumir memória sem limite.
- Sem pedido de consentimento. Os destinos de saída não executam código na sua máquina — eles recebem dados numa URL que você configurou. O
HERMES_SAFE_MODE=1continua pulando o registro, igual acontece com plugins e hooks de shell. Note que os payloads incluem entradas de ferramenta e metadados de evento, então só aponte para endpoints em que você confia, e prefirahttps://.
O hermes hooks list mostra os destinos de saída configurados ao lado dos hooks de shell, incluindo se cada destino está assinado.