Instructions in CLAUDE.md are requests. Claude usually follows them, but nothing forces it to. A hook is different: it's a command that Claude Code runs automatically at a set point in its workflow, every time, whether or not the model remembers. Formatting after an edit, blocking a dangerous command and sending yourself a notification are all good hook jobs.
This recipe sets up two hooks: one that runs Prettier on every file Claude edits, and one that blocks a risky command. It assumes Claude Code is already installed and that you're comfortable editing a JSON file.
How hooks work
A hook is tied to an event, such as "just before a tool runs" or "just after a tool finishes". When the event fires, Claude Code runs your command and passes it details about what's happening as JSON on standard input. Your command can then do something and, for some events, influence what Claude does next.
The events you'll use most:
| Event | When it fires | Can it block? |
|---|---|---|
PreToolUse | Before a tool call runs | Yes |
PostToolUse | After a tool call succeeds | No, the tool has already run |
UserPromptSubmit | When you send a prompt | Yes |
Notification | When Claude needs your attention | No |
Stop | When Claude finishes responding | Yes, it can ask Claude to keep going |
SessionStart | When a session begins | No |
Anthropic's hooks reference lists more events than this, and the list grows over time, so treat the table as the beginner's shortlist.
Step 1: Pick where the hooks live
Hooks go in a Claude Code settings file under a top-level hooks key.
| File | Scope |
|---|---|
~/.claude/settings.json | All your projects |
.claude/settings.json | This project. Commit it to share with your team |
.claude/settings.local.json | This project, just for you. Git ignores it if Claude Code created the file; if you made it by hand, add it to .gitignore yourself |
For this recipe, use .claude/settings.json in a project. If the file already exists, add the hooks key alongside what's there rather than replacing the file.
Step 2: Know the shape of a hook
Every hook entry has the same three layers:
{
"hooks": {
"EventName": [
{
"matcher": "ToolNameOrPattern",
"hooks": [
{ "type": "command", "command": "your-command-here" }
]
}
]
}
}
- The event name (
PreToolUse,PostToolUse, and so on) picks when it runs. matchernarrows it to certain tools. For tool events it matches the tool's name, andEdit|Writemeans "either of those".Bashmatches shell commands. Leave the matcher out to match everything for that event.hooksis the list of things to run."type": "command"runs a shell command.
Step 3: Auto-format every file Claude edits
Claude's edits don't always match your project's formatting. A PostToolUse hook can fix that straight after each edit.
This example uses Prettier, which you'd need available in the project (for example via npm install --save-dev prettier). It also uses jq, a small tool for reading JSON. Install it with your package manager (brew install jq on macOS) if jq --version doesn't work.
Add this to .claude/settings.json:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs npx prettier --write"
}
]
}
]
}
}
Reading it from the inside out:
- Claude edits or creates a file with the
EditorWritetool. - Claude Code runs the command and feeds it a JSON description of the tool call on standard input.
jq -r '.tool_input.file_path'pulls the edited file's path out of that JSON.xargs npx prettier --writehands the path to Prettier, which reformats the file in place.
If your project has file types Prettier doesn't handle, it will print an error for those files. You can narrow the job with a stricter matcher or by swapping in a different formatter (ruff format, gofmt, and so on). The pattern stays the same.
Step 4: Block a risky command before it runs
PreToolUse runs before the tool, so it's the place to stop something. The rule is simple:
- Exit code 0: carry on. (This doesn't approve the command. Normal permission prompts still apply.)
- Exit code 2: block the tool call. Whatever your script writes to standard error is fed back to Claude as the reason, so it can try something safer.
- Any other exit code: treated as a hook error. It's shown to you, but the tool call still goes ahead.
That last point matters: a script that crashes with exit code 1 does not block anything. If you want a block, you must exit with 2 deliberately.
Create a script at .claude/hooks/block-risky.sh:
#!/bin/bash # Reads the hook's JSON from stdin and blocks a few risky shell commands. command=$(jq -r '.tool_input.command') if echo "$command" | grep -Eq 'rm -rf|git push --force|git reset --hard'; then echo "Blocked: that command can destroy work. Ask the user first." >&2 exit 2 fi exit 0
Make it executable:
chmod +x .claude/hooks/block-risky.sh
Then register it in the same settings file. The full file with both hooks looks like this:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-risky.sh"
}
]
}
],
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs npx prettier --write"
}
]
}
]
}
}
$CLAUDE_PROJECT_DIR is the folder where your session started, so the path works wherever Claude happens to be. Keep it wrapped in double quotes, as above, in case the path contains spaces.
A pattern list like this is a seat belt, not a security boundary. It will catch the obvious cases but not every way of phrasing a destructive command. For firm limits, combine it with Claude Code's permission rules and the Git safety net.
Step 5: Check that the hooks loaded
Start Claude Code in the project and type:
/hooks
This opens a read-only list of your configured hooks, with each one labelled by where it came from (user settings, project settings, local settings, a plugin). Select one to see the command it runs. If the menu is empty, the usual causes are invalid JSON or a settings file in the wrong place. Restart the session if you edited the file and nothing changed.
Step 6: Test them
- Formatting: ask Claude to "add a function to any file, with messy spacing". Check that the saved file is tidy.
- Blocking: ask Claude to "run
git reset --hard". It should report that the command was blocked, along with your message, and offer another route.
Step 7: Ideas for more hooks
- Notify yourself. A
Notificationhook can run a desktop notification command so you can switch tasks while Claude works. The hooks guide has a ready-made example for your operating system. - Run tests when Claude finishes. A
Stophook can run your test command. If it fails, Claude can be told to keep fixing. Be careful here: a hook that always asks Claude to continue will loop, so check the reference for how to detect that Claude is already continuing from a stop hook. - Add context at the start. A
SessionStarthook can print something useful (like the current Git branch) for Claude to read. - Guard secrets. A
PreToolUsehook with a matcher ofEdit|Writecan reject changes to.envfiles by checkingtool_input.file_path.
Troubleshooting
Nothing happens. Run /hooks and confirm the hook is listed. If it isn't, check the JSON is valid (a trailing comma is the classic culprit) and that hooks is a top-level key.
The matcher doesn't match. Tool names are case-sensitive: Bash, Edit, Write. A typo means the hook silently never fires.
"command not found: jq". Hooks run in your shell environment. Install jq, or write the script in a language you already have.
My script fails but the tool still runs. Only exit code 2 blocks. Any other non-zero code is treated as an error that doesn't stop the tool.
Edits take longer. A hook runs on every matching call, so keep them quick. Run a formatter on the one file, not the whole project.
Safety notes
Hooks run shell commands with your permissions, automatically. Treat the hooks section of a settings file like code: read it before you accept a project from someone else, and never paste in a hook you don't understand. Keep commands narrow, avoid anything that deletes or publishes, and test a new hook in a throwaway project first.
Recap
- Add a
hookskey to.claude/settings.json: event, thenmatcher, then acommand. - Your command receives JSON on standard input. Read fields such as
tool_input.file_pathortool_input.commandwithjq. - In a
PreToolUsehook, exit code 2 blocks the call and your stderr message goes to Claude. - Use
/hooksto confirm what loaded.
Next, pair hooks with skills for repeatable prompts and subagents for delegated work.