feat(hermes): plugin captain-http-callback (HTTP delivery adapter)

Adiciona plugin externo pro Hermes Agent que entrega a resposta do agente
como POST HTTP a uma URL configurável — em vez de empurrar pra plataforma
de mensageria (Telegram, Slack, etc) como o Hermes faz por default.

Por quê:
O Hermes nativamente entrega respostas em plataformas conhecidas. Quando
integramos o Hermes como cérebro de outro backend (Captain / Chatwoot),
precisamos da resposta de volta via HTTP pro backend continuar o fluxo
(mandar pro cliente WhatsApp, atualizar conversa, etc). O Hermes não tem
deliver type "http_callback" built-in, então criamos via API de plugin
oficial deles (kind: platform).

Arquivos:
- plugin.yaml — manifest (kind=platform)
- __init__.py — entrypoint (re-exporta register)
- adapter.py — HttpCallbackAdapter implementando BasePlatformAdapter
- README.md — uso, formato do POST, signing HMAC opcional

Como funciona:
1. Backend (Captain) → POST /webhooks/<rota> no Hermes (entrada)
2. Hermes processa via Codex/Anthropic/Gemini conforme config dele
3. Hermes invoca este plugin (deliver=http_callback)
4. Plugin POSTa resposta na URL configurada via --deliver-chat-id
5. Backend recebe e roteia pro destinatário real

Validado end-to-end no Hermes da VPS com:
- Subscription criada via `hermes webhook subscribe ... --deliver http_callback`
- POST simulando msg do cliente → resposta chegou no servidor de teste
  em ~11s (tempo de processamento via subscription Codex)
- Plugin enabled e descoberto via `hermes plugins list`

Próximo passo (separado, em outro PR): cliente Captain (outgoing + incoming
endpoint) que conecta o Captain ao Hermes via este plugin.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Rodribm10 2026-05-01 13:13:15 -03:00
parent 7700afd508
commit 89b471831d
4 changed files with 309 additions and 0 deletions

View File

@ -0,0 +1,76 @@
# captain-http-callback
Hermes Agent platform plugin que entrega a resposta do agente como `POST` HTTP a uma URL configurável.
## Por que existe
O Hermes nativamente entrega respostas em plataformas conhecidas (Telegram, Slack, Discord, WhatsApp, etc). Quando integramos o Hermes como cérebro de outro backend (no nosso caso o **Captain / Chatwoot**), precisamos da resposta de volta via HTTP para o backend continuar o fluxo (mandar pro cliente WhatsApp, atualizar conversa, etc) — esse plugin fornece essa ponte.
## Fluxo
```
1. Captain → POST /webhooks/<rota> no Hermes (entrada da mensagem do cliente)
2. Hermes processa via LLM (Codex/Anthropic/Gemini conforme config dele)
3. Hermes invoca este plugin com a resposta gerada
4. Plugin → POST <url-configurada> com a resposta
5. Captain recebe, identifica conversa, manda pro WhatsApp
```
## Instalação
Plugin é discovered automaticamente quando colocado em `~/.hermes/plugins/captain-http-callback/`. Após copiar os arquivos, reinicie o gateway:
```bash
pkill -f "hermes.*gateway" && sleep 2
nohup hermes gateway run --replace > /var/log/hermes-gateway.log 2>&1 &
```
Verifique:
```bash
hermes plugins list | grep http_callback
```
## Uso
Cria webhook subscription apontando para este deliver type:
```bash
hermes webhook subscribe minha-rota \
--prompt "Cliente disse: {message}. Responda como ..." \
--deliver http_callback \
--deliver-chat-id "https://seu-backend.example/api/hermes_callback"
```
`--deliver-chat-id` é interpretado como a **URL de callback**. Quando o agente terminar de processar, este plugin POSTa nessa URL.
## Formato do POST de callback
```http
POST <url-configurada> HTTP/1.1
Content-Type: application/json; charset=utf-8
X-Hermes-Callback-Signature: sha256=<hex-hmac> (opcional, se signing_secret configurado)
{
"content": "<resposta gerada pelo agente>",
"reply_to": "<id da mensagem original ou null>",
"metadata": { ... },
"timestamp": <unix epoch>
}
```
## Assinatura HMAC opcional
Define a env var `CAPTAIN_HTTP_CALLBACK_SECRET` em `~/.hermes/.env` (ou no shell do Hermes). Quando set, o plugin assina cada POST com `HMAC-SHA256(secret, body)` no header `X-Hermes-Callback-Signature`. O backend valida a assinatura antes de processar.
```bash
# em ~/.hermes/.env
CAPTAIN_HTTP_CALLBACK_SECRET=<gere com: openssl rand -hex 32>
```
## Limitações
- **Send-only.** Não recebe mensagens. A entrada da conversa precisa vir via outro adapter (geralmente o `webhook` adapter built-in).
- **Resposta esperada:** o backend precisa aceitar `POST` JSON e responder `2xx`. Plugin loga warning em qualquer status `>= 300` e retorna falha pro Hermes.
- **Timeout default:** 15s. Configurável via `extra.timeout_seconds` no `config.yaml`.
- **Sem retry built-in.** Se o backend retornar erro, é falha — o Hermes pode escolher logar e seguir. Adicione retry no backend caller se precisar.

View File

@ -0,0 +1,3 @@
from .adapter import register
__all__ = ["register"]

View File

@ -0,0 +1,216 @@
"""HTTP Callback Platform Adapter for Hermes Agent.
A plugin-based gateway adapter that delivers the agent response as an HTTP
POST to a configurable URL instead of pushing it to a chat platform.
Designed for backend integrations (e.g. Captain / Chatwoot Hermes) where
the response must be returned synchronously to a known service endpoint.
Usage:
hermes webhook subscribe my-route \\
--deliver http_callback \\
--deliver-chat-id "http://your-backend/api/hermes_callback"
The URL passed via ``--deliver-chat-id`` is what receives the POST when the
agent finishes processing the inbound webhook.
Environment variables:
CAPTAIN_HTTP_CALLBACK_SECRET Optional HMAC-SHA256 signing key. When set,
each POST includes header
``X-Hermes-Callback-Signature: sha256=<hex>``
computed over the raw request body.
"""
import asyncio
import hashlib
import hmac
import json
import logging
import os
import time
from typing import Any, AsyncIterator, Dict, Optional
import aiohttp
from gateway.config import Platform
from gateway.platforms.base import BasePlatformAdapter, MessageEvent, SendResult
logger = logging.getLogger(__name__)
class HttpCallbackAdapter(BasePlatformAdapter):
"""Send-only adapter that POSTs the agent response to an HTTP URL.
Inbound message reception is NOT supported this adapter is meant to
pair with the built-in ``webhook`` adapter (which receives requests and
invokes the agent). The flow is:
external service -> POST /webhooks/<route> (webhook adapter)
-> agent processes
-> http_callback adapter POSTs response back
"""
def __init__(self, config, **kwargs):
platform = Platform("http_callback")
super().__init__(config=config, platform=platform)
extra = getattr(config, "extra", {}) or {}
self.signing_secret: str = (
os.getenv("CAPTAIN_HTTP_CALLBACK_SECRET")
or extra.get("signing_secret", "")
)
self.timeout_seconds: float = float(extra.get("timeout_seconds", 15))
self._session: Optional[aiohttp.ClientSession] = None
# ------------------------------------------------------------------ lifecycle
async def connect(self) -> bool:
"""Open a reusable aiohttp session — no real network handshake."""
if self._session is None or self._session.closed:
self._session = aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=self.timeout_seconds),
)
logger.info(
"[http_callback] adapter ready (signing=%s, timeout=%.1fs)",
"yes" if self.signing_secret else "no",
self.timeout_seconds,
)
return True
async def disconnect(self) -> None:
if self._session is not None and not self._session.closed:
await self._session.close()
self._session = None
# ------------------------------------------------------------------ send
async def send(
self,
chat_id: str,
content: str,
reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""POST the agent response to ``chat_id`` (interpreted as a URL)."""
url = (chat_id or "").strip()
if not url.startswith(("http://", "https://")):
return SendResult(
success=False,
error=f"Invalid callback URL: {url!r} (must start with http:// or https://)",
)
body: Dict[str, Any] = {
"content": content,
"reply_to": reply_to,
"metadata": metadata or {},
"timestamp": int(time.time()),
}
body_bytes = json.dumps(body, ensure_ascii=False).encode("utf-8")
headers = {"Content-Type": "application/json; charset=utf-8"}
if self.signing_secret:
sig = hmac.new(
self.signing_secret.encode("utf-8"),
body_bytes,
hashlib.sha256,
).hexdigest()
headers["X-Hermes-Callback-Signature"] = f"sha256={sig}"
if self._session is None or self._session.closed:
await self.connect()
try:
async with self._session.post(url, data=body_bytes, headers=headers) as resp:
status = resp.status
resp_text = await resp.text()
if 200 <= status < 300:
logger.info("[http_callback] POST %s -> HTTP %d", url, status)
return SendResult(
success=True,
message_id=f"hcb-{int(time.time() * 1000)}",
)
logger.warning(
"[http_callback] POST %s -> HTTP %d (body[:200]=%r)",
url,
status,
resp_text[:200],
)
return SendResult(
success=False,
error=f"HTTP {status}: {resp_text[:200]}",
)
except asyncio.TimeoutError:
return SendResult(
success=False,
error=f"Timeout after {self.timeout_seconds}s",
)
except aiohttp.ClientError as e:
logger.exception("[http_callback] aiohttp error for %s", url)
return SendResult(success=False, error=f"{type(e).__name__}: {e}")
except Exception as e: # pylint: disable=broad-except
logger.exception("[http_callback] unexpected error for %s", url)
return SendResult(success=False, error=f"{type(e).__name__}: {e}")
# ------------------------------------------------------------------ no-op
async def send_typing(self, chat_id: str, metadata=None) -> None: # noqa: D401
"""No-op — HTTP callback has no typing indicator."""
return None
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
return {
"chat_id": chat_id,
"platform": "http_callback",
"type": "http_callback",
}
async def receive_messages(self) -> AsyncIterator[MessageEvent]:
"""Adapter is send-only — never yields events."""
if False: # pragma: no cover - never executed; keeps async generator typing
yield # type: ignore[misc]
return
# ---------------------------------------------------------------------------
# Plugin registry hooks
# ---------------------------------------------------------------------------
def check_requirements() -> bool:
"""``aiohttp`` ships with Hermes — should always be importable."""
try:
import aiohttp # noqa: F401
return True
except ImportError:
return False
def validate_config(config) -> bool: # pragma: no cover - trivial
"""No global config required — URL comes per-subscription via chat_id."""
return True
def is_connected(config) -> bool: # pragma: no cover - trivial
"""Always considered ready (no persistent connection)."""
return True
def register(ctx) -> None:
"""Plugin entry point invoked by Hermes during plugin discovery."""
ctx.register_platform(
name="http_callback",
label="HTTP Callback",
adapter_factory=lambda cfg: HttpCallbackAdapter(cfg),
check_fn=check_requirements,
validate_config=validate_config,
is_connected=is_connected,
required_env=[],
install_hint="aiohttp (bundled with Hermes — no extra install needed)",
emoji="🔗",
pii_safe=True,
allow_update_command=False,
platform_hint=(
"Você está respondendo via callback HTTP a um sistema backend. "
"Sua resposta será enviada por POST JSON a um endpoint configurado "
"pelo desenvolvedor — não vai para um chat humano direto. Mantenha "
"o formato natural como se fosse pro cliente final; o backend "
"encaminha sua resposta ao destinatário real."
),
)

View File

@ -0,0 +1,14 @@
name: captain-http-callback
kind: platform
version: 0.1.0
description: >
HTTP callback platform adapter for Hermes Agent.
POSTs the agent response back to a configurable URL (passed as
--deliver-chat-id when creating the webhook subscription).
Optional HMAC-SHA256 signing via env var CAPTAIN_HTTP_CALLBACK_SECRET.
Use case: integrate Hermes as the LLM/agent brain behind another
backend (e.g. Captain / Chatwoot) where the agent response must be
delivered synchronously to a known service endpoint, rather than to
a chat platform like Telegram or Slack.
author: Captain (fazer.ai)