# 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:

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