Pular para o conteúdo principal

Documentação oficial desta página · v0.20.6

Criar um plugin do Hermes

Este guia percorre a construção de um plugin completo do Hermes, do zero. No fim você terá um plugin funcionando, com várias ferramentas, hooks de ciclo de vida, arquivos de dados distribuídos junto e uma skill embutida — tudo o que o sistema de plugins suporta.

Não tem certeza de qual guia você precisa?

O Hermes tem várias interfaces plugáveis distintas — algumas usam APIs register_* em Python, outras são guiadas por configuração ou por diretórios que você só larga no lugar. Use este mapa primeiro:

Se você quer acrescentar…Leia
Ferramentas próprias, hooks, comandos de barra, skills ou subcomandos de CLIEste guia (a superfície geral de plugins)
Uma extensão do aplicativo nativo de desktop (painéis, páginas, barra de status, paleta, temas)SDK de Plugin do Desktop
Uma extensão do painel web (abas, slots do shell, temas)Estender o Painel
Um backend de LLM / inferência (provedor novo)Plugins de Provedor de Modelo
Um canal de gateway (Discord/Telegram/IRC/Teams/etc.)Adicionar Adaptadores de Plataforma
Um backend de memória (Honcho/Mem0/Supermemory/etc.)Plugins de Provedor de Memória
Um motor de compressão de contextoPlugins de Motor de Contexto
Um backend de geração de imagemPlugins de Provedor de Geração de Imagem
Um backend de geração de vídeoPlugins de Provedor de Geração de Vídeo
Um backend de busca/extração na webPlugins de Provedor de Busca na Web
Um backend de navegador na nuvem (provedor de sessão CDP no estilo Browserbase)Plugins de Provedor de Navegador
Um backend de gerenciador de segredos (cofre / gerenciador de senhas / keystore do sistema)Plugins de Fonte de Segredos
Um provedor de OIDC/autenticação do painelPainel Web — provedores próprios — ctx.register_dashboard_auth_provider()
Um backend de TTS (qualquer CLI — Piper, VoxCPM, Kokoro, clonagem de voz, …)Provedores de comando próprios para TTS — guiado por configuração, sem Python
Um backend de STT (CLI própria de whisper / ASR)Transcrição de Mensagens de Voz — aponte HERMES_LOCAL_STT_COMMAND para um modelo tokenizado em argv
Ferramentas externas via MCP (sistema de arquivos, GitHub, Linear, qualquer servidor MCP)MCP — declare mcp_servers.<name> no config.yaml
Hooks de eventos do gateway (disparam na partida, em eventos de sessão, em comandos)Hooks de Eventos — largue HOOK.yaml + handler.py em ~/.hermes/hooks/<name>/
Hooks de shell (rodam um comando de shell em eventos)Hooks de Shell — declare em hooks: no config.yaml
Fontes extras de skills (repositórios próprios no GitHub, índices privados de skills)Skills — hermes skills tap add <repo> · Publicar um tap
Um provedor de inferência de núcleo, de primeira classe (não um plugin)Adicionar Provedores

Veja a tabela completa de interfaces plugáveis para uma visão consolidada de todas as superfícies de extensão, inclusive as guiadas por configuração (TTS, STT, MCP, hooks de shell) e as de diretório solto (hooks do gateway).

Plugins de produtos de terceiros são distribuídos à parte — não entram na árvore do núcleo

Plugins que integram o produto ou projeto de outra pessoa — backends de observabilidade/métricas, conectores de SaaS de fornecedores, painéis de analytics, amarrações com serviços pagos — são construídos e distribuídos como repositórios de plugin independentes, e não mesclados em NousResearch/hermes-agent. Os usuários instalam em ~/.hermes/plugins/ ou por um entry point do pip; tudo neste guia funciona igual a partir de um repositório independente. Isso é uma decisão de acoplamento e manutenção (o núcleo se move rápido e nós não somos donos do seu backend), não uma régua de qualidade — um plugin pode ser excelente e ainda assim pertencer ao próprio repositório. Divulgue-o no canal #plugins-skills-and-skins do Discord da Nous Research. Veja o CONTRIBUTING.md para a política.

Pacotes Portable Agent Plugins v1​

O Hermes também consegue instalar e carregar pacotes em diretório que seguem o formato Agent Plugins v1.0.0. Esse é um adaptador de compatibilidade para os componentes portáteis que o Hermes já tem. Ele não substitui os plugins nativos de plugin.yaml mais register(ctx).

meu-plugin-portatil/
├── plugin.json
├── skills/
│ └── summarize/
│ ├── SKILL.md
│ └── references/
└── mcp.json

Instale e ative um pacote portátil pelo fluxo normal:

hermes plugins install owner/repository --no-enable
hermes plugins list
hermes plugins enable <plugin-name>

Os pacotes portáteis ficam desativados depois da instalação, a menos que você os habilite explicitamente. Um pacote habilitado pode fornecer diretórios skills/*/SKILL.md imediatos e servidores MCP por stdio a partir do mcp.json na raiz. As skills são somente leitura, ficam em namespace e são carregadas por skills_list mais skill_view. Os comandos de MCP são passados como um único token executável, com uma lista de argumentos separada, nunca por um shell. Use skills_list para descobrir o nome qualificado completo da skill. Os namespaces de skill portáteis têm a forma determinística agent-plugin-<slug>-<hash>, derivada da chave do plugin descoberto, para que nomes higienizados não colidam.

O Hermes valida localmente o plugin.json, o frontmatter das Agent Skills, as localizações fixas dos componentes, o mcp.json, os caminhos resolvidos e a contenção de links simbólicos. Ele não busca esquemas JSON enquanto carrega um pacote. Uma skill ou entrada de MCP ruim é pulada na própria fronteira, quando os componentes irmãos válidos ainda podem carregar. O PLUGIN_ROOT aponta para a raiz resolvida do pacote. O PLUGIN_DATA aponta para um diretório gravável no escopo do perfil, gerenciado pelo Hermes. Os valores declarados no env do MCP portátil são dados visíveis do pacote, não um mecanismo de armazenamento de segredo. Não coloque credenciais no mcp.json.

O subconjunto portátil atual suporta entradas de MCP por stdio e por Streamable HTTP. As entradas portáteis de streamable-http são roteadas pelo cliente MCP remoto nativo que o Hermes já tem (o mesmo runtime que move a configuração de mcp_servers baseada em URL), com as regras de fronteira da v1 aplicadas: a URL precisa ser http(s) absoluta, sem informação de usuário nem fragmento; HTTP puro só é aceito para hosts localhost/loopback; e os cabeçalhos configurados nunca são repassados em um redirecionamento entre origens diferentes. Entradas antigas de sse são reportadas e puladas. O Agent Plugins v1 não define confiança, permissões, procedência nem sandbox. Habilitar um pacote dá às instruções e ao executável local dele a mesma postura de confiança total dos outros plugins do Hermes já instalados.

A especificação renderizada hoje rotula a v1.0.0 como Working Draft, enquanto o repositório versionado da especificação a registra como Published. O Hermes baseia o comportamento nos identificadores de esquema canônicos da v1.0.0 e no texto normativo, não em qualquer um desses rótulos mutáveis de status. Este é um subconjunto suportado explícito, não uma alegação de conformidade completa com o Agent Plugins.

Contrato de compatibilidade dos plugins nativos​

Os plugins nativos de plugin.yaml mais register(ctx) são protegidos por comportamento, não por um número global de API de plugin. O Hermes não expõe um PLUGIN_API_VERSION, não exige um api: casando no manifesto inteiro e não prende uma versão de API a valores que não têm relação com ela. Um plugin que usa um comportamento documentado deve continuar funcionando depois de uma atualização normal do Hermes.

As regras de compatibilidade são:

  • Evoluir por acréscimo. Os métodos documentados de PluginContext não são removidos nem renomeados. Parâmetros novos são opcionais, têm valor padrão e devem ser só por palavra-chave. Campos de retorno existentes não são removidos nem retipados em silêncio.
  • As cargas de hook são cargas por palavra-chave. Dados novos de hook entram como campos por palavra-chave, nunca mudando o significado ou a posição de um campo existente. O Hermes inspeciona as assinaturas dos callbacks: um callback antigo recebe os campos que declara, enquanto um callback com **kwargs recebe a carga atual completa. Plugins novos devem aceitar **kwargs para poderem aderir a dados acrescentados sem outra mudança de assinatura.
  • Os manifestos estão abertos a acréscimos. Campos desconhecidos em plugin.yaml são ignorados. Assim, versões mais antigas do Hermes conseguem carregar um plugin cujo manifesto tem metadados introduzidos por uma versão mais nova, desde que o código do plugin em si use comportamento de runtime suportado.
  • As interfaces de provedor crescem por padrões. Métodos novos de provedor têm implementação padrão. Contexto novo de callback é opcional e só é repassado quando a inspeção de assinatura mostra que o provedor o aceita. Acrescentar um método abstrato ou um argumento repassado incondicionalmente exige uma janela de migração, e não uma mudança de assinatura de um dia para o outro.
  • Versione o contrato que atravessa uma fronteira. Uma capacidade pode ter a própria versão de esquema quando define uma carga de rede ou um formato persistido (por exemplo, cargas de observador ou estado de fonte de segredos). Mantenha os campos aditivos dentro daquele esquema local. O estado persistido e a configuração do plugin precisam continuar legíveis, ou você entrega uma migração explícita; sessões retomadas gravadas no formato antigo ainda precisam ser reproduzidas. Não acrescente literais de versão a valores de callback ou de contexto que não têm relação com isso.

Política de descontinuação​

Um comportamento nativo documentado de plugin só pode ser descontinuado com tudo o que segue:

  1. um substituto e instruções de migração no guia de plugins e nas notas de versão;
  2. um aviso emitido no máximo uma vez por processo, nomeando o substituto e a versão mais próxima em que a remoção acontece;
  3. suporte ao comportamento antigo por pelo menos duas versões menores seguintes; e
  4. cobertura de compatibilidade baseada em comportamento, tanto para o caminho antigo quanto para o substituto, durante toda essa janela.

A remoção depois da janela precisa incluir qualquer migração necessária para dados persistidos ou sessões retomáveis. Na prática, aliases e adaptadores aditivos são preferidos à remoção.

O Hermes garante esse contrato com fixtures congeladas de plugins externos, descobertas a partir de um HERMES_HOME isolado. Esses testes carregam e invocam o plugin pelo PluginManager; eles verificam registro e resultados de callback de verdade, e não listas internas de símbolos ou a forma do código-fonte.

O que você vai construir​

Um plugin de calculadora com duas ferramentas:

  • calculate — avalia expressões matemáticas (2**16, sqrt(144), pi * 5**2)
  • unit_convert — converte entre unidades (100 F → 37.78 C, 5 km → 3.11 mi)

Mais um hook que registra toda chamada de ferramenta e um arquivo de skill embutido.

Passo 1: crie o diretório do plugin​

Crie um diretório e siga para o Passo 2:

mkdir -p ~/.hermes/plugins/calculator
cd ~/.hermes/plugins/calculator

Valide com o Plugin Doctor​

O hermes plugins doctor [path-or-id] roda a mesma descoberta de diretórios, o mesmo parser de manifesto, a mesma importação em namespace, o mesmo register(ctx), o mesmo registro de hooks e o mesmo registro de ferramentas que o próprio Hermes usa. Ele reporta nomes de hook inválidos, callbacks que não aceitam **kwargs, falhas de registro e diferenças entre as ferramentas/hooks declarados e os registrados. Passe --ci para sair com código diferente de zero em caso de erro:

hermes plugins doctor . --ci

O Doctor usa um HERMES_HOME temporário, restaura o estado de registro dos plugins depois da checagem e bloqueia conexões diretas de socket em Python, para pegar acesso acidental à rede enquanto o registro roda. Isso não é uma sandbox: o código do plugin ainda executa no mesmo processo, com as permissões do usuário atual, e pode criar subprocessos — então só rode o Doctor em código no qual você confia o bastante para importar.

Passo 2: escreva o manifesto​

Crie o plugin.yaml:

name: calculator
version: 1.0.0
description: Calculadora — avalia expressões e converte unidades
provides_tools:
- calculate
- unit_convert
provides_hooks:
- post_tool_call

Isso diz ao Hermes: "sou um plugin chamado calculator, forneço ferramentas e hooks". Os campos provides_tools e provides_hooks são listas do que o plugin registra.

Campos opcionais que você poderia acrescentar:

author: Seu Nome
requires_env: # condiciona o carregamento a variáveis de ambiente; perguntadas na instalação
- SOME_API_KEY # formato simples — plugin desativado se faltar
- name: OTHER_KEY # formato rico — mostra descrição/url durante a instalação
description: "Chave do serviço Other"
url: "https://other.com/keys"
secret: true
capabilities: # superfícies privilegiadas do host que você pede (fluxo de consentimento)
- tools.override # substituir ferramentas embutidas (precisa de consentimento do usuário)
- llm.model_override # escolher o modelo das chamadas de LLM feitas pelo host

Declarar capacidades​

Se o seu plugin precisa de uma superfície privilegiada do host — sobrepor uma ferramenta embutida, escolher o modelo das chamadas de ctx.llm etc. —, declare em capabilities:. Na hora de instalar ou habilitar, o usuário vê a lista e consente uma vez; se uma versão posterior acrescentar uma capacidade, o fluxo de atualização pergunta de novo, só sobre o acréscimo. Capacidades não declaradas ou não consentidas simplesmente ficam desligadas (falham fechadas), então sonde antes de usar e degrade com elegância:

def register(ctx):
if ctx.has_capability("tools.override"):
ctx.register_tool(..., override=True)
else:
ctx.register_tool(...) # registra com um nome que não conflita

IDs de capacidade conhecidos: tools.override, llm.provider_override, llm.model_override, llm.agent_id_override, llm.profile_override, llm.task_override (veja hermes_cli/plugin_capabilities.py para o registro canônico). IDs desconhecidos são ignorados. As chaves de configuração antigas, uma por capacidade (plugins.entries.<id>.allow_tool_override, …), ainda funcionam, mas estão descontinuadas — declare capacidades em vez disso, para que os usuários vejam uma única tela de consentimento auditável. Capacidades são consentimento + auditoria, não uma sandbox: elas apenas controlam superfícies de API do host, nada além.

Plugins distribuídos por pip não têm um diretório com plugin.yaml depois de instalados, então declare as capacidades nos metadados da distribuição, pelo grupo de entry point companheiro hermes_agent.plugin_capabilities. Cada declaração se chama <plugin-id>.<capability-id> e aponta para o mesmo objeto do seu entry point hermes_agent.plugins:

[project.entry-points."hermes_agent.plugins"]
calculator = "my_pkg:register"

[project.entry-points."hermes_agent.plugin_capabilities"]
"calculator.tools.override" = "my_pkg:register"

O Hermes lê isso dos metadados instalados sem importar o seu código, então o hermes plugins capabilities e o fluxo de consentimento continuam corretos nas instalações por pip.

Referência do manifesto v2​

O plugin.yaml também suporta um esquema v2 aditivo (#64165). Todo campo é opcional; um manifesto sem manifest_version é um manifesto v1 e segue totalmente suportado para sempre. Campos desconhecidos nunca quebram o carregamento — eles são ignorados com um aviso (compatibilidade para a frente), e um manifest_version mais novo do que este Hermes entende ainda carrega, com um aviso.

CampoTipoSignificado
manifest_versionintVersão do formato do arquivo de manifesto. Ausente = 1. Máximo atual: 2. Independente de api_version.
api_versionintGeração da API de plugin de runtime que o plugin mira (superfície do ctx / assinaturas de hook). É de propósito um eixo separado de manifest_version — um plugin com api_version: 1 pode usar um manifesto v2.
requires_pluginslistDependências entre plugins: - id: other-plugin, com version_range: ">=1.0,<2" opcional. Consultivo: uma dependência faltando registra um aviso claro, mas o plugin ainda carrega — sonde em runtime com ctx.has_plugin("other-plugin"). A ordem de carregamento respeita essas arestas: quando A precisa de B, o register() de B roda antes do de A (ordenação topológica, empate resolvido em ordem alfabética; ciclos geram aviso e voltam à ordem alfabética).
python_dependencieslist de strRequisitos de pip declarados (por exemplo, "requests>=2.0,<3"). Só uma costura de declaração — o Hermes valida, e o hermes plugins install / hermes plugins doctor mostram os que faltam com uma dica de pip install, mas o Hermes nunca instala sozinho. Fixe os limites superiores.
config_schemamappingDescrição no estilo JSON Schema das chaves em plugins.entries.<id>.settings: api_url: {type: str, default: "", description: "...", required: false}. Validada no carregamento; divergências geram avisos acionáveis nomeando a chave e o tipo esperado — nunca falha de carregamento. Tipos: str, int, float, bool, list, dict (mais os aliases do JSON Schema).
licensestrID de licença no estilo SPDX (por exemplo, MIT).
homepagestrURL do projeto.
tagslist de strTags livres para descoberta (por exemplo, [gateway, telegram]).
# plugin.yaml — exemplo de manifesto v2
name: my-plugin
version: 1.2.0
manifest_version: 2
api_version: 1
license: MIT
homepage: https://github.com/owner/my-plugin
tags: [gateway, demo]
requires_plugins:
- id: other-plugin
version_range: ">=1.0,<2"
python_dependencies:
- "somepkg>=1.0,<2" # mostrado, nunca instalado automaticamente
config_schema:
api_url: {type: str, default: "", description: "Endpoint do serviço"}
o isolamento de dependências de pip ficou para depois

O python_dependencies é de propósito apenas declarar e mostrar. Instalar pacotes arbitrários no venv compartilhado do Hermes é uma superfície de conflito e de cadeia de suprimentos, então o desenho de isolamento dessa costura de instalação (instalar com arquivo de constraints contra o lock do host, ou diretórios vendorizados por plugin, ou detecção de conflito com recusa) é um desdobramento explicitamente adiado — veja a revisão de segunda rodada em #64165 e #15220. Os pacotes de plugins (#64166) se apoiam nesses campos da v2.

Passo 3: escreva os esquemas das ferramentas​

Crie o schemas.py — é isso que o LLM lê para decidir quando chamar as suas ferramentas:

"""Esquemas das ferramentas — o que o LLM enxerga."""

CALCULATE = {
"name": "calculate",
"description": (
"Evaluate a mathematical expression and return the result. "
"Supports arithmetic (+, -, *, /, **), functions (sqrt, sin, cos, "
"log, abs, round, floor, ceil), and constants (pi, e). "
"Use this for any math the user asks about."
),
"parameters": {
"type": "object",
"properties": {
"expression": {
"type": "string",
"description": "Math expression to evaluate (e.g., '2**10', 'sqrt(144)')",
},
},
"required": ["expression"],
},
}

UNIT_CONVERT = {
"name": "unit_convert",
"description": (
"Convert a value between units. Supports length (m, km, mi, ft, in), "
"weight (kg, lb, oz, g), temperature (C, F, K), data (B, KB, MB, GB, TB), "
"and time (s, min, hr, day)."
),
"parameters": {
"type": "object",
"properties": {
"value": {
"type": "number",
"description": "The numeric value to convert",
},
"from_unit": {
"type": "string",
"description": "Source unit (e.g., 'km', 'lb', 'F', 'GB')",
},
"to_unit": {
"type": "string",
"description": "Target unit (e.g., 'mi', 'kg', 'C', 'MB')",
},
},
"required": ["value", "from_unit", "to_unit"],
},
}

Por que os esquemas importam: o campo description é como o LLM decide quando usar a sua ferramenta. Seja específico sobre o que ela faz e quando usar. Os parameters definem quais argumentos o LLM passa.

Passo 4: escreva os handlers das ferramentas​

Crie o tools.py — é o código que executa de fato quando o LLM chama as suas ferramentas:

"""Handlers das ferramentas — o código que roda quando o LLM chama cada uma."""

import json
import math

# Globais seguros para avaliar expressões — sem acesso a arquivo nem rede
_SAFE_MATH = {
"abs": abs, "round": round, "min": min, "max": max,
"pow": pow, "sqrt": math.sqrt, "sin": math.sin, "cos": math.cos,
"tan": math.tan, "log": math.log, "log2": math.log2, "log10": math.log10,
"floor": math.floor, "ceil": math.ceil,
"pi": math.pi, "e": math.e,
"factorial": math.factorial,
}


def calculate(args: dict, **kwargs) -> str:
"""Avalia uma expressão matemática com segurança.

Regras para handlers:
1. Receba args (dict) — os parâmetros que o LLM passou
2. Faça o trabalho
3. Devolva uma string JSON — SEMPRE, até em caso de erro
4. Aceite **kwargs, por compatibilidade para a frente
"""
expression = args.get("expression", "").strip()
if not expression:
return json.dumps({"error": "No expression provided"})

try:
result = eval(expression, {"__builtins__": {}}, _SAFE_MATH)
return json.dumps({"expression": expression, "result": result})
except ZeroDivisionError:
return json.dumps({"expression": expression, "error": "Division by zero"})
except Exception as e:
return json.dumps({"expression": expression, "error": f"Invalid: {e}"})


# Tabelas de conversão — os valores estão em unidades base
_LENGTH = {"m": 1, "km": 1000, "mi": 1609.34, "ft": 0.3048, "in": 0.0254, "cm": 0.01}
_WEIGHT = {"kg": 1, "g": 0.001, "lb": 0.453592, "oz": 0.0283495}
_DATA = {"B": 1, "KB": 1024, "MB": 1024**2, "GB": 1024**3, "TB": 1024**4}
_TIME = {"s": 1, "ms": 0.001, "min": 60, "hr": 3600, "day": 86400}


def _convert_temp(value, from_u, to_u):
# Normaliza para Celsius
c = {"F": (value - 32) * 5/9, "K": value - 273.15}.get(from_u, value)
# Converte para o destino
return {"F": c * 9/5 + 32, "K": c + 273.15}.get(to_u, c)


def unit_convert(args: dict, **kwargs) -> str:
"""Converte entre unidades."""
value = args.get("value")
from_unit = args.get("from_unit", "").strip()
to_unit = args.get("to_unit", "").strip()

if value is None or not from_unit or not to_unit:
return json.dumps({"error": "Need value, from_unit, and to_unit"})

try:
# Temperatura
if from_unit.upper() in {"C","F","K"} and to_unit.upper() in {"C","F","K"}:
result = _convert_temp(float(value), from_unit.upper(), to_unit.upper())
return json.dumps({"input": f"{value} {from_unit}", "result": round(result, 4),
"output": f"{round(result, 4)} {to_unit}"})

# Conversões por razão
for table in (_LENGTH, _WEIGHT, _DATA, _TIME):
lc = {k.lower(): v for k, v in table.items()}
if from_unit.lower() in lc and to_unit.lower() in lc:
result = float(value) * lc[from_unit.lower()] / lc[to_unit.lower()]
return json.dumps({"input": f"{value} {from_unit}",
"result": round(result, 6),
"output": f"{round(result, 6)} {to_unit}"})

return json.dumps({"error": f"Cannot convert {from_unit} → {to_unit}"})
except Exception as e:
return json.dumps({"error": f"Conversion failed: {e}"})

Regras principais para handlers:

  1. Assinatura: def my_handler(args: dict, **kwargs) -> str
  2. Retorno: sempre uma string JSON. Tanto no sucesso quanto no erro.
  3. Nunca levante exceção: capture tudo e devolva um JSON de erro no lugar.
  4. Aceite **kwargs: o Hermes pode passar contexto adicional no futuro.

Passo 5: escreva o registro​

Crie o __init__.py — é ele que liga os esquemas aos handlers:

"""Plugin de calculadora — registro."""

import logging

from . import schemas, tools

logger = logging.getLogger(__name__)

# Acompanha o uso das ferramentas por hooks
_call_log = []

def _on_post_tool_call(tool_name, args, result, task_id, **kwargs):
"""Hook: roda depois de toda chamada de ferramenta (não só das nossas)."""
_call_log.append({"tool": tool_name, "session": task_id})
if len(_call_log) > 100:
_call_log.pop(0)
logger.debug("Tool called: %s (session %s)", tool_name, task_id)


def register(ctx):
"""Liga os esquemas aos handlers e registra os hooks."""
ctx.register_tool(name="calculate", toolset="calculator",
schema=schemas.CALCULATE, handler=tools.calculate)
ctx.register_tool(name="unit_convert", toolset="calculator",
schema=schemas.UNIT_CONVERT, handler=tools.unit_convert)

# Este hook dispara em TODAS as chamadas de ferramenta, não só nas nossas
ctx.register_hook("post_tool_call", _on_post_tool_call)

O que o register() faz:

  • É chamado exatamente uma vez, na partida
  • O ctx.register_tool() coloca a sua ferramenta no registro — o modelo a enxerga na hora
  • O ctx.register_hook() assina eventos de ciclo de vida
  • O ctx.register_cli_command() registra um subcomando de CLI (por exemplo, hermes my-plugin <subcommand>)
  • O ctx.register_command() registra um comando de barra dentro da sessão (por exemplo, /myplugin <args> na CLI ou no chat do gateway) — veja Registrar comandos de barra abaixo
  • O ctx.dispatch_tool(name, arguments) — chama qualquer outra ferramenta (embutida ou de outro plugin) com o contexto do agente pai (aprovações, credenciais, task_id) já ligado automaticamente. Útil em handlers de comando de barra que precisam invocar terminal, read_file ou qualquer outra ferramenta como se o modelo tivesse chamado direto.
  • O ctx.get_config() / ctx.set_config() acessam só o namespace de configurações deste plugin; o ctx.state guarda dados de runtime do próprio plugin, sob o perfil ativo.
  • Se esta função quebrar, o plugin é desativado, mas o Hermes segue funcionando bem

Exemplo de dispatch_tool — um comando de barra que roda uma ferramenta:

def handle_scan(ctx, raw_args: str):
"""Implementa o /scan invocando a ferramenta terminal pelo registro."""
result = ctx.dispatch_tool("terminal", {"command": f"find . -name '{raw_args}'"})
return result # devolvido à interface de chat de quem chamou

def register(ctx):
# Os handlers recebem uma única string raw_args; capture o ctx com uma lambda.
ctx.register_command(
"scan",
lambda raw: handle_scan(ctx, raw),
description="Find files matching a glob",
)

A ferramenta despachada passa pelos fluxos normais de aprovação, redação e orçamento — é uma invocação de ferramenta de verdade, não um atalho por fora deles.

Guardar configurações e estado de runtime​

Use chaves de configuração relativas ao plugin para o comportamento visível ao usuário. O Hermes as resolve sob plugins.entries.<plugin-id>.settings e rejeita caminhos globais, entre plugins e de travessia:

def register(ctx):
endpoint = ctx.get_config("endpoint", default="https://example.invalid")
retries = ctx.get_config("retry.attempts", default=3)

ctx.set_config("endpoint", endpoint)
ctx.set_config("retry.attempts", retries)

Use o ctx.state para cursores, caches e dados de deduplicação do próprio plugin, em vez de colocar contabilidade de runtime no config.yaml:

def register(ctx):
cursor = ctx.state.get("cursor", default={"page": 0})
ctx.state.set("cursor", {"page": cursor["page"] + 1})

O estado fica no escopo do perfil, é substituído de forma atômica, é seguro entre escritores concorrentes e tem limite de 10 MiB por plugin. Os pacotes portáteis compartilham o mesmo diretório do PLUGIN_DATA deles; os plugins nativos recebem um namespace resistente a colisão e seguro no Windows. Estado existente malformado é reportado e preservado.

Configuração e estado têm donos diferentes: as configurações são comportamento visível ao usuário no config.yaml, enquanto o estado é dado de runtime do próprio plugin, em <HERMES_HOME>/plugin-data/. Nenhuma das duas APIs expõe o namespace de outro plugin.

Passo 6: teste​

Suba o Hermes:

hermes

Você deve ver calculator: calculate, unit_convert na lista de ferramentas do banner.

Experimente estes prompts:

Quanto é 2 elevado a 16?
Converta 100 fahrenheit para celsius
Quanto é a raiz quadrada de 2 vezes pi?
Quantos gigabytes são 1,5 terabytes?

Confira o status dos plugins:

/plugins

Saída:

Plugins (1):
✓ calculator v1.0.0 (2 tools, 1 hooks)

Depurar a descoberta de plugins​

Se o seu plugin não aparece — ou aparece mas não carrega —, defina HERMES_PLUGINS_DEBUG=1 para obter logs de descoberta detalhados no stderr:

HERMES_PLUGINS_DEBUG=1 hermes plugins list

Você vai ver, para cada fonte de plugin (embutida, do usuário, do projeto, entry points):

  • quais diretórios foram varridos e quantos manifestos cada um rendeu
  • por manifesto: chave resolvida, nome, tipo, fonte, caminho em disco
  • motivos de pulo: disabled via config, not enabled in config, exclusive plugin, no plugin.yaml, depth cap reached
  • no carregamento: o plugin sendo importado, mais um resumo de uma linha do que o register(ctx) registrou (ferramentas, hooks, comandos de barra, comandos de CLI)
  • em falha de parse: o traceback completo da exceção (erros de scanner do YAML etc.)
  • em falha de register(): o traceback completo, apontando a linha do seu __init__.py que levantou o erro

Os mesmos logs são sempre escritos em ~/.hermes/logs/agent.log, no nível WARNING (só as falhas) e no nível DEBUG (tudo) quando a variável de ambiente está definida. Então, se você não consegue rodar com a variável (por exemplo, de dentro do gateway), acompanhe o arquivo de log:

hermes logs --level WARNING | grep -i plugin

Motivos comuns para um plugin não aparecer:

  • Não habilitado na configuração — os plugins são opt-in. Rode hermes plugins enable <name> (o nome vem da saída de plugins list, que pode ser <category>/<plugin> em layouts aninhados).
  • Layout de diretório errado: os pacotes nativos usam ~/.hermes/plugins/<plugin-name>/plugin.yaml (plano) ou um nível de categoria. Os pacotes portáteis usam um plugin.json na raiz, nos mesmos lugares. Qualquer coisa mais funda é ignorada.
  • Falta o __init__.py: os pacotes nativos precisam de plugin.yaml e de __init__.py com uma função register(ctx). Os pacotes portáteis não importam Python e não exigem __init__.py.
  • kind errado — os adaptadores de gateway precisam de kind: platform no manifesto. Os provedores de memória são detectados automaticamente como kind: exclusive e roteados pela configuração memory.provider, em vez de plugins.enabled.

A estrutura final do seu plugin​

~/.hermes/plugins/calculator/
├── plugin.yaml # "sou o calculator, forneço ferramentas e hooks"
├── __init__.py # Ligação: esquemas → handlers, registro de hooks
├── schemas.py # O que o LLM lê (descrições + especificação dos parâmetros)
└── tools.py # O que roda (funções calculate, unit_convert)

Quatro arquivos, separação clara:

  • O manifesto declara o que o plugin é
  • Os esquemas descrevem as ferramentas para o LLM
  • Os handlers implementam a lógica de fato
  • O registro conecta tudo

O que mais os plugins conseguem fazer?​

Distribuir arquivos de dados​

Coloque quaisquer arquivos no diretório do seu plugin e leia-os no momento da importação:

# Em tools.py ou __init__.py
from pathlib import Path

_PLUGIN_DIR = Path(__file__).parent
_DATA_FILE = _PLUGIN_DIR / "data" / "languages.yaml"

with open(_DATA_FILE) as f:
_DATA = yaml.safe_load(f)

Isso vale para arquivos que você distribui. Estado que você escreve é outra coisa — veja a próxima seção.

Guardar estado durável​

Nunca escreva estado de runtime no diretório do seu plugin: aquilo é a árvore de instalação, e o hermes plugins update / remove faz git pull ou apaga tudo — os dados dos seus usuários morrem junto. O lugar sancionado é a raiz de dados por plugin, que sobrevive aos dois e acompanha o perfil ativo:

from plugins.plugin_storage import plugin_data_dir, plugin_db

# <hermes home>/plugin-data/<name>/ — criado no primeiro uso
state_file = plugin_data_dir("my-plugin") / "state.json"

# Ou um banco SQLite em <diretório de dados>/data.db (modo WAL, amigável a threads)
conn = plugin_db("my-plugin")
conn.execute("CREATE TABLE IF NOT EXISTS runs (id TEXT PRIMARY KEY)")

Um diretório por plugin significa que os dados de cada plugin ficam inspecionáveis em um único lugar previsível. Segredos não pertencem aqui — leitura de credencial passa pelo caminho padrão de .env / escopo de segredo, como em todo o resto.

Embutir skills​

Plugins podem distribuir arquivos de skill que o agente carrega com skill_view("plugin:skill"). Registre-os no seu __init__.py:

~/.hermes/plugins/my-plugin/
├── __init__.py
├── plugin.yaml
└── skills/
├── my-workflow/
│ └── SKILL.md
└── my-checklist/
└── SKILL.md
from pathlib import Path

def register(ctx):
skills_dir = Path(__file__).parent / "skills"
for child in sorted(skills_dir.iterdir()):
skill_md = child / "SKILL.md"
if child.is_dir() and skill_md.exists():
ctx.register_skill(child.name, skill_md)

Agora o agente consegue carregar as suas skills pelo nome em namespace:

skill_view("my-plugin:my-workflow") # → a versão do plugin
skill_view("my-workflow") # → a versão embutida (inalterada)

Propriedades principais:

  • As skills de plugin são somente leitura — elas não entram em ~/.hermes/skills/ e não dá para editá-las por skill_manage.
  • As skills de plugin não aparecem no índice <available_skills> do prompt de sistema — são carregamentos explícitos, por adesão.
  • Nomes de skill sem namespace não são afetados — o namespace evita colisão com as skills embutidas.
  • Quando o agente carrega uma skill de plugin, um banner de contexto do pacote é colocado antes, listando as skills irmãs do mesmo plugin.
Padrão antigo

O antigo padrão de shutil.copy2 (copiar uma skill para ~/.hermes/skills/) ainda funciona, mas cria risco de colisão de nome com as skills embutidas. Prefira o ctx.register_skill() em plugins novos.

Condicionar a variáveis de ambiente​

Se o seu plugin precisa de uma chave de API:

# plugin.yaml — formato simples (compatível com o anterior)
requires_env:
- WEATHER_API_KEY

Se WEATHER_API_KEY não estiver definida, o plugin é desativado com uma mensagem clara. Sem quebra, sem erro no agente — só um "Plugin weather disabled (missing: WEATHER_API_KEY)".

Quando as pessoas rodam hermes plugins install, elas são perguntadas de forma interativa por qualquer variável de requires_env que esteja faltando. Os valores são salvos no .env automaticamente.

Para uma experiência de instalação melhor, use o formato rico, com descrições e URLs de cadastro:

# plugin.yaml — formato rico
requires_env:
- name: WEATHER_API_KEY
description: "API key for OpenWeather"
url: "https://openweathermap.org/api"
secret: true
CampoObrigatórioDescrição
nameSimNome da variável de ambiente
descriptionNãoMostrado ao usuário durante a pergunta de instalação
urlNãoOnde conseguir a credencial
secretNãoSe true, a digitação fica escondida (como um campo de senha)

Os dois formatos podem ser misturados na mesma lista. Variáveis já definidas são puladas em silêncio.

Instalar dependências Python opcionais sob demanda​

Se o seu plugin embrulha um SDK que nem todo usuário terá instalado (um SDK de fornecedor, uma biblioteca pesada de ML, um pacote específico de plataforma), não faça o import no topo do módulo. Use o auxiliar tools.lazy_deps.ensure(...) dentro do handler da ferramenta — o Hermes instala o pacote no primeiro uso, condicionado à configuração security.allow_lazy_installs do usuário.

# tools.py
from tools.lazy_deps import ensure, FeatureUnavailable

def my_tool_handler(args, **kwargs):
try:
ensure("my-plugin.my-backend") # a chave precisa estar em LAZY_DEPS
except FeatureUnavailable as exc:
return {"error": str(exc)}

import my_backend_sdk # agora é seguro
...

Duas regras do modelo de segurança em tools/lazy_deps.py:

RegraPor quê
A sua chave de recurso precisa aparecer na lista de permissões LAZY_DEPS que vem na árvoreImpede que uma configuração maliciosa convença o Hermes a instalar pacotes arbitrários — só as especificações que o próprio Hermes distribui são elegíveis
As especificações são só por nome no PyPINada de --index-url, git+https:// ou caminhos file:. Fixe versões com a PEP 440 ("my-sdk>=1.2,<2") dentro da entrada da lista de permissões

Para plugins de terceiros distribuídos por pip, declare as dependências opcionais como extras em [project.optional-dependencies] no seu próprio pyproject.toml e diga às pessoas para rodar pip install your-plugin[backend] — esse caminho não passa por lazy_deps. A dança de instalação sob demanda é mais útil para plugins embutidos, em que distribuir uma dependência dura em toda instalação inflaria a pegada base do Hermes.

Quando security.allow_lazy_installs: false está definido globalmente, o ensure() levanta FeatureUnavailable na hora, com uma dica de correção — o seu plugin deve capturar isso e degradar com elegância (devolver um resultado de erro, não quebrar o laço de ferramentas).

Singletons preguiçosos seguros para threads​

Plugins muitas vezes guardam em cache um objeto caro — um cliente de SDK, uma sessão HTTP, um pool de conexões — em uma variável de módulo construída no primeiro uso:

_client = None

def get_client():
global _client
if _client is not None:
return _client
_client = ExpensiveClient(...) # ← corrida TOCTOU
return _client

Isso é um tiro no pé. O Hermes roda várias threads em um só processo (chamadas de ferramenta delegadas, workers de segundo plano, o fork de automelhoria), então duas threads podem chegar ao get_client() antes de _client ser definido, as duas passam pela checagem de is not None, as duas rodam a construção cara, e a segunda escrita atropela a primeira — vazando o recurso que a perdedora abriu (conexão, descritor de arquivo, thread de segundo plano).

Não escreva o lock na mão. Use os auxiliares em plugins/plugin_utils.py:

from plugins.plugin_utils import lazy_singleton, SingletonSlot

# Acessor sem argumentos → decore-o:
@lazy_singleton
def get_client():
return ExpensiveClient(load_config()) # roda exatamente uma vez

client = get_client() # seguro entre threads
get_client.reset() # descarta a instância (testes / desmontagem)


# Acessor que recebe um argumento de construção → use um slot:
_slot: SingletonSlot = SingletonSlot()

def get_client(config=None):
return _slot.get(lambda: ExpensiveClient(resolve(config)))

def reset_client():
_slot.reset()

Os dois serializam as primeiras chamadas concorrentes com travamento de dupla checagem e rodam a fábrica no máximo uma vez. Se a fábrica levantar erro, nada é guardado em cache e a chamada seguinte tenta de novo. O plugin de memória do honcho (plugins/memory/honcho/client.py) é o consumidor de referência.

Regra de bolso: toda vez que você escrever global _something seguido de uma checagem de is None e de uma construção, use um destes no lugar.

Disponibilidade condicional de ferramenta​

Para ferramentas que dependem de bibliotecas opcionais:

ctx.register_tool(
name="my_tool",
schema={...},
handler=my_handler,
check_fn=lambda: _has_optional_lib(), # False = ferramenta escondida do modelo
)

Sobrepor uma ferramenta embutida​

Para substituir uma ferramenta embutida pela sua própria implementação (por exemplo, trocar a ferramenta de navegador padrão por um backend CDP de Chrome com interface, ou substituir o web_search por um índice corporativo próprio), passe override=True:

def register(ctx):
ctx.register_tool(
name="browser_navigate", # o mesmo nome da embutida
toolset="plugin_my_browser", # o seu próprio namespace de toolset
schema={...},
handler=my_custom_navigate,
override=True, # adesão explícita
)

Sem override=True, o registro rejeita qualquer registro que sombrearia uma ferramenta existente de outro toolset — isso evita sobrescrita acidental. Sobrepor uma ferramenta embutida exige, além disso, que o operador adira por plugins.entries.<plugin_id>.allow_tool_override: true no config.yaml; sem essa trava, o register_tool(override=True) levanta PluginToolOverrideError. A sobreposição é registrada em log, então fica auditável em ~/.hermes/logs/agent.log. Os plugins carregam depois das ferramentas embutidas, então a ordem de registro está certa: o seu handler substitui o embutido.

Plugins que não vêm embutidos também precisam de uma concessão do operador. Para qualquer plugin que não venha com o núcleo do Hermes (fonte de usuário, de projeto ou de pip), o override=True contra uma ferramenta embutida existente exige também uma adesão por plugin no config.yaml:

plugins:
entries:
my-plugin: # a chave de registro do plugin, vinda de `hermes plugins list`
allow_tool_override: true

Sem a concessão, o ctx.register_tool(..., override=True) levanta PluginToolOverrideError; e como as exceções de register() são capturadas pelo carregador, o plugin é desativado e o Hermes continua. A trava existe porque um plugin habilitado que substituísse em silêncio uma ferramenta privilegiada como shell_exec ou write_file poderia interceptar tudo o que o modelo roteia por ela. Plugins embutidos ficam de fora: uma sobreposição ali é uma decisão de quem mantém o projeto. Se a configuração não puder ser carregada, a trava falha fechada.

Normalmente você nunca edita essa chave na mão. O hermes plugins enable <name> pergunta se deve conceder a capacidade ao habilitar um plugin não embutido (com "não" como padrão), e as flags --allow-tool-override / --no-allow-tool-override pulam a pergunta em instalações por script. A mesma concessão também controla o deregister(): sem ela, um plugin não consegue remover uma ferramenta que não é dele (o que, de outro modo, seria um jeito de contornar a checagem de sobreposição).

Registrar vários hooks​

def register(ctx):
ctx.register_hook("pre_tool_call", before_any_tool)
ctx.register_hook("post_tool_call", after_any_tool)
ctx.register_hook("pre_llm_call", inject_memory)
ctx.register_hook("on_session_start", on_new_session)
ctx.register_hook("on_session_end", on_session_end)

Referência de hooks​

Cada hook está documentado por inteiro na referência de Hooks de Eventos — assinaturas de callback, tabelas de parâmetros, exatamente quando cada um dispara e exemplos. Aqui vai o resumo:

HookDispara quandoAssinatura do callbackRetorno
pre_tool_callAntes de qualquer ferramenta executartool_name: str, args: dict, task_id: strdiretiva opcional: {"action": "block", "message": ...} veta a chamada; {"action": "approve", "message": ...} escala para a trava de aprovação humana
post_tool_callDepois que qualquer ferramenta retornatool_name: str, args: dict, result: str, task_id: str, duration_ms: intignorado
pre_llm_callUma vez por turno, antes do laço de chamada de ferramentassession_id: str, user_message: str, conversation_history: list, is_first_turn: bool, model: str, platform: strinjeção de contexto
post_llm_callUma vez por turno, depois do laço de chamada de ferramentas (só em turnos bem-sucedidos)session_id: str, user_message: str, assistant_response: str, conversation_history: list, model: str, platform: strignorado
pre_api_requestAntes de cada requisição crua à API do provedor (várias por turno, quando o modelo chama ferramentas)session_id: str, model: str, provider: str, base_url: str, api_mode: str, api_call_count: int, message_count: int, tool_count: int, approx_input_tokens: int, max_tokens: int, request: dictignorado
post_api_requestDepois que cada requisição crua à API do provedor retornaos campos de pre_api_request mais api_duration: float, finish_reason: str, response_model: str | None, usage: dict, response: dict, assistant_content_chars: int, assistant_tool_call_count: intignorado
api_request_errorUma chamada à API do provedor levantou errocampos de correlação mais status_code: int | None, retry_count: int | None, max_retries: int | None, retryable: bool | None, reason: str | None, error: dict, request: dictignorado
on_session_startSessão nova criada (só no primeiro turno)session_id: str, model: str, platform: strignorado
on_session_endFim de toda chamada de run_conversation + saída da CLIsession_id: str, completed: bool, interrupted: bool, model: str, platform: strignorado
on_session_finalizeA CLI ou o gateway desmonta uma sessão ativasession_id: str | None, platform: strignorado
on_session_resetO gateway troca por uma chave de sessão nova (/new, /reset)session_id: str, platform: strignorado
gateway_platform_eventUm evento autorizado, nativo da plataforma, é normalizado na fronteira do gateway (por ora, reações do Telegram)platform: str, event_type: str, payload: dictignorado
kanban_task_claimedUma tarefa de kanban é assumida (processo despachante, antes de o worker nascer)task_id: str, board: str | None, assignee: str | None, run_id: int | None, profile_name: strignorado
kanban_task_completedUma tarefa de kanban termina (processo do worker)task_id, board, assignee, run_id, profile_name, summary: str | Noneignorado
kanban_task_blockedUma tarefa de kanban fica bloqueada (processo do worker)task_id, board, assignee, run_id, profile_name, reason: str | Noneignorado

A maioria dos hooks é de observadores do tipo dispare e esqueça — os valores de retorno deles são ignorados. As exceções são o pre_llm_call, que pode injetar contexto na conversa, e o pre_tool_call, que pode devolver uma diretiva de bloqueio ou de aprovação.

Todos os callbacks devem aceitar **kwargs, por compatibilidade para a frente. Se um callback de hook quebra, o erro é registrado e ele é pulado. Os outros hooks e o agente continuam normalmente.

Os hooks de ciclo de vida do kanban disparam depois que a mudança no banco do quadro é confirmada, então um callback sempre enxerga estado durável e nunca consegue segurar o lock de escrita do SQLite. Como os workers de kanban rodam como subprocessos hermes -p <profile> chat -q separados, o kanban_task_claimed dispara no processo despachante, enquanto o kanban_task_completed / kanban_task_blocked dispara no processo do worker — coloque o hook no despachante para observar toda transição de forma centralizada, ou no worker para ter contexto de sessão por tarefa.

Os hooks de requisição de API são observadores da requisição crua ao provedor, um nível abaixo do par pre_llm_call / post_llm_call de cada turno: um único turno que chama ferramentas faz várias requisições de API, e esses hooks disparam em torno de cada uma. Eles existem para plugins de observabilidade (rastreamento, contabilidade de custo, painéis de latência). Os kwargs request e response são visões JSON higienizadas e com teto de tamanho da carga do provedor (chaves sensíveis redigidas, strings longas truncadas, objetos de SDK normalizados), e o usage é um dicionário simples com o resumo de tokens. Toda carga leva os campos de correlação turn_id, api_request_id, task_id, session_id e api_call_count, para que um plugin consiga costurar requisições, chamadas de ferramenta e turnos. O api_request_error dispara quando uma chamada ao provedor levanta erro e acrescenta status_code, retry_count / max_retries, retryable, reason e um dicionário error com type e message.

Injeção de contexto do pre_llm_call​

Este é o único hook cujo valor de retorno importa. Quando um callback de pre_llm_call devolve um dicionário com a chave "context" (ou uma string simples), o Hermes injeta aquele texto na mensagem de usuário do turno atual. É esse o mecanismo para plugins de memória, integrações de RAG, guardrails e qualquer plugin que precise dar contexto adicional ao modelo.

Formato de retorno​

# Dicionário com a chave context
return {"context": "Recalled memories:\n- User prefers dark mode\n- Last project: hermes-agent"}

# String simples (equivalente à forma de dicionário acima)
return "Recalled memories:\n- User prefers dark mode"

# Devolva None ou não devolva nada → sem injeção (só observação)
return None

Qualquer retorno não nulo e não vazio com a chave "context" (ou uma string simples não vazia) é coletado e anexado à mensagem de usuário do turno atual.

Vazamento de contexto grande demais​

O contexto de cada hook tem teto de 10.000 caracteres por padrão. Tudo acima do teto é escrito em $HERMES_HOME/hook_outputs/<session_id>/<uuid>.txt e substituído por uma prévia de começo e fim, mais o caminho salvo. O modelo consegue ler o conteúdo completo por read_file ou terminal, se realmente precisar. Isso impede que um plugin desgovernado infle o prompt de todo turno seguinte e estoure o prefixo de cache do prompt. Ajuste no config.yaml:

hooks:
output_spill:
enabled: true # padrão: true
max_chars: 10000 # padrão; aumente para abrir mão do vazamento
preview_head: 500 # caracteres mostrados no topo da prévia
preview_tail: 500 # caracteres mostrados no fim da prévia
# directory: null # padrão: $HERMES_HOME/hook_outputs

Como a injeção funciona​

O contexto injetado é anexado à mensagem de usuário, não ao prompt de sistema. Isso é uma escolha de projeto deliberada:

  • Preservação do cache de prompt — o prompt de sistema fica idêntico de um turno para o outro. A Anthropic e a OpenRouter guardam em cache o prefixo do prompt de sistema, então mantê-lo estável economiza mais de 75% em tokens de entrada em conversas com vários turnos. Se os plugins alterassem o prompt de sistema, todo turno seria uma falha de cache.
  • Efêmero — a injeção acontece só na hora da chamada de API. A mensagem de usuário original no histórico da conversa nunca é alterada, e nada é persistido no banco da sessão.
  • O prompt de sistema é território do Hermes — ele contém orientações específicas do modelo, regras de imposição de ferramentas, instruções de personalidade e conteúdo de skill em cache. Os plugins contribuem com contexto ao lado da entrada do usuário, não alterando as instruções centrais do agente.

Exemplo: plugin de recuperação de memória​

"""Plugin de memória — recupera contexto relevante de um banco vetorial."""

import httpx

MEMORY_API = "https://your-memory-api.example.com"

def recall_context(session_id, user_message, is_first_turn, **kwargs):
"""Chamado antes de cada turno de LLM. Devolve as memórias recuperadas."""
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 # nada a injetar

text = "Recalled context from previous sessions:\n"
text += "\n".join(f"- {m['text']}" for m in memories)
return {"context": text}
except Exception:
return None # falha em silêncio, não quebra o agente

def register(ctx):
ctx.register_hook("pre_llm_call", recall_context)

Exemplo: plugin de guardrails​

"""Plugin de guardrails — impõe políticas de conteúdo."""

POLICY = """You MUST follow these content policies for this session:
- Never generate code that accesses the filesystem outside the working directory
- Always warn before executing destructive operations
- Refuse requests involving personal data extraction"""

def inject_guardrails(**kwargs):
"""Injeta o texto da política em todo turno."""
return {"context": POLICY}

def register(ctx):
ctx.register_hook("pre_llm_call", inject_guardrails)

Exemplo: hook só de observação (sem injeção)​

"""Plugin de analytics — acompanha metadados do turno sem injetar contexto."""

import logging
logger = logging.getLogger(__name__)

def log_turn(session_id, user_message, model, is_first_turn, **kwargs):
"""Dispara antes de cada chamada de LLM. Devolve None — nenhum contexto injetado."""
logger.info("Turn: session=%s model=%s first=%s msg_len=%d",
session_id, model, is_first_turn, len(user_message or ""))
# Sem retorno → sem injeção

def register(ctx):
ctx.register_hook("pre_llm_call", log_turn)

Vários plugins devolvendo contexto​

Quando vários plugins devolvem contexto no pre_llm_call, as saídas deles são juntadas com linhas em branco e anexadas à mensagem de usuário de uma vez. A ordem segue a ordem de descoberta dos plugins (alfabética pelo nome do diretório do plugin).

Middleware: mudar o que acontece​

Os hooks observam o laço do agente (com as poucas formas de direcionamento documentadas acima). O middleware muda o que acontece: o middleware de requisição reescreve a carga efetiva antes que qualquer coisa adiante a enxergue, e o middleware de execução embrulha a chamada em si. Registre-o pelo mesmo ponto de entrada register(ctx):

def cap_find_output(tool_name, args, **kwargs):
"""Reescreve comandos find do terminal para limitar a saída."""
command = args.get("command", "")
if tool_name == "terminal" and command.startswith("find "):
return {
"args": {**args, "command": command + " | head -100"},
"source": "my-plugin",
"reason": "cap find output",
}
return None # deixa a chamada como está

def register(ctx):
ctx.register_middleware("tool_request", cap_find_output)

A lista canônica de tipos é o VALID_MIDDLEWARE em hermes_cli/middleware.py:

TipoRecebeContrato de retorno
tool_requesttool_name, args, original_args, kwargs de contextoDevolva {"args": {...}} para substituir os argumentos efetivos da ferramenta antes que hooks, guardrails, aprovações e execução os enxerguem. Devolva None para deixar a chamada como está.
llm_requestrequest, original_request, kwargs de contextoDevolva {"request": {...}} para substituir os kwargs efetivos do provedor antes que o Hermes os envie.
tool_executiona carga mais o next_callEmbrulha a execução da ferramenta. Chame next_call(payload) exatamente uma vez para rodar a cadeia adiante (ou pule para curto-circuitar) e devolva o resultado.
llm_executiona carga mais o next_callMesma forma, embrulhando a chamada ao provedor.

Regras que importam na prática:

  • O middleware de requisição forma cadeia: cada callback enxerga a carga como foi reescrita pelos callbacks anteriores, enquanto o original_args / original_request sempre carrega a cópia anterior ao middleware. As cargas são copiadas entre os callbacks, então mute à vontade.
  • Você pode incluir strings source, reason e name no dicionário devolvido. Elas caem no rastro do middleware, que os hooks observadores adiante recebem no kwarg middleware_trace.
  • O next_call no middleware de execução é de uso único. Chamá-lo duas vezes levanta erro, porque isso reexecutaria o provedor ou a ferramenta.
  • Um callback de middleware que levanta erro é registrado em log e pulado; a cadeia continua. Uma falha adiante, levantada depois do seu next_call, se propaga como ela mesma. O middleware nunca consegue quebrar o caminho básico de runtime.
  • As cargas de middleware levam o middleware_schema_version (hermes.middleware.v1) junto com os campos de telemetria do observador.
  • Tipos desconhecidos são registrados com um aviso em vez de falhar, então um plugin escrito contra um Hermes mais novo ainda carrega em um mais antigo.

Registrar comandos de CLI​

Os plugins podem acrescentar a própria árvore de subcomandos hermes <plugin>:

def _my_command(args):
"""Handler de hermes my-plugin <subcommand>."""
sub = getattr(args, "my_command", None)
if sub == "status":
print("All good!")
elif sub == "config":
print("Current config: ...")
else:
print("Usage: hermes my-plugin <status|config>")

def _setup_argparse(subparser):
"""Monta a árvore de argparse de hermes my-plugin."""
subs = subparser.add_subparsers(dest="my_command")
subs.add_parser("status", help="Show plugin status")
subs.add_parser("config", help="Show plugin config")
subparser.set_defaults(func=_my_command)

def register(ctx):
ctx.register_tool(...)
ctx.register_cli_command(
name="my-plugin",
help="Manage my plugin",
setup_fn=_setup_argparse,
handler_fn=_my_command,
)

Depois do registro, as pessoas podem rodar hermes my-plugin status, hermes my-plugin config etc.

Os plugins de provedor de memória usam uma abordagem por convenção: acrescente uma função register_cli(subparser) ao arquivo cli.py do seu plugin. O sistema de descoberta de plugins de memória a encontra automaticamente — sem precisar chamar ctx.register_cli_command(). Veja o guia de Plugins de Provedor de Memória para os detalhes.

Condicionado ao provedor ativo: os comandos de CLI de plugins de memória só aparecem quando o provedor deles é o memory.provider ativo na configuração. Se a pessoa não configurou o seu provedor, os seus comandos de CLI não vão poluir a ajuda.

Registrar comandos de barra​

Os plugins podem registrar comandos de barra dentro da sessão — comandos que as pessoas digitam durante uma conversa (como /lcm status ou /ping). Eles funcionam tanto na CLI quanto no gateway (Telegram, Discord etc.).

def _handle_status(raw_args: str) -> str:
"""Handler de /mystatus — chamado com tudo que vem depois do nome do comando."""
if raw_args.strip() == "help":
return "Usage: /mystatus [help|check]"
return "Plugin status: all systems nominal"

def register(ctx):
ctx.register_command(
"mystatus",
handler=_handle_status,
description="Show plugin status",
)

Depois do registro, as pessoas podem digitar /mystatus em qualquer sessão. O comando aparece no autocompletar, na saída de /help e no menu do bot do Telegram.

Assinatura: ctx.register_command(name: str, handler: Callable, description: str = "", args_hint: str = "")

ParâmetroTipoDescrição
namestrNome do comando sem a barra inicial (por exemplo, "lcm", "mystatus")
handlerCallable[[str], str | None]Chamado com a string crua de argumentos. Também pode ser async.
descriptionstrMostrado no /help, no autocompletar e no menu do bot do Telegram

Principais diferenças em relação ao register_cli_command():

register_command()register_cli_command()
Invocado como/name em uma sessãohermes name em um terminal
Onde funcionaSessões de CLI, Telegram, Discord etc.Só no terminal
O handler recebeString crua de argumentosNamespace do argparse
Caso de usoDiagnóstico, status, ações rápidasÁrvores complexas de subcomando, assistentes de configuração

Proteção contra conflito: se um plugin tenta registrar um nome que conflita com um comando embutido (help, model, new etc.), o registro é rejeitado em silêncio, com um aviso no log. Os comandos embutidos sempre têm precedência.

Handlers assíncronos: o despacho do gateway detecta e aguarda handlers assíncronos automaticamente, então você pode usar funções síncronas ou assíncronas:

async def _handle_check(raw_args: str) -> str:
result = await some_async_operation()
return f"Check result: {result}"

def register(ctx):
ctx.register_command("check", handler=_handle_check, description="Run async check")

Despachar ferramentas a partir de comandos de barra​

Handlers de comando de barra que precisam orquestrar ferramentas (criar um subagente com delegate_task, chamar file_edit etc.) devem usar o ctx.dispatch_tool() em vez de mexer nas entranhas do framework. O contexto do agente pai (dicas de espaço de trabalho, indicador de progresso, herança de modelo) é ligado automaticamente.

def register(ctx):
def _handle_deliver(raw_args: str):
result = ctx.dispatch_tool(
"delegate_task",
{
"goal": raw_args,
"toolsets": ["terminal", "file", "web"],
},
)
return result

ctx.register_command(
"deliver",
handler=_handle_deliver,
description="Delegate a goal to a subagent",
)

Assinatura: ctx.dispatch_tool(name: str, args: dict, *, parent_agent=None) -> str

ParâmetroTipoDescrição
namestrNome da ferramenta como está no registro de ferramentas (por exemplo, "delegate_task", "file_edit")
argsdictArgumentos da ferramenta, no mesmo formato que o modelo enviaria
parent_agentAgent | NoneSobreposição opcional. Quando omitido, é resolvido a partir do agente de CLI atual (ou degrada com elegância no modo gateway)

Comportamento em runtime:

  • Modo CLI: o parent_agent é resolvido a partir do agente de CLI ativo, então as dicas de espaço de trabalho, o indicador de progresso e a escolha do modelo são herdados como se espera.
  • Modo gateway: não existe agente de CLI, então as ferramentas degradam com elegância — o espaço de trabalho é lido do diretório de trabalho configurado do terminal e nenhum indicador de progresso é mostrado.
  • Sobreposição explícita: se quem chama passa parent_agent= explicitamente, isso é respeitado e não é sobrescrito.

Esta é a interface pública e estável para despachar ferramentas a partir de comandos de plugin. Os plugins não devem mexer em ctx._cli_ref.agent nem em estado privado parecido.

Agir de dentro de um hook (perfil + ferramentas)​

O ctx._cli_ref só é preenchido em uma sessão interativa de CLI. Ele é None no gateway, em execuções não interativas de hermes chat -q e em sessões de worker criadas pelo kanban — então qualquer lógica de plugin que dependa de _cli_ref simplesmente não faz nada, em silêncio, exatamente nesses contextos. Duas APIs estáveis e independentes de sessão cobrem o que os hooks de fato precisam:

  • ctx.profile_name — o nome do perfil ativo (por exemplo, "default", ou o perfil do responsável em um worker de kanban). Derivado de HERMES_HOME, então funciona em todo lugar, sem depender de _cli_ref.
  • ctx.dispatch_tool(name, args) — invoca qualquer ferramenta registrada (embutida ou de plugin), inclusive as ferramentas kanban_*, delegate_task, terminal, read_file etc. Funciona a partir de callbacks de hook, não importa em qual processo o hook dispare.

Juntas, elas deixam um hook de ciclo de vida do kanban observar uma transição e agir sobre o quadro sem tocar nas entranhas do framework:

def register(ctx):
def on_blocked(*, task_id, reason=None, **kw):
# Roda no processo do worker; aqui ctx._cli_ref é None.
ctx.dispatch_tool("kanban_comment", {
"task_id": task_id,
"comment": f"[{ctx.profile_name}] auto-noted block: {reason}",
})
ctx.register_hook("kanban_task_blocked", on_blocked)

Para rodar um hermes <subcommand> completo (por exemplo, hermes kanban show), chame o shell com a ferramenta terminal, via ctx.dispatch_tool("terminal", {"command": "hermes kanban show ..."}) — não existe uma ponte de comando de barra no mesmo processo para sessões de worker sem interface, e as ferramentas são o jeito suportado de conduzir o Hermes a partir de um hook.

Tratar cliques de botão do Block Kit do Slack​

Plugins que postam mensagens de Block Kit com elementos interativos (botões, menus de overflow, seletores de data etc.) podem registrar os handlers de clique direto no adaptador do Slack — sem precisar aplicar monkey patch em slack_bolt.AsyncApp.

def register(ctx):
async def _on_approve(ack, body, action):
# Faça o ack em até 3 segundos — exigência do slack_bolt.
await ack()
# body["channel"]["id"], body["user"]["id"], body["message"]["ts"]
# action["action_id"], action["value"]
sweep_id = (action.get("value") or "").split("|", 1)[-1]
# ...faça o trabalho determinístico e depois poste um retorno.

ctx.register_slack_action_handler("inbox_sweep_approve", _on_approve)

Assinatura: ctx.register_slack_action_handler(action_id, callback) -> None

ParâmetroTipoDescrição
action_idstr | re.Pattern | dictO que quer que o slack_bolt.App.action() aceite: um action_id literal, uma regex compilada casando vários ids, ou um dicionário de restrição como {"action_id": "...", "block_id": "..."}
callbackcallable assíncronoRecebe (ack, body, action), segundo a convenção do slack_bolt

Comportamento em runtime:

  • O handler entra na fila no momento em que o plugin carrega e é ligado ao slack_bolt.AsyncApp do adaptador quando a plataforma Slack conecta.
  • Cada callback é embrulhado de forma defensiva: se o seu handler levanta erro, o gateway registra o erro e faz o melhor esforço para dar ack no clique, para o Slack parar de repetir.
  • Valem as regras padrão do slack_bolt — await ack() em até 3 segundos e só depois o trabalho mais longo.
  • Em instalações com vários espaços de trabalho, o handler dispara para cliques de qualquer espaço conectado; use body["team"]["id"] se precisar limitar o comportamento por espaço.

Este é o jeito público de um plugin participar da interatividade do Slack. Plugins mais antigos podem aplicar patch em SlackAdapter.connect; prefira esta API. Para a superfície completa do slack_bolt (eventos, atalhos, comandos — não só ações de Block Kit), use o register_platform_handler("slack", ...) genérico logo abaixo.

Registrar handlers nativos de plataforma (qualquer plataforma)​

Plugins que precisam receber eventos de plataforma que o adaptador do núcleo não roteia — tipos extras de atualização, callbacks nativos de botão, eventos de reação e de membro, rotas de webhook — podem registrar uma fábrica de handlers que o adaptador da plataforma invoca na hora de conectar. Isso funciona em todas as plataformas de gateway.

def register(ctx):
def _wire(native, adapter):
# native: o objeto de cliente/app da plataforma (veja a tabela abaixo)
# adapter: a instância do adaptador da plataforma (trate como somente leitura)
# Importe os SDKs de plataforma AQUI, para o register() funcionar sem eles.
...

ctx.register_platform_handler("discord", _wire)

Assinatura: ctx.register_platform_handler(platform, factory) -> None

ParâmetroTipoDescrição
platformstrNome da plataforma de gateway, em minúsculas ("telegram", "discord", "slack", "matrix", ...)
factorycallableRecebe (native, adapter) na hora de conectar

O que é o native, por plataforma:

PlataformaObjeto nativeGanchos típicos
telegramApplication do PTBadd_handler — qualquer tipo de atualização, callbacks com escopo por padrão
discorddiscord.ext.commands.Botadd_listener — reações, eventos de membro, tópicos, voz
slackslack_bolt.AsyncAppapp.event() / app.action() / app.command()
matrixCliente do Matrixcallbacks de evento
teamsApp do Teamsdecoradores on_message / on_card_action
dingtalkDingTalkStreamClientregister_callback_handler para outros tópicos de stream
feishucliente lark_oapichamadas de API; roteamento de eventos
line, api_server, msgraph_webhookweb.Application do aiohttprouter.add_get/post — rotas próprias (ligadas antes de o roteador congelar)
todo o resto (whatsapp, signal, irc, email, sms, ntfy, wecom, weixin, bluebubbles, yuanbao, ...)Nonegancho na hora de conectar; trabalhe pelo handle do adapter

Comportamento em runtime:

  • As fábricas entram na fila no momento em que o plugin carrega e são invocadas quando a plataforma conecta — nas plataformas em que a ordem de despacho importa (Telegram, Slack, Teams, roteadores aiohttp), elas rodam antes de os handlers do núcleo se registrarem, então os handlers de plugin com escopo têm precedência e todo o resto passa adiante.
  • Sempre limite o escopo dos handlers que você acrescenta a tabelas de despacho por primeira correspondência. No Telegram, use CallbackQueryHandler(..., pattern=r"^myplugin:") — um handler sem escopo engoliria os fluxos de botão do núcleo (aprovações de execução, seletor de modelo, pedidos de esclarecimento).
  • Cada fábrica é isolada: se ela levanta erro, o erro é registrado e a plataforma conecta assim mesmo.
  • Importe os SDKs de plataforma dentro do corpo da fábrica, não no nível do módulo — o register() precisa funcionar quando o SDK não está instalado.
  • Um mesmo plugin pode registrar fábricas para várias plataformas; cada uma dispara só quando a plataforma dela conecta.

Alias do Telegram: o ctx.register_telegram_handler(factory) é um alias de compatibilidade para ctx.register_platform_handler("telegram", factory).

Exemplo — Telegram, botões inline com escopo por padrão:

def register(ctx):
def _wire(application, adapter):
from telegram.ext import CallbackQueryHandler

async def _on_button(update, context):
query = update.callback_query
await query.answer()
# ...trata os callbacks de "myplugin:*"

application.add_handler(
CallbackQueryHandler(_on_button, pattern=r"^myplugin:")
)

ctx.register_platform_handler("telegram", _wire)

Exemplo — Discord, eventos de reação:

def register(ctx):
def _wire(bot, adapter):
async def on_raw_reaction_add(payload):
... # por exemplo, votação ou moderação por reação

bot.add_listener(on_raw_reaction_add, "on_raw_reaction_add")

ctx.register_platform_handler("discord", _wire)
dica

Este guia cobre os plugins gerais (ferramentas, hooks, comandos de barra, comandos de CLI). As seções abaixo esboçam o padrão de escrita de cada tipo especializado de plugin; cada uma leva ao guia completo, com a referência de campos e os exemplos.

Tipos especializados de plugin​

O Hermes tem cinco tipos especializados de plugin além da superfície geral. Cada um é distribuído como um diretório em plugins/<category>/<name>/ (embutido) ou ~/.hermes/plugins/<category>/<name>/ (do usuário). O contrato muda conforme a categoria — escolha a que você precisa e leia o guia completo dela.

Plugins de provedor de modelo — acrescentar um backend de LLM​

Largue um perfil em plugins/model-providers/<name>/:

# plugins/model-providers/acme/__init__.py
from providers import register_provider
from providers.base import ProviderProfile

register_provider(ProviderProfile(
name="acme",
aliases=("acme-inference",),
display_name="Acme Inference",
env_vars=("ACME_API_KEY", "ACME_BASE_URL"),
base_url="https://api.acme.example.com/v1",
auth_type="api_key",
default_aux_model="acme-small-fast",
fallback_models=("acme-large-v3", "acme-medium-v3"),
))
# plugins/model-providers/acme/plugin.yaml
name: acme-provider
kind: model-provider
version: 1.0.0
description: Acme Inference — API direta compatível com OpenAI

Descoberto de forma preguiçosa na primeira vez que alguma coisa chama get_provider_profile() ou list_providers() — auth.py, config.py, doctor.py, models.py, runtime_provider.py e o transporte de chat_completions se ligam a ele automaticamente. Plugins do usuário sobrepõem os embutidos pelo nome.

Guia completo: Plugins de Provedor de Modelo — referência de campos, hooks sobreponíveis (prepare_messages, build_extra_body, build_api_kwargs_extras, fetch_models), escolha de api_mode, tipos de autenticação, testes.

Plugins de plataforma — acrescentar um canal de gateway​

Largue um adaptador em plugins/platforms/<name>/:

# plugins/platforms/myplatform/adapter.py
from gateway.platforms.base import BasePlatformAdapter

class MyPlatformAdapter(BasePlatformAdapter):
async def connect(self): ...
async def send(self, chat_id, text): ...
async def disconnect(self): ...

def check_requirements():
import os
return bool(os.environ.get("MYPLATFORM_TOKEN"))

def _env_enablement():
import os
tok = os.getenv("MYPLATFORM_TOKEN", "").strip()
if not tok:
return None
return {"token": tok}

def register(ctx):
ctx.register_platform(
name="myplatform",
label="MyPlatform",
adapter_factory=lambda cfg: MyPlatformAdapter(cfg),
check_fn=check_requirements,
required_env=["MYPLATFORM_TOKEN"],
# Preenche PlatformConfig.extra a partir do ambiente, para que instalações
# só com variáveis apareçam em `hermes gateway status` sem instanciar o SDK.
env_enablement_fn=_env_enablement,
# Adesão à entrega por cron: `deliver=myplatform` roteia para esta variável.
cron_deliver_env_var="MYPLATFORM_HOME_CHANNEL",
emoji="💬",
platform_hint="You are chatting via MyPlatform. Keep responses concise.",
)
# plugins/platforms/myplatform/plugin.yaml
name: myplatform-platform
label: MyPlatform
kind: platform
version: 1.0.0
description: Adaptador de gateway do MyPlatform
requires_env:
- name: MYPLATFORM_TOKEN
description: "Bot token from the MyPlatform console"
password: true
optional_env:
- name: MYPLATFORM_HOME_CHANNEL
description: "Default channel for cron delivery"
password: false

Guia completo: Adicionar Adaptadores de Plataforma — contrato completo de BasePlatformAdapter, roteamento de mensagens, condicionamento por autenticação, integração com o assistente de configuração. Olhe plugins/platforms/irc/ para um exemplo funcional só com a biblioteca padrão.

Plugins de provedor de memória — acrescentar um backend de conhecimento entre sessões​

Largue uma implementação de MemoryProvider em plugins/memory/<name>/:

# plugins/memory/my-memory/__init__.py
from agent.memory_provider import MemoryProvider

class MyMemoryProvider(MemoryProvider):
@property
def name(self) -> str:
return "my-memory"

def is_available(self) -> bool:
import os
return bool(os.environ.get("MY_MEMORY_API_KEY"))

def initialize(self, session_id: str, **kwargs) -> None:
self._session_id = session_id

def sync_turn(self, user_content, assistant_content, *,
session_id="", messages=None) -> None:
...

def prefetch(self, query, *, session_id="") -> str:
...

def get_tool_schemas(self) -> list[dict]:
return [] # @abstractmethod obrigatório — veja o guia completo

def register(ctx):
ctx.register_memory_provider(MyMemoryProvider())

Os provedores de memória são de escolha única — só um fica ativo por vez, escolhido pelo memory.provider no config.yaml.

Guia completo: Plugins de Provedor de Memória — a ABC MemoryProvider inteira, contrato de threads, isolamento por perfil, registro de comandos de CLI pelo cli.py.

Plugins de motor de contexto — trocar o compressor de contexto​

# plugins/context_engine/my-engine/__init__.py
from agent.context_engine import ContextEngine

class MyContextEngine(ContextEngine):
@property
def name(self) -> str:
return "my-engine"

def update_from_response(self, usage) -> None: ...
def should_compress(self, prompt_tokens: int = None) -> bool: ...
def compress(self, messages, current_tokens=None, focus_topic=None,
force=False, memory_context="") -> list: ...

def register(ctx):
ctx.register_context_engine(MyContextEngine())

Os motores de contexto são de escolha única — escolhidos pelo context.engine no config.yaml.

Guia completo: Plugins de Motor de Contexto.

Backends de geração de imagem​

Largue um provedor em plugins/image_gen/<name>/:

# plugins/image_gen/my-imggen/__init__.py
from agent.image_gen_provider import ImageGenProvider

class MyImageGenProvider(ImageGenProvider):
@property
def name(self) -> str:
return "my-imggen"

def is_available(self) -> bool: ...
def generate(self, prompt: str, aspect_ratio="landscape", **kwargs) -> dict:
# devolve success_response(...) / error_response(...)
...

def register(ctx):
ctx.register_image_gen_provider(MyImageGenProvider())
# plugins/image_gen/my-imggen/plugin.yaml
name: my-imggen
kind: backend
version: 1.0.0
description: Backend próprio de geração de imagem

Guia completo: Plugins de Provedor de Geração de Imagem — a ABC ImageGenProvider inteira, metadados de list_models() / get_setup_schema(), os auxiliares success_response()/error_response(), saída em base64 x URL, sobreposições do usuário, distribuição por pip.

Exemplos de referência: plugins/image_gen/openai/ (DALL-E / GPT-Image pelo SDK da OpenAI), plugins/image_gen/openai-codex/, plugins/image_gen/xai/ (geração de imagem do Grok).

Superfícies de extensão fora do Python​

O Hermes também aceita extensões que não são plugins Python. Elas aparecem na tabela de interfaces plugáveis; as seções abaixo esboçam rapidamente cada estilo de escrita.

Servidores MCP — registrar ferramentas externas​

Servidores do Model Context Protocol (MCP) registram as próprias ferramentas no Hermes sem nenhum plugin Python. Declare-os no ~/.hermes/config.yaml:

mcp_servers:
filesystem:
command: "npx"
args: ["-y", "@modelcontextprotocol/server-filesystem", "/home/usuario/projetos"]
timeout: 120

linear:
url: "https://mcp.linear.app/sse"
auth:
type: "oauth"

O Hermes se conecta a cada servidor na partida, lista as ferramentas dele e as registra junto com as embutidas. O LLM as enxerga exatamente como qualquer outra ferramenta. Guia completo: MCP.

Hooks de eventos do gateway — disparar em eventos de ciclo de vida​

Largue um manifesto + handler em ~/.hermes/hooks/<name>/:

# ~/.hermes/hooks/long-task-alert/HOOK.yaml
name: long-task-alert
description: Send a push notification when a long task finishes
events:
- agent:end
# ~/.hermes/hooks/long-task-alert/handler.py
async def handle(event_type: str, context: dict) -> None:
if context.get("duration_seconds", 0) > 120:
# envia a notificação …
pass

Os eventos incluem gateway:startup, session:start, session:end, session:reset, agent:start, agent:step, agent:end e o curinga command:*. Erros nos hooks são capturados e registrados — eles nunca travam o fluxo principal.

Guia completo: Hooks de Eventos do Gateway.

Hooks de shell — rodar um comando de shell em chamadas de ferramenta​

Se você só quer rodar um script quando uma ferramenta dispara (notificações, logs de auditoria, alertas no desktop, formatadores automáticos), use hooks de shell no config.yaml — sem Python:

hooks:
- event: post_tool_call
command: "notify-send 'Tool ran: {tool_name}'"
when:
tools: [terminal, patch, write_file]

Suporta os mesmos eventos dos hooks de plugin em Python (pre_tool_call, post_tool_call, pre_llm_call, post_llm_call, on_session_start, on_session_end, pre_gateway_dispatch), mais saída estruturada em JSON para as decisões de bloqueio do pre_tool_call.

Guia completo: Hooks de Shell.

Fontes de skills — acrescentar um registro próprio de skills​

Se você mantém um repositório de skills no GitHub (ou quer puxar de um índice da comunidade além das fontes embutidas), acrescente-o como um tap:

hermes skills tap add myorg/skills-repo
hermes skills search my-workflow --source myorg/skills-repo
hermes skills install myorg/skills-repo/my-workflow

Publicar o seu próprio tap é só um repositório no GitHub com diretórios skills/<skill-name>/SKILL.md — sem servidor nem cadastro em registro.

Guias completos: Central de Skills · Publicar um tap próprio (layout do repositório, exemplo mínimo, caminhos fora do padrão, níveis de confiança).

TTS / STT por modelos de comando​

Qualquer CLI que leia ou escreva áudio ou texto pode ser plugada pelo config.yaml — sem código Python:

tts:
provider: voxcpm
providers:
voxcpm:
type: command
command: "voxcpm --ref ~/voice.wav --text-file {input_path} --out {output_path}"
output_format: mp3
voice_compatible: true

Para STT, aponte HERMES_LOCAL_STT_COMMAND para um modelo tokenizado em argv. Ele roda sem interpretação implícita de shell; embrulhe explicitamente em sh -c, cmd /c ou PowerShell se o comando local confiável precisar de sintaxe de shell. Marcadores suportados: {input_path}, {output_path}, {format}, {voice}, {model}, {speed} (TTS); {input_path}, {output_dir}, {language}, {model} (STT). Qualquer CLI que interaja com caminhos vira um plugin automaticamente.

Guias completos: Provedores de comando próprios para TTS · STT.

Distribuir por pip​

Para compartilhar plugins publicamente, acrescente um entry point ao seu pacote Python:

# pyproject.toml
[project.entry-points."hermes_agent.plugins"]
my-plugin = "my_plugin_package"
pip install hermes-plugin-calculator
# Plugin descoberto automaticamente na próxima partida do hermes

Distribuir para NixOS​

O Nix não tem mais suporte explícito

O Nix/NixOS não é mais um caminho de instalação com suporte explícito (só o melhor esforço) — veja Instalação com Nix. Esta seção fica aqui para quem já implanta em NixOS.

Quem usa NixOS pode instalar o seu plugin de forma declarativa se você fornecer um pyproject.toml com entry points:

Plugins por entry point (recomendado para distribuição):

# configuration.nix do usuário
services.hermes-agent.extraPythonPackages = [
(pkgs.python312Packages.buildPythonPackage {
pname = "my-plugin";
version = "1.0.0";
src = pkgs.fetchFromGitHub {
owner = "you";
repo = "hermes-my-plugin";
rev = "v1.0.0";
hash = "sha256-..."; # nix-prefetch-url --unpack
};
format = "pyproject";
build-system = [ pkgs.python312Packages.setuptools ];
})
];

Plugins em diretório (sem precisar de pyproject.toml):

services.hermes-agent.extraPlugins = [
(pkgs.fetchFromGitHub {
owner = "you";
repo = "hermes-my-plugin";
rev = "v1.0.0";
hash = "sha256-...";
})
];

Veja o guia de instalação com Nix para a documentação completa, inclusive uso de overlay e checagem de colisão.

Erros comuns​

O handler não devolve uma string JSON:

# Errado — devolve um dict
def handler(args, **kwargs):
return {"result": 42}

# Certo — devolve uma string JSON
def handler(args, **kwargs):
return json.dumps({"result": 42})

Falta o **kwargs na assinatura do handler:

# Errado — vai quebrar se o Hermes passar contexto extra
def handler(args):
...

# Certo
def handler(args, **kwargs):
...

O handler levanta exceções:

# Errado — a exceção se propaga, a chamada de ferramenta falha
def handler(args, **kwargs):
result = 1 / int(args["value"]) # ZeroDivisionError!
return json.dumps({"result": result})

# Certo — capture e devolva um JSON de erro
def handler(args, **kwargs):
try:
result = 1 / int(args.get("value", 0))
return json.dumps({"result": result})
except Exception as e:
return json.dumps({"error": str(e)})

Descrição de esquema vaga demais:

# Ruim — o modelo não sabe quando usar
"description": "Does stuff"

# Bom — o modelo sabe exatamente quando e como
"description": "Evaluate a mathematical expression. Use for arithmetic, trig, logarithms. Supports: +, -, *, /, **, sqrt, sin, cos, log, pi, e."