Skip to content

Valid Hooks Reference

All hooks available in VALID_HOOKS (hermes_cli/plugins.py):

pre_tool_call, post_tool_call, transform_terminal_output, transform_tool_result,
transform_llm_output, pre_llm_call, post_llm_call, pre_api_request, post_api_request,
api_request_error, on_session_start, on_session_end, on_session_finalize,
on_session_reset, subagent_start, subagent_stop, pre_gateway_dispatch,
pre_approval_request, post_approval_response

Hooks not used (and why):

  • transform_* — output mutation, not needed for telemetry
  • on_session_reset — fired by /reset; would be useful for clearing session state without restart, but not yet wired
  • pre_gateway_dispatch / pre_approval_request / post_approval_response — documented as “observers only”; cannot block or modify

Plugin Discovery Gotcha — duplicate name in ~/.hermes/plugins/ silently shadows

Section titled “Plugin Discovery Gotcha — duplicate name in ~/.hermes/plugins/ silently shadows”

Hermes’ loader (hermes_cli/plugins.py::discover_plugins) recursively scans every subdirectory of ~/.hermes/plugins/ for a plugin.yaml, parses each one, and indexes them by their declared name. Two directories whose manifests share the same name collide on that key — the later one parsed wins, the earlier one is dropped without any warning or error. Filesystem order decides who “wins” (alphabetical in practice), so a backup directory left next to the active plugin will usually shadow it.

This bit us with api_request_error (issue #43, PR #44). The server had:

~/.hermes/plugins/
├── hermes-telemetry/ ← current (v0.7.0, has the fix)
└── hermes-telemetry.bak.1781730291/ ← old backup, both declare name: hermes-telemetry

The .bak was loaded instead of the active dir. Its older __init__.py did not call register_hook("api_request_error", ...), so has_hook("api_request_error") returned False and the dispatcher in run_agent.py::_invoke_api_request_error_hook silently short-circuited on every 404. Editing plugin.yaml or __init__.py in the active dir had zero effect — Hermes was reading the other dir entirely.

Rules of thumb:

  • Never keep a backup of a plugin inside ~/.hermes/plugins/. Move it out (mv ~/.hermes/plugins/foo.bak ~/foo.bak) or rename its name: in the manifest so it gets a distinct key.
  • The provides_hooks list in plugin.yaml is declarative only — the loader does not filter registrations against it. Keep it accurate anyway so the manifest matches the code, but do not rely on adding/removing entries there to enable/disable a hook.
  • To verify which path Hermes is actually loading, run with debug logging and look for the Loading plugin '<name>' ... path=<dir> line:
Terminal window
cd ~/.hermes/hermes-agent && python3 -c "
import logging; logging.basicConfig(level=logging.DEBUG)
from hermes_cli import plugins as p; p.discover_plugins()
print('api_request_error registered:', p.has_hook('api_request_error'))
" 2>&1 | grep -E "Loading plugin 'hermes-telemetry'|api_request_error registered"

api_request_error — failure ledger (v18) + model-unavailable detection (issue #43)

Section titled “api_request_error — failure ledger (v18) + model-unavailable detection (issue #43)”

Fires at the moment of an API client error, before the exception is diluted into the RuntimeError that surfaces in cron.scheduler. Verified kwargs (agent/api_request_hooks.py::_invoke_api_request_error_hook):

kwarg Notes
session_id The active session
task_id, turn_id, api_request_id, api_call_count Identity of the failed attempt (v18: persisted on the ledger row)
error Dict: {"type": "NotFoundError", "message": "…"} — not top-level error_type/error_message
request Dict: {"method": "POST", "body": {...}}; the requested model id is request["body"]["model"] — not the older flat api_kwargs
api_duration Seconds (multiply by 1000 for ms) — not api_start_time
started_at, ended_at Epoch seconds
status_code HTTP status (404 for model-not-found)
retryable False for 404s; may be None when unclassified
reason Classifier enum, e.g. "model_not_found"
retry_count, max_retries Retry context on the ledger row
model, provider The agent’s configured model/provider

The handler does two independent jobs:

  1. Ledger (v18). Writes one llm_call_errors row for every firing, before any filtering — see § Turn identity and the failure ledger.
  2. Model-unavailable alert. Filters to status_code == 404 AND retryable is False and then:
    • Upserts model_unavailable_alerts keyed on (model, provider). Repeated 404s bump occurrences and refresh last_seen_at; first_seen_at is preserved.
    • Queues a pending entry in _pending_model_unavailable_alerts[session_id] so the next pre_llm_call injects a one-shot warning that names the model, provider, status, and occurrence count.

The filter, upsert, queueing, and injected text of (2) are unchanged by v18 — only the field reading was corrected to the nested shape above (with the flat names kept as a fallback), which is why a live-shaped 404 previously stored an empty model_unavailable_alerts.error_message.

Sibling to free→paid: same family of provider-side changes (deprecation, :free promo end) but the call fails entirely instead of just billing.


Source references verified against git clone --depth=1 https://github.com/NousResearch/hermes-agent.
Inspected: hermes_cli/plugins.py, agent/conversation_loop.py, model_tools.py, cron/scheduler.py, tools/delegate_tool.py, agent/usage_pricing.py.

hermes-telemetry is an independent community plugin for Hermes Agent. It is not affiliated with or endorsed by Nous Research.