# hooks-side-quest ## A quick refresher on hooks A [hook](/primitives/hooks) is a [shell command](/cards/shell-command) Claude Code runs *automatically* at a specific [lifecycle event](/cards/lifecycle-event) in the session — before a tool is called, after a file is edited, when Claude finishes a turn, when it's waiting for your input. You declare hooks in a JSON settings file; the [harness](/cards/harness) executes them. **The model does not get a vote.** If the event fires, the command runs. That determinism is the whole point. Most of Claude Code's *intelligence* is probabilistic: the model reads your CLAUDE.md, your prompt, your skills, and *decides* what to do next. Hooks are a deterministic layer underneath that. Where skills or prompts nudge the model, hooks bypass it entirely. ### The lifecycle events Each hook is attached to a specific moment in the session. The diagram below shows where the common ones fire: ![Diagram of the Claude Code hook lifecycle](/hooks-lifecycle.svg) The beginner shortlist — the seven events you'll use the most: | Event | When it fires | |-------|---------------| | `SessionStart` | A session begins or resumes | | `UserPromptSubmit` | You submit a prompt, before Claude processes it | | `PreToolUse` | Before a tool call executes (can block) | | `PostToolUse` | After a tool call succeeds | | `Notification` | When Claude Code needs your attention | | `Stop` | When Claude finishes responding | | `SessionEnd` | When a session terminates | Claude Code actually exposes **many** more events than this — one for essentially every meaningful transition in a session (permission dialogs, subagent spawning/finishing, file changes on disk, context compaction, MCP elicitation, worktree create/remove, and more). You don't need them for this quest, and the [primitive page](/primitives/hooks) has the full catalogue. Skim it later; come back when you need something specific. When the event fires, Claude Code pipes a JSON payload to your hook on [stdin](/cards/stdin) (the tool about to be called, the file about to be edited, the prompt you submitted). Your script can let the action proceed (`exit 0`), block it (`exit 2` with a message on [stderr](/cards/stderr) that Claude then sees as feedback), inject context into Claude's window (via [stdout](/cards/stdout), for `SessionStart` and `UserPromptSubmit`), or just do something side-effectful — play a sound, log a line, run a formatter, post to Slack. ### How hooks differ from the other primitives Claude Code is extended through five primitives. Hooks are the one you reach for when you need a **guarantee**, not a suggestion: - **[Skills](/primitives/skills)** are model-invoked. Claude decides whether to pull a skill into context based on the conversation. Use a skill when you want to *teach* Claude how to do something well — the model's judgment is part of the point. - **[MCPs](/primitives/mcps)** expose external systems (databases, APIs, Slack, GitHub) as tools Claude can call. Use an MCP when the model needs to *reach* something it otherwise couldn't. - **[Slash Commands](/primitives/slash-commands)** are prompts you invoke by name (`/review`, `/deploy`). Use one when *you* want a shortcut. - **[Subagents](/primitives/subagents)** fork off isolated work. Use them for orchestration and parallelism. - **Hooks** are the only primitive that runs *without the model's involvement*. Use a hook when the requirement is "this must happen every time." A rough decision rule: if the requirement is **"this must happen,"** it's a hook. If it's **"Claude should know how to do this,"** it's a skill. If it's **"Claude needs access to this,"** it's an MCP. The sound hook you're about to build is the smallest useful example of that "must happen" shape: when Claude needs you, your laptop *always* chimes — no matter what the model is thinking about. ## Why a sound hook? The moment you start running **multiple Claude Code sessions at once** — one in your docs repo, one in your data-analysis project, one in a third terminal — or the moment you start **multitasking** away from Claude (reading a paper, drafting an email, getting coffee), you lose track of when any given session is waiting for you. A sound hook fixes that. You wire a short audio file to a lifecycle event. When Claude needs you — or finishes a turn, or is about to touch a file — your laptop makes a small noise. You don't have to babysit the terminal. I use **bird calls**, because I genuinely don't mind hearing them on repeat. Pick something you can live with. A chime, a wooden knock, a single piano note, a frog ribbit. Avoid anything startling — you'll hear it more often than you think. ## The quest You're going to: 1. Pick a lifecycle event. 2. Pick a sound. 3. Decide where the sound file lives on your disk. 4. Use Claude Code to scaffold a `.claude/settings.json` template. 5. **Hand-edit** the template to wire your event to your sound. 6. Test it. The hand-editing step matters. You can always ask Claude Code to write the hook for you, but doing it yourself once — reading the JSON, understanding the fields — is the difference between "I use hooks" and "I understand hooks." --- ### Step 1 — Pick an event Choose **one** of these to start. Each produces a very different behavior: | Event | What triggers it | Good for | |-------|------------------|----------| | `Stop` | Claude finishes a turn | Knowing a long task is done | | `Notification` | Claude is waiting on you (permission prompt, idle) | Knowing you're the bottleneck | | `PreToolUse` | Claude is about to call a tool | Auditing — lots of sounds | | `PostToolUse` | A tool just finished | Rhythm of work — also lots of sounds | | `SessionStart` | A session begins or resumes | A "welcome" chime once per session | If you're unsure, start with `Notification`. It fires when Claude actually *needs* you, which is the most useful signal in a multitasking workflow. ### Step 2 — Pick a sound Constraints: - **Short** — under ~1.5 seconds. You will hear this a lot. - **Not startling** — no alarms, no klaxons, nothing loud. - **Tolerable on repeat** — put it on a 30-second loop in your head. Still okay? Keep it. Where to get one: - **The sounds already on your Mac.** `/System/Library/Sounds/` ships with `Glass.aiff`, `Tink.aiff`, `Pop.aiff`, `Ping.aiff`, `Funk.aiff`, and a handful of others. These work without downloading anything and are the fastest way to get this side quest moving. - **A sound you already have or record yourself.** Any `.wav`, `.mp3`, `.aiff`, or `.m4a` file will play. If you want something more personal — a whistle, a bell on your desk, a two-second clip from a song you like — record it on your phone, AirDrop it over, and use that. Use a built-in for this quest unless you already have a specific sound in mind; you can swap it later. ### Step 3 — Make a `hooks` repo for your sounds (and future hooks) **Good news: there is no sandbox.** A hook is an ordinary shell command running with your permissions. The sound file can live anywhere on your disk that you can read from. Three reasonable options exist: - **`~/.claude/sounds/`** — user-global. The hook works in any project. This is what your lab lead does, and it's the shortest path. - **`.claude/sounds/` inside a specific project** — travels with the repo; collaborators get it too. Use `$CLAUDE_PROJECT_DIR` in the hook command to make the path portable. - **A dedicated `~/Development/hooks/` repo** — this is what we're going to do. You'll build more hooks over time; they deserve their own git-tracked home. Create the repo: ```bash mkdir -p ~/Development/hooks/sounds cd ~/Development/hooks git init ``` Move your sound into it: ```bash mv ~/Downloads/your-sound.wav ~/Development/hooks/sounds/ ``` Make a stub README so the repo has something to commit: ```bash cat > README.md <<'EOF' # My Claude Code hooks Personal hook scripts and assets for Claude Code. ## Layout - `sounds/` — short audio files used by sound-playing hooks - `scripts/` — shell scripts invoked by hook commands (coming soon) EOF ``` Commit: ```bash git add . && git commit -m "Initial hooks repo with first sound" ``` You don't need to push this anywhere yet. The point is that your sounds — and, later, your hook *scripts* — live in one versioned place you control. When you rebuild your laptop, you clone this repo; all your hooks come back. ### Step 4 — Scaffold `settings.json` with a prompt Open a Claude Code session in any project (or make a throwaway folder). Paste this prompt: > Create a file at `.claude/settings.json` in the current project with an empty `hooks` object that includes placeholder entries for `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `Notification`, and `Stop`. Each placeholder should have the correct nested structure (`[{ "hooks": [{ "type": "command", "command": "" }] }]`) but leave the `command` field empty. Do not run any hooks. After creating the file, show me its contents. Claude Code will produce a template that looks roughly like: ```json { "hooks": { "SessionStart": [{ "hooks": [{ "type": "command", "command": "" }] }], "UserPromptSubmit": [{ "hooks": [{ "type": "command", "command": "" }] }], "PreToolUse": [{ "hooks": [{ "type": "command", "command": "" }] }], "PostToolUse": [{ "hooks": [{ "type": "command", "command": "" }] }], "Notification": [{ "hooks": [{ "type": "command", "command": "" }] }], "Stop": [{ "hooks": [{ "type": "command", "command": "" }] }] } } ``` If you'd rather the hook be available in **every** project, create the same file at `~/.claude/settings.json` instead. (You can ask Claude Code for that too — just change the path in the prompt.) ### Step 5 — Hand-code the hook Now open `.claude/settings.json` (or `~/.claude/settings.json`) in your editor. **Delete every event block except the one you chose in Step 1.** Then fill in the `command` field with the shell command that plays your sound. On macOS, the command is `afplay <path-to-sound>`. On Linux, it's `paplay` or `aplay`. On Windows (via WSL or PowerShell), it's `powershell -c (New-Object Media.SoundPlayer '<path>').PlaySync()`. Example — a `Notification` hook playing a bird call from your new `~/Development/hooks/sounds/` repo: ```json { "hooks": { "Notification": [ { "hooks": [ { "type": "command", "command": "afplay ~/Development/hooks/sounds/bird.wav" } ] } ] } } ``` Alternative — if you instead chose to keep sounds project-local and want the hook to work for collaborators: ```json { "hooks": { "Notification": [ { "hooks": [ { "type": "command", "command": "afplay \"$CLAUDE_PROJECT_DIR\"/.claude/sounds/bird.wav" } ] } ] } } ``` Save the file. **Type the JSON yourself** — don't paste it. The muscle memory is the point of this exercise. ### Step 6 — Test it 1. Start (or restart) a Claude Code session in the project where your `settings.json` lives. 2. Run `/hooks` inside the session. Your hook should appear under the event you chose. If it doesn't, your JSON probably has a typo — run it through [jsonlint.com](https://jsonlint.com/) or ask Claude Code to validate it. 3. Trigger the event. - `Stop`: ask Claude to do anything, wait for it to finish. - `Notification`: ask Claude to do something that needs your approval (e.g., a Bash command, if you haven't pre-approved Bash). - `PreToolUse` / `PostToolUse`: any tool call. - `SessionStart`: just start a new session. You should hear your sound. If you don't: run `claude --debug` and watch the event stream. The debug output tells you whether the hook fired, what command it ran, and what exit code came back. --- ## Reflection Once it works, sit with it for a bit. Notice: - **How fast feedback arrives.** You no longer need to glance at the terminal. - **Which event you picked, and whether it was the right one.** Too many sounds? Try `Notification` instead of `PostToolUse`. Too few? The opposite. - **Where you put the sound file, and whether that was right.** If you only use Claude Code in one project, project-local is fine. If you use it across your whole research workflow, user-global saves you setup. You now have the simplest possible hook, end-to-end, by hand. Every other hook pattern — formatting code, blocking edits, logging — is the same shape with a different command in the `command` field. ## Going further: hooks that need API keys Your sound hook is simple because it only needs a local file. But real hooks often need credentials — caching every prompt you submit to an Airtable base, logging tool calls to a Postgres database, posting to your lab's Slack when a long run finishes. Those hooks need API keys. This is where people get themselves in trouble. The temptation is to paste the key straight into the `command` field in `.claude/settings.json`. **Don't.** That file is checked into git. A raw key there is a leak waiting to happen. The pattern we recommend for this course is a **gitignored dotfile plus a hook script**. Keep secrets in a gitignored file (e.g. `~/Development/hooks/.env`), and put the real hook logic in a shell script that `source`s the dotfile at runtime: ```bash # ~/Development/hooks/.env (gitignored — NEVER commit) export AIRTABLE_API_KEY="pat_abc123..." ``` ```bash # ~/Development/hooks/scripts/cache-prompt.sh #!/bin/bash source ~/Development/hooks/.env # Read the hook payload from stdin (the JSON Claude Code pipes in) INPUT=$(cat) PROMPT=$(echo "$INPUT" | jq -r '.prompt // empty') # POST to Airtable curl -s -X POST "https://api.airtable.com/v0/YOUR_BASE/Prompts" \ -H "Authorization: Bearer $AIRTABLE_API_KEY" \ -H "Content-Type: application/json" \ -d "$(jq -n --arg p "$PROMPT" '{fields: {prompt: $p}}')" \ > /dev/null ``` Make the script executable, and add a `.gitignore` so you can't commit the secret by accident: ```bash chmod +x ~/Development/hooks/scripts/cache-prompt.sh echo ".env" >> ~/Development/hooks/.gitignore ``` Then in your settings file, just reference the script — **no secrets ever touch JSON**: ```json { "hooks": { "UserPromptSubmit": [ { "hooks": [ { "type": "command", "command": "~/Development/hooks/scripts/cache-prompt.sh" } ] } ] } } ``` **Why this pattern:** the secret lives in one file, gitignored, on one machine. Your `settings.json` is safe to commit. Your script is safe to commit. Anyone reading your settings sees a path, not a key. If the `.env` ever leaks, you rotate one key, not ten. This also sets you up for more complex hooks later: the `scripts/` directory in your new `~/Development/hooks/` repo is exactly where they go. ### A note on `$CLAUDE_PROJECT_DIR` and friends Claude Code *sets* some environment variables for your hook — `$CLAUDE_PROJECT_DIR` (project root), session info, etc. Those are for making **paths** portable inside your hook. They are not a secrets store. Never try to stuff credentials into them. ### Rules of thumb - **Never** put API keys in `.claude/settings.json` (it gets committed). - **Never** put API keys directly in the `command` field of a hook. - **Always** gitignore any `.env` file before the secret goes into it. - **Rotate** any key you suspect has leaked — don't assume a commit can be "fully" removed from git history. ## Further reading - [Hooks (primitive page)](/primitives/hooks) — the full explainer - [Hooks reference](https://docs.claude.com/en/docs/claude-code/hooks) — canonical events and JSON schema - [Hooks guide](https://docs.claude.com/en/docs/claude-code/hooks-guide) — walkthroughs and more patterns