Windows Toast Notify for Hermes CLI
Fire a Windows toast notification when Hermes CLI needs user input —
either a clarify question or a dangerous-command approval — so the user
doesn't have to watch the terminal.
CLI mode has zero built-in notification capability (unlike the Desktop app).
This skill bridges that gap using winotify (Python) + shell hooks.
When to Load
- User wants toast notifications when Hermes asks a question or needs approval
- User wants to verify existing toast setup is working
- Toasts stopped appearing and need debugging
Architecture
Three layers, bundled in this skill:
hermes-hook-notify.py ← hook bridge: reads stdin JSON from Hermes, dispatches
↓ calls (same directory)
hermes-notify.py ← toast emitter: winotify → Windows toast
↓ registered in
config.yaml ← hooks: pre_tool_call(clarify) + pre_approval_request
| Script | Location | Role |
|---|---|---|
scripts/hermes-notify.py |
This skill's scripts/ dir |
Pure toast emitter. Takes title + body args, fires winotify toast. |
scripts/hermes-hook-notify.py |
This skill's scripts/ dir |
Hook bridge. Reads JSON payload from stdin, extracts event info, calls hermes-notify.py. Paths resolved via __file__ — no hardcoding. |
scripts/setup.py |
This skill's scripts/ dir |
One-command setup. Finds Hermes venv, installs winotify, registers hooks in config.yaml. |
Setup (one command)
python scripts/setup.py
Then restart the session (/reset or relaunch Hermes). The setup script:
- Discovers the Hermes venv Python via
%LOCALAPPDATA%\hermes\hermes-agent\venv - Installs
winotifyinto that venv (skips if already present) - Reads
config.yaml(found viahermes config path) - Writes hook entries for
pre_approval_requestandpre_tool_call(matcher: clarify) - Prints the exact paths used so the user can verify
After setup, fully quit and relaunch Hermes. Shell hooks are loaded at
startup; mid-session changes have no effect. /reset only resets the
conversation — it does NOT re-register hooks. Close the terminal window
(or Ctrl+C) and start Hermes again.
Verification
After restarting, verify the pipeline end-to-end:
1. Toast emitter (bottom layer)
"<venv-python>" "<skill-dir>/scripts/hermes-notify.py" "测试" "toast 发射器直测"
Should see "toast sent" and a toast popup.
2. Hook bridge (manual payload)
echo '{"hook_event_name":"pre_tool_call","tool_name":"clarify","tool_input":{"question":"测试 clarify toast"}}' | python "<skill-dir>/scripts/hermes-hook-notify.py"
echo '{"hook_event_name":"pre_approval_request","command":"rm -rf /tmp/test"}' | python "<skill-dir>/scripts/hermes-hook-notify.py"
Both should pop a toast. Hook script outputs {}.
3. Live clarify (full pipeline)
Ask the agent to call clarify. A toast should appear before the question prompt.
4. Live approval (full pipeline)
Run a dangerous command (e.g. rm -rf /tmp/test). A toast should appear before the approval prompt.
Design Principles
- No hardcoded user paths. Scripts discover venv Python via
%LOCALAPPDATA%and resolve sibling scripts via__file__. The only path the user touches is runningsetup.py. - Scripts bundled in skill. No manual file creation.
setup.pyhandles registration. - duration=long (25s) — default "short" (5s) is too easy to miss.
- Audio cue — plays Windows Default notification sound so the user is alerted even when not looking at the screen.
- Timestamp suffix — each toast body ends with
(HH:MM:SS)to bypass Windows same-AppId toast suppression (consecutive toasts from the same AppId within ~30-60s are silently dropped). - subprocess.run (blocking), not Popen — ensures the toast subprocess finishes before the hook returns. Popen's child can be orphaned when the parent's
subprocess.run(capture_output=True)captures the hook's stdout and moves on. - stdout → DEVNULL — hook runs silently, doesn't pollute agent output. Switch to PIPE when debugging.
Pitfalls
winotify
- AUMID trap. Raw PowerShell WinRT
ToastNotificationManager.Show()returns success but Windows silently drops the toast if no AppUserModelID is registered. winotify handles AUMID registration automatically (creates a Start Menu shortcut on first run). This is why winotify was chosen over raw PowerShell. - Must install into Hermes venv, not system pip. The venv Python is the one that runs hook scripts.
setup.pyhandles this automatically.
Hook bridge
- Do NOT use
sys.executable. Shell hooks are spawned viasubprocess.run(argv, shell=False). Thepythonon PATH may resolve to a uv-managed Python without winotify, not the Hermes venv Python. The hook script must discover the venv Python via%LOCALAPPDATA%.setup.pyhardcodes the discovered path into the hook script at setup time so the hook doesn't need to search at runtime. pre_approval_requestpayload fields are inextra, not top-level.shell_hooks._serialize_payload()puts non-standard kwargs (command, description, pattern_key, etc.) intopayload["extra"]. Check bothpayload.get("command")andpayload.get("extra", {}).get("command").- subprocess.DEVNULL hides errors. When debugging, temporarily replace
stdout=DEVNULL, stderr=DEVNULLwithstdout=PIPE, stderr=PIPE+proc.communicate()to capture actual errors.
Hooks registration
hermes config setcannot write list-valued hooks. Settinghooks.pre_tool_callto a JSON array produces a quoted YAML string that the hooks parser silently ignores. Always manually edit the YAML file.setup.pydoes this.- Hooks need
/resetto take effect. Shell hooks are registered at startup. hermes hooks listmay report "No shell hooks" on Windows. It reads from~/.hermes/config.yamlby default, but the actual config may be at%LOCALAPPDATA%\hermes\config.yaml. Check withhermes config path.
Windows toast behavior
- Same-AppId toast suppression. Windows silently drops consecutive toasts from the same AppId within ~30-60s. The timestamp suffix in hermes-notify.py bypasses this.
- Windows notifications must be enabled. Settings → System → Notifications → "Get notifications from apps and other senders" must be ON.
- Focus Assist / Do Not Disturb will suppress toasts. Check Windows settings.
- Session-approved patterns skip the hook. When the user chooses "session" or "always" for a dangerous command,
is_approved()short-circuits andpre_approval_requestdoes not fire. Only "allow once" re-fires the hook every time. This is by design — the hook observes the prompt, not every execution.
Debugging
If toasts don't appear, debug in this order:
- Is winotify installed in the venv? Run
<venv-python> -c "import winotify; print('ok')". - Does the emitter work? Run hermes-notify.py directly with test args.
- Does the hook bridge work? Pipe JSON payload manually (see Verification step 2).
- Are hooks registered? Check config.yaml has the
hooks:block with correct paths. - Is the hook actually firing? Add file-based logging to hermes-hook-notify.py (append timestamped lines to a log file), because hook stdout/stderr are captured by
shell_hooks._spawn()and not shown to the user.