| name | interactive-reports |
| description | Use when presenting a wall of information or a multi-choice decision that would overload chat, when the user wants to see HTML/diagrams/wireframes in a browser before deciding, or when they say "let's go web", "report server", "dual-channel", or "interactive report". Builds an HTML form page served by a durable local HTTP server (ports 9100-9199) that stays alive for repeated submissions while you poll for the user's response. |
| license | MIT |
| platforms | ["macos","linux"] |
Interactive Reports
Claude Code tool mapping
This skill was authored for the Hermes agent runtime. The body below uses
Hermes tool names; under Claude Code, translate them as follows:
| Body says | Claude Code equivalent |
|---|
terminal(background=True, notify_on_complete=True, command="…") | Bash with run_in_background: true. The harness re-invokes you when the background command exits — that is notify_on_complete. |
terminal("open http://localhost:PORT/") | Bash running open http://localhost:PORT/ (macOS). Also print the http:// URL in chat — it's clickable. |
execute_code (Python subprocess.Popen block) | Bash running python3. Either launch the server with Bash + run_in_background: true, or run the Popen/preexec_fn snippet as a python3 script. |
process(action="kill", session_id="proc_xxx") | Kill the background shell via the harness, or lsof -ti :PORT | xargs kill. |
browser_console(...) / browser_vision | No built-in browser-control tool in Claude Code. For your own debugging, curl the server endpoints. Never ask the user to use the console — primary actions must be on-page buttons (see Critical rules). |
HERMES_AGENT_NAME / HERMES_SESSION_ID env vars | Unset under Claude Code — mini-server.py falls back to a timestamp namespace automatically. No action needed. |
Stable namespace under Claude Code (IR_NS): Claude Code has no
session-id env var exposed to a backgrounded server, so the namespace defaults
to a timestamp (fine for a single agent). To pin a stable NS — and avoid the
NS-drift-on-restart bug where pages 404 after a server restart — start the
server with an explicit override: IR_NS=ir_myreport python3 /tmp/..._server.py.
Precedence: IR_NS > IR_AGENT/IR_SESSION > HERMES_* > timestamp. Pin
IR_NS once and reuse it for every restart in the same session so all
/tmp/{NS}_* paths stay consistent.
The Hermes skill-scanner --force install note from the original has been
removed; it does not apply to Claude Code.
TL;DR — How to Use
1. plan WHAT decision + what info wall supports it
2. write /tmp/<NS>_page.html (single file, dark theme, forms, submit → /submit)
3. copy templates/mini-server.py → /tmp/<NS>_server.py, edit PAGES dict
4. terminal(background=true, notify_on_complete=true,
command="python3 /tmp/<NS>_server.py")
5. read NS + PORT from stdout: ir_ready ns=ir_argus_<ts> port=9100
— if stdout is empty, probe ports with curl (see Pitfalls)
6. terminal("open http://localhost:{PORT}/")
7. user submits form → server saves JSON to /tmp/{NS}_responses/{ts}.json
8. agent polls /last-response on the server → reads JSON, continues in chat
Critical rules:
- Always present URLs with
http:// prefix (e.g. http://localhost:9100/, not localhost:9100). Bare host:port strings are not clickable in chat — the user called this out explicitly.
- PRIMARY ACTIONS MUST BE BUTTONS. Never tell the user to open browser console, paste commands, or run terminal commands to interact with a running report. If the user needs to trigger an action, it needs a clickable button on the page. Console-only interaction was explicitly rejected: "wire that shit into the UI. a MUST not a SHOULD." Use
browser_console for your own debugging — never ask the user to do it.
- Only actual form elements get hover/pointer. Cards, diagrams, steps, tier info =
cursor: default, no hover. Users will click anything that highlights.
- Server NEVER auto-opens browser. Agent controls
open explicitly. Remove webbrowser from server.
- Namespace ALL artifacts and polling script. Response files, HTML page, server script, port. Use NS =
ir_{AGENT}_{SESSION}. All artifacts go under /tmp/{NS}_*. The polling script (see scripts/poller.py) should be copied to /tmp/{NS}_poller.py. Never share a single poller instance between concurrent agents — timestamp thresholds collide.
- Scan ports 9100-9199 for the first free port. Never hardcode a single port -- concurrent sibling agents collide.
- Use the durable server pattern (
templates/mini-server.py v2+): no self.server.shutdown(), server stays alive, /last-response polling. The old self-terminating pattern kills sibling servers on the same machine -- this is a known bug (kanban t_7e288107).
Durable Server Pattern (Recommended — v2+)
WARNING: The old self-terminating pattern (self.server.shutdown()) kills the
ENTIRE Python process. If two agents run sibling report servers on the same
machine, one agent's form submission kills ALL sibling servers. This was a
production bug (kanban task t_7e288107).
Use the durable server pattern instead. The server:
- Never calls
self.server.shutdown()
- Stays alive for repeated submissions
- Saves each response as a timestamped JSON file
- Exposes a
/last-response JSON endpoint for the agent to poll
- Works with sibling servers on the same machine
- No port scan races (scans wider range 9100-9199)
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps({"ok": True}).encode())
Agent-side polling pattern (use scripts/poller.py — no permission prompts):
cp scripts/poller.py /tmp/<NS>_poller.py
terminal(background=True, notify_on_complete=True,
command="python3 /tmp/<NS>_poller.py <PORT>")
The poller script uses urllib.request directly — no shell | python3 -c
pipelines, so Hermes security checks do not trigger. It exits 0 when a
new submission arrives, printing the JSON to stdout, where notify_on_complete
delivers it to the conversation.
Bash fallback (works but triggers permission prompts):
terminal(background=True, notify_on_complete=True, command="""\
for i in {1..60}; do
resp=$(curl -s http://localhost:<PORT>/last-response 2>/dev/null)
if echo "$resp" | grep -q '"submitted": false'; then sleep 2
elif echo "$resp" | grep -q '"action"'; then echo "RESPONSE: $resp"; exit 0
else sleep 2
fi
done; echo 'TIMEOUT'
""")
Float comparison trap: Do NOT compare received_at timestamps with
bash -gt. Bash integer comparison silently fails on float values like
1778971166.2522619. The Python poller handles this correctly. If you must
use bash, strip decimals first with ${resp%.*}, then compare with -gt.
Trade-off: Requires polling instead of pure process-exit detection.
Benefit: Works with concurrent sibling agents. No crashes. Repeated
submissions accepted.
How it works
- The mini-server (
templates/mini-server.py) is a generic template that
ships with the skill. It is NOT rewritten per session -- the agent copies it
to /tmp/{NS}_server.py and edits the PAGES dict.
- On startup, it auto-computes its namespace (NS) from
HERMES_AGENT_NAME
and HERMES_SESSION_ID (or a timestamp), scans ports 9100-9199 for the
first free one, and emits a machine-parseable startup signal on stdout:
ir_ready ns=ir_argus_1712345678 port=9100
- The agent reads NS and PORT from this line, then opens the browser.
- When the user submits the form (POST /submit), the server:
- Saves structured JSON to
/tmp/{NS}_responses/{ts}.json
- Writes latest to
/tmp/{NS}_responses/latest.json
- Responds to the browser with
{"ok": true}
- Server stays alive for repeated submissions
- The agent polls
/last-response on the server to detect the submission
and reads the JSON to continue in chat.
Legacy note: old self-terminating pattern
The v1 self-terminating pattern (self.server.shutdown()) is
deprecated. It killed ALL sibling servers on the same machine when one
got a form submission. The v2 template (templates/mini-server.py) uses the
durable pattern above. If you encounter an old copy of the template, replace
it with the current one from the skill.
Multi-Agent Namespacing
Multiple Hermes agents can run interactive reports simultaneously. Every
artifact must be scoped to prevent collisions. Derive AGENT from
HERMES_AGENT_NAME env var (default: "argus"). Derive SESSION from
HERMES_SESSION_ID or str(int(time.time())) truncated to 12 chars.
Namespace: ir_{AGENT}_{SESSION}.
| Artifact | Pattern |
|---|
| HTML page | /tmp/{NS}_page.html |
| Response directory | /tmp/{NS}_responses/ (auto-created by server, contains timestamped .json files) |
| Server script | /tmp/{NS}_server.py (copy of template, edit PAGES dict) |
| Port | First free in 9100-9199 (auto-scanned at startup) |
Port scanning — scan 9100-9199 at startup to find a free port:
import socket
for try_port in range(9100, 9201):
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(1)
try:
if s.connect_ex(("127.0.0.1", try_port)) != 0:
PORT = try_port; s.close(); break
finally:
s.close()
Print the chosen port to stdout so the agent reads it from process output.
Startup signal format:
ir_ready ns={NS} port={PORT}
The agent reads NS and PORT from this line to construct file paths and
the browser URL.
Use Cases
Use this when:
- You have a wall of information to present (comparisons, architectures,
options, trade-offs) that would overload the chat channel
- The user needs to make a decision with multiple interdependent choices
- The user wants to see visual layouts, diagrams, or wireframes before
committing
- The user explicitly asks for HTML, browser, or dual-channel interaction
- The user says "let's work with a web server", "let's go web", "report server",
"dual-channel reporting", or "interactive report"
- You want to present live session status (metrics, decisions, page views)
in an auto-polling browser page
- Self-referential / meta-demo: The skill can serve presentation pages
about itself (feature walkthroughs, marketing screenshots, Threads post
previews) by combining static asset serving with the normal PAGES pattern.
This was used in production to build and serve a complete Threads post
series about the skill, including embedded screenshots.
- Agent-to-browser streaming — push agent thinking/logs/game-state to
the browser in real-time via SSE. See
references/sse-streaming-pattern.md.
Do NOT use this for:
- Quick answers or single-option responses (chat is faster)
- Design exploration (use
sketch for variant comparison)
- One-off visual artifacts (use
claude-design)
- Wireframes/mockups (use
wireframe-prototyping)
Method
plan the report → build HTML file(s) → start Python server → open browser → wait for submission → read response → continue in chat
1. Plan the report structure
Before writing HTML, decide:
- What decision is being made? What choices does the user need to pick from?
- What information wall supports it? Comparisons, weight estimates, architecture diagrams, trade-off matrices.
- What form elements are needed? Radio groups (mutually exclusive choices), checkboxes (multi-select), selects (dropdown options), textareas (free-form comments), submit button.
- How many pages? One comprehensive page, or split into linked pages (e.g., decision page → deployment wireframe).
2. Build the HTML file
Write to /tmp/<NS>_page.html. Single-file HTML with everything inline:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Your Title</title>
<style>
:root {
--bg: #0d1117;
--surface: #161b22;
--surface2: #21262d;
--border: #30363d;
--accent: #58a6ff;
--accent2: #bc8cff;
--green: #3fb950;
--yellow: #d29922;
--red: #f85149;
--text: #e6edf3;
--text2: #8b949e;
--text3: #6e7681;
}
body {
background: var(--bg); color: var(--text);
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
line-height: 1.6;
max-width: 960px; margin: 0 auto; padding: 0 1.5rem;
}
</style>
</head>
<body>
<script>
</script>
</body>
</html>
Design principles for the information wall:
- Dark theme matching the terminal/agent aesthetic (GitHub-dark inspired)
- Card grid for overview items (3-column, auto-fit responsive)
- Comparison table for provider/option comparisons (sticky header, color-coded cells)
- Architecture diagram — monospace ASCII art or CSS-based visual topology
- Decision blocks with colored borders (accent blue for primary choices)
- Dimension cards for 3-6 key metrics with color-coded values
- Tags for categorical metadata (colored borders: green/ok, yellow/warn, red/bad)
- Checklist with checked/unchecked items and strikethrough prefilled items
- Sticky submit bar at bottom that appears on scroll
- Code blocks with dark background and monospace font for exec-ready commands
- Navigation bar between pages at the top when serving multiple pages
Form element patterns:
<div class="radio-group" id="choice-id">
<label class="radio-option" onclick="selectRadio(this)">
<input type="radio" name="group_name" value="option-a" checked>
<div>
<div class="label">Option A Label</div>
<div class="desc">One-line description of this option</div>
</div>
</label>
</div>
<select id="select-id">
<option value="a">Option A</option>
<option value="b" selected>Option B (default)</option>
</select>
<textarea id="comments" placeholder="Free-form text..." rows="4"></textarea>
<div class="decision-block">
<h3>Tier 1: Homelab Memory Strategy</h3>
<div class="radio-group">
<label class="radio-option" onclick="selectRadio(this)">
<input type="radio" name="tier1" value="hindsight-docker" checked>
<div>
<div class="label">Hindsight in Docker</div>
<div class="desc">Docker container with PostgreSQL volume</div>
</div>
</label>
<label class="radio-option" onclick="selectRadio(this)">
<input type="radio" name="tier1" value="none">
<div>
<div class="label">Skip memory provider</div>
<div class="desc">Stick with pure chat interaction only</div>
</div>
</label>
</div>
</div>
<div class="decision-block">
<h3>Tier 2: Argus (Standalone, Travels)</h3>
<div class="radio-group">
<label class="radio-option" onclick="selectRadio(this)">
<input type="radio" name="tier2" value="local-embedded" checked>
<div>
<div class="label">Hindsight local-embedded</div>
<div class="desc">Self-contained daemon, works offline</div>
</div>
</label>
</div>
</div>
<script>
function submitForm() {
const data = {
page: 'multi-tier-decision',
tier1: document.querySelector('input[name="tier1"]:checked')?.value,
tier2: document.querySelector('input[name="tier2"]:checked')?.value,
comments: document.getElementById('comments').value
};
fetch('/submit', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(data) })
.then(r => r.json())
.then(resp => { if (resp.ok) window.location.href = '/submitted'; });
}
</script>
Submit JS pattern:
function submitForm() {
const data = {
page: 'page-name',
timestamp: new Date().toISOString(),
field_name: document.querySelector('input[name="x"]:checked')?.value,
comments: document.getElementById('comments').value,
};
fetch('/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
}).then(r => r.json()).then(resp => {
if (resp.ok) window.location.href = '/submitted';
});
}
Radio group handler:
function selectRadio(el) {
el.querySelector('input[type="radio"]').checked = true;
el.parentElement.querySelectorAll('.radio-option')
.forEach(o => o.classList.remove('selected'));
el.classList.add('selected');
}
document.querySelectorAll('.radio-option input:checked')
.forEach(r => r.closest('.radio-option').classList.add('selected'));
3. Copy and configure the server
Use Python's built-in http.server with the durable server template.
The template ships as templates/mini-server.py -- a generic server that is
not rewritten per session. The agent copies it to a namespaced path.
cp templates/mini-server.py /tmp/<NS>_server.py
Then edit the PAGES dict in /tmp/<NS>_server.py to point to your HTML
file(s). The default uses the namespace: "/" : "/tmp/{NS}_page.html".
The server automatically:
- Computes NS from
HERMES_AGENT_NAME and HERMES_SESSION_ID
- Scans ports 9100-9199 for a free port
- Emits
ir_ready ns={NS} port={PORT} and response_dir={DIR} on stdout
- Serves files from the PAGES dict
- Saves form submissions to
/tmp/{NS}_responses/{ts}.json
- Exposes
/last-response for agent polling
- Stays alive for repeated submissions (no self-termination)
Example PAGES configuration for multi-page reports:
PAGES = {
"/": "/tmp/ir_argus_abc123_page.html",
"/wireframe": "/tmp/ir_argus_abc123_wireframe.html",
"/session": "/tmp/ir_argus_abc123_session.html",
}
4. Serve static assets alongside your HTML
If your report references images (<img src="...">), external CSS, or JS
files, the PAGES-only server will return 404. Two options:
Option A (recommended): Set STATIC_DIR in the server template.
This ships with the template and requires zero patching:
STATIC_DIR = os.path.expanduser("~/Desktop/screenshots")
Now <img src="/diagram.png"> resolves to ~/Desktop/screenshots/diagram.png.
Supports PNG, JPG, GIF, SVG, CSS, JS, JSON, HTML, TXT, MD — all with correct
MIME types and Access-Control-Allow-Origin: *.
Option B — manual route (for one-off needs):
Add this to do_GET before the final else:
elif path.startswith("/assets/") and ".." not in path:
rel = path[len("/assets/"):]
file_path = os.path.join("/tmp/ir_assets", rel)
ext = os.path.splitext(file_path)[1].lower()
mime = {".png":"image/png",".jpg":"image/jpeg",".gif":"image/gif",
".svg":"image/svg+xml",".css":"text/css",".js":"application/javascript"}.get(ext,"application/octet-stream")
self._serve_file(file_path, mime)
Copy screenshots/diagrams into /tmp/ir_assets/ before starting the server,
then reference them as <img src="/assets/diagram.png"> in your HTML.
The template now also includes a do_OPTIONS handler for CORS preflight
requests, fixing fetch errors from cross-origin contexts.
For the full pattern (directory-based serving, safety checks, MIME mapping,
base64 inline alternative, and a real-world example), see
references/static-asset-serving.md.
Alternatives:
- Base64 inline — embed as data URIs. Zero server changes, but huge
HTML files. Use for small icons/diagrams only (< 10KB).
- Direct route — one
elif per file. Clean for 1-3 assets.
5. Track session metrics (optional)
For live dashboards that auto-poll, add metrics tracking to the server:
pvs = 0
form_count = 0
chat_turns = 0
last_agent_msg = "Session started."
if path == "/session-status":
elapsed = int(time.time() - SESSION_START)
status = {
"pages_served": pvs,
"form_responses": form_count,
"chat_turns": chat_turns,
"elapsed": f"{elapsed//60}m{elapsed%60}s",
"last_agent_message": last_agent_msg,
"active_decision": "decide-provider",
"uptime_seconds": elapsed,
}
self._serve_json(status)
Then build a live session page (/session) that auto-polls /session-status:
<script>
function pollStatus() {
fetch('/session-status')
.then(r => r.json())
.then(d => {
document.getElementById('pages').textContent = d.pages_served;
document.getElementById('responses').textContent = d.form_responses;
document.getElementById('turns').textContent = d.chat_turns;
document.getElementById('elapsed').textContent = d.elapsed;
});
}
setInterval(pollStatus, 2000);
pollStatus();
</script>
You can update chat_turns and last_agent_msg from the agent side:
curl -s -X POST -d '{"chat_turns":5,"last_agent_msg":"working on X"}' http://localhost:9100/update-status
Or expose a PUT/POST /update-status endpoint on the server.
6. Start the server and open the browser
Use the durable server pattern (v2) -- do NOT self-terminate.
Start the durable server (from templates/mini-server.py) via execute_code
to avoid bash ioctl issues. The server auto-computes NS, scans ports 9100-9199
for a free one, and prints the chosen port to stdout on startup:
import subprocess, os, time, signal
proc = subprocess.Popen(
["python3", "/tmp/<NS>_server.py"],
stdout=open("/tmp/<NS>_server.stdout", "w"),
stderr=open("/tmp/<NS>_server.stderr", "w"),
cwd="/tmp",
preexec_fn=lambda: signal.signal(signal.SIGHUP, signal.SIG_IGN)
)
time.sleep(2)
with open("/tmp/<NS>_server.stdout") as f:
out = f.read().strip()
port_line = [l for l in out.split('\\n') if l.startswith('ir_ready')]
if port_line:
_, _, port_part = port_line[0].split()
PORT = port_part.split('=')[1]
import webbrowser
webbrowser.open(f"http://localhost:{PORT}/")
The agent reads PORT from execute_code stdout and opens the browser.
This avoids the bash wrapper entirely.
On form submit: saves JSON to /tmp/{NS}_responses/{ts}.json, exposes it
via /last-response. Server stays alive. The agent polls /last-response
to detect new submissions.
Legacy path (terminal background -- may fail from ioctl):
terminal(background=True, command="python3 /tmp/<NS>_server.py")
terminal("sleep 2 && open http://localhost:{PORT}/")
If terminal(background=true) causes ioctl errors or the process exits
unexpectedly with code 137/143, switch to the execute_code pattern above.
7. Wait for submission and read the response
After telling the user the page is up, wait for them to interact.
Durable server path (recommended): Poll /last-response on the server:
for i in {1..60}; do
resp=$(curl -s http://localhost:<PORT>/last-response 2>/dev/null)
if echo "$resp" | grep -q '"submitted": false'; then sleep 2
elif echo "$resp" | grep -q '"action"'; then echo "RESPONSE: $resp"; exit 0
else sleep 2
fi
done; echo 'TIMEOUT'
When a response appears, read it and continue the session based on the user's
choices. Clear the response directory after processing to accept fresh
submissions: rm -rf /tmp/{NS}_responses/.
Agent-side pattern when the user says "Continue" or doesn't engage:
If the user says "Continue toward the goal" or similar without submitting the
form, check the response file first. If empty, the user is pushing the
experiment forward — advance the capability (add pages, fix UX, fill data
gaps). Do not ask them to submit the form again or explain the page again.
Multi-session isolation: All artifacts live at /tmp/{NS}_* and are
machine-local. If the server was started in a prior session, its namespacing
prevents collisions. Kill and restart the server for a fresh session. Always
delete the old response file before starting a new session.
8. Server lifecycle
process(action="kill", session_id="proc_xxx")
terminal("rm -rf /tmp/<NS>_responses/")
Confirmation page
After a successful form submission, redirect to /submitted. Show a simple
confirmation card with:
- "✓ Response Sent" header (green)
- Text confirming delivery to the agent
- Option to submit again (by reloading the page)
File locations
| Item | Path |
|---|
| HTML reports | /tmp/{NS}_page.html (single file) or /tmp/{NS}_<name>.html (multi-page) |
| Durable server | templates/mini-server.py (copy to /tmp/{NS}_server.py) |
| Response directory | /tmp/{NS}_responses/ (auto-created by server) |
| Latest response file | /tmp/{NS}_responses/latest.json |
Where NS = ir_{AGENT}_{SESSION}. All under /tmp/ — disposable.
Port conventions
- Interactive report server: ports 9100–9199 (dedicated range, auto-scanned, first free wins). This is separate from the old 8899-8950 range used by other skill components — avoids sibling agent port collisions.
- 8090: Wireframe prototyping (see
wireframe-prototyping skill)
- 8910: Metis dashboard / Dolos server
The wider range (9100-9199 vs old 9100-9199) reduces port-scan race conditions
when multiple sibling agents start report servers simultaneously.
References
-
references/concept-lab-pattern.md — Multi-page concept lab for creative/design exploration: decisions page → wireframe → deep-dive comparison. Build pages 2-3 proactively while waiting for form submissions.
-
references/ns-drift-on-restart.md — Server NS recomputes on restart, causing FileNotFoundErrors. Hardcode NS fix.
-
references/single-endpoint-agent-init.md — One GET endpoint returns everything an agent needs to start. Eliminates multi-step setup and console pasting. Includes deterministic seed-as-ID pattern.
-
references/api-proxy-endpoints.md — Adding custom POST handlers to the report server that proxy to external APIs (e.g. live search, data lookup from within an interactive report). Includes client-side JS patterns, error handling, and CORS requirements.
-
references/static-asset-serving.md — Serving images, CSS, and JS alongside your HTML report: directory-based routes, direct routes, and base64 inline alternatives with safety checks and MIME mapping.
-
references/screenshot-capture-technique.md — Capturing browser screenshots of your report pages for promotional/documentation use: browser_vision with non-vision models, full-page vs viewport behavior, uniqueness verification, file staging, and server-side serving alongside the report.
-
references/wall-of-information-css.md — Complete CSS toolkit for dark-themed interactive reports: cards, comparison tables, diagram blocks, radio groups, form elements, sticky submit bar, confirmation page, responsive breakpoints.
-
references/ux-patterns.md — UX patterns, interactive-vs-static rules, form design, color semantics
-
references/sse-streaming-pattern.md — Real-time SSE streaming for agent-to-browser push (thinking logs, game state, progress). Covers ThreadingTCPServer requirement, SSE endpoint pattern, client-side JS, canvas-in-flex-layout sizing, and custom server architecture beyond the mini-server template.
-
references/hardware-verification-before-rendering.md — Before building a report that depends on machine specs, SSH into hosts for real data. Commands, examples, pitfalls.
-
references/webhook-delivery-types.md — Valid delivery types (log, github_comment, platform names) and the local trap (produces Unknown deliver type warning).
-
references/config-state-verification.md — Commands and patterns for scraping live Hermes state (hermes tools list, hermes config show, hermes skills list) before making recommendations in reports. Prevents the "recommend something already installed" error.
-
scripts/poller.py — Standalone Python polling script. No shell pipes, no | python3 -c trigger for Hermes security scanner. Uses urllib.request directly. Accepts <port> and optional <last_received_at> timestamp. Exits 0 on new submission, printing JSON to stdout. Ships with the skill, copy to /tmp/{NS}_poller.py per session.
-
templates/mini-server.py — Generic durable server template (v2). Ships with the skill, not rewritten per session. Auto-computes NS, scans ports 9100-9199, emits ir_ready, stays alive for repeated submissions. Does NOT self-terminate — the old v1 pattern (self.server.shutdown()) was removed due to multi-agent collision bug (kanban t_7e288107).
Live Status Polling (Session Dashboard)
For pages that track session state, add a /session-status JSON endpoint to
the server and have the page poll it every 2 seconds:
Server-side — add a module-level stats tracker:
pvs = 0
form_count = 0
chat_turns = 0
last_agent_msg = "Session started."
SESSION_START = time.time()
Serve as JSON at /session-status:
elif path == "/session-status":
elapsed = int(time.time() - SESSION_START)
status = {
"pages_served": pvs,
"form_responses": form_count,
"chat_turns": chat_turns,
"elapsed": f"{elapsed}s",
"last_agent_message": last_agent_msg,
"active_decision": "current-decision-node-id",
}
self._serve_json(status)
Client-side — poll from the page:
async function pollStatus() {
const resp = await fetch('/session-status');
const data = await resp.json();
}
setInterval(pollStatus, 2000);
Quick-Action Buttons
For decision pages, include pre-configured action buttons that submit common
intents with a single click. Place them between the decision tree and the
freeform textarea:
<div class="action-btn" onclick="quickSubmit('Go ahead and execute the plan')">🚀 Execute</div>
<div class="action-btn" onclick="quickSubmit('I need a different approach')">🔄 Rethink</div>
<div class="action-btn" onclick="quickSubmit('Show me the comparison again')">📊 Compare</div>
Where quickSubmit sends a named message to the agent:
function quickSubmit(msg) {
fetch('/submit', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({page:'session', source:'quick-action', message: msg})
});
}
Decision Tree Display
A live session page can show the session's decision flow as a visual tree.
Each node has: icon, label, and status (pending / active / done). The
server's /session-status endpoint reports active_decision to highlight
the current node:
<div class="tree" id="decision-tree"></div>
<script>
const DECISIONS = [
{ id: 'phase-1', icon: '🔍', label: 'Research options', status: 'done' },
{ id: 'phase-2', icon: '🏗️', label: 'Choose approach', status: 'active' },
{ id: 'phase-3', icon: '📋', label: 'Deploy', status: 'pending' },
];
function renderTree(activeId) {
}
</script>
Pitfalls
- CRITICAL: Restart the poller after every submission. The polling loop exits
when it finds a submission (or times out). If you tell the user "submit again"
without restarting the poller first, their next click will be silently lost.
The
notify_on_complete on a background poller fires exactly once — when it
exits. After processing the submission, copy+start a fresh poller BEFORE
re-opening the browser or telling the user to submit again.
- CRITICAL: Only interactive elements get hover states and pointer cursors. Static information cards, description blocks, diagram sections, metric displays, step-numbered deployment plans, and tier cards must have
cursor: default with no hover transition. Users interpret hover highlighting as "this is clickable" — fake interactivity is confusing and was explicitly called out as a problem. Radio options, checkboxes, buttons, nav links, and checkable tree nodes are interactive. Everything else is not. This applies to the steps CSS and any tier-card or info-card — they present data, they do not respond to clicks.
- CRITICAL:
terminal(background=true) Python servers may lose stdout or die from bash ioctl errors. The bash wrapper around background processes calls tcsetattr on a non-TTY fd. Two failure modes: (a) hard — the process exits with code 137/143 before serving a single request; (b) soft — the process survives and serves content normally, but stdout capture fails, making the ir_ready startup signal invisible in process output. In the soft mode, find the server by probing ports with curl: for p in {9100..9199}; do code=$(curl -so /dev/null -w '%{http_code}' http://localhost:$p/ 2>/dev/null); [ "$code" = 200 ] && { echo "Found on $p"; break; }; done. The workaround remains: launch via execute_code with a direct subprocess.Popen call. See "Start the server" section for the execute_code pattern. This is the single most likely failure mode when running interactive reports.
Refinement from practice: When using execute_code + subprocess.Popen, add preexec_fn=lambda: signal.signal(signal.SIGHUP, signal.SIG_IGN) to the Popen call. This prevents the child server from dying when the execute_code process terminates and delivers SIGHUP to the process group. Without it, the server can exit silently after a few seconds even though the tool call itself succeeds.
- CRITICAL: Use ThreadingTCPServer when your server has SSE or long-lived connections.
socketserver.TCPServer is single-threaded — an SSE while True loop blocks ALL other requests (form submissions, state polling, everything). Always use socketserver.ThreadingTCPServer with daemon_threads=True for servers with SSE endpoints. This was discovered in production: a maze-viewer server with SSE stalled completely under TCPServer, and requests timed out until the SSE client disconnected.
- Canvas rendering in flex layouts needs explicit sizing. A
<canvas> inside a flex column+row layout will render at 0x0 unless the container chain has min-height: 0 on every flex ancestor and width:100%; height:100% on the canvas wrapper. The default min-height: auto on flex items prevents shrinking below content height. Pattern: maze-panel { display:flex; flex:1; min-height:0; } → maze-canvas-wrap { width:100%; height:100%; display:flex; } → canvas { display:block; }. Without this, users see a tiny or invisible visualization.
- Do NOT auto-open the browser from the server's
__main__ block. The agent opens the browser deliberately once. Remove webbrowser.open from the server template entirely — the agent controls browser lifecycle via terminal("open http://...").
- Don't build the HTML and the server as a single script. Keep them separate. The server script serves files; the HTML files change per report. Separation makes iteration faster.
- CRITICAL: Verify system configuration state before making recommendations about what to install or enable. If your report recommends installing tools, enabling config options, or changing system state, you MUST scrape the actual live state first with commands like
hermes config show, hermes tools list, hermes skills list, and ls ~/.hermes/skills/*/SKILL.md (or find ~/.hermes/skills -name SKILL.md). Build the report from ground truth, not assumptions. The user will catch incorrect recommendations immediately and it wastes trust. See references/config-state-verification.md for the specific commands per platform.
- CRITICAL: Port collision with sibling agents. The port range (9100-9199) is shared
by ALL copies of this skill running on the same machine. If another agent started a
report server first, it owns one of these ports. Your port scanner may pick the SAME
port (race condition) if both scan nearly simultaneously, or you may start on a port
that another agent's server grabs moments later. Your server then dies silently,
and /last-response polls return nothing. Mitigations:
- Kill the full range before starting:
for p in {9100..9199}; do lsof -ti :$p 2>/dev/null | xargs kill -9 2>/dev/null; done; sleep 1
- Use a dedicated port outside the shared range for high-reliability sessions:
set
PORT = 9001 (hardcoded, no scanning) in the server script. Trade-off: you
must ensure no other process uses it.
- If /last-response consistently returns
submitted: false even after the user
says they clicked, check if the server is alive: curl -s http://localhost:$PORT/.
If dead, a sibling likely collided on your port.
- Float comparison in bash polling loops.
[ "$ts" -gt 1778971044 ] fails silently
because -gt is integer-only and received_at values are floats like
1778971166.2522619. Always use the Python scripts/poller.py instead — it handles
float comparison natively. If you must use bash, strip decimals first: ts_int=${ts%.*}.
- Kill ALL stale processes on the port range before starting. An old server holding port 8899 won't release instantly. Kill by PID, then wait 1s for the port to fully release before starting the new server. Without this, the new server's port scanner can race with the dying old one and both end up on the same port. Use:
lsof -ti :9100 | xargs kill 2>/dev/null; sleep 1. Better yet, kill the FULL 9100-9199 range before starting: for p in {9100..9199}; do lsof -ti :$p 2>/dev/null | xargs kill -9 2>/dev/null || true; done; sleep 1.
- Read the startup signal from process output, not by guessing. The server emits
ir_ready ns=... port=... on stdout. Read it to get NS and PORT before opening the browser. Do not assume port 9100 -- the scanner may have picked a different one. If process output is consistently empty despite the process status showing "running" (soft ioctl-failure mode), probe ports with curl: for p in {9100..9199}; do code=$(curl -so /dev/null -w '%{http_code}' http://localhost:$p/ 2>/dev/null); [ "$code" = 200 ] && { echo "Found on $p"; break; }; done.
- Clear the response directory on restart. Stale responses from a prior run get read as if the user just submitted. Delete
/tmp/{NS}_responses/ when starting fresh.
- CRITICAL: Do NOT delete the response directory while the server is running. The server creates
RESPONSE_DIR on startup via os.makedirs(exist_ok=True) but does NOT recreate it on each POST. If you shutil.rmtree the directory mid-session, subsequent form submissions fail silently (500 errors, no JSON written). Restart the server if you need to clear responses mid-session.
- Read the LATEST response, not a stale one. Multiple submissions write to
latest.json. But if you changed the page (e.g., rebuilt the concept lab with different form fields) and read old JSON, you'll present wrong data to the user. After any page rebuild, clear the response dir AND restart the server.
NEVER delete the responses directory while the server is running. The server creates the directory at startup and writes submissions to it. If you shutil.rmtree the responses directory mid-session (e.g., during a page rebuild), the server will silently fail to write form submissions — the user clicks "Submit" and nothing happens, with no error message. This happened in production: user reported "I clicked lock-in but nothing happened. boo." If you must rebuild the page, either (a) restart the server after recreating the responses dir, or (b) mkdir -p the directory back before telling the user to submit again.
- If the user submits multiple times, the latest response is overwritten. Each submission writes to
/tmp/{NS}_responses/latest.json. The agent should track which submissions it has already processed by comparing timestamps.
- Verify form submissions against conversation context before building on them. The responses directory accumulates JSON from multiple submissions. If you rebuild the page mid-session, old submissions remain in the directory. Reading
latest.json may return a STALE submission from 20 minutes ago that doesn't match what the user just said they picked. Pattern: after reading the form response, echo the key choices back to the user ("I read: Social Deduction Tournament, Fog of War, Code Bots — correct?") before building the next phase on potentially wrong data. In one session, the agent built an entire Phase 2 API spec and wireframe on Betrayal Race — the user corrected: "wait - you didn't read my answers at all! I picked Social Deduction Tournament." This was a stale JSON read.
- The
/last-response endpoint returns the most recent submission. The agent polls this endpoint to detect new submissions. If no submission has been received, it returns {"submitted": false}.
- After submitting, redirect to
/submitted so the user doesn't resubmit accidentally (double-submission of the same form corrupts the intent).
- If the user submits multiple times (persistent server), the response file is overwritten each time. The last submission wins. This is intentional — the user can change their mind.
- For the self-terminating server, only one submission is accepted. After that, the server shuts down. For multi-submit workflows, remove the
self.server.shutdown() call or use a fresh server instance.
- Avoid excessive complexity. One page or two linked pages is enough. More than that and the user loses track of what they're deciding.
- When the user corrects an assumption, verify before rendering. If they say your architecture is wrong, SSH into the machines, run real commands, and build the corrected page from real data — don't just write a new HTML page based on the correction without independent verification.
- Live session page polls do NOT inflate page counters. The
/session-status endpoint is handled in its own branch and doesn't increment pvs. Only actual page visits (GET on a PAGES-registered route) increment the counter. The dashboard's JS polls /session-status every 2s — those don't count as page views.
- CRITICAL: Verify the browser actually renders your content, not just that the server responds 200.
- CRITICAL: Dark-theme canvas fill colors MUST have high contrast against the background. A fill of
#111827 on background #0d1117 differs by only 1-2 points per RGB channel — effectively invisible. Users report "nothing is showing" even though the canvas IS rendering. Minimum 10 points per channel difference. For visited/active cells on #0d1117, use fills like #1d2b3a + overlay rgba(88,166,255,0.35). Verify: ctx.getImageData(x,y,1,1).data should differ visibly from background RGB. When a custom HTML/JS page depends on client-side state variables (an ID, session token, or any value that must match between agent-driven backend operations and the browser's JS scope), serving the correct HTML is not enough. The user said "not a single thing is showing" — the server was up, HTML was correct, but the browser was showing stale data because its JS variables (mazeId, sessionId) referenced a different backend entity than the one the agent was driving. Detection: after opening the browser, use browser_console to check whether the JS state matches. Fix: inject the correct values directly: browser_console(expression='varName = "correctValue"; renderFunction();'). Cause: the page auto-generated an ID on load that differs from the one the agent created programmatically; or the user navigated away and back, resetting JS scope; or the page auto-refreshed. This is distinct from a dead server — the backend is alive, the HTML is served, but nothing renders.
- Reset counters when restarting the server. If you're reusing an old server process, its global counters (pvs, form_count) carry over from the previous session. Kill and restart fresh.
browser_vision captures full-page, not viewport. When taking screenshots for promotional use, browser_vision always renders the entire document regardless of scroll position. Scrolling then calling browser_vision again produces an identical image. If you need distinct screenshots, serve different pages/URLs and capture each separately. Always verify uniqueness with md5 before using multiple screenshots as separate assets. See references/screenshot-capture-technique.md for the full workflow.
- CRITICAL: When patching the server to add custom endpoints, insert new
elif blocks BEFORE the static file handler, not after. The mini-server template has a final elif STATIC_DIR block that serves static files. If you insert a new endpoint handler after this block (by replacing only the STATIC_DIR line in a patch), the new code ends up orphaned inside the static file handler's scope, breaking everything. Always patch ABOVE the STATIC_DIR block, and verify indentation after patching. The server crashes silently with broken structure.
/update-status collides with /submit if both save to the same file. Keep status updates and form submissions on separate endpoints or separate response files.
- CRITICAL: Echo parsed choices before acting on a submission. Form JSON can be corrupted by JS bugs, stale page state, or multiple submissions. Before designing anything from a user's form choices, read
latest.json, echo a plain-text summary of the parsed choices back to the user, and ask "Is this correct?" Wait for confirmation before spending cycles on architecture, API specs, or wireframes. A 10-second echo saves a 10-minute rebuild. This session: a concept lab form appeared to select Betrayal Race when the user had actually selected Social Deduction — full Phase 2 API spec was built for the wrong game type. The mini-server recomputes NS from env vars or timestamp on every startup. When restarting the server mid-session, this may produce a DIFFERENT NS than the one used for HTML files — all pages 404. Either hardcode NS in the server (NS = "ir_argus_<fixed>") or restart via execute_code with explicit NS. See references/ns-drift-on-restart.md. This bit this session: NS changed from ir_argus_20260517_021 to ir_argus_20260517_130 on restart, breaking all routes.