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_responseHooks not used (and why):
transform_*— output mutation, not needed for telemetryon_session_reset— fired by/reset; would be useful for clearing session state without restart, but not yet wiredpre_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-telemetryThe .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 itsname:in the manifest so it gets a distinct key. - The
provides_hookslist inplugin.yamlis 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:
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:
- Ledger (v18). Writes one
llm_call_errorsrow for every firing, before any filtering — see§ Turn identity and the failure ledger. - Model-unavailable alert. Filters to
status_code == 404 AND retryable is Falseand then:- Upserts
model_unavailable_alertskeyed on(model, provider). Repeated 404s bumpoccurrencesand refreshlast_seen_at;first_seen_atis preserved. - Queues a pending entry in
_pending_model_unavailable_alerts[session_id]so the nextpre_llm_callinjects a one-shot warning that names the model, provider, status, and occurrence count.
- Upserts
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.