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.
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 CLI | Este 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 contexto | Plugins de Motor de Contexto |
| Um backend de geração de imagem | Plugins de Provedor de Geração de Imagem |
| Um backend de geração de vídeo | Plugins de Provedor de Geração de Vídeo |
| Um backend de busca/extração na web | Plugins 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 painel | Painel 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 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
PluginContextnã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
**kwargsrecebe a carga atual completa. Plugins novos devem aceitar**kwargspara poderem aderir a dados acrescentados sem outra mudança de assinatura. - Os manifestos estão abertos a acréscimos. Campos desconhecidos em
plugin.yamlsã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:
- um substituto e instruções de migração no guia de plugins e nas notas de versão;
- 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;
- suporte ao comportamento antigo por pelo menos duas versões menores seguintes; e
- 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.
| Campo | Tipo | Significado |
|---|---|---|
manifest_version | int | Versão do formato do arquivo de manifesto. Ausente = 1. Máximo atual: 2. Independente de api_version. |
api_version | int | Geraçã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_plugins | list | Dependê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_dependencies | list de str | Requisitos 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_schema | mapping | Descriçã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). |
license | str | ID de licença no estilo SPDX (por exemplo, MIT). |
homepage | str | URL do projeto. |
tags | list de str | Tags 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 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:
- Assinatura:
def my_handler(args: dict, **kwargs) -> str - Retorno: sempre uma string JSON. Tanto no sucesso quanto no erro.
- Nunca levante exceção: capture tudo e devolva um JSON de erro no lugar.
- 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 invocarterminal,read_fileou 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; octx.stateguarda 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__.pyque 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 deplugins 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 umplugin.jsonna raiz, nos mesmos lugares. Qualquer coisa mais funda é ignorada. - Falta o
__init__.py: os pacotes nativos precisam deplugin.yamle de__init__.pycom uma funçãoregister(ctx). Os pacotes portáteis não importam Python e não exigem__init__.py. kinderrado — os adaptadores de gateway precisam dekind: platformno manifesto. Os provedores de memória são detectados automaticamente comokind: exclusivee roteados pela configuraçãomemory.provider, em vez deplugins.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 porskill_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.
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
| Campo | Obrigatório | Descrição |
|---|---|---|
name | Sim | Nome da variável de ambiente |
description | Não | Mostrado ao usuário durante a pergunta de instalação |
url | Não | Onde conseguir a credencial |
secret | Não | Se 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:
| Regra | Por quê |
|---|---|
A sua chave de recurso precisa aparecer na lista de permissões LAZY_DEPS que vem na árvore | Impede 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 PyPI | Nada 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 _somethingseguido de uma checagem deis Nonee 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:
| Hook | Dispara quando | Assinatura do callback | Retorno |
|---|---|---|---|
pre_tool_call | Antes de qualquer ferramenta executar | tool_name: str, args: dict, task_id: str | diretiva opcional: {"action": "block", "message": ...} veta a chamada; {"action": "approve", "message": ...} escala para a trava de aprovação humana |
post_tool_call | Depois que qualquer ferramenta retorna | tool_name: str, args: dict, result: str, task_id: str, duration_ms: int | ignorado |
pre_llm_call | Uma vez por turno, antes do laço de chamada de ferramentas | session_id: str, user_message: str, conversation_history: list, is_first_turn: bool, model: str, platform: str | injeção de contexto |
post_llm_call | Uma 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: str | ignorado |
pre_api_request | Antes 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: dict | ignorado |
post_api_request | Depois que cada requisição crua à API do provedor retorna | os 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: int | ignorado |
api_request_error | Uma chamada à API do provedor levantou erro | campos 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: dict | ignorado |
on_session_start | Sessão nova criada (só no primeiro turno) | session_id: str, model: str, platform: str | ignorado |
on_session_end | Fim de toda chamada de run_conversation + saída da CLI | session_id: str, completed: bool, interrupted: bool, model: str, platform: str | ignorado |
on_session_finalize | A CLI ou o gateway desmonta uma sessão ativa | session_id: str | None, platform: str | ignorado |
on_session_reset | O gateway troca por uma chave de sessão nova (/new, /reset) | session_id: str, platform: str | ignorado |
gateway_platform_event | Um evento autorizado, nativo da plataforma, é normalizado na fronteira do gateway (por ora, reações do Telegram) | platform: str, event_type: str, payload: dict | ignorado |
kanban_task_claimed | Uma 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: str | ignorado |
kanban_task_completed | Uma tarefa de kanban termina (processo do worker) | task_id, board, assignee, run_id, profile_name, summary: str | None | ignorado |
kanban_task_blocked | Uma tarefa de kanban fica bloqueada (processo do worker) | task_id, board, assignee, run_id, profile_name, reason: str | None | ignorado |
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:
| Tipo | Recebe | Contrato de retorno |
|---|---|---|
tool_request | tool_name, args, original_args, kwargs de contexto | Devolva {"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_request | request, original_request, kwargs de contexto | Devolva {"request": {...}} para substituir os kwargs efetivos do provedor antes que o Hermes os envie. |
tool_execution | a carga mais o next_call | Embrulha 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_execution | a carga mais o next_call | Mesma 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_requestsempre carrega a cópia anterior ao middleware. As cargas são copiadas entre os callbacks, então mute à vontade. - Você pode incluir strings
source,reasonenameno dicionário devolvido. Elas caem no rastro do middleware, que os hooks observadores adiante recebem no kwargmiddleware_trace. - O
next_callno 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âmetro | Tipo | Descrição |
|---|---|---|
name | str | Nome do comando sem a barra inicial (por exemplo, "lcm", "mystatus") |
handler | Callable[[str], str | None] | Chamado com a string crua de argumentos. Também pode ser async. |
description | str | Mostrado 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ão | hermes name em um terminal |
| Onde funciona | Sessões de CLI, Telegram, Discord etc. | Só no terminal |
| O handler recebe | String crua de argumentos | Namespace do argparse |
| Caso de uso | Diagnó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âmetro | Tipo | Descrição |
|---|---|---|
name | str | Nome da ferramenta como está no registro de ferramentas (por exemplo, "delegate_task", "file_edit") |
args | dict | Argumentos da ferramenta, no mesmo formato que o modelo enviaria |
parent_agent | Agent | None | Sobreposiçã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 deHERMES_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 ferramentaskanban_*,delegate_task,terminal,read_fileetc. 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âmetro | Tipo | Descrição |
|---|---|---|
action_id | str | re.Pattern | dict | O 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": "..."} |
callback | callable assíncrono | Recebe (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.AsyncAppdo 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âmetro | Tipo | Descrição |
|---|---|---|
platform | str | Nome da plataforma de gateway, em minúsculas ("telegram", "discord", "slack", "matrix", ...) |
factory | callable | Recebe (native, adapter) na hora de conectar |
O que é o native, por plataforma:
| Plataforma | Objeto native | Ganchos típicos |
|---|---|---|
telegram | Application do PTB | add_handler — qualquer tipo de atualização, callbacks com escopo por padrão |
discord | discord.ext.commands.Bot | add_listener — reações, eventos de membro, tópicos, voz |
slack | slack_bolt.AsyncApp | app.event() / app.action() / app.command() |
matrix | Cliente do Matrix | callbacks de evento |
teams | App do Teams | decoradores on_message / on_card_action |
dingtalk | DingTalkStreamClient | register_callback_handler para outros tópicos de stream |
feishu | cliente lark_oapi | chamadas de API; roteamento de eventos |
line, api_server, msgraph_webhook | web.Application do aiohttp | router.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, ...) | None | gancho 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)
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/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."