Guide
Claude Code hooks, explained and set up
A hook is a shell command Claude Code runs at a fixed point in its own lifecycle, handed the event as JSON on stdin. That is the entire idea. What makes hooks powerful is that some of them can answer back and change what happens next - a tool call can be allowed, denied, or sent back for a prompt before it runs. This guide covers the lifecycle, where the config lives, the exact shapes, and a first hook that works.
The mental model
Instructions in a prompt or a project file are requests: the model usually follows them. A hook is not a request. It is code that runs, every time, at a point the runtime guarantees. If you need something to always happen - a formatter after every edit, a log of every command, a hard block on touching production config - a hook is the only mechanism that gives you that certainty.
Three things define any hook: when it runs, which is the event; what it runs on, which is the matcher; and what it does, which is usually a command.
The lifecycle: which events fire when
A session moves through a predictable sequence, and hooks hang off its points. These are the ones you will actually use:
| Event | Fires when | Typical use |
|---|---|---|
| SessionStart | A session begins or resumes | Inject current context, such as the branch or open tickets |
| UserPromptSubmit | You send a message, before the model sees it | Add context, or block a prompt outright |
| PreToolUse | A tool call is about to run | Allow, deny, or force a prompt. Validate arguments |
| PostToolUse | A tool call has finished | Format the file that was just edited, run a type check |
| Notification | Claude Code wants your attention | Post a desktop notification, play a sound |
| Stop | The main agent finishes responding | Run the test suite, announce that the turn is done |
| SubagentStop | A subagent finishes | Same, scoped to background work |
| PreCompact | Before the context is compacted | Save state you do not want summarised away |
| SessionEnd | The session closes | Clean up, write a log |
There are more events than these, covering permission decisions, subagent lifecycle, file changes and configuration changes. The set grows between releases, so treat the list above as the stable core and run /hooks in your own install to see everything the version you have supports.
The two that carry most of the value are PreToolUse, because it is the only one that can stop something, and PostToolUse, because it is where automatic formatting belongs.
Where the config lives
Hooks go in a settings.json file under a top-level hooks key. There are three locations, and they matter:
~/.claude/settings.json- yours, every project. Personal notification and sound hooks belong here..claude/settings.json- the project's, committed to git. Team-wide formatting and guardrails belong here, where they get reviewed like any other code..claude/settings.local.json- the project's, not committed. Machine-specific paths and your own experiments.
Organisations can also deploy managed settings that take precedence over all three. The /hooks slash command shows you what is registered in the running session, which is the fastest way to find out why your hook is not firing.
The config shape
The structure trips people up once and then never again. Under the event name is a list of matcher blocks, and each block has its own list of hooks. Two levels of array, on purpose: several matchers per event, several commands per matcher.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/format.sh",
"timeout": 30
}
]
}
]
}
}The matcher is matched against the tool name and accepts alternation, so Edit|Write catches both. Omit it, or use *, to match everything - which is what you want for events like Stop that have no tool attached. Notification hooks can match on the notification type instead, which is how you give permission requests a different sound from finished runs.
Use an absolute path or one built from $CLAUDE_PROJECT_DIR. A relative path resolves against whatever the working directory happens to be, and that is a bug you will chase for an hour.
Input, output, and exit codes
Every hook gets a JSON object on stdin. Common fields on all of them:
{
"session_id": "abc123",
"transcript_path": "/Users/you/.claude/projects/.../abc123.jsonl",
"cwd": "/Users/you/code/project",
"hook_event_name": "PreToolUse"
}Tool events add tool_name and tool_input; notification events add message and a title. Going back the other way you have two options.
Exit codes, for simple cases. Exit 0 means success and the run continues. A non-zero exit is an error; on a blocking event the stderr text is fed back to the model so it can react, which makes a failing linter into a self-correcting loop.
JSON on stdout, for control. This is how a PreToolUse hook decides the fate of a tool call:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Edit the template, not the generated file."
}
}permissionDecision takes allow, which skips the prompt, deny, which stops the call, or ask, which forces the prompt even if a rule would have allowed it. The reason string is shown to the model on a deny, so write it as an instruction rather than a complaint. A PreToolUse hook can also rewrite the call's arguments before it runs, and add context for the model.
Your first hook
A useful one to start with: format every file the agent edits. Save this as .claude/hooks/format.sh in your project and chmod +x it.
#!/bin/bash
# read the event, pull out the path that was edited
file=$(cat | /usr/bin/python3 -c \
'import json,sys; print(json.load(sys.stdin).get("tool_input",{}).get("file_path",""))')
# only touch files we know how to format
case "$file" in
*.ts|*.tsx|*.js|*.json) npx --no-install prettier --write "$file" 2>/dev/null ;;
*.py) ruff format "$file" 2>/dev/null ;;
esac
exit 0Register it against PostToolUse with the Edit|Write matcher shown above, then start a session and edit a file. If nothing happens, run /hooks to confirm it is registered, then run the script by hand with a sample JSON piped into it. Nine times out of ten it is the path or the executable bit.
Two rules worth internalising
A hook runs with your full user permissions, automatically, with no confirmation. It is your shell script and Claude Code will execute it exactly as written. Quote every variable, especially paths that came out of the event JSON, and never build a command by string-concatenating a value the model produced.
And keep them fast. Hooks with a long timeout on PreToolUse put a pause in front of every single tool call, which is a strange way to make an agent feel slower.
A note on what hooks are used for beyond automation
The same mechanism is what lets an external app take part in a session. When Claude Code asks for permission, its hook blocks on a local Unix socket waiting for a decision. Anything on the other end of that socket can send the answer back, and the prompt resolves in place with nothing typed into the terminal.
That is how Noveriq works: it is a native macOS app that shows every running session in the MacBook notch and lets you approve or deny from there. It writes its hook configuration on first launch and removes it when you delete the app, so it is not a config file you maintain. If you would rather build your own version of the notification half, the notifications guide has a working script.
Questions
Do hooks work in every project automatically?
Only the ones in ~/.claude/settings.json. Project hooks apply to the project that contains them.
Why is my hook not firing?
In order of likelihood: the matcher does not match the tool name, the script is not executable, the path is relative, or the JSON has a syntax error and the whole settings file was skipped. Run /hooks first.
Can a hook change what the model sees?
Yes. Several events accept additional context on stdout, and a PreToolUse hook can rewrite a tool call's input before it executes.
Are hooks a security boundary?
They are a useful guardrail, not a sandbox. A deny rule in a hook is strong and deterministic, but the hook itself runs as you and can do anything you can do. Review hooks that arrive in a repo the way you would review a build script.
Hooks you do not have to write
Noveriq configures its own hooks on first launch, shows every session in the notch, and cleans up after itself when you remove it.
Get Noveriq for Mac$9.99 once · macOS 15 or later · 30 days, money back