Documentation

How it works

Dispatcher and hook architecture. The exact path between a Claude event and the toast on your desktop.

Five steps separate a Claude Code event from the toast on your desktop. No polling, no daemon — just the official hook system + a ~3 KB dispatcher with zero dependencies.

1. Claude Code emits a hook event

Claude Code fires events at specific moments. Ringly subscribes to five:

EventWhen it fires
NotificationClaude requests permission or input
StopClaude finishes a response
StopFailureAn API error ends the session
SubagentStopA subagent finishes (off by default)
SessionStartStart of session (used to check npm for updates)

Claude Code loads the hooks declared in the plugin’s hooks.json and runs them via shell.

2. hooks.json runs dispatch.mjs

The plugin/hooks/hooks.json file registers all events and points at a Node script:

{
  "hooks": [
    {
      "name": "Notification",
      "command": "node",
      "args": ["${CLAUDE_PLUGIN_ROOT}/hooks/dispatch.mjs", "Notification"]
    }
    // ... 4 more events
  ]
}

dispatch.mjs is a single file, ~3 KB, zero dependencies (only uses node:fs, node:path, node:child_process). It’s the hot path — runs every time Claude fires a hook.

3. The dispatcher reads ~/.claude/settings.json

Right at the start, the dispatcher opens settings.json and checks Ringly’s options:

  • Language (language) — pt-BR / en-US / auto
  • Active events (events_*) — if the current event is off, exit silently
  • Sound (sound) — passed to the toast XML as <audio silent="true"/> when off
  • Debug (debug) — enables detailed logging

The read happens at runtime, every time a hook fires. No cache, no restart, no persistent environment variable. Changed it via ringly config? Applies on the next notification.

SessionStart is treated as a separate path: instead of becoming a toast directly, it triggers an npm version check (throttled to 24h) that only turns into a toast when a newer release is out.

4. Tries 3 paths in order

If the event is enabled, the dispatcher reads the JSON payload from stdin and tries, in this order:

  1. Node module ringly/hook — the normal path once the CLI is installed (step 1). Brings the richest translations and project context.
  2. ringly CLI binary on PATH — used when require can’t resolve the module but the CLI exists globally.
  3. Embedded PowerShell + WinRT fallback — last resort. Only fires if the two above failed. Does not replace step 1 — since the AUMID has to be registered by ringly init, without the CLI this fallback plays at most a beep and exits.

Each attempt has a 12-second timeout (margin over PowerShell’s 8s). If it fails, moves on silently. A hook failure never breaks the Claude Code session — every try / catch lands at logger.error() + exit 0.

5. Toast XML via registered AUMID

On Windows, the toast is generated as XML and shown via ToastNotificationManager using the Claude.Code.CLI AUMID registered by ringly init:

<toast launch="...">
  <visual>
    <binding template="ToastGeneric">
      <text>Claude finished</text>
      <text>Ready for your next instruction · my-project</text>
    </binding>
  </visual>
  <audio src="ms-winsoundevent:Notification.Default" />
</toast>

A beep is played as a fallback if Focus Assist or Do Not Disturb is blocking notifications.

Robust block detection (PowerShell 5.1)

On several Windows 11 / PowerShell 5.1 setups, $notifier.Setting returned a mis-typed enum due to a known PowerShell bug (WinRT implements IInspectable but not IDispatch). Result: a false-positive BLOCKED: (with empty value in the logs) that aborted Show().

Since v0.2.4, the check reads $notifier.Setting.value__ (the intrinsic backing field of any .NET enum, accessed directly without going through the broken COM adapter) and compares against integer 0. If the read fails for any reason, the code proceeds and calls Show() anyway — any real error falls into the catch with a meaningful message rather than a phantom block.

Visual summary

Claude Code

    ▼  emits event (Notification | Stop | StopFailure | SubagentStop | SessionStart)
hooks.json

    ▼  command: "node"  args: ["dispatch.mjs", "<Event>"]
dispatch.mjs (~3kb, zero deps)

    ▼  reads ~/.claude/settings.json
    │  ┌─ event off? exit 0 silently
    │  └─ on? continue

    ▼  tries require("ringly/hook") ................... ✓ rich path
    │  └ failed? tries `ringly` binary on PATH ........ ✓ rich path
    │     └ failed? PowerShell + WinRT embedded ....... ✓ beep, no toast

ToastNotificationManager.Show(xml) via AUMID Claude.Code.CLI
Edit on GitHub Last updated: 2026-05-26