System Architecture
Version: 5.0.1 | Last Updated: 2026-04-11
This document explains the technical architecture of claude-code-audio-hooks v5.0.1.
Design constraints
The project is AI-operated, not human-operated:
- No interactive CLI prompts. All scripts auto-engage non-interactive mode on non-TTY or when
CLAUDE_NONINTERACTIVE=1is set. - No human-readable error logs. All logs are NDJSON (
audio-hooks.v1schema) with stablecodeenums and machine-actionablehint+suggested_commandfields. - No GUIs.
- No 2FA / CAPTCHA gates.
- Every config knob is settable in one shot via
audio-hooks setor a typed setter. - Every state read returns a single JSON document in <100ms.
- All documentation that Claude Code reads is self-contained, structured, and current.
- Single monolith, one repo, one codebase. No microservices. The plugin lives inside the same repo as a subdirectory.
High-level architecture
flowchart LR CC[Claude Code event] -->|stdin JSON| MR{native matcher<br/>routing} MR -->|session_start_resume| HR[hook_runner.py] MR -->|stop_failure_rate_limit| HR MR -->|notification_idle_prompt| HR MR -->|...| HR HR -->|reads| RL[rate-limit pre-check<br/>marker debounce] HR -->|reads| CFG[user_preferences.json] HR -->|reads| MARK[snooze + focus-flow markers] HR -->|fires| AUDIO[Audio playback<br/>26 MP3s, 2 themes] HR -->|fires| NOTIF[Desktop notification] HR -->|fires| TTS[TTS announcement] HR -->|fires| WH[Webhook subprocess<br/>fire-and-forget] HR -->|writes| LOG[(NDJSON event log<br/>schema audio-hooks.v1)] style CC fill:#4A90E2,color:#fff style HR fill:#7ED321,color:#000 style RL fill:#F5A623,color:#000 style AUDIO fill:#F5A623,color:#000 style WH fill:#9013FE,color:#fff style LOG fill:#50E3C2,color:#000
Components
1. hooks/hook_runner.py (canonical, ~1700 lines)
The Python hook runner is the single source of truth for hook event handling. It is invoked in two ways:
| Invocation | Trigger | CLAUDE_PLUGIN_DATA set? |
|---|---|---|
| Plugin install | ${CLAUDE_PLUGIN_ROOT}/runner/run.py <event> from hooks/hooks.json | yes |
| Script install | python ~/.claude/hooks/hook_runner.py <event> from ~/.claude/settings.json | no |
The runner accepts both canonical hook names (stop, notification, session_start) and synthetic matcher variants (session_start_resume, stop_failure_rate_limit, notification_idle_prompt). Synthetic names are mapped to a canonical hook plus a per-variant audio override via SYNTHETIC_EVENT_MAP.
Per-invocation flow:
flowchart TD EVT[Hook event fires<br/>e.g. Stop] --> SETCTX[set log context<br/>session_id + hook_type] SETCTX --> ENABLED{is_hook_enabled?} ENABLED -->|no| EXIT[exit 0 silent] ENABLED -->|yes| SNOOZE{is_snoozed?} SNOOZE -->|yes| EXIT SNOOZE -->|no| LOAD[load_config + plugin_option overlay] LOAD --> RLCHK[check_rate_limits<br/>marker debounced] RLCHK --> DEB{should_debounce?} DEB -->|yes| EXIT DEB -->|no| FILTER{should_filter?<br/>user regex on stdin fields} FILTER -->|yes, exclude| EXIT FILTER -->|no| AUDIT[check_and_self_update] AUDIT --> FF[Focus Flow start/stop] FF --> CTX[get_notification_context<br/>+ universal suffix] CTX --> AUDIO{mode=audio*?} AUDIO -->|yes| PLAY[play_audio] PLAY --> NOTIF{mode=notif*?} AUDIO -->|no| NOTIF NOTIF -->|yes| SEND[send_desktop_notification] SEND --> TTS{tts.enabled?} NOTIF -->|no| TTS TTS -->|yes + speak_assistant_message| SPEAK1[play_tts last_assistant_message] TTS -->|yes| SPEAK2[play_tts static or context] SPEAK1 --> WH{webhook.enabled?} SPEAK2 --> WH TTS -->|no| WH WH -->|yes| WEBHOOK[send_webhook<br/>fire-and-forget subprocess] WH -->|no| LOG WEBHOOK --> LOG[log_event<br/>NDJSON] LOG --> EXIT_OK[exit 0] style EVT fill:#4A90E2,color:#fff style PLAY fill:#7ED321,color:#000 style WEBHOOK fill:#9013FE,color:#fff style LOG fill:#50E3C2,color:#000
Key functions:
| Function | Purpose |
|---|---|
_resolve_synthetic_event(raw_arg) | Maps synthetic names to canonical + audio override |
_resolve_config_file() | Resolves user_preferences.json path: CLAUDE_PLUGIN_DATA → plugin context detection → explicit override → legacy script path |
_apply_plugin_option_overlay(config) | Overlays CLAUDE_PLUGIN_OPTION_* env vars onto loaded config |
is_hook_enabled(hook_type) | Reads enabled_hooks.<name> with v5.0 default-on for permission_denied and task_created |
is_snoozed() | Reads marker file at ${QUEUE_DIR}/snooze_until |
should_debounce(hook_type) | Per-hook debounce marker |
should_filter(hook_type, stdin, config) | User-defined regex filters on stdin fields |
check_rate_limits(stdin, config) | v5.0: inspects rate_limits field, fires one-shot warning per (window, threshold, resets_at) |
get_notification_context(hook, stdin, level) | Builds the notification text with v5.0 enrichment (last_assistant_message, worktree, agent, etc.) |
_format_context_suffix(stdin, level) | Universal [session: foo, worktree: bar] suffix |
play_audio(file) | Platform dispatch: play_audio_windows / _macos / _linux / _wsl |
send_desktop_notification(title, msg, urgency) | Platform dispatch: osascript / notify-send / PowerShell NotifyIcon |
play_tts(message) | Platform dispatch: say / espeak / spd-say / SAPI |
send_webhook(...) | v5.0: fire-and-forget via subprocess.Popen so parent exits immediately |
log_event(level, action, **fields) | NDJSON writer with stable schema, log rotation 5MB / 3 files |
log_error_event(code, action, ...) | Adds error.code + error.hint + error.suggested_command |
2. bin/audio-hooks (canonical CLI)
Three files:
| File | Role |
|---|---|
bin/audio-hooks.py | Python entry point (~1100 lines), 27 subcommands |
bin/audio-hooks | Bash wrapper that probes python3 / python / py and exec’s the .py file. Skips Microsoft Store python3 stub on Windows. |
bin/audio-hooks.cmd | Windows shim that runs python audio-hooks.py %* |
The bash wrapper exists because Git Bash on Windows doesn’t reliably handle Python shebangs and the Microsoft Store python3 stub at WindowsApps\python3.exe exits 49 silently when invoked. The wrapper probes each candidate with a -c "import sys" test and skips broken stubs.
Subcommand dispatch table lives at the bottom of audio-hooks.py (the DISPATCH dict). Adding a new subcommand: write cmd_<name>(args) -> int, add to DISPATCH, add an entry to _build_manifest()’s subcommands list.
Plugin context detection (_is_running_from_plugin()): the binary lives at <plugin_root>/bin/audio-hooks.py when invoked from a plugin install. We detect this by checking for <plugin_root>/.claude-plugin/plugin.json. When detected, _config_path() resolves to ~/.claude/plugins/data/audio-hooks-chanmeng-audio-hooks/user_preferences.json (the canonical plugin data dir per Claude Code’s docs) and auto-initialises from default_preferences.json on first read.
3. plugins/audio-hooks/ (Claude Code plugin)
Self-contained plugin layout, populated by bash scripts/build-plugin.sh from the canonical sources.
plugins/audio-hooks/
├── .claude-plugin/
│ └── plugin.json # name, version, userConfig
├── hooks/
│ ├── hooks.json # matcher-scoped hook registration (auto-discovered)
│ └── hook_runner.py # copy of /hooks/hook_runner.py
├── runner/
│ └── run.py # imports bundled hook_runner.py and dispatches
├── skills/
│ └── audio-hooks/
│ └── SKILL.md # natural-language activation
├── bin/
│ ├── audio-hooks # bash wrapper
│ ├── audio-hooks.py # Python entry
│ └── audio-hooks.cmd # Windows shim
├── audio/
│ ├── default/ # 26 voice files
│ └── custom/ # 26 chime files
└── config/
└── default_preferences.json # template (auto-copied to plugin data dir)
hooks/hooks.json registers per-matcher handlers using synthetic event names:
{
"hooks": {
"Notification": [
{ "matcher": "permission_prompt",
"hooks": [{ "type": "command",
"command": "python \"${CLAUDE_PLUGIN_ROOT}/runner/run.py\" notification_permission_prompt",
"async": true, "timeout": 10 }] },
{ "matcher": "idle_prompt", "hooks": [...] },
{ "matcher": "auth_success", "hooks": [...] },
{ "matcher": "elicitation_dialog","hooks": [...] }
],
"SessionStart": [
{ "matcher": "startup", "hooks": [...session_start_startup] },
{ "matcher": "resume", "hooks": [...session_start_resume] },
{ "matcher": "clear", "hooks": [...session_start_clear] },
{ "matcher": "compact", "hooks": [...session_start_compact] }
],
"StopFailure": [
{ "matcher": "rate_limit", "hooks": [...stop_failure_rate_limit] },
{ "matcher": "authentication_failed", "hooks": [...stop_failure_authentication_failed] },
{ "matcher": "billing_error|invalid_request|server_error|max_output_tokens|unknown",
"hooks": [...stop_failure_other] }
]
// ... and so on for all 25 events
}
}Native matcher routing happens at the settings.json layer (Claude Code’s matcher engine), not inside Python branching. Faster, configurable per-matcher, and per-handler async: true means a slow rate-limit-failure path doesn’t block the auth-failure path.
Auto-discovery: don’t put "hooks": "./hooks/hooks.json" in plugin.json — Claude Code auto-discovers hooks/hooks.json from the standard location, and declaring it twice causes “Duplicate hooks file detected” load errors.
runner/run.py is a thin wrapper that walks up from its own directory looking for hooks/hook_runner.py (which is bundled inside the plugin), inserts that path into sys.path, and calls hook_runner.main().
skills/audio-hooks/SKILL.md is the natural-language activation surface. YAML frontmatter declares trigger phrases like “snooze audio”, “configure audio hooks”, “why is there no sound”. When Claude detects an intent matching one of these, it loads the SKILL body which is a structured prose-and-table guide telling Claude exactly which audio-hooks subcommand to run for any user request. The golden rule baked into the SKILL: always run audio-hooks manifest first if you’re unsure of the project’s current surface area.
4. bin/audio-hooks-statusline (Claude Code status line)
Two-line bottom bar registered in ~/.claude/settings.json via audio-hooks statusline install. Reads stdin JSON Claude Code provides (model name, session_id, workspace.git_worktree, rate_limits, context_window) and emits two lines of plain text with ANSI colors:
[Opus] 🔊 Audio Hooks v5.0.3 | 6/26 Sounds | Webhook: ntfy | Theme: Voice
[MUTED 23m] 🌿 feat/audio-v5 ████░░░░ API Quota: 78% █████░░░ Context: 65% ⚠️ /compactThe API Quota bar uses thresholds GREEN <70%, YELLOW 70-89%, RED ≥90%. The Context bar uses agent-safety thresholds: GREEN <50% (safe), YELLOW 50-80% (should /compact), RED >80% (agent “dumb zone”). Actionable hints (⚠️ /compact or 🛑 /compact) appear in yellow/red zones.
Users can customise which segments appear via statusline_settings.visible_segments (array of segment names). 10 segments available — Line 1: model, version, sounds, webhook, theme; Line 2: snooze, focus, branch, api_quota, context. Empty array (default) shows all. Example: audio-hooks set statusline_settings.visible_segments '["context","api_quota"]' shows only the two progress bars.
refreshInterval: 60 is set in the registration so snooze countdowns, rate-limit bars, and context usage bars update during idle periods. The script caches audio-hooks status for 5 seconds keyed on session_id to keep render time <100ms.
5. scripts/
| Script | Purpose | AI-callable? |
|---|---|---|
install-complete.sh | Legacy script install | yes (auto non-interactive on non-TTY) |
install-windows.ps1 | PowerShell installer for Windows | yes |
quick-setup.sh | Lite tier (zero deps, no Python) | yes |
quick-configure.sh | Lite tier hook toggling | yes |
quick-unsetup.sh | Lite tier uninstall | yes |
snooze.sh | Legacy snooze CLI | yes (audio-hooks snooze is preferred) |
uninstall.sh | Legacy uninstall | yes (auto non-interactive, --purge for full removal) |
build-plugin.sh | Sync canonical → plugin layout | yes (NDJSON output, --check flag for CI) |
generate-audio.py | ElevenLabs audio generator | yes (NDJSON output, --force / --only / --dry-run) |
configure.sh | Human-only menu | no — auto-redirects to audio-hooks via INTERACTIVE_SCRIPT JSON pointer when invoked non-interactively |
test-audio.sh | Human-only menu | no — same |
diagnose.py | Legacy diagnose | yes (audio-hooks diagnose is preferred) |
Hook event lifecycle (full detail)
sequenceDiagram participant CC as Claude Code participant SJ as ~/.claude/settings.json<br/>(plugin hooks merged) participant RUNNER as runner/run.py participant HR as hook_runner.py participant CONFIG as user_preferences.json participant LOG as events.ndjson participant AUDIO as Audio player CC->>CC: Internal event<br/>(e.g. Stop) CC->>SJ: Look up matcher SJ->>RUNNER: spawn `python run.py stop` (async) RUNNER->>HR: import + main() HR->>HR: parse stdin JSON HR->>HR: _set_log_context(session_id, hook) HR->>LOG: log "hook_start" HR->>HR: is_hook_enabled? alt disabled HR->>LOG: log "hook_status DISABLED" HR-->>CC: exit 0 else enabled HR->>HR: is_snoozed? alt snoozed HR->>LOG: log "hook_status SNOOZED" HR-->>CC: exit 0 else not snoozed HR->>CONFIG: load (with plugin_option overlay) HR->>HR: check_rate_limits(stdin, config) opt threshold crossed HR->>AUDIO: play warning audio HR->>LOG: log "rate_limit_alert" end HR->>HR: should_debounce? alt debounced HR->>LOG: log "hook_status DEBOUNCED" HR-->>CC: exit 0 else not debounced HR->>HR: should_filter? (user regex) HR->>HR: build context + suffix HR->>AUDIO: play_audio HR->>HR: send_desktop_notification HR->>HR: play_tts (with optional speak_assistant_message) HR->>HR: send_webhook (subprocess fire-and-forget) HR->>LOG: log "hook_status PLAYED" HR-->>CC: exit 0 end end end
Path resolution
flowchart TD START[hook_runner or audio-hooks startup] --> Q1{CLAUDE_PLUGIN_DATA<br/>set?} Q1 -->|yes| PLUGIN_HOOK[plugin hook context] PLUGIN_HOOK --> P1[CONFIG: $PLUGIN_DATA/user_preferences.json] PLUGIN_HOOK --> P2[QUEUE: $PLUGIN_DATA/queue/] PLUGIN_HOOK --> P3[LOGS: $PLUGIN_DATA/logs/] Q1 -->|no| Q2{script lives in<br/><plugin_root>/?} Q2 -->|yes| PLUGIN_CLI[plugin CLI context] PLUGIN_CLI --> R1[CONFIG: ~/.claude/plugins/data/audio-hooks-chanmeng-audio-hooks/user_preferences.json] PLUGIN_CLI --> R2[QUEUE: same dir/queue/] PLUGIN_CLI --> R3[LOGS: same dir/logs/] Q2 -->|no| Q3{CLAUDE_AUDIO_HOOKS_DATA<br/>set?} Q3 -->|yes| EXPLICIT[explicit override] EXPLICIT --> E1[CONFIG: $CLAUDE_AUDIO_HOOKS_DATA/user_preferences.json] Q3 -->|no| LEGACY[legacy script install] LEGACY --> L1[CONFIG: <project_dir>/config/user_preferences.json] LEGACY --> L2[QUEUE: <temp>/claude_audio_hooks_queue/] style PLUGIN_HOOK fill:#7ED321,color:#000 style PLUGIN_CLI fill:#7ED321,color:#000 style EXPLICIT fill:#F5A623,color:#000 style LEGACY fill:#9013FE,color:#fff
The plugin data dir is at ~/.claude/plugins/data/{id}/ where {id} is the plugin name with non-alnum chars replaced by -. For audio-hooks@chanmeng-audio-hooks the id is audio-hooks-chanmeng-audio-hooks.
NDJSON event log
Schema: audio-hooks.v1. One JSON object per line. Event types are stable.
action | level | When |
|---|---|---|
hook_start | debug | Every hook invocation, with synthetic_variant if matcher-routed |
hook_status | info | Final status: PLAYED, DISABLED, SNOOZED, DEBOUNCED, FILTERED, NO_AUDIO_CONFIG, FILE_NOT_FOUND, PLAY_FAILED |
rate_limit_alert | warn | Rate-limit threshold crossed; includes window, threshold, used_percentage, resets_at |
tts_spoken | info | TTS dispatched |
webhook_dispatched | info | Webhook subprocess spawned |
audio_override_resolved | debug | Synthetic matcher variant resolved an audio override |
play_audio | info | Audio successfully dispatched to platform player |
legacy_error | error | Caught from log_error() legacy wrapper |
lookup_audio | error | AUDIO_FILE_MISSING |
webhook_dispatch | error | WEBHOOK_TIMEOUT or WEBHOOK_HTTP_ERROR |
Error events carry an error object with code (stable enum), message, hint, and optionally suggested_command.
Stable error code enum
Defined in hook_runner.py’s ErrorCode class. Add new codes here, never rename existing ones.
class ErrorCode:
AUDIO_FILE_MISSING = "AUDIO_FILE_MISSING"
AUDIO_PLAYER_NOT_FOUND = "AUDIO_PLAYER_NOT_FOUND"
AUDIO_PLAY_FAILED = "AUDIO_PLAY_FAILED"
INVALID_CONFIG = "INVALID_CONFIG"
CONFIG_READ_ERROR = "CONFIG_READ_ERROR"
WEBHOOK_HTTP_ERROR = "WEBHOOK_HTTP_ERROR"
WEBHOOK_TIMEOUT = "WEBHOOK_TIMEOUT"
NOTIFICATION_FAILED = "NOTIFICATION_FAILED"
TTS_FAILED = "TTS_FAILED"
SETTINGS_DISABLE_ALL_HOOKS = "SETTINGS_DISABLE_ALL_HOOKS"
PROJECT_DIR_NOT_FOUND = "PROJECT_DIR_NOT_FOUND"
SELF_UPDATE_FAILED = "SELF_UPDATE_FAILED"
UNKNOWN_HOOK_TYPE = "UNKNOWN_HOOK_TYPE"
INTERNAL_ERROR = "INTERNAL_ERROR"The bin/audio-hooks.py cmd_diagnose function adds two more codes that are CLI-specific (not from hook_runner): DUAL_INSTALL_DETECTED and INTERACTIVE_SCRIPT.
_ERROR_HINTS (a dict in hook_runner.py) maps each code to a hint (one sentence) and suggested_command (a literal audio-hooks ... command). When log_error_event(code, action, message) is called, the resulting NDJSON event has the full error object populated automatically.
Backwards compatibility
| Pre-v5.0 surface | v5.0.1 status |
|---|---|
~/.claude/settings.json legacy hook entries (Notification, Stop, SubagentStop, PermissionRequest) | Still work — canonical hook names resolve in hook_runner.main() |
Free-text debug.log, errors.log, hook_triggers.log | Replaced by events.ndjson. Legacy log_debug/log_error/log_trigger are now thin NDJSON wrappers. |
<project>/config/user_preferences.json (script install) | Still the resolution target for legacy script installs |
bash scripts/install-complete.sh interactive mode | Still works for human users; auto non-interactive on non-TTY |
scripts/snooze.sh CLI | Still works; audio-hooks snooze is preferred |
scripts/diagnose.py | Still works; audio-hooks diagnose is preferred (returns JSON) |
Pre-v5 user_preferences.json schema | Forward-compatible — new keys are optional with sensible defaults |
Build pipeline
flowchart LR DEV[Developer edits canonical] --> EDIT[/hooks/, /bin/, /audio/, /config/] EDIT --> BUILD[bash scripts/build-plugin.sh] BUILD --> CHECK[bash scripts/build-plugin.sh --check] CHECK -->|in_sync| TEST[python bin/audio-hooks.py test all] TEST --> VALIDATE[claude plugin validate plugins/audio-hooks] VALIDATE --> COMMIT[git commit + push] CHECK -->|out_of_sync| FAIL[CI fails] VALIDATE -->|errors| FIX[fix manifest] FIX --> BUILD style DEV fill:#4A90E2,color:#fff style BUILD fill:#7ED321,color:#000 style VALIDATE fill:#F5A623,color:#000 style COMMIT fill:#9013FE,color:#fff
Adding a new hook event (when Claude Code adds one)
- Add the canonical name + audio filename to
DEFAULT_AUDIO_FILESandCUSTOM_AUDIO_FILESinhook_runner.py. - Add a branch in
get_notification_context(hook_type, ...)for the notification text. - Add the entry to
HOOK_CATALOGinbin/audio-hooks.py. - Add an entry to
enabled_hooksinconfig/default_preferences.json(with default on/off). - Add the event handler to
plugins/audio-hooks/hooks/hooks.json(with matchers if applicable). - If matcher-scoped, add synthetic event entries to
SYNTHETIC_EVENT_MAPinhook_runner.py. - Add audio entries to
config/audio_manifest.jsonand runpython scripts/generate-audio.py. - Run
bash scripts/build-plugin.sh. - Test:
python bin/audio-hooks.py test <new_hook>. - Update
CLAUDE.mdandREADME.mdhook tables. Bump version, update CHANGELOG.
Adding a new audio file
- Add an entry to
config/audio_manifest.json:filename,theme,type(voiceorsound_effect),textprompt. ELEVENLABS_API_KEY=... python scripts/generate-audio.py --only <new_file>.bash scripts/build-plugin.sh.- Commit the new MP3 + manifest entry.
Testing locally
# From a fresh terminal — verify the binary works
python bin/audio-hooks.py manifest
python bin/audio-hooks.py status
python bin/audio-hooks.py test all
python bin/audio-hooks.py diagnose
# Verify the plugin layout
bash scripts/build-plugin.sh --check
claude plugin validate plugins/audio-hooks
# Verify a specific hook with mock stdin
echo '{"session_id":"t","hook_event_name":"Stop","last_assistant_message":"test"}' | \
python hooks/hook_runner.py stop
# Test rate-limit alert
echo '{"session_id":"t","rate_limits":{"five_hour":{"used_percentage":85,"resets_at":9999999999}}}' | \
python hooks/hook_runner.py stopSee also
- CLAUDE.md — canonical AI-facing operating guide
- README.md — public-facing project introduction
- CHANGELOG.md — version history including the v5.0/v5.0.1 detail
audio-hooks manifest— live machine description of every subcommand and config key