A small web app that lets you page through Hermes Agent conversation sessions turn by turn and inspect exactly what goes into the model's context window — the full assembled system prompt, the user message, and every tool call paired with its result, all in true execution order.
It's a single stdlib Python server plus one HTML file. No dependencies, no build step, no framework.
Heads up — Rotom requires a Hermes plugin that is NOT in stock Hermes. Rotom is a reader over Hermes'
state.db, and the data it renders is persisted by a plugin (rotom-capture) that captures per-turn prompts. A vanilla Hermes install does not write the per-turn Honcho recall snapshots, and the trace view depends on them. The session list may populate without the plugin; the part that makes Rotom worth running does not. Read Hermes-side requirements — you need the plugin installed and enabled, not just the same Hermes version.
Prerequisite: Hermes Agent
installed and used at least once, so that ~/.hermes/state.db exists with some
sessions in it — plus the rotom-capture plugin described below. The viewer
reads that database; it does not generate data of its own.
git clone https://github.com/plastic-labs/rotom.git
cd rotom
python3 server.pyThen open http://localhost:32850/ in any browser. That's it — no other process is required.
You should see your recent sessions in the left sidebar. Pick one and use ← /
→ to page through its turns.
Rotom is a reader over Hermes' SQLite store at ~/.hermes/state.db. It cannot
show data Hermes never wrote, and the data it depends on comes from a
Hermes plugin (rotom-capture) that lives in ~/.hermes/plugins/ —
outside the Hermes repo, so it survives hermes update (see
HERMES-PATCHES.md in this repo for the full setup guide). This is not
upstream stock behavior. To run Rotom on your machine you need the plugin
installed and enabled.
What Rotom reads, and where each piece comes from:
-
sessions.system_prompt— the system prompt captured once at session start (Persona, MEMORY, USER PROFILE, and the Honcho tooling preamble — the "use honcho_profile/..." text). This is a column on thesessionstable that stock Hermes populates; on the build Rotom targets it's filled for 424 of 428 sessions. Note this is the static prompt, not the per-turn recalled Honcho payload. -
prompt_snapshotstable — the keystone, and the reason the trace view is worth anything. The per-turn Honcho recall block (<memory-context>: Session Summary, User/AI Representation, Peer Card, Deductive/Inductive observations) is assembled at request time, sent to the model, and discarded by stock Hermes — never persisted. Therotom-captureplugin adds:- a
prompt_snapshotstable (self-creates viaCREATE TABLE IF NOT EXISTSon plugin load), - a
pre_api_requesthook that stashes the assembled prompt + extracted Honcho block, - a
post_api_requesthook that writes the row with token usage, gated behind a config flag and crash-isolated.
It is off by default. To enable:
# config.yaml trace: capture_honcho: true plugins: enabled: - rotom-capture
Then restart the Hermes process — plugins are discovered at startup, and snapshots only accrue going forward (there is no backfill of past sessions). Without this, Rotom's Honcho lane is empty.
The capture also stores the full assembled prompt (
assembled_prompt, the wholeapi_messagesarray as sent). Rotom surfaces it as a "Full assembled prompt (exact payload sent)" lane at the top of each turn — the literal message array the model received, one collapsible entry per message with role badges and per-message token estimates. It's the ground-truth view every other lane is derived from. - a
-
messages.tool_calls/tool_call_id— needed to render tool calls with their arguments in execution order. Stock Hermes does store these; the risk is only if a different Hermes version stores them differently.
Per-message true token counts are NOT available even on the patched build:
messages.token_countisNULLfor every row. Rotom showschars/4estimates labeled~est— never mistake those for provider truth. Populating real per-message counts was scoped but never implemented.
Bottom line: you need (a) the rotom-capture plugin installed in
~/.hermes/plugins/ and added to plugins.enabled, and (b)
trace.capture_honcho: true set with a process restart. See
HERMES-PATCHES.md in this repo; it has the schema, the plugin structure,
the four safety properties (config flag not .env, crash-isolated,
out-of-band so prompt caching survives, capture-only-when-present), and the
enable steps. Without the plugin, Rotom runs and lists sessions but the
per-turn context view won't have the data it's designed to show.
The defaults work out of the box. Override with environment variables if you need to:
| Var | Default | What it does |
|---|---|---|
PORT |
32850 |
Listen port |
HOST |
127.0.0.1 |
Bind address (set 0.0.0.0 for remote) |
STATE_DB |
~/.hermes/state.db |
Path to your Hermes state DB |
TV_BASE |
http://127.0.0.1:32848 |
Television base URL, for theming only |
TV_TOKEN |
~/.television/state/token |
TV bearer token file, for share links |
Example — a different port and an explicit DB path:
PORT=8080 STATE_DB=/home/me/.hermes/state.db python3 server.py
# open http://localhost:8080/For any session you pick from the sidebar, each turn is shown as a single
call-stepped round-trip. Within a turn Hermes makes one API call per model
round-trip (each tool result triggers another call with the grown
transcript), so an API-call stepper pages through every call (call 1 of N). For the selected call you see the full round-trip, top to bottom:
- INPUT (→ sent to the model) — the exact
api_messagespayload for this call, banded into cached (the byte-stable system prefix), replayed (transcript messages identical to the previous call — collapsed by default, diffed via a per-message signature), and new (what was appended this call — expanded by default: the delta you're actually reading). Nothing is omitted — the collapsed bands are present and one click away, so a surprising response is always fully reconstructable. The captured Honcho<memory-context>recall rides inside the user message on call 1 and is split into its sub-sections here. Context compaction is surfaced as a first-class event: on the call where the compressor rewrites the prefix, INPUT carries a badge and its replayed band force-expands (calling it "unchanged" would be a lie); re-compaction mid-turn shows as distinct badges. - OUTPUT (← model response) — the text + reasoning +
finish_reasonthe model returned on this call. This is NOT in the input payload (that's input-only); it's derived from the assistant frame this call produced. - TOOLS (⇄ args in → result back) — each tool call this round-trip made,
with its arguments pretty-printed per-key (the literal input to the tool)
paired with its result. Heavy fields (e.g. a
write_filecontentfield) are token-weighted and flagged as replaying every subsequent call — the compounding cost made visible.
The provider's input / cache-read token counts for the selected call are shown inline.
Sessions can be pinned (the pin icon on each row) to float them to the top
of the list. Pins are a local UI preference stored in localStorage — the
backend is read-only and never writes to your DB.
Keyboard: ← / → page between turns.
browser ──loads──> http://localhost:32850/ (this app)
│
server.py (stdlib HTTPServer, threaded)
│
┌───────────────┴────────────────┐
reads state.db (optional) proxies a
~/.hermes/state.db Television theme stylesheet
sessions + messages + fonts from TV_BASE
server.py— stdlibhttp.server(no deps), threaded, bindsHOST:PORT.public/index.html— single-file vanilla-JS frontend, all paths relative.
The viewer reads the live SQLite store at ~/.hermes/state.db
(sessions + messages tables), opened read-only so it never blocks
Hermes' writer. It does not read the legacy ~/.hermes/sessions/*.jsonl
files — Hermes stopped writing those in May 2026; everything since lives in
state.db (the same store session_search uses), including the stored
system_prompt column that powers the context view.
| Route | Returns |
|---|---|
GET / |
the viewer HTML |
GET /sessions |
150 most recent sessions (id, title, model, tokens, times) |
GET /trace/<id> |
{ meta, system_prompt, turn_count, turns[] } |
GET /canonical.css |
proxies the TV active-theme stylesheet (optional) |
GET /themes/* |
proxies TV theme font files (optional) |
GET /token |
TV bearer token (used only for share links) |
GET /health |
{ "status": "ok", "port": <PORT> } |
Security note: the
/tokenendpoint serves the contents of the Television bearer token file. Do not expose it to untrusted networks — relevant if you setHOST=0.0.0.0to allow remote access.
parse_turns() groups messages into turns (one user message + everything until
the next user message). build_trace() walks each turn's messages in order
and emits a steps[] array, pairing every tool result to its originating call
via tool_call_id. That's what makes tool calls render in real execution order
with the command inline, instead of collapsing a turn to its final text and a
flat bucket of orphaned results.
server.py is just a foreground process. For a clean clone, python3 server.py (or a process manager of your choice) is all you need.
If you want it to survive reboots and self-heal, a tiny watchdog cron works
well. The reference one used on the original deployment lives at
~/.hermes/scripts/ctx_trace_watchdog.sh and runs every 5 minutes:
- Healthy (
/health→ 200): exits silently. - Down: kills any stale process by listening port (
ss -tlnp→ pid — the process argv is justpython3 server.py, sopkill -fon the path does NOT match it), relaunches detached, polls health, prints one line only if it had to restart.
Force a fresh start (e.g. after a code change):
PORT=32850
PIDS=$(ss -tlnpH "sport = :$PORT" | grep -oE 'pid=[0-9]+' | cut -d= -f2 | sort -u)
[ -n "$PIDS" ] && kill -9 $PIDS
cd /path/to/rotom && nohup python3 server.py >/tmp/ctx-trace.log 2>&1 & disownVerify:
curl -s http://127.0.0.1:32850/health # {"status":"ok","port":32850}
curl -s -o /dev/null -w "%{http_code}\n" \
http://127.0.0.1:32850/ # 200Rotom runs perfectly without Television (TV). TV is an
optional theming dependency — if it's not running, the page falls back to its
own built-in dark theme and everything else works exactly the same. There's
also a local light/dark toggle (top right) that persists in localStorage.
If you do have a Television instance and want Rotom to match its theme, configure the two TV-related environment variables:
| Var | Default | What it does |
|---|---|---|
TV_BASE |
http://127.0.0.1:32848 |
Television base URL |
TV_TOKEN |
~/.television/state/token |
Path to the TV bearer token file |
When TV_BASE is reachable, Rotom proxies theme assets through two optional
endpoints:
GET /canonical.css— proxies the TV active-theme stylesheet so the viewer picks up the same colors and fonts as the rest of the dashboard.GET /themes/*— proxies TV theme font files referenced by the stylesheet.
The GET /token endpoint serves the contents of the TV bearer token file
(used to generate share links with TV). As noted in the endpoints table above,
this endpoint should not be exposed to untrusted networks.
If nothing is running at TV_BASE, these endpoints return empty or error
responses and the viewer uses its built-in theme — no crash, no missing
content.