🪆 Composition — Skill A invoca skill B
Skills bem desenhadas são unidades pequenas e focadas. Quando uma tarefa precisa de várias capacidades, você compõe: a skill orquestradora chama outras skills via hermes.invoke(), recebe o resultado e segue. O resultado é um grafo de skills, não um monólito.
💡 Por que compor em vez de inlinar tudo
- •Reuso real: a mesma
summarize-pdfserve para skills de email, jurídico e research. - •Eval isolado: testa
fetch-pricessozinha sem rodar a skill pai inteira. - •Substituição barata: troca a implementação de
send-emailsem mexer em quem chama.
Exemplo real: skill briefing-diario que compõe outras três skills já existentes:
# ~/.hermes/skills/briefing-diario/skill.py
from hermes import skill, invoke
from datetime import date
@skill(
name="briefing-diario",
description="Use when the user asks for the daily briefing, morning summary, or 'what's new today'.",
)
def run(ctx):
# Cada invoke roda outra skill registrada e retorna structured output
calendar = invoke("calendar-today", {"user": ctx.user})
inbox = invoke("inbox-triage", {"since": "08:00", "max": 20})
news = invoke("news-digest", {"topics": ctx.prefs["topics"], "n": 5})
return ctx.render(
"briefing.md.j2",
date=date.today().isoformat(),
calendar=calendar,
inbox=inbox,
news=news,
)
Grafo resultante visualizado pelo CLI:
$ hermes skill graph briefing-diario
briefing-diario
├── calendar-today (deps: mcp/google-calendar)
├── inbox-triage (deps: mcp/gmail, skill/summarize-thread)
│ └── summarize-thread (deps: llm/haiku)
└── news-digest (deps: tool/rss-fetch, skill/dedupe-articles)
└── dedupe-articles (deps: llm/haiku)
5 skills · 4 MCP/tool deps · depth=2 · no cycles ✓
💡 Dica
Rode hermes skill graph <name> antes de publicar. Ele detecta ciclos, mede profundidade e mostra dependências externas. Profundidade > 4 quase sempre indica que você precisa repensar a separação.
⚠️ Cuidado: loop infinito de composition
Se A chama B e B chama A (mesmo indiretamente), você cria um ciclo. O Hermes detecta ciclos diretos via skill graph, mas ciclos condicionais (só sob certo input) escapam. Defina max_depth no invoke() e tenha kill switch: invoke("b", args, max_depth=3).
🤖 State machines — Workflows de N passos com estado
Quando a skill precisa de vários passos sequenciais com estado persistente (cada passo lê o output do anterior, pode falhar e retomar), você sai do "uma chamada e responde" e entra em state machine. O Hermes oferece @hermes.step com checkpointing automático.
# ~/.hermes/skills/onboard-customer/skill.py
from hermes import skill, step, invoke
@skill(name="onboard-customer",
description="Use when onboarding a new B2B customer end-to-end.")
class Onboard:
@step(order=1)
def validate_input(self, ctx, state):
assert state["email"] and "@" in state["email"]
state["domain"] = state["email"].split("@")[1]
return state
@step(order=2, retries=3, backoff=2.0)
def create_account(self, ctx, state):
resp = ctx.tools.crm.create_account(
email=state["email"], domain=state["domain"]
)
state["account_id"] = resp["id"]
return state
@step(order=3, depends_on=["create_account"])
def send_welcome(self, ctx, state):
invoke("send-email", {
"to": state["email"],
"template": "welcome-b2b",
"vars": {"account_id": state["account_id"]},
})
state["welcomed_at"] = ctx.now().isoformat()
return state
@step(order=4, depends_on=["create_account"], on_error="skip")
def schedule_kickoff(self, ctx, state):
slot = invoke("calendar-find-slot", {"duration_min": 30})
state["kickoff"] = slot
return state
Em execução, o estado é serializado entre passos. Se send_welcome falhar no passo 3, ao retomar o Hermes lê o checkpoint e pula direto para o passo 3 — account_id já está no estado.
📊 Quando vale state machine
- 3+ passos com dependências entre si
- Pelo menos um passo com chamada externa instável (HTTP, fila, banco remoto)
- Necessidade de retomar após crash sem refazer trabalho
- Auditoria: quem perguntar "onde está o cliente X?" deve ver o passo atual
💡 Dica
Inspecione o estado de uma run com hermes run inspect <run_id>. Para retomar: hermes run resume <run_id>. O checkpoint fica em ~/.hermes/state/runs/<run_id>.json.
📚 RAG dentro de skill — Embedding + vector store local
Skills frequentemente precisam consultar um corpus específico (docs internos, base de produtos, jurisprudência). Embutir RAG na skill — em vez de depender de um MCP externo — dá controle total sobre o chunking, embedding e relevância.
# ~/.hermes/skills/policy-qa/skill.py
import chromadb
from chromadb.utils import embedding_functions
from hermes import skill
EMB = embedding_functions.SentenceTransformerEmbeddingFunction(
model_name="all-MiniLM-L6-v2"
)
_client = chromadb.PersistentClient(path=".hermes_data/policy")
_coll = _client.get_or_create_collection(
name="policies", embedding_function=EMB
)
@skill(
name="policy-qa",
description=("Use when the user asks about internal HR, security, "
"travel or expense policies."),
)
def run(ctx, question: str):
hits = _coll.query(query_texts=[question], n_results=5)
snippets = [
f"[{m['source']}] {d}"
for d, m in zip(hits["documents"][0], hits["metadatas"][0])
]
prompt = (
"Answer ONLY from the policy snippets below. "
"If unsure, say you don't know.\n\n"
+ "\n---\n".join(snippets)
+ f"\n\nQuestion: {question}"
)
return ctx.llm.complete(prompt, model="hermes-3-llama-3.1-70b")
def ingest(path):
"""Run once via: hermes skill exec policy-qa:ingest ./policies/"""
import pathlib, uuid
for f in pathlib.Path(path).glob("*.md"):
text = f.read_text()
chunks = [text[i:i+800] for i in range(0, len(text), 600)]
_coll.add(
ids=[str(uuid.uuid4()) for _ in chunks],
documents=chunks,
metadatas=[{"source": f.name} for _ in chunks],
)
✓ FAZER em RAG embutido
- ✓Versionar o índice junto da skill (path relativo)
- ✓Expor função
ingest()separada dorun() - ✓Retornar fontes (metadados) junto da resposta
- ✓Setar
n_resultsentre 3 e 8 — mais é ruído
✗ NÃO fazer
- ✗Re-embedar todo o corpus em cada
run() - ✗Mandar 50 chunks pro modelo — context blow
- ✗Chunks de 50 palavras (perde contexto) ou 5000 (perde precisão)
- ✗Esconder que a resposta veio de RAG — usuário precisa saber
🔀 Multi-LLM skills — Router escolhendo modelo por subtarefa
Nem toda subtarefa merece o modelo grande. Classificar intenção, extrair JSON ou reescrever uma linha são tarefas que o modelo pequeno faz por 10% do custo. Skills sérias roteiam internamente: Haiku para triagem, Hermes 70B para o trabalho pesado, GPT-4o para visão.
# ~/.hermes/skills/smart-reply/skill.py
from hermes import skill
MODELS = {
"classify": "claude-haiku-4",
"extract": "claude-haiku-4",
"draft": "hermes-3-llama-3.1-70b",
"vision": "gpt-4o-mini",
"long_form": "hermes-3-llama-3.1-405b",
}
def pick(task: str, payload: dict) -> str:
if payload.get("has_image"): return MODELS["vision"]
if task == "classify": return MODELS["classify"]
if task == "extract": return MODELS["extract"]
if payload.get("tokens", 0) > 8000: return MODELS["long_form"]
return MODELS["draft"]
@skill(name="smart-reply",
description="Use when drafting a reply to an incoming customer email.")
def run(ctx, email: dict):
# 1. classify intent — cheap model
intent = ctx.llm.complete(
f"Intent (one of: question, complaint, sales, spam)?\n{email['body']}",
model=pick("classify", email),
max_tokens=10,
).strip().lower()
if intent == "spam":
return {"action": "discard"}
# 2. extract structured facts — cheap model
facts = ctx.llm.json(
f"Extract: order_id, sentiment(-1..1), urgency(low/med/high)\n{email['body']}",
model=pick("extract", email),
)
# 3. draft reply — big model, has full context
draft = ctx.llm.complete(
ctx.render("reply.j2", email=email, intent=intent, facts=facts),
model=pick("draft", {**email, "tokens": len(email["body"]) // 4}),
)
return {"intent": intent, "facts": facts, "draft": draft}
Tabela de roteamento típica de uma skill madura:
| Subtarefa | Modelo | Custo rel. | Por quê |
|---|---|---|---|
| Classificar intent (1 label) | Haiku 4 | 1x | Output curto, decisão simples |
| Extrair JSON estruturado | Haiku 4 | 1x | Schema guia o modelo |
| Redigir resposta natural | Hermes 70B | 8x | Tom e nuance importam |
| Síntese de 50k tokens | Hermes 405B | 25x | Contexto longo + raciocínio |
| Descrever screenshot | GPT-4o-mini | 3x | Visão multimodal nativa |
📊 Economia real medida
- 72% de redução de custo em skills
smart-replyque rotearam classify/extract para Haiku - 3x mais rápido em p50 (Haiku responde em ~400ms vs Hermes 70B ~1.2s)
- ~0% de perda de qualidade — porque classify não precisava do modelo grande
🧪 Skill com side-effects — Idempotência, retries, locks
Toda skill que muda o mundo (manda email, cria registro, debita conta) precisa tratar três coisas: o que acontece se rodar duas vezes (idempotência), o que fazer quando falha no meio (retries) e como evitar duas execuções concorrentes (lock).
# ~/.hermes/skills/charge-customer/skill.py
import hashlib
from hermes import skill, lock, idempotent
@skill(name="charge-customer",
description="Use to charge a customer for an order. Idempotent by order_id.")
@lock(key="charge:{order_id}", timeout=30) # 1 execução por vez
@idempotent(key="charge:{order_id}", ttl_days=30) # mesma chave → mesmo resultado
def run(ctx, order_id: str, amount_cents: int, currency: str = "BRL"):
# idempotency_key derivado e estável
ikey = hashlib.sha256(
f"{order_id}:{amount_cents}:{currency}".encode()
).hexdigest()
return ctx.tools.stripe.charge(
amount=amount_cents,
currency=currency,
metadata={"order_id": order_id},
idempotency_key=ikey, # propaga ao provider também
# retries da própria SDK Stripe lidam com 5xx transitórios
)
Os 3 decoradores cobrem cenários distintos. Tabela de padrões clássicos:
| Padrão | Resolve | Quando usar |
|---|---|---|
| Saga | Compensação de passos parcialmente aplicados | Workflows multi-passo sem transação distribuída |
| Chain of responsibility | Roteamento entre handlers | Skill que tenta N estratégias até uma resolver |
| Mediator | Acoplamento entre N skills | Vários produtores/consumidores via event bus |
| Outbox | Garantia at-least-once em side-effects | Sempre que perda silenciosa é inaceitável |
| Circuit breaker | Cascata de falhas | Dependência externa flutuando |
⚠️ Atenção
Modelos retomam silenciosamente em alguns runtimes — se a skill já criou o registro e o modelo "decide" tentar de novo, sem idempotência você cobra duas vezes. @idempotent + idempotency_key propagado ao provider são obrigatórios em qualquer skill financeira ou que dispare comunicação externa.
🎯 Anti-patterns — Skills que viram monólitos
O sintoma é sempre o mesmo: SKILL.md com 500 linhas, run() com 12 ifs aninhados, description tentando descrever 8 capacidades diferentes. O roteador para de conseguir escolher, o eval vira impossível, ninguém quer mexer.
✓ Sinais de skill saudável
- ✓1 skill = 1 responsabilidade verbalizável em 1 frase
- ✓Description começa com "Use when..." e tem < 200 chars
- ✓SKILL.md cabe em uma tela (~150 linhas)
- ✓Eval com 5-15 casos cobre 90% do uso
- ✓Compõe outras skills via
invoke()
✗ Sinais de monólito
- ✗Description com "Use when X, OR Y, OR Z..." (3+ ORs)
- ✗Mega-skill com 500 linhas e 8 funções privadas
- ✗
run()com switch/if no início roteando "modos" - ✗Eval com 50+ casos porque a skill faz tudo
- ✗Nome genérico tipo
helper,utils,do-stuff
Refactor típico de skill monolítica em 3 skills focadas:
Antes: email-assistant (520 linhas)
"Use when user wants to triage, summarize, draft a reply, schedule from email, OR archive."
Roteador escolhia a skill em 41% dos casos relevantes. Eval com 87 casos, 22 falhando. Ninguém mais querida tocar.
Quebra em 3 skills + 1 orquestradora
inbox-triage · summarize-thread · draft-reply (+ briefing-diario as composer)
Cada uma com description focada, SKILL.md de 80-120 linhas, eval próprio de 8-12 casos.
Depois: trigger rate 89%, eval verde
3 semanas após o refactor
Skills passaram a ser reutilizadas por outras orquestradoras (briefing-diario, weekly-review). Custo de manutenção caiu ~60%.
💡 Dica
Regra prática: se você não consegue descrever a skill em uma frase sem usar "ou", ela já é dois skills. Quebre antes de o monólito enraizar.
✅ Resumo do Módulo
invoke(), profundidade controlada, skill graph antes de publicar.@step + checkpointing automático; retoma onde parou; obrigatório quando há 3+ passos com chamadas externas.Próximo módulo:
6.4 — 🏗️ Construindo um MCP server próprio. Vamos do zero, em Python e Node, expor uma API interna da empresa para o Hermes. É o caminho mais comum de adoção corporativa.