| name | hermes-agentvibes-hook |
| description | Auto-speak every Hermes response via AgentVibes TTS on a remote machine over SSH. Portable skill — works with any AgentVibes setup. |
| category | tts |
Hermes ↔ AgentVibes TTS Hook
Send every Hermes response as spoken audio to a remote machine via the AgentVibes SSH receiver.
Architecture
Hermes (server/Docker)
└── agent:end event fires
└── hooks/agentvibes-tts/handler.py
└── strip markdown, truncate at word boundary
└── JSON payload → base64-encode
└── SSH → agentvibes-receiver@<host>:<port>
└── Remote machine queues & plays via Piper TTS
Setup
1. Install AgentVibes on the target machine (the one with speakers)
npx agentvibes install
2. Generate an SSH key on the Hermes server
ssh-keygen -t ed25519 -f /absolute/path/to/id_ed25519_agentvibes -N ""
Use an absolute path — tilde (~) expansion does not work inside Python subprocess argument lists.
Register the public key on the target machine's agentvibes-receiver user.
3. Create the hook directory and files
Create <hermes_home>/hooks/agentvibes-tts/HOOK.yaml:
name: agentvibes-tts
description: Send agent responses to AgentVibes TTS remotely
events:
- agent:end
Create <hermes_home>/hooks/agentvibes-tts/handler.py — update the four config constants at the top:
"""
AgentVibes TTS Hook — fires on agent:end to speak the agent's response.
Sends the response via SSH to the AgentVibes receiver using the full JSON
payload format (supports voice, project, effects metadata).
"""
import asyncio
import base64
import json
import logging
import os
import re
import subprocess
import time
AGENTVIBES_SSH_KEY = "/absolute/path/to/id_ed25519"
AGENTVIBES_HOST = "<target-ip-or-hostname>"
AGENTVIBES_PORT = "<receiver-port>"
AGENTVIBES_USER = "agentvibes-receiver"
AGENTVIBES_VOICE = "en_US-libritts-high::Leo-8"
AGENTVIBES_PROJECT = "hermes"
MAX_CONTENT_LEN = 200
PREFIX = "Hermes here, "
_KNOWN_HOSTS = "/absolute/path/to/known_hosts"
_LOG_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "tts-hook.log")
_log = logging.getLogger("agentvibes-tts")
if not _log.handlers:
try:
_h = logging.FileHandler(_LOG_FILE, encoding="utf-8")
_h.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
_log.addHandler(_h)
except OSError:
_log.addHandler(logging.NullHandler())
_log.setLevel(logging.INFO)
_last_sent: float = 0.0
_MIN_INTERVAL_S: float = 3.0
def _tts_speak(text: str) -> None:
"""Build JSON payload and send to AgentVibes receiver over SSH."""
global _last_sent
if not text or not text.strip():
return
now = time.monotonic()
if now - _last_sent < _MIN_INTERVAL_S:
_log.info("rate-limited: %.1fs since last send — skipping", now - _last_sent)
return
_last_sent = now
full_text = PREFIX + text.strip()
max_len = len(PREFIX) + MAX_CONTENT_LEN
if len(full_text) > max_len:
truncated = full_text[:max_len].rsplit(" ", 1)[0]
full_text = truncated + "..."
payload = base64.b64encode(
json.dumps({
"text": full_text,
"voice": AGENTVIBES_VOICE,
"project": AGENTVIBES_PROJECT,
"provider": "piper",
"pretext": "",
"effects": "",
"music": "",
"volume": "",
"speed": "",
}).encode()
).decode()
cmd = [
"ssh",
"-i", AGENTVIBES_SSH_KEY,
"-o", "ConnectTimeout=5",
"-o", "StrictHostKeyChecking=accept-new",
"-o", f"UserKnownHostsFile={_KNOWN_HOSTS}",
"-o", "BatchMode=yes",
"-p", AGENTVIBES_PORT,
f"{AGENTVIBES_USER}@{AGENTVIBES_HOST}",
payload,
]
try:
result = subprocess.run(cmd, capture_output=True, timeout=15)
if result.returncode != 0:
stderr = result.stderr.decode(errors="replace").strip()
_log.warning("ssh exit %d: %s", result.returncode, stderr or "(no stderr)")
else:
stdout = result.stdout.decode(errors="replace").strip()
_log.info("queued OK: %s", stdout)
except subprocess.TimeoutExpired:
_log.warning("ssh timed out after 15s to %s:%s", AGENTVIBES_HOST, AGENTVIBES_PORT)
except Exception as exc:
_log.warning("ssh error: %s", exc)
async def handle(event_type: str, context: dict) -> None:
"""Async handler — only processes agent:end events."""
if event_type != "agent:end":
return
response = (context.get("response") or "").strip()
if not response:
return
response = _strip_markdown(response)
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, _tts_speak, response)
def _strip_markdown(text: str) -> str:
"""Remove markdown artifacts that sound unnatural in TTS."""
text = re.sub(r"```[\s\S]*?```", "", text)
text = re.sub(r"`([^`]+)`", r"\1", text)
text = re.sub(r"\*{1,2}([^\*]+)\*{1,2}", r"\1", text)
text = re.sub(r"\[([^\]]+)\]\([^\)]+\)", r"\1", text)
text = re.sub(
r"[\U0001F300-\U0001F9FF"
r"\U00002600-\U000027FF"
r"\U0000FE00-\U0000FE0F"
r"•→↓↑]+",
"",
text,
)
text = re.sub(r"\s+", " ", text).strip()
return text
4. Restart gateway
hermes gateway restart
5. Verify
Check hooks/agentvibes-tts/tts-hook.log after the next agent turn. Every SSH call logs its result (exit code, receiver ACK, or error message).
Configuration Reference
| Constant | Description | Notes |
|---|
AGENTVIBES_SSH_KEY | Absolute path to SSH private key | No ~ — subprocess will not expand it |
AGENTVIBES_HOST | Hostname or IP of target machine | Tailscale IP recommended |
AGENTVIBES_PORT | SSH port of AgentVibes receiver | Set during npx agentvibes install |
AGENTVIBES_USER | SSH user on target machine | agentvibes-receiver |
AGENTVIBES_VOICE | Voice in voiceId::SpeakerName format | e.g. en_US-libritts-high::Leo-8 |
AGENTVIBES_PROJECT | Project tag shown in receiver log | hermes |
MAX_CONTENT_LEN | Max chars of spoken content | Prefix length NOT counted |
PREFIX | Spoken intro before each response | Hermes here, |
_KNOWN_HOSTS | Absolute path to persistent known_hosts | Required for accept-new to survive restarts |
_MIN_INTERVAL_S | Seconds between TTS calls | Prevents queue flooding on rapid turns |
How It Works
- Guards on
event_type == "agent:end" — ignores all other hook events
- Strips markdown (code blocks, bold, links, emoji — anywhere in the string)
- Prepends
PREFIX, truncates at last word boundary before MAX_CONTENT_LEN chars, appends ...
- Builds JSON payload with voice, project, and provider fields (uses AgentVibes full format, not legacy plain text)
- Base64-encodes and sends over SSH — every call logged to
tts-hook.log
StrictHostKeyChecking=accept-new + explicit UserKnownHostsFile protects against MITM after first connect
Troubleshooting
- No audio, no
tts-hook.log: Check HERMES_HOME env var so the hook file is found; check directory write permissions
- SSH permission denied: Register the key's public half on the
agentvibes-receiver user
- SSH host key changed error: Remove the stale entry from the
_KNOWN_HOSTS file and reconnect once
- "Queued" but silent on target: Check the queue watcher (
tts-watcher.ps1) is running on the target machine
- Responses not spoken: Check
tts-hook.log — SSH errors are now logged with full detail
MCP Server Integration
AgentVibes ships with an MCP server (npx -y -p agentvibes@beta agentvibes-mcp-server) with tools for voice selection, effects, background music, and mute/unmute. Install it in your Hermes MCP config to control AgentVibes with natural language commands. The MCP server must run on the target machine where speakers live.