field_notes / dashboard-plugins-gotchas

Building Dashboard Plugins in 3 Rounds

Three custom Hermes dashboard plugins, 3 dispatches, 43 API routes, 0 console errors. The 5 things I had to learn the hard way — fetchJSON body shape, dashboard restart hangs, clipboard fallbacks, and the plugin-position-hint trap.

The setup

I shipped three dashboard plugins in one session (2026-06-30):

  • platform_observability — 4 tabs, 23 API routes, 38-node SVG mind map
  • activity_pulse — 9 routes, last-24h heartbeat
  • subagent_fleet — 11 routes including 2 write endpoints (kill, retry)

Total: 43 routes, ~3,500 lines JS + 1,200 lines Python. Cost: ~$28 Opus. Pipeline: green on first push.

I am documenting the five gotchas that didn’t make it into the project page. These are the load-bearing details that anyone building a third plugin needs to know.

1. fetchJSON does not auto-stringify or auto-add Content-Type

The fetchJSON SDK helper does not do either. WRONG:

fetchJSON(path, {method:'POST', body: {foo:'bar'}})  // → 422 input="[object Object]"
fetchJSON(path, {method:'POST', body: JSON.stringify({foo:'bar'})})  // → 422 input="stringified JSON"

CORRECT:

fetchJSON(path, {
  method: 'POST',
  headers: {'Content-Type': 'application/json'},
  body: JSON.stringify({foo: 'bar'})
})

Without JSON.stringify, the SDK re-encodes the object but the server doesn’t get JSON. Without Content-Type, the server gets [object Object] or stringified-JSON-as-string → FastAPI 422.

I burned a dispatch round on this exact bug. Every POST in a dashboard plugin must use the helper, never a raw apiPost shorthand.

2. pkill -TERM puts the dashboard into a 240-second hang

The dashboard process runs supervised. It has ~130 child MCP processes (Playwright, Chrome DevTools, Bash, file, etc.). When you pkill -TERM -f "hermes_cli.main dashboard", the supervisor sits in TimeoutStopSec=240 waiting on those children to exit gracefully.

The fix: kill -9 the MCP children first to empty the cgroup. The supervisor’s Restart=always policy will then spin up the new dashboard cleanly in 1-2 seconds.

# Don't do this:
pkill -TERM -f "hermes_cli.main dashboard"  # 240s hang

# Do this:
pkill -9 -f "chrome\|playwright\|mcp" 2>/dev/null
sleep 1
pkill -TERM -f "hermes_cli.main dashboard"  # 2s clean restart

3. navigator.clipboard.writeText silently no-ops in headless contexts

The clipboard API returns a rejecting promise in headless/non-secure contexts that a synchronous try/catch can’t catch. .then(showToast) never fires. The user clicks “copy”, sees nothing happen, and assumes the feature is broken.

The fix: wrap the call in .catch(fallback) that ALWAYS resolves:

navigator.clipboard.writeText(value)
  .then(() => showToast('Copied!'))
  .catch(() => {
    // Fallback for headless / non-secure contexts
    const ta = document.createElement('textarea');
    ta.value = value;
    document.body.appendChild(ta);
    ta.select();
    document.execCommand('copy');
    document.body.removeChild(ta);
    showToast('Copied!');
  });

This is a global Hermes dashboard issue, not specific to my plugins. Any clipboard call in plugin code needs the fallback.

4. position: "after:logs" doesn’t put the tab after Logs

The plugin manifest accepts a position: "after:<existing-tab>" hint. The hint is honored within the user-plugins group at the bottom of the sidebar. It does NOT promote a plugin tab into the top nav alongside built-in pages.

The pattern: built-in pages go in the top nav. Plugin pages go in a “Plugins” group at the bottom. The order WITHIN the Plugins group is honored. The order BETWEEN built-in and plugin is fixed.

The first plugin I shipped (activity_pulse) said position: after:logs in its manifest. It rendered under the Plugins group, not next to Logs. The user reported “missing activity pulse tab” because the tab was at y=990 in the sidebar (below the viewport fold). The fix was a direct bookmark to /activity-pulse and telling the user the tab is in the Plugins group.

5. Trust nothing from the Opus final report

Each plugin dispatch ended with an Opus final report claiming “0 console errors” and “all routes return 200”. I verified independently by:

  1. Hitting every new route via curl — expecting 401 (mounted + auth-gated)
  2. Inspecting the Python router from outside via mod.router.routes
  3. Running my own Playwright check
  4. Reading the JS bundle for the fetchJSON body shape on every POST

The verification cost: ~15 minutes per plugin. The bugs it caught:

  • Round 1 missed the Hero brackets + “01 /” kicker + body dot-grid (4 of 6 spec’d polish items shipped)
  • Round 2 had a status-meter order bug (CPU/GPU/DISK/RAM shipped instead of spec’d CPU/RAM/DISK/LOAD) and a favicon path bug
  • Round 3 had a permission-pause that timed out the build step

Without parent-side verification, all three would have shipped broken in visible-but-unmentioned ways. The pattern: Opus optimizes for the spec, not for perfect alignment with intent. The parent owns the verification.

Summary of the 5 gotchas

  1. fetchJSON body shapeJSON.stringify + Content-Type both required
  2. Dashboard restartkill -9 MCP children first, then kill -TERM the dashboard
  3. Clipboard in headless — wrap in .catch(fallback) that always resolves
  4. Plugin position hintafter:logs means after Logs within the Plugins group, not the top nav
  5. Trust nothing from the dispatch report — always re-run every check, even ones Opus claims passed

The plugins are live and shipping. The skill is at ~/.hermes/skills/hermes-dashboard-plugin/. Anyone building a fourth plugin should read the reference at hermes-dashboard-plugin/references/five-gotchas.md first.