hermes-agent-telegram-dashboard

Read-only status dashboard for Hermes Agent as one pinned Telegram message, edited in place on a schedule. No Mini App, no LLM call.
The updater lives inside the gateway as a plugin (plugin/): the bot token never leaves the
engine, the message is edited through the engine's own Telegram adapter. A cron tick with a
separate bot (python -m telegram_dashboard) remains as the fallback for installations without
the plugin API.
Install
The plugin is listed in the Hermes Plugin Catalog under its manifest name; the catalog pins the reviewed commit, and the install checks out exactly that commit into the gateway's plugin directory:
hermes plugins install telegram_dashboard_probe
The plugin folder plugin/telegram_dashboard_probe/ is self-contained: the telegram_dashboard
package lives inside it (module paths below, like telegram_dashboard/grok.py, are relative to
that folder). Without the catalog, one line puts the folder in place from a clone:
git clone https://github.com/bablobanov/hermes-agent-telegram-dashboard.git && cd hermes-agent-telegram-dashboard && install -d "${HERMES_HOME:-$HOME/.hermes}/plugins" && cp -r plugin/telegram_dashboard_probe "${HERMES_HOME:-$HOME/.hermes}/plugins/"
Either way, then enable it in config.yaml (plugins.enabled, the chat_id setting; the table
under "Deploying on a gateway" below), restart the gateway, pin the message it sends. Nothing is
installed into the engine's environment; SHA256SUMS in the repository root lists every file of
the plugin folder, so sha256sum -c SHA256SUMS run from plugin/ (or from the gateway's
plugins/ directory) verifies a copy.
What the message shows
One phone screen, about thirty characters per line, then a details block that Telegram shows collapsed. The width is a guide, not a law: a phone sets a proportional font, so a line of the same count fits or wraps by what is on it (an emoji or a Cyrillic letter is wider than a Latin one). An event with a name beyond ASCII is kept shorter on purpose; the time of a limit is never rounded for the width, and a long limits line wraps:
🟢 Healthy · Sep 25 12:21 +05 status and the dated data stamp (the pinned header shows it)
Gateway ✓ · Telegram ✓ words when something is off: stopped, disconnected;
⚠️ no sends / stalled / quiet: connected but deaf
Backup ✓ 6 h ago the last state.db backup; ⚠️ when it failed or is older than 26 h
Drift ✓ 0 of 481 keys that differ from the approved baseline
Cron ✓ 27 jobs active jobs; ⚠️ N of M failing, ticker silent, ticks failing
🧠 Limits used
Claude · no data a line without a number never shows a zero
⚠️ Codex 98% (1h21m) the spent share, the time to the reset; the mark from 90%
Grok · usage not started a state in words is never turned into a number
Kimi 3% (29d) every window, in the provider's own order
Gemini · no data no number without billing; ⚠️ hit limit for an hour after one
🤖 Hermes 0.21.3 → 0.21.5 the version the gateway runs → the latest upstream release;
🆕 and its age for a day after a release came out
▎Details collapsed, a blank line between the sources: confirmation
▎… time, the odd data minute, period, coverage; the channel's
times; absolute backup time and integrity, drift check time,
the cron failures; whether each platform's agent sees its
rules; the memory notebooks and the approval queue; the
plans, logins and Gemini refusals; release dates; the reason
of every "no data"; the dashboard's own version last
The dashboard is one pinned text message in the agent's private chat or in a topic of its group. The plugin inside the gateway edits it in place every few minutes through the engine's own Telegram adapter, so the pinned-message bar at the top of the chat always shows the current status line, and the message under it carries the rest: gateway and Telegram state (and whether the connected channel is deaf), the last backup, config drift, the cron ticker and its jobs, whether the agent sees its rules (the context files in its system prompt), whether its memory works (the notebooks against their limits, the writes waiting for approval), the account limits of every provider with the time to each reset, the Hermes version the gateway runs next to the latest upstream release, and a collapsed details block with the reasons and the timestamps. Nothing to open, nothing to install on the reader's side, no second bot, no LLM call: it is there every time the chat is opened.
The picture is an illustration of what the message carries; the message itself is the plain text above.
Up to five events that need attention come right after the top block, before the limits. Account limits: Claude and Codex from the Hermes usage facade, Grok's weekly pool from the surface xAI serves its own Grok CLI, Kimi Code's windows from the surface the Kimi Code platform serves its own clients (see "Limits" below), and any provider from a local process of your own (see "External limit sources"). Gemini has no number without billing, never a zero: the last time Google refused the engine a call is on its line while it is recent and always in the details (see "Gemini refusals from the engine log"). Nothing is dropped from the old screen, only moved: a line without a number still names its reason, in the details.
A source that cannot prove a value says unknown. A source that does not exist on this
installation says unsupported. Both lower coverage; neither turns green (one exception, the
cron run history: it only adds to a line that stands on its own file, see "The cron line").
Coverage itself (Profiles 1/1 · sources 10/10) lives in the details and comes up to the screen
only when it is incomplete (Profile coverage 2/3, Not observed: …, Stale: …). The details
close with the dashboard's own version (Dashboard 0.11.0) after a blank line, so the installed
copy can be told from the pinned message at a glance.
Freshness is a load-bearing requirement
A pinned dashboard with a dead updater looks exactly like a calm system. Therefore:
- the message always carries the absolute data time and the last Telegram confirmation time
- a message not confirmed for two periods gets a loud banner on its first line; a confirmation
within one period plus a little slack (
min(60 s, period/2), timer jitter of an updater that reads its own record one period later) still counts as confirmed python -m telegram_dashboard --config c.json --checkreports message freshness with exit code 0 (confirmed), 1 (lagging), 2 (stale, lost, never) for an external watchdog. A confirmation time ahead of the clock by more than a minute isstale, not "very fresh"- the check reads the record of whichever path delivers:
plugin_state_pathfor the plugin (the engine'splugin-data/<namespace>/state.json), otherwisestate_pathof the cron tick. The two paths never write the same file, and a check pointed at the wrong one saysnever
message is not modified from Telegram is treated as a confirmation, not a failure. A missing
message is logged at ERROR, recorded, recreated once, and announced in the new message.
Compatibility across Hermes versions
The dashboard probes capabilities, never version numbers. The version line shows a number and
decides nothing by it (see "The Hermes version line"). telegram_dashboard/compat_matrix.json
records per source how it is probed and on which versions it was verified. A new release lowers
coverage and names what is missing instead of breaking.
What "verified" means here, honestly:
| Hermes | How |
|---|---|
0.21.1 (2237be3559) |
the plugin path executed against the real engine objects (tests/test_probe_plugin.py) and a continuous pilot on a live gateway since 2026-09-11 |
0.21.3 (v2026.9.14) |
the pilot gateway after its update; the Kimi credential resolver, registry row and the adapter's _edit_text read in the engine source; the plugin edits the pinned message as HTML through _edit_text on that gateway since 2026-09-25 (screen_format: html in its record, no fallback to plain taken); the public edit_message read in the source takes no parse mode, so the signature check picks _edit_text (html_verb: _edit_text); for 0.9.0 the cron files, the |
| ticker stamps, the run history's schema and the adapter's traffic counters read in the engine | |
source (compat_matrix.json, rows cron, cron_runs, telegram_traffic); for 0.10.0 the saved prompt in state.db, its context block and the runtime line naming the agent's directory read in the source, built by the engine's own functions and read back in tests/test_context_files_engine.py (row context_files) |
|
0.21.5 (v2026.9.24) |
_edit_text and edit_message read in the engine source: same signatures and bodies as 0.21.1 and 0.21.3; for 0.10.0 the saved prompt's context block and runtime line read in the source and built by the engine's own functions in tests/test_context_files_engine.py, as on 0.21.3 (row context_files) |
0.21.6 (v0.21.6, 2026-10-08) |
the whole suite against the installed engine on Python 3.14.6, the interpreter 0.21.6 requires; hermes plugins validate: ok: true, no warnings; every engine touchpoint of every source read against 0.21.5 (the matrix's 0.21.6 rows): the same or compatible, except three the plugin now reads as 0.21.6 does (the config through the engine's own YAML parser, the version without a constant, a context file's BOM); the plugin loaded by the engine's own plugin discovery in process, the default, and refused under plugins.isolation: host (below) |
main (485979ddf4, 2026-09-28) |
edit_message and _edit_text read in the engine source: same signatures, the public verb still without a parse mode |
main (6ec05205a9, 2026-09-30) |
the cron files (the heartbeat now carries the pid; the first token is read), the run history's schema and the adapter's traffic counters read in the engine source: the same fields and attributes as 0.21.3 |
| 0.20.5 | read in the source of a desktop install: the sources degrade, the plugin API is absent (see the floor below) |
Anything else is not verified. Above these versions the sources are probed at runtime and
degrade to unsupported; nothing here promises "works on all versions".
Delivery has a floor: Hermes 0.21.1. The plugin path needs
PluginContext.register_platform_handler and the adapters' _wire_plugin_handlers, and both
are absent in 0.20.x (checked on a 0.20.5 install: not present anywhere in the tree; spawn_task
and PluginState exist there but nothing would ever call the factory). On 0.20.x the plugin
does not load a handler and nothing is delivered; the only path there is the fallback cron tick
with its own bot token. The compatibility principle still holds for the sources (gateway
state, limits, drift degrade per version); it does not extend to delivery, and this README
does not promise otherwise.
In process only. Hermes 0.21.6 added plugins.isolation. Its default, in_process, loads
the plugin into the gateway as before. With plugins.isolation: host the engine runs
third-party plugins in a separate host process and refuses there the one call delivery rests
on, register_platform_handler (its factory receives the adapter's native SDK client): the
plugin does not load, and the engine says so (ctx.register_platform_handler() cannot run in the plugin host; hermes plugins validate reports the verdict in_process). The plugin
cannot fix that on its side: the message is edited through the live Telegram adapter, which
stays in the gateway. A gateway under host has the fallback cron tick with its own bot token.
Three prohibitions, learned from a predecessor that died of them: no imports of engine
internals, no replacement of core files, no post-merge hooks or assume-unchanged.
One named exception: account limits come from agent.account_usage.fetch_account_usage, a public
function with the same signature on every verified version, called exactly the way the gateway's
own /usage calls it (worker thread, deadline, failures never propagate; limits.py). An import
failure makes the source unsupported. The exception is a row in the matrix, not a habit.
The plugin: the screen inside the gateway
plugin/telegram_dashboard_probe/ is the plugin. Every tick it collects, renders the first screen
and edits the same message; the counter text of the vertical slice is gone.
register(ctx) → ctx.register_platform_handler("telegram", wire)
→ adapter.connect() calls wire(app, adapter)
→ ctx.spawn_task(tick loop) (exactly once; reconnects do not add loops)
→ each tick: the traffic counters read off the adapter as attributes, then
collect_all_async → render_dashboard → to_telegram_html and
to_telegram_plain, then adapter = runner.adapters["telegram"] if connected,
adapter.send(plain) once, then every tick the HTML form through the verb the
adapter has for it: edit_message(html, parse_mode="HTML") when its signature
takes a parse mode, else the adapter's own _edit_text(html, "HTML"),
adapter.edit_message(plain) in the same tick when the HTML edit is refused
Two traps found by reading engine 0.21.1 and built into the plugin: connect() runs the factory
again on every reconnect with a new adapter instance, and TelegramAdapter.edit_message does not
forward to the replacement adapter (send does). A loop that keeps its first adapter would report
"Not connected" forever while looking alive. tests/test_probe_plugin.py executes the chain
against the real engine objects with the Telegram Bot mocked and simulates the reconnect.
The screen is HTML when the adapter has a verb for it, plain text otherwise. Which verb is read off the adapter object on every tick, never off a version number, in this order:
- the public
edit_messagewhen its signature has a parameter namedparse_mode(positional-or-keyword or keyword-only;**kwargsdoes not count, a verb that swallowed the keyword would send the markup as plain text and answer success). Called withparse_mode="HTML"; aSendResultwithsuccess=Falseis a refusal of the form - otherwise the adapter's own
_edit_text(chat, id, text, parse_mode), the same on 0.21.1, 0.21.3, 0.21.5 and main, which takes a parse mode and raises on refusal - otherwise plain, with
html_error: no HTML verb (edit_message without parse_mode, no _edit_text)
On every engine known today the public verb takes no parse mode: without finalize it sends
plain text (the engine builds its PTB application without Defaults), and its finalize=True
path converts to MarkdownV2 and, when the escaped payload exceeds 4096 UTF-16 units, splits it
into NEW continuation messages, which a pinned dashboard must never do. So today the form goes
through _edit_text; the day the public verb takes a parse mode, the plugin switches to it
without a release. The HTML form is bold headings and the details in one
<blockquote expandable>, both Bot API 7.4 features that any bot may use. On any refusal but
"not modified" the same tick edits plain through the public verb. A plain edit that succeeds
right after a failed HTML one means the form was refused: the screen stays plain (upper-case
headings, the details shown in full), the record says screen_format: plain with the reason in
html_error (the exception class, or edit_message: <its error>), one warning goes to the
journal, and HTML is tried again twelve ticks later. The record also names the verb that carries
the HTML while it does (html_verb: edit_message or _edit_text, null while plain). The
private method is a capability, never a promise (compat_matrix.json, row screen_html). The
one send that creates the message (and a recreation after a loss) goes through the adapter's
own markdown conversion as plain text; the next tick edits it into the HTML form. The cron path
sends the HTML form with its own bot.
A tick that cannot build the screen still edits the message. Whatever fails while composing
(a collector, the render, the package import), the message gets a loud one-screen notice with
the exception class and the time, and the record carries last_render_error. Yesterday's text
on a pinned dashboard looks exactly like a calm system, which is the failure this project exists
to prevent. Inside the gateway the drift command, the usage facade and the read of the engine's
error log run in worker threads with deadlines (collect_all_async), so a slow provider or a
hung disk never stalls the Telegram event loop.
Deploying on a gateway (0.21.x; 0.20.x has no register_platform_handler):
-
hermes plugins install telegram_dashboard_probe(the catalog pin), or copyplugin/telegram_dashboard_probe/to$HERMES_HOME/plugins/telegram_dashboard_probe/, with thetelegram_dashboard/package inside it as the repository keeps it. The engine loads a directory plugin as a package with__path__, so the package beside the plugin is imported as a relative package and nothing is installed into the engine's environment. Atelegram_dashboardinstalled in the interpreter is the fallback; with neither the message says so instead of showing a screen -
in
config.yaml: addtelegram_dashboard_probetoplugins.enabledand setplugins.entries.telegram_dashboard_probe.settings:setting env fallback meaning chat_idHERMES_DASHBOARD_PROBE_CHATrequired; no chat, no handler thread_idHERMES_DASHBOARD_PROBE_THREADforum topic; omit for General period_secondsHERMES_DASHBOARD_PROBE_PERIODdefault 60; finite, clamped to [0.01, 86400]; while the Telegram adapter is not connected yet (right after a start) the next try comes in 30 s, not a period later hermes_homeHERMES_HOMEdefault ~/.hermes; wheregateway_state.jsonlives, and the engine'slogs/errors.log, which the Gemini line readsdrift_reportHERMES_DASHBOARD_PROBE_DRIFT_REPORTJSON with checked_at,exit_code,stdoutdrift_command(config only, a list) argv of check_drift.py; run off-loop on EVERY tick, 30 s limit, never two at oncelimits_enabledHERMES_DASHBOARD_PROBE_LIMITSdefault on; 0/false/no/offturns it offdisplay_timezoneHERMES_DASHBOARD_PROBE_TZIANA name; unknown degrades to UTC limits_refresh_secondsHERMES_DASHBOARD_PROBE_LIMITS_REFRESHdefault 900, floor 60; how often Grok, Kimi and the external sources are asked (not every tick) limits_sources(config only, a list) up to 4 local sources of limits: urlon loopback, optionalkey_envandtimeout_seconds; see "External limit sources"backup_statusHERMES_DASHBOARD_PROBE_BACKUP_STATUSJSON status of the last state.dbbackup (see "The backup line"); unset = the line says "not observed"context_filesHERMES_DASHBOARD_PROBE_CONTEXT_FILESdefault on; 0/false/no/offturns the rules off, no event and no details line per platform (the details then sayRules: off in the dashboard settings); see "The rules line"memoryHERMES_DASHBOARD_PROBE_MEMORYdefault on; 0/false/no/offturns the memory section off, no event and no details lines (the details then sayMemory: off in the dashboard settings); see "The memory section"Neither drift source configured means drift is
unsupportedon the screen, never zero. The drift reader takes numbers, never words: adrift_commandprints a linekeys_changed=N keys_total=M(positions that differ, keys in the baseline) and exits 0 when clean, 1 on drift, 2 when it could not check; our owncheck_drift.pyprints it last. Output without that line falls back to numbered sections[N] …: countand has no total (Drift ✓ N keys); output with neither readsDrift: unknownwhen the exit code says drift (exit 0 without them still reads as clean,Drift ✓ 0 keys). A source that hangs is not started again until its worker returns (one worker per source across ticks), so a stuck facade or command cannot fill the gateway's executor. -
restart the gateway; the message appears in the configured chat and its screen moves
Use a chat you can sacrifice. The plugin writes its delivery record to the plugin state
($HERMES_HOME/plugin-data/<namespace>/state.json, one key probe) so a gateway restart edits
the same message. The directory name carries a digest of the plugin id
(agent-plugin-telegram_dashboard_probe-<8 hex>); the plugin logs the exact path at start
(probe: delivery record for --check plugin_state_path: ...) at INFO, which a gateway that
journals WARNING and above never shows: take the path from disk
(ls $HERMES_HOME/plugin-data/ | grep probe). The plugin's observability rests on that state
file, not on the journal; an empty grep probe: in the journal means nothing.
The record also carries limits_cache, one entry per provider read on its own cadence
(grok, kimi), each with the last attempt (attempted_at) and its item, so the interval
survives a restart and the state file shows when the provider was last asked. A record from
before Kimi (Grok's attempt at the top level) is moved under grok once. Beside it,
release_cache holds the once-a-day check of the latest Hermes release the same way
(attempted_at and its item), and external_cache the external limit sources: one entry per
source under the first eight hex digits of its URL's SHA-256 (the URL itself is not written),
with the provider, the plan and the login date it last named. gemini_log_cache keeps the last
Gemini 429 the engine's log showed (last_429: its time, limit, retry and model, never the
message; the limit and the retry are kept, not shown) and when the log was last read
(checked_at), so a 429 that rotated out of the log is still the last one. cron_cache holds
where the run-history read left off (runs_checked_at), the last tick that looked
(checked_at) and the failures the engine erased that the details still hold (held, by job
id: the kind, the time, the name, the kind of the error in a word, how many ticks shown).
traffic_cache holds the polling generation and the update counter last seen, the last tick
that saw the counter grow, the longest gap between updates per day of the last seven, the first
sighting of a blocked send path and the last send error the engine logged.
The cron line
Cron ✓ 27 jobs active jobs (enabled and without a pause marker: scheduled or running; or enabled and parked in error); paused in the details
Cron ⚠️ 2 of 27 failing a run failed, a result was not delivered, a job is blocked or overdue
Cron ⚠️ ticker silent 12 min no heartbeat for longer than the engine's own threshold (200 s)
Cron ⚠️ ticks failing 2 h the ticker beats, but every tick ends in an error
Cron: no data the files could not be read; the reason in the details
Cron: not observed no cron directory on this installation
The sources are the engine's own files under HERMES_HOME/cron/, read only: jobs.json
(every job with the outcome of its last run: last_status, last_error, last_delivery_error,
failure_streak, next_run_at, last_run_at, state, paused_at), the ticker's stamps
(ticker_heartbeat, ticker_last_success, ticker_last_error; the first token of each is the
epoch, which is why main's <epoch> <pid> heartbeat reads the same), and executions.db, the
run history, opened mode=ro with PRAGMA query_only in a worker under the tick's deadline
(cron_jobs.py, cron_runs.py). Never read: prompt, script, deliver (chat ids live
there), skills, the model, the error column of the history, state.db.
What counts as a failure is the engine's own rule set (hermes cron status): a last_status
outside ok, delivery_failed, delivery_queued is a failed run (a literal the plugin does not
know is named as it is and is never green); a last_delivery_error is a run whose result never
reached its chat, a failure like any other (decision of 01.10: a job that ran but nobody heard is
not green); blocked_config is blocked; a next_run_at more than 15 minutes in the past while
the ticker lives is overdue. A failed run's streak is the engine's failure_streak; the delivery
streak the engine does not keep is counted from the history (delivery_outcome = 'failed' rows
in a row). A paused job carries no failure: its last run is history. A pause is read as the
ticker reads it (is_job_runnable): a job off, in the paused state or stamped paused_at is
paused, so an enabled record with the stamp alone (a half-pause the engine itself self-disables
on its next loop, and hermes cron list shows active until then) is never overdue here (0.9.1).
The fields are read as the ticker reads them (0.9.2): enabled: null is off, a number in
paused_at is a stamp, a Paused state written by hand is no pause marker, and a job the
ticker may fire is active whatever its state short of completed.
The engine's terminal error state is a failure on an enabled job whatever its stamps, counted
among the active. Only a job counted among the active carries a failure, so the line never reads
1 of 0 (0.9.2): a job switched off carries none in any state and is counted paused (one that
completed in neither column), as hermes cron status does not read it.
A failure the next successful run erased from jobs.json between two ticks is still a failure
nobody saw: the history shows it, and the details keep it for two ticks with the run that
replaced it (Cron daily-digest: run failed Sep 30 11:35, ok since 11:40; cron_cache.held in
the record). A history that cannot be read, or one the installation does not keep, is one
source that did not answer: the line and the events stand on jobs.json, the delivery
streak reads streak unknown, the coverage says Not observed: cron history (unavailable)
(a chronos cron provider keeps no history: not on this installation), and the status is
left to the other sources. The history is the one source that lowers the coverage and never
the status: it only adds to a line that stands on jobs.json, and an installation that keeps
none would otherwise never be green. The jobs file that cannot be read is ⚪ like any source.
The engine's error texts never reach the screen. last_error, last_delivery_error and the
ticker's error are reduced to the kind of the error in a word, in the order the engine's own
health projection uses (auth, rate limit, timeout, not connected, chat unavailable,
network, permission denied, fd exhaustion, stale code, else error): (rate limit) in
the details, the text nowhere, not even in the record. The shared sanitizer, which since 0.9.0
also masks chat ids ([id]) and delivery targets ([target]), is the second line, not the
first.
Severity: a failed run, an undelivered result, a blocked or an overdue job is a warning; a dead
ticker, or one whose every tick fails, is critical, because nothing scheduled runs. At most
three cron events on the screen; the line carries the full count; the details name every job
with its time, its streak and the kind of its error. The job's name in an event is cut to eleven
characters and an ellipsis, to eight when it carries a character beyond ASCII (Cyrillic glyphs
are wider on a phone); the details carry it whole, up to 24. The thresholds are the engine's own: 200 s
for the heartbeat (three ticks plus slack), 15 minutes for an overdue job. The layered order of
the checks (ticker alive, record consistent, run recorded, delivery confirmed) follows
itpartypattaya/hermes-cron (MIT); no code is
taken from it. python -m telegram_dashboard --demo 3, 21, 22 and 24 show the forms.
Connected but deaf: the Telegram line
Gateway ✓ · Telegram ✓ says the adapter is running: is_connected on the engine's adapter is
literally "the adapter was started", nothing about updates arriving or replies leaving. The
adapter keeps counters of its own for that, and the plugin reads four of them off the live
adapter object it already holds, as attributes, once per tick, calling nothing on it:
_updates_received_total, _polling_generation, _polling_last_progress_monotonic and
_polling_generation_started_monotonic, plus one public property beside them,
send_path_degraded (declared on BasePlatformAdapter, overridden by the Telegram adapter;
telegram_traffic.py).
Three verdicts replace the ✓ on the line while the adapter says connected:
Gateway ✓ · Telegram ⚠️ no sends the adapter's own send gate is closed past the 120 s reconnect
grace: every reply fails while this message may still move (critical)
Gateway ✓ · Telegram ⚠️ stalled no successful getUpdates round-trip for five minutes (critical)
Gateway ✓ · Telegram ⚠️ quiet no update seen for longer than this installation usually waits (warning)
The quiet threshold is relative: twice the longest gap between updates seen in the last seven days, at least 6 hours and at most 48, and never before the first update was seen at all. An absolute threshold lies at night on a one-person bot; a relative one without a floor shouts on a chatty group at lunch; one without a ceiling never surfaces a dead bot on a quiet installation. A new polling generation (a reconnect) resets the counter: a lower number is a reset, not silence, and the first update of the new generation counts.
The details always carry the times: Telegram last update seen Sep 30 12:30 · polling ok 12:30
(plus sends blocked since … and quiet 9 h, usual gap up to 4 h when they apply) and
Telegram send errors: last Sep 30 11:58 or none in the log. "Last update seen" is the tick
that saw the counter grow, not the update's own time. Failed sends are the engine's own
Failed to send Telegram message ERROR lines in logs/errors.log (the same tail the Gemini
line reads, in its own worker); three in an hour are an event. Successful sends are not logged
at that level: the last successful send the plugin can vouch for is its own confirmed edit
(Confirmed in the details). Without the counters (an adapter of another shape, or the cron
path, which holds no adapter) the line keeps its old word and the details say Telegram traffic: no data with the reason. python -m telegram_dashboard --demo 23 shows the form.
Four private attributes, read as a capability, not a promise; one public property. The send
gate, send_path_degraded, is the adapter's public API since 0.21.1 and is read as such. The
four polling attributes are not part of the engine's public contract: like _edit_text for the
HTML form, a future adapter that renames or retypes them degrades the line to no data with the
reason and never raises inside the adapter (tests/test_invariants.py pins that nothing is
called on it). The plugin's catalog entry is to disclose the two on lines of their own with this
release, and a public
snapshot of the polling state on the adapter is the upstream change that would turn the
capability into a contract.
The rules line: does the agent see its context files
An AGENTS.md the agent never sees looks exactly like one it follows: nothing fails, the
answers are just worse. Hermes looks for its project context files (.hermes.md, AGENTS.md,
CLAUDE.md, .cursorrules) in the agent's working directory, and for a gateway with
terminal.cwd: . (or unset) and the local backend that directory is the service user's home,
not the service's WorkingDirectory (hermes gateway install sets that to HERMES_HOME). A
file put next to the service, or next to SOUL.md, is silently never loaded. The dashboard
judges it per platform of the gateway, from the system prompt the engine saved for that
platform's latest session, and comes up to the screen only when an agent does not see its rules,
as one event per platform under "Needs attention" that turns the status to a warning (the
healthy screen keeps its fifteen lines; there is no separate rules line beside the event).
Of the five events the screen shows, one place is kept for the rules when other sources fill
them: those sources have lines of their own, the rules do not:
- Telegram: rules not loaded no file in the prompt, while one is there for it
- Telegram: rules outdated the prompt holds another text than the file: /new
- Telegram: rules truncated over context_file_max_chars, the middle cut out
- Telegram: rules blocked the engine's injection scan refused the file
The details always carry one line per platform:
Rules Telegram: ✓ AGENTS.md · 11,162 chars · session Oct 7 14:32 when the agent sees them,
otherwise the files and what to do (AGENTS.md only in the gateway's directory, the agent works in another, only in HERMES_HOME, changed …, after session …: /new, in the agent's directory, not in the prompt of session …, not UTF-8, the engine skips it in silence, in the prompt, no such file now, differ from the prompt of session …), and also no context file where the agent works (never an alarm), context files off for it in the engine's config, the agent's directory not known here (a sandbox prompt with no host directory to
compare), or the reason of a no data (the coverage line names that source on the screen).
A /new there is for the chat of the platform it names, sent by hand: in the HTML message the
file names and /new are code, which Telegram turns into neither a link (AGENTS.md ends in a
top-level domain) nor a command a tap would send to the dashboard's own chat.
- how it judges: the files are found on disk by the engine's own rules, never by headings
read out of the prompt: in the agent's directory,
.hermes.mdup to the git root, else theAGENTS.mdchain from the git root down (AGENTS.override.mdfirst in each directory), elseCLAUDE.md, else the cursor rules; the first kind found wins. They are rendered the way the engine renders them and compared with the prompt right after its# Project Contextheader: the same text is loaded, the engine's own cut of that text (its marker with this text's length, the head and the tail it kept) is truncated, the scan's notice under the file's heading is blocked, anything else outdated. The agent's directory is the one the prompt's runtime block names, read back the way the engine reads it to check a stored prompt - a snapshot, not the files this minute: the engine builds the prompt on a session's first
turn and keeps it until a compression, a model switch or
/new. When the prompt holds the current text, its end is proven by the engine's next part right after it (the skills index, the workspace snapshot, the memory block, the timestamp line or the runtime block): then the file's time does not matter, atouchor a rebuild after an edit reads loaded. Without that proof a file changed after the session started is outdated until/new: a saved prompt cannot otherwise say where an older, longer text ended, so a rule removed from the end must not read as loaded. The change time is the newest of the file's and its link's modification times; a time ahead of the clock is no time. One case slips through: a copy that keeps an old time (rsync -a,cp -p), made after the session started, that removed a rule from the very end of the file, with no known engine part after the text in the prompt - where else rules may wait: only
.hermes.mdandAGENTS.mdcount in the gateway process's working directory and inHERMES_HOME, where the agent does not look; aCLAUDE.mdor.cursorrulesthere is taken to be another tool's (anAGENTS.mdkept there for another tool raises the alarm too; turn the line off for such a gateway). A file of whitespace is no file, as for the engine; a file that is not UTF-8 (a Windows editor's ANSI or "Unicode") the engine skips in silence, and the line says so. The Hermes source tree is never taken for a place of rules (its contributorAGENTS.mdis not loaded for a messaging platform on purpose) - which platforms: the entries of
gateway_state.jsonthat the running gateway wrote (their writer stamps are the record's own pid and start, the way the engine's status tells live from preserved), so a platform switched off long ago is not judged forever; another profile's entries belong to its ownstate.db; Telegram right after a start and when there is no record. A platform whosegateway.platforms.<name>.skip_context_filesis set is off, not an alarm - a sandbox backend (docker and the like) names no host directory in the prompt; discovery
then ran in the engine's
TERMINAL_CWDwhen that exists on the host, else in the process's own directory, and so does the verdict; with neither (the cron tick withoutgateway_dir) rules in the prompt areunknown, never an alarm - what is read:
HERMES_HOME/state.db, openedmode=rowithPRAGMA query_only(the same read-only connection as the cron history), two short statements per platform: the newest session row with a saved prompt (its start and the prompt's hash, by the source index), then that one prompt. No other session's prompt is read, and the connection is closed before any file is, so a database in rollback-journal mode holds its writer back for milliseconds. In the prompt: the header, the block right after it and the runtime block's working directory. On disk: the context files by the names the engine loads, their text (up to 4 MB, to compare) and change time; a leading BOM is read the way the running engine reads it (0.21.6 drops it beforestrip, earlier versions after it; told by the default encoding of the engine's file reader, both ways tried where no engine is loaded). Inconfig.yaml: onlygateway.platforms.<name>.skip_context_files, through the engine's own YAML parser (hermes_yamlfrom 0.21.6, else PyYAML); a file that is there and cannot be read makes a missing blockunknown, never an alarm. In the gateway process: the engine'sTERMINAL_CWD. All in a worker under the tick's deadline - what is never shown or logged: the prompt and the files' text, session ids, paths. The
screen carries file names as the engine labels them, lengths, times and verdicts; nothing of the line
is kept in the plugin's record; a failure is the exception's class only
(
tests/test_context_files.pyplants a phrase in every file and prompt and counts it in the screen, the snapshot and the log: zero) - what is never read: messages, any other column of
sessions, other sessions' prompts, any other key ofconfig.yaml,.env,auth.json. Nothing is written anywhere - only the latest session per platform: after an edit and
/newin one chat the line is calm while another chat of the same platform still runs on the old text - off:
context_files: falsehides the line and drops it from the coverage; the details sayRules: off in the dashboard settings, so off by choice never looks like a line that vanished
tests/test_context_files_engine.py builds the context and runtime blocks with the installed
engine's own functions and reads them back (0.21.3, 0.21.5 and 0.21.6: every kind of file, a
BOM-only file and a BOM before blank lines, a chain in
a repository, a chain with a cut file, a chain cut twice, a cut cursor bundle, an override file,
a directory reached through a link); it skips where Hermes is not importable.
The memory section: does the agent keep what it writes
Hermes keeps a profile's built-in memory in two notebooks, MEMORY.md (the agent's notes) and
USER.md (what it knows of the user), each held to a character limit (2,200 and 1,375 by
default). With memory.write_approval on (skills.write_approval for skills), a write from the
gateway, a cron job or the background review waits as a file in pending/ until someone sends
/memory approve, and nothing tells anyone; a background review's replace or remove waits there
even with the gate off. A queue can stand for weeks while the agent believes it remembered. Like
the rules, the memory has no line of its own on the screen, and a place of its own among the five
events; it becomes an event when it fails:
Memory: 58 writes stuck 24d(Skills: …likewise): the oldest waiting write is older than 3 days. Approval and rejection both delete a write's file, so a write that old has had neither, whatever landed beside itMemory: USER.md over limit: more text than the limit, the engine's own warning on load (every add refused until the agent frees room);not UTF-8: a file the engine cannot decode (it loads none of it and refuses every write);bad limit: a limit no add can pass (zero, below zero, or not a number). A notebook near its limit is no event: the engine runs notebooks close to their cap, and a refused add asks the model to consolidate in the same turnMemory provider: unavailable: the engine said in this gateway process that the configured provider reports unavailable (0.21.6 says so once per process, so it stands until the next start);Memory provider: 3 errors: the engine's warnings about the provider in the errors log over the last day
The numbers are in the details, always:
> Memory: MEMORY.md 1,934/2,200 (87%) · USER.md 1,301/1,375 (94%, one entry 71%)
> Memory queue: 58 waiting since Sep 2 · last write Aug 30 16:05 · approval on
> Waiting adds: USER.md 6,410 chars, 74 free: they do not fit
> Skills queue: 12 waiting since Sep 10 · approval on
> Memory provider honcho: no errors logged in 24 h, last Sep 24 10:12
- counted the engine's way: the entries split on the line holding
§, stripped, empty and repeated ones dropped, joined with the delimiter; the engine's ownMemoryStoregives the same count (tests/test_memory.py, on 0.21.3 and 0.21.6). The share of one entry shows when it holds half the limit or more - the last write is the notebooks' modification time: the engine keeps no journal of applied writes, so a hand edit moves it too; for skills, the curator's ledger. A waiting write's time is its file's, which nothing rewrites
- the waiting adds are measured, not replayed: the characters each add would write against the room left, at most (an add the engine finds already there writes nothing); a replace or a remove is not counted, nor an add to a notebook the engine has switched off
- a provider leaves no status, only warnings in
logs/errors.log; no warning is not proof it works (0.21.3 logs a provider it cannot find at debug level only) - what is read:
config.yaml(memory.*limits, switches, the gate and the provider's name,skills.write_approval) through the engine's own YAML parser, each value as the engine takes it (write_modeof old configs is not read at run time, so neither here; the managed overlay and${VAR}references are not applied), unread = limits and gate unknown, never guessed; the two notebooks (up to 4 MB), decoded as strictly as the engine decodes them; the names and times of the waiting files, and of the memory ones (up to 256 KB each) the action, the target and the length of each add; the ledger's time; the tail of the errors log for the provider's warnings, their time and whether one says unavailable. All in a worker under the tick's deadline. Nothing is written anywhere (tests/test_invariants.py, 13) - what is never shown or logged: the text of a notebook, an entry, a waiting write or its
summary, a log line, a path.
tests/test_memory.pyplants a phrase in every one of them and counts it in the screen, the HTML, the snapshot and the log: zero - one profile: the gateway's own
HERMES_HOME; an empty home (no config, notebook or queue) isunsupported - off:
memory: falsehides the section and drops it from the coverage; the details sayMemory: off in the dashboard settings
The backup line
Backup ✓ 6 h ago sits in the top block, beside the gateway line, in every state: a screen
silent about the norm makes silence indistinguishable from confirmation. The absolute time and
the integrity verdict are in the details (Backup Sep 24 05:31 · integrity ok). The source is a
JSON status a backup job writes on every run (ok, phase, reason, integrity,
finished_epoch or finished_at); the dashboard reads that file only and never opens the
database or the copy. A failed run is loud on the line (Backup ⚠️ failed 6 h ago, the
<phase>: <reason> in the details) and an event; a status older than 26 h is marked
(⚠️ older than 26 h) and an event; a missing or unreadable file is no data with the reason in
the details and an event; no backup_status configured is not observed. The status shape is
the one our own timer writes (state_db_publish.py in the operator repository); any writer that
produces the same keys works.
The Hermes version line
🤖 Hermes 0.21.3 → 0.21.5 sits after the limits: the version the running gateway serves, then
the release upstream (NousResearch/hermes-agent) marks Latest. The same release reads
🤖 Hermes 0.21.5 ✓; no answer from upstream reads 🤖 Hermes 0.21.3 · no data, with the reason
in the details. The details carry both release dates, how many releases lie between them and
when upstream was last checked (Hermes 0.21.3 of Sep 14, latest 0.21.5 of Sep 24,
2 releases behind · checked Sep 25 16:40). For a day after Latest came out the line carries
🆕 and the release's age (🤖 Hermes 0.21.5 → 0.21.6 🆕 3h), and the details the time it came
out (New release: Hermes 0.21.6 out Oct 8 11:51). The mark goes with Latest: a line too narrow
for Latest beside a long version of ours leaves both to the details, so the mark never dates ours.
The line informs, nothing more. Updating Hermes is a process, not a restart: the line carries no threshold, no button, no command and no advice to update, and the new-release mark says only that a release is new. Update by your own process.
- The version is the one of the code the gateway imported at start-up, looked up in
sys.modules: a capability, not a version gate. Up to 0.21.5 that is the constanthermes_cli.__version__. From 0.21.6 there is none: the module reads its install stamp from disk on every access, so after an update on disk it names the new code, not the running one, and without a stamp it says0.0.0. There the identity the gateway resolved at start-up comes first (hermes_cli.version_info, read as process state, never computed here), unless git gave it for another tree (no stamp and no.gitof its own: 0.21.6 then asks the process home'shermes-agentclone, whatever that holds); the stamp last;0.0.0readsno version stamp on this installation, never a version, and an error of the engine's lookupversion lookup failed. Notimportlib.metadata(an editable install keeps the dist-info of install time), not the engine'sbuild_info.get_code_identity(refresh=True)(inside the gateway it would restamp the gateway's owncode_sha) - The latest release comes from unauthenticated GETs to
api.github.com:releases/latestwith the last answer's ETag (a stored one a header cannot carry is dropped), andreleases?per_page=100(about 1 MB, the list carries every release's notes) only when Latest names another version than the last answer. A new ETag alone is no new release: upstream edits a release's notes after publication. The version is the one in the release name (Hermes Agent v0.21.6,Hermes Agent v0.21.5 (v2026.9.24)), else a tag in the release scheme (v0.21.6, used since 0.21.6), never a date tag (v2026.9.24, the scheme before it); releases behind are positions on upstream's list, never arithmetic on version numbers; drafts and pre-releases are not counted. The engine's own update check (check_for_updatesinhermes_cli/banner.py) counts commits behindmainand is not used - No LLM, no agent. The request is plain stdlib
urllib, made by the plugin itself in its own worker thread, under Grok's single-flight deadline (Flights, 25 s; Kimi's is 15 s, for one request) and the cache policy Grok and Kimi share. The agent, its sessions and its tools take no part. A GitHub that hangs costs this line its answer for the tick (no answer within 25 s) and nothing else: the check starts with the tick and runs beside every other source, the gateway's event loop keeps running, the tick waits for it no longer than that deadline, and a hung request is not started a second time.tests/test_hermes_version.pypins each of these; a crash of the check waits for the next interval like a failed answer, so a bug cannot ask GitHub on every tick - At most every 15 minutes; a failure keeps the last answer. The last answer and attempt
live in the record under
release_cacheand survive a restart. A check goes out at most once per 15 minutes, whatever the tick's period: every tick at a period of 15 minutes or more, so a new release is on the screen within one tick of a 30-minute gateway, and within 15 minutes at the 60-second default. Without a token GitHub allows an address 60 requests an hour, and a 304 counts too (measured): 4 an hour plus one list per new release leave the rest to whatever else asks from the address, the engine included; fewer than 10 left in GitHub's window, or none, waits for its reset (Retry-Afterfirst, an hour at most). A failed check (GitHub rate limit,HTTP 503,request failed: URLError,answer shape: …) keeps the last answer on the line, and the details say since when it stands (Hermes latest as of Oct 8 12:10: GitHub rate limit · checked Oct 8 12:40); with no answer yet the line isno data. A reason never quotes the answer: GitHub's rate-limit message carries the caller's IP - Not a source. The line moves neither the status nor the coverage, so a GitHub outage does not turn the screen ⚪. A crash of the collector itself is still an event, like any collector's
- The cron fallback tick (
python -m telegram_dashboard) has no version line: it runs outside the gateway, where there is no gateway version to read, and keeps no durable cache
Limits: three kinds that never mix
Every limit line is one of three things, and the caption says which: an official quota
(a number the provider itself reports, with its own reset date), local accounting (tokens
this installation counted; it never knows the remaining quota, nor spend outside the agent), or
"no data" with a reason. "Official" means the number came from the provider, not
the surface is in the provider's docs: the engine's own Anthropic and Codex fetchers read
undocumented surfaces (api/oauth/usage, the ChatGPT backend), and so does the Grok reader.
A documented surface would be preferable; an undocumented provider number is still the
provider's number, and a local count is not.
A refusal is an event, not a number. Google gives a free-tier Gemini key no quota figure on
any surface, so the Gemini line has no number and never a zero. What the installation does have
is the HTTP 429 the engine logs when Google turns a call down, and it is said as what it is, in
plain words: a per-minute refusal is ⚠️ Gemini hit limit 5 min ago on the line, which moves
no status, like the mark from 90%; a daily one is ⚠️ Gemini paused till 23:00 and an event
until the reset; the last one is always in the details (see "Gemini refusals from the engine
log").
An official line is one line: ⚠️ Codex 98% (1h21m). Every window the provider reports is on
it, in the provider's own order, as N% (time to reset): the spent share (every percent on the
screen is spent, never remaining, from every source; the heading says 🧠 Limits used) and the
time to that window's reset, never earlier than the real one: the time is when the quota is
back. Under a day it goes to the minute, rounded up (45m, 1h21m, 23h59m); from a day on to
the hour, the hour rounded up (1d, 1d5h, 4d3h for four days, two hours and 37 minutes); a
zero second unit is not written (3h, 4d); (?) when the reset date cannot be read. The time
of a limit is never rounded for the width: a line with two windows and the warning mark can pass
the phone line and wraps on a narrow phone (⚠️ Claude 29% (3h34m) · 87% (4d)), a number and a
time stay whole. The mark comes from 90% spent in any window, or from a window the provider
itself calls warning or critical. A window carries no length label: the share and its reset
are what the reader acts on, the plan
(where the provider names it) is in the details, and a length the source does not state would
be a guess (the engine names Codex's windows Session and Weekly by position, and on some
plans the first one is the week). A limit for one model keeps its scope
(Claude 37% (3h) · 12% (4d) · Opus 5% (4d)). A model's own limit from an external source gets
a line of its own under the account's line, Claude Fable 9%, without the time when it resets
together with the account's window of the same label (see "External limit sources"). A state the provider reports in words is never
turned into a number or a zero (Grok · usage not started). The plans the providers name are
one details line, Plans: Claude Max 5x · Codex Prolite · Grok SuperGrok; a plan nobody
reports is not guessed.
There is no bar: Telegram draws the block glyphs from a fallback font, and a bar by fifths says
less than the number after it; Telegram has no text colour either, so the mark is the only
emphasis. The countdown counts from the data time on the first line: a message that stopped
updating is the freshness banner's business, not the countdown's. A number read on its own
cadence must not borrow the screen's stamp: the details line Data 12:21 · Kimi 12:16 names
every number read at another minute than the screen, and a cached number is never shown older
than its refresh interval (quota_cache.py), after two intervals the source is Stale on the
screen. A stale number under a fresh stamp looks like knowledge, which is the one thing a limits
block must never do.
Grok (telegram_dashboard/grok.py): the weekly pool of the SuperGrok subscription, read
from https://cli-chat-proxy.grok.com/v1/billing?format=credits with the token the engine
already holds for xai-oauth, obtained through the engine's own resolver
(hermes_cli.auth_xai.resolve_xai_oauth_runtime_credentials, so the screen shows the quota
of exactly the grant inference uses) and the client header the Grok CLI sends. The plan name
comes from …/v1/settings (subscription_tier_display), optional, and shows in the details
(Plans: … Grok SuperGrok). The resolver is Hermes' own auth code and acts as it does for any
Hermes request to xAI: a token close to expiry is refreshed at xAI's token endpoint and saved to
auth.json, and a refresh xAI refuses for good clears the dead token there. The plugin's own
code writes neither. Neither request follows a redirect: urllib would carry the token on to
whatever host the redirect names, so a redirect is no data with its status (HTTP 302).
Probed on 2026-09-12: the inference host api.x.ai answers 404 for this path, so the proxy host
is required. Policy: one attempt per limits_refresh_seconds, success or failure; a failed attempt is "no data"
with its reason until the next interval; a cached number older than the interval is not shown.
The shape is checked strictly (currentPeriod.type == USAGE_PERIOD_TYPE_WEEKLY, a finite
percent in 0..100, a readable end date); anything else is named, not guessed. One answer is a
state rather than a changed shape: right after the weekly reset the proxy leaves
creditUsagePercent out altogether until the first request of the new period (probed
2026-09-25). The line then reads Grok · usage not started, never a zero, and only while the
period is the current one and on-demand spend is an explicit zero; a percent missing under any
other conditions is named (no creditUsagePercent). Grok is its own source on the coverage
line (Grok quota).
Kimi (telegram_dashboard/kimi.py): the windows of the Kimi Code subscription, read from
<base URL>/v1/usages on the very host the engine uses for Kimi inference. The key and the base
URL come from the engine's own resolver
(hermes_cli.auth.resolve_api_key_provider_credentials("kimi-coding"): .env first, then the
credential pool, then the key-prefix redirect), so the screen shows the quota of exactly the
credential inference uses, and the request carries the client header the engine sends to that
host. Without a usable key in .env or the gateway's environment that resolver loads Hermes'
credential pool, and loading saves the pool back to auth.json when it changes something, for
example: a placeholder key in .env (changeme, ***) seeded into the pool, a row an older
Hermes wrote with the secret in it, an emptied auth_type. Hermes makes the same save itself the
first time it loads that pool (hermes auth list, an agent run on Kimi); a settled pool is not
written, and the plugin's own code writes nothing there. The request follows no redirect, like
Grok's. The shape is the one the official client parses (@moonshot-ai/kimi-code-oauth,
managed-usage.ts): usages.limit_7d (legacy plans) and usages.limit_month_total (new
plans), each with used_ratio in 0..1 and a reset_time, every window on the line with its own
reset (Kimi 3% (29d)). usages.limit_5h is not shown for now: on the pilot account it has read
0 at every reading while limits[] beside it reported 55 of 100 for a 300-minute window with the
same reset, and the plan shows no five-hour limit; a probe decides what either field is. Same
policy and cache as Grok; the request never goes through the credential pool's rotation, so a
failed request cannot mark the pool exhausted. Not in Kimi's docs; a changed shape is named,
not guessed. Kimi is its own source on the coverage line (Kimi quota).
Codex comes from the facade like Claude, and so does its plan (plan_type, title-cased:
Prolite), in the details beside the others. The engine names Codex's first window Session
whatever its length, so the line shows the share and the reset only.
Claude on an installation without an Anthropic credential: the line says no data and the
details say no account token, a reason, never a zero. A local source that answers for Claude
takes the line's place (see "External limit sources").
The check for the plugin path is the same --check, pointed at that file:
{ "plugin_state_path": "~/.hermes/plugin-data/<namespace>/state.json", "period_seconds": 300 }
period_seconds here is the tolerance the check grants, not the plugin's period: with the
plugin editing every 60 s, 300 means five missed ticks before the first alert (confirmed up to
one period plus slack, lagging up to two, stale past that). The record also carries
tick_failed when a tick raised, so a loop that dies every tick does not keep an old edited
on disk.
External limit sources
A provider the plugin has no reader for, or one this installation reads some other way, can be
answered by a local process of your own. limits_sources lists up to 4 sources; each is a URL on
loopback that answers one GET with a small JSON document, contract 1 below. The plugin knows
nothing about who answers.
limits_sources: # in plugins.entries.telegram_dashboard_probe.settings
- url: http://127.0.0.1:8080/v1/usage
key_env: USAGE_SOURCE_KEY # optional: sent as "Authorization: Bearer <its value>"
timeout_seconds: 90 # optional: default 20, from 1 to 120
The answer is 200 with a JSON object of at most 64 KB:
{
"contract": 1,
"provider": "Claude",
"state": "ok",
"reason": null,
"plan": "Max 5x",
"login_expires_at": "2026-10-27T21:00:00Z",
"fetched_at": "2026-09-28T18:16:00Z",
"windows": [
{"label": "session", "used_percent": 42, "resets_at": "2026-09-28T21:00:00Z", "scope": null, "severity": "normal"},
{"label": "week", "used_percent": 86, "resets_at": "2026-10-01T23:00:00Z", "scope": null, "severity": "warning"},
{"label": "week", "used_percent": 100, "resets_at": "2026-10-01T23:00:00Z", "scope": "Fable", "severity": "critical"}
]
}
contractis1; any other version isno datawithcontract N not supportedprovider, up to 24 characters, names the line. A name the screen already has (Claude, Codex, Grok, Kimi) takes that line's place; any other gets a line of its own before Geministate:okputs the windows on the screen;login_expiredreadsClaude · login expiredand raises the eventClaude login expired;unavailableisno datawithreason(up to 60 characters) in the detailswindows, up to 6, in the order the screen shows them:used_percentfrom 0 to 100 ornull;resets_atornull;scope, the model a limit applies to,nullfor the account's own limit;severity, one ofnormal,warning,critical, ornull;labelis optional and not shown (windows carry no length label). The account's windows share one line. Every model's limit gets a line of its own after it (⚠️ Claude Fable 100%); the time to its reset is left out when the account's window with the samelabelresets within the same minute, and said otherwise (Claude Fable 9% (2h)).warningandcriticalput the mark on the line as 90% doesplan, optional, up to 24 characters: the details linePlans: …login_expires_at, optional: when the login behind the numbers ends. The details sayClaude login until Oct 27; from three days before it the eventClaude login expires in 2 days, counted from the last date the source gave, so a source that fails in those days still warnsfetched_at, optional: when the source read its numbers; the details name it when it differs from the screen's minute (Data 07:21 · Claude 06:51). A stamp ahead of the clock is replaced by the time of reading- unknown keys are ignored, and every string is cleaned like any other text on the screen
The guards, and why:
- the URL is
httpon127.0.0.1,localhostor[::1], without credentials in it. Any other entry is refused when the settings are read: the plugin's log names it at start, its line saysno datawith the reason, and nothing is requested. The request ignores proxies from the environment and follows no redirect: a redirect would carry the key to wherever it points. An answer other than 200, over 64 KB or not JSON isno datawith the reason; the body of an error answer is not read key_envnames a variable in the gateway's environment, never a bot token.TELEGRAM_BOT_TOKEN(the engine's Telegram adapter reads it),HERMES_DASHBOARD_BOT_TOKEN(the fallback tick's token) and any name ending in_BOT_TOKEN, in any case, are refused and never read: the plugin does not read the bot token itself, and a setting must not become a way to send it to a local port. The variable is read intelegram_dashboard/external.pyand nowhere else- the tick waits for a source no longer than 25 s, Grok's deadline (Kimi's is 15 s, for one
request). The request itself runs on in its worker with the source's own
timeout_seconds, never two at once for one source. On a miss the screen keeps the source's last line with that line's own stamp in the details while it is younger than two ticks or twolimits_refresh_seconds, whichever is longer; the late answer is the next tick's line. A slow source never holds the tick back - a source is asked once per
limits_refresh_seconds, like Grok and Kimi, and is a source of its own on the coverage line (Claude limits):okandlogin_expiredare answers;unavailable, silence and a shape the contract does not allow areunavailable limits_enabledoff turns the sources off too. The fallback tick reads the same list from its JSON config and asks each source on every run, one after another, within its own timeout; it needs no engine for them
python -m telegram_dashboard --demo 17 shows a source with a model's limit, the plans and a
login that ends in two days; --demo 18 the same source after the login expired.
Gemini refusals from the engine log
Google reports Gemini quota only to a project with billing enabled (Cloud Monitoring answers 403
without it), the Rate Limit page of AI Studio needs a browser login, and the engine reads no
limit by key. The one trace a free-tier key leaves is the HTTP 429 the engine writes to its own
log when Google refuses a call, and that refusal is what the Gemini line shows, in plain words
(telegram_dashboard/gemini_log.py).
- what is read: the last 256 KB of
$HERMES_HOME/logs/errors.log(WARNING and above, rotated by the engine at 2 MB), from the first whole line to the last. Until the first successful read (the record then holdslast_429, null when nothing was seen) the whole file is read, so a 429 from before the plugin arrived is the last one at once; a file over 4 MB is not the engine's rotation and is read by its tail even then, as is a first read that cannot be decoded. An entry counts when its first line holdsGeminiandHTTP 429: the text-to-speech tool's error, the native Gemini adapter's summary and a failed streamed call all do, a 400 or a 403 does not. From it the plugin keeps four things: the time, and thelimit, themodeland the seconds to retry when Google's message names them. The screen shows the time, the model and the kind of limit the retry tells, never the limit's number or the retry itself. The engine logs one refusal up to three times within a second, the later copies cut short; entries within ten seconds of the newest are one refusal and lend each other the fields - what is never read: the message's text beyond those fields (it never reaches the screen),
tracebacks, session ids,
agent.log,gateway.log,auth.json,state.dband the rotatederrors.log.1and.2. No network - seen by Hermes only: a script that calls Gemini on its own, such as a skill's, is not in
the engine's log. The details say so on every screen:
Gemini refusals seen by Hermes: … - per minute or per day: Gemini's free quotas are per minute and per day, and a per-minute
window ends within a minute, so a retry longer than two minutes is the daily quota. A daily
refusal is the event
Gemini daily limit used upand⚠️ Gemini paused till 23:00on the line until the reset Google named, in the screen's zone; any other refusal, one without a retry among them, is the mark only, for an hour:⚠️ Gemini hit limit 5 min ago. After that the line saysGemini · no dataagain, and the details keeplast Sep 29 14:03, per-minute limit, model gemini-2.5-flash-preview-tts(a daily one saysdaily limit till …, one without a retry names no kind). A daily quota spent just before Google's reset asks for a short retry and looks per-minute - memory: the record keeps the last 429 (
gemini_log_cache), so one that rotated out of the tail is still the last one. The fallback cron tick keeps no record, so each of its runs reads the whole file, up to 4 MB - time: the engine stamps its log in the host's local time without an offset; the plugin runs in the same process on the same host and converts with the process's zone. The hour repeated when summer time ends adds an hour to the age of a 429 from that hour, once a year
- source:
Gemini logon the coverage line. Nologs/directory isnot on this installation; the directory withouterrors.logis a log with no 429 (the engine on Windows creates the file at the first warning); a file that cannot be read isunavailable, and the line keeps what the record remembers. The read runs in a worker under the tick's 25 s deadline, like an external source.limits_enabledoff leaves the log unread - an external source that answers for Gemini (contract 1) takes the line's place with its numbers; the 429 the log saw stays in the details and in the event
python -m telegram_dashboard --demo 19 shows a per-minute 429, --demo 20 a daily one.
The watchdog: watchdog/dashboard_probe_check.py
A dead plugin cannot edit the message to say it is dead, so the check must run outside the
plugin. watchdog/dashboard_probe_check.py is that check in the shape of a Hermes cron job with
--no-agent --script: the scheduler runs it as python <path> from $HERMES_HOME/scripts/
(scripts are accepted from nowhere else), delivers whatever it prints, and treats empty stdout
as silence. Cron passes no arguments, so the config is dashboard_probe_check.json next to the
script (plugin_state_path, period_seconds, check_interval_seconds; a path as the first
argument serves manual runs, --verbose prints the state line even when silent).
hermes cron create "*/5 * * * *" --name dashboard-probe-check --no-agent --script dashboard_probe_check.py --deliver telegram:<chat_id>:<alert_topic>
Repeat policy, and why: the durable state lives in the message itself, whose freshness stamp is
what the dashboard is for; an alert is an interruption, not an indicator, and twelve an hour
teach the reader to stop reading the watchdog. So confirmed is silence; lagging and stale
speak on the first tick past the confirmation threshold and then once an hour; never and
lost speak every tick, because there is no message to carry the state and silence there would
be indistinguishable from health. The repeat is stateless (counted from the confirmation stamp,
the watchdog stays read-only) and its hourly window is exactly one tick wide, so a tick the
scheduler skipped swallows that hour's alert; the next hour speaks again. Exit code is 0 in
every judged state; non-zero only when the watchdog itself is broken (config or package
missing), which the scheduler reports as a failed script.
Deployment ships a cut of the package next to the script, not the whole thing:
telegram_dashboard/ with __init__.py, schema.py, freshness.py, timeparse.py only
(PACKAGE_MODULES in the script). tests/test_watchdog_script.py runs the script in a
subprocess with -I against a directory holding exactly that cut, so an import creeping past it
fails the test, not the server. The plugin and the cut are two artifacts of one commit; nothing
but their sha256 ties them together on the server, so check both at delivery.
Coverage boundary: the job runs inside the same gateway as the plugin and alerts through the
same channel. It covers "plugin dead, gateway alive" and not "gateway dead": there both are
silent, and that silence looks exactly like health. Acceptance therefore needs one real run from
the scheduler with the alert actually arriving (hermes cron run bypasses the scheduler's
dispatch), not only a manual run that prints.
Running the fallback tick
The package runs from the plugin folder; point Python at it (or at a deployed copy):
export PYTHONPATH=plugin/telegram_dashboard_probe
python -m telegram_dashboard --demo 2 # render static verification state 2
python -m telegram_dashboard --config c.json --dry-run
python -m telegram_dashboard --config c.json # edit the pinned message
python -m telegram_dashboard --config c.json --check # freshness of the message itself
Config is JSON. Every deployment-specific value (chat id, topic id, paths, token variable name) lives there, not in code:
{
"hermes_home": "~/.hermes",
"chat_id": "-1001234567890",
"thread_id": "17585",
"period_seconds": 300,
"state_path": "~/.hermes/scripts/dashboard-delivery.json",
"token_env": "HERMES_DASHBOARD_BOT_TOKEN",
"drift_command": ["python3", "/path/to/check_drift.py"],
"backup_status": "/path/to/daily-status.json",
"display_timezone": "UTC",
"limits_enabled": true,
"context_files": true,
"memory": true,
"gateway_dir": "/path/the/gateway/runs/in",
"recreate_on_loss": true
}
Limits are only available when the tick runs in an interpreter that can import the engine (the
gateway's own); elsewhere they degrade to unsupported. The cron tick runs in a directory of its
own, so the rules line looks for rules left beside the gateway only in the gateway_dir it is
given (the service's WorkingDirectory); context_files: false (or "off") turns the line off,
memory: false the memory section.
Tests
python -m pytest tests # plus `pip install tzdata` on Windows
ruff check . && ruff format --check . && mypy --strict -p telegram_dashboard
pyproject.toml points pytest and mypy at the package inside the plugin folder. Every run prints
telegram_dashboard.__file__ in the header: a green run that does not say which tree it tested
proves nothing. Run it without -q: pytest hides the header in quiet mode. No test reaches a provider: the Grok, Kimi and GitHub attempts are stubbed by an autouse
fixture in tests/conftest.py unless a test passes its own fake, and the running Hermes version
there is a fixed one; a plugin loaded by the test
helpers takes that same patched package, not a second copy of it under the plugin's name
(tests/probe_fakes.py, load_plugin). tests/test_probe_plugin.py skips unless the Hermes engine is
importable; to run it, use an interpreter with the engine and python-telegram-bot installed
(for example uv sync --extra messaging in an engine checkout with UV_PROJECT_ENVIRONMENT
pointing outside the checkout, then that venv's python -m pytest tests/test_probe_plugin.py).
tests/test_invariants.py reads the plugin's own source and pins thirteen properties a later change
cannot undo quietly: the bot token is never read, no port is listened on, the drift command
runs without a shell, state goes through the engine's plugin state only, the one task is
spawned through ctx.spawn_task, the run history and state.db are opened read-only and
query-only through one connection function (the only SQLite the plugin opens), nothing under
HERMES_HOME/cron is ever written and the cron and traffic modules import no network client,
the entry registers no hook, tool, middleware or command, the traffic probe reads the adapter's
counters without calling anything on it, and the rules line writes nothing, imports no network
client and logs no text, the memory section opens files to read bytes only, opens no database,
imports no network client and logs nothing, no file of the plugin folder carries an invisible character (the
catalog validator warns on one), and no word of any demo screen, sent as HTML, becomes a link,
a command, a mention or a hashtag outside <code>: a word that looks like a file name
(name.ext, the extension starting with a letter) or a /command is code by its form, not by a
list. The plain fallback has no code: there Telegram may still link a file name and make /new
a command a tap would send to the dashboard's own chat.
License
MIT.





