Skip to content

Dashboard Plugin Surface

In addition to the standalone HTML dashboard (dashboard/serve.py, port 8765), hermes-telemetry also ships as a plugin for the official Hermes web dashboard. The two surfaces co-exist; they share the SQLite DB but zero Python code.

File layout (verified against the Hermes loader)

Section titled “File layout (verified against the Hermes loader)”

The Hermes dashboard discovers plugins by scanning ~/.hermes/plugins/<name>/dashboard/manifest.json (hermes_cli/web_server.py::_discover_dashboard_plugins, lines 11333-11434 of NousResearch/hermes-agent@main). Because this repo is cloned into ~/.hermes/plugins/hermes-telemetry/, the plugin files MUST live inside dashboard/ — the same directory as serve.py and index.html. The discovery rule is non-negotiable.

dashboard/
├── serve.py ← standalone surface (stdlib http.server, port 8765)
├── index.html ← standalone SPA (vendored Chart.js, CDN fallback)
├── manifest.json ← plugin manifest
├── plugin_api.py ← plugin backend (FastAPI APIRouter; loaded by Hermes)
└── dist/index.js ← plugin frontend (IIFE; no build step)

The Hermes loader imports plugin_api.py via importlib.util.spec_from_file_location(module_name, api_path) (hermes_cli/web_server.py:11856-11881). The module is not registered as part of any package, so relative imports (from . import _db) fail at load time. Hence plugin_api.py is self-contained — all DB helpers, budget math, and FastAPI routes live in one file. No _db.py sibling.

Why the standalone and plugin share no code

Section titled “Why the standalone and plugin share no code”

The user contract is: the standalone dashboard MUST keep working unchanged when the plugin evolves. Co-locating in dashboard/ was forced by the loader, but the two are independent products:

Surface Entry point Server Auth
Standalone dashboard/serve.py stdlib BaseHTTPRequestHandler none (loopback default)
Plugin dashboard/plugin_api.py Hermes FastAPI app Hermes session cookie

tests/test_dashboard_plugin_isolation.py enforces:

  • plugin_api.py does not import serve (in any module form).
  • serve.py does not import plugin_api.
  • No third Python file in dashboard/ is imported by both surfaces (would re-couple them).
  • Manifest version equals the package __version__ (lockstep release).

plugin_api.py opens telemetry.db with PRAGMA query_only=ON. The plugin is observability-only — capture still flows through the runtime hooks (__init__.py). The test test_db_connection_is_read_only asserts that any INSERT via the plugin connection raises OperationalError.

{
"name": "hermes-telemetry",
"label": "Telemetry",
"icon": "Activity",
"version": "<must equal __version__>",
"tab": { "path": "/telemetry", "position": "after:analytics" },
"slots": ["sessions:top", "cron:top", "header-right", "analytics:bottom"],
"entry": "dist/index.js",
"api": "plugin_api.py"
}

Verified fields:

  • tab.position accepts "end", "after:<path>", "before:<path>" (web_server.py:11382-11391).
  • slots is documentation only. The real binding happens in the JS bundle via window.__HERMES_PLUGINS__.registerSlot(...) (extending-the-dashboard.md § Augmenting built-in pages).
  • api is validated by _safe_plugin_api_relpath (web_server.py:11296-11330); absolute paths or .. traversal cause backend mount to be skipped (the static assets still load). This is fix for GHSA-5qr3-c538-wm9j.

The shell renders <PluginSlot name="..." /> only for the names below. Source: website/docs/user-guide/features/extending-the-dashboard.md § Slot catalogue (upstream lines 577-604 at time of writing — prefer the section heading over the line numbers, which move).

Shell-wide — render anywhere in the app chrome:

Slot Location
backdrop Inside the <Backdrop /> layer stack, above the noise layer.
header-left Before the Hermes brand in the top bar.
header-right Before the theme/language switchers in the top bar.
header-banner Full-width strip below the nav.
sidebar Cockpit sidebar rail — only rendered when layoutVariant === "cockpit".
pre-main Above the route outlet (inside <main>).
post-main Below the route outlet (inside <main>).
footer-left / footer-right Footer cell content (replaces the default).
overlay Fixed-position layer above everything else.

Page-scoped — :top and :bottom on each built-in page:

sessions:* · analytics:* · logs:* · cron:* · skills:* · config:* · env:* · docs:* · chat:*

logs:top sits above the filter toolbar and logs:bottom below the log viewer; docs:top sits above the iframe; chat:* is only active when embedded chat is enabled. Everything else is the plain top/bottom of the page content area.

This list grew. Until 2026-08-24 this section recorded four slots and called them “the entire verified catalogue”. That was true when written and silently stopped being true. Treat any slot list in this repo as a snapshot, re-verify against the upstream section before designing around it, and update the date above when you do.

Slot Widget
sessions:top Last-run summary card (cost · tokens · model).
cron:top 7-day cron cost + failure badge.
header-right 24h spend + budget level (variant=destructive on hard breach).
analytics:bottom Daily cost line chart (vendored Chart.js served locally; CDN fallback only).

Four of the twenty-eight available slots. manifest.json lists the same four, but that field is documentation only — the binding happens in dist/index.js via registerSlot().

Slot names are NOT free-form — verify before adding new ones

Section titled “Slot names are NOT free-form — verify before adding new ones”

The shell only renders slots whose names appear in the catalogue above. Registering an unknown slot via registerSlot() is a silent no-op: the widget loads but nothing on the page ever mounts it. Do not invent slot names — alerts:top and warnings:top, for instance, do not exist.

If you need a visible surface and no slot fits, render the widget inside TelemetryPage (the plugin’s own tab) — that page is fully under our control.

Revisit candidate. The free→paid transitions widget renders inside TelemetryPage on the recorded rationale that “no shell slot fits a tier change surface”. header-banner — a full-width strip below the nav — now exists and is plausibly that surface. The decision predates it; re-evaluate before assuming it still holds.

Re-verify the catalogue against upstream before adding a slot:

https://raw.githubusercontent.com/NousResearch/hermes-agent/main/website/docs/user-guide/features/extending-the-dashboard.md

Note the path: the doc lives under website/docs/user-guide/features/, not docs/. A wrong path here 404s silently, which is exactly how the old four-slot list survived long past its expiry.

The SDK does not currently expose an useActiveSession hook, so sessions:top shows the most recent run instead of the per-row session. When that hook lands upstream, swap the implementation — the backend endpoint /session/{session_id} is already in place.

Chart.js is vendored locally at dashboard/vendor/chart.umd.min.js and served by the standalone dashboard itself first. index.html falls back to cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js only if the local asset fails to load. This removes the main failure mode behind Chart is not defined on offline, filtered, or flaky networks while keeping a fallback path for manual recovery.

The dashboard still has no build step — the vendored asset is a checked-in static file, not a bundler output.

Both surfaces are upgraded with a single git pull in ~/.hermes/plugins/hermes-telemetry. The manifest version is pinned to __version__ by test_plugin_version_matches_package, so a release tag implicitly ships both surfaces in lockstep.

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