Every time Claude Code runs a tool — reads a file, writes an edit, executes a Bash command — it fires events. Hooks let you run your own scripts in response to those events, without Claude having to think about it.
Want prettier to run after every file edit? Add a PostToolUse hook on the Write tool. Want a Slack notification when a long task finishes? Add a Stop hook. Want to log every Bash command Claude executes? Add a PostToolUse hook on Bash. None of these require you to ask Claude; they happen automatically because the hook is there.
The four hook types
PreToolUse — runs before a tool executes. Can inspect the tool call and optionally block it.
PostToolUse — runs after a tool executes. Receives both the input (what Claude called the tool with) and the output (what the tool returned).
Notification — runs when Claude sends you a message. Useful for alerting you when Claude needs attention while running in the background.
Stop — runs when Claude finishes a session or task. Useful for end-of-session cleanup, notifications, or logging.
Configuring hooks
Hooks are configured in Claude Code's settings files — either ~/.claude/settings.json (user-level, applies to all sessions) or .claude/settings.json (project-level, committed to your repo).
The structure:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write",
"hooks": [
{
"type": "command",
"command": "prettier --write $CLAUDE_TOOL_INPUT_FILE_PATH"
}
]
}
]
}
}
Each hook type maps to an array of hook objects. Each object has:
matcher— the tool name to match (or"*"to match all tools)hooks— an array of commands to run, each withtype: "command"and acommandstring
Environment variables available to hooks
Claude Code injects context into the hook's environment so you can act on what happened:
| Variable | When available | What it contains |
|---|---|---|
CLAUDE_TOOL_NAME | All hooks | The tool that was called (e.g., "Write", "Bash") |
CLAUDE_TOOL_INPUT_* | Pre/PostToolUse | Tool input fields (varies by tool) |
CLAUDE_TOOL_RESULT_* | PostToolUse | Tool result fields |
CLAUDE_TOOL_INPUT_FILE_PATH | Write/Edit hooks | The file path being written |
CLAUDE_TOOL_INPUT_COMMAND | Bash hooks | The command that was executed |
The exact variable names depend on the tool. For a Bash tool call, CLAUDE_TOOL_INPUT_COMMAND contains the command. For a Write call, CLAUDE_TOOL_INPUT_FILE_PATH contains the path of the file being written.
Practical hooks
Auto-format on file save
The most common hook: run prettier after every file write.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write",
"hooks": [
{
"type": "command",
"command": "prettier --write \"$CLAUDE_TOOL_INPUT_FILE_PATH\" 2>/dev/null || true"
}
]
},
{
"matcher": "Edit",
"hooks": [
{
"type": "command",
"command": "prettier --write \"$CLAUDE_TOOL_INPUT_FILE_PATH\" 2>/dev/null || true"
}
]
}
]
}
}
The 2>/dev/null || true suppresses errors and prevents hook failures from blocking Claude. If prettier fails on a file (syntax error before Claude finishes editing), Claude continues working.
Auto-stage changes after write
If you want every file Claude writes to be automatically staged:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write",
"hooks": [
{
"type": "command",
"command": "git add \"$CLAUDE_TOOL_INPUT_FILE_PATH\" 2>/dev/null || true"
}
]
}
]
}
}
Combine with the prettier hook (list multiple hooks in the array) to format and stage automatically.
Log all Bash commands
An audit trail of every shell command Claude runs:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "echo \"[$(date -u +%Y-%m-%dT%H:%M:%SZ)] CMD: $CLAUDE_TOOL_INPUT_COMMAND\" >> ~/.claude/bash-audit.log"
}
]
}
]
}
}
This creates a timestamped log at ~/.claude/bash-audit.log. Useful in team environments where you want visibility into what Claude is doing, or in compliance contexts where audit trails are required.
Slack notification when a task finishes
For long-running tasks where you've stepped away from the terminal:
{
"hooks": {
"Stop": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "curl -s -X POST -H 'Content-type: application/json' --data '{\"text\":\"Claude Code task completed\"}' $SLACK_WEBHOOK_URL"
}
]
}
]
}
}
Set SLACK_WEBHOOK_URL in your shell environment (from your Slack workspace's Incoming Webhooks integration). This fires whenever Claude finishes a session — whether it completed successfully, hit an error, or was stopped manually.
For more informative notifications, write a small shell script instead:
#!/bin/bash
# ~/.claude/hooks/notify-stop.sh
STATUS=${CLAUDE_SESSION_STATUS:-"unknown"}
DURATION=${CLAUDE_SESSION_DURATION:-"unknown"}
curl -s -X POST -H 'Content-type: application/json' \
--data "{\"text\":\"Claude Code finished | Status: $STATUS | Duration: $DURATION\"}" \
"$SLACK_WEBHOOK_URL"
Then reference it in your settings:
{
"hooks": {
"Stop": [
{
"matcher": "*",
"hooks": [{"type": "command", "command": "~/.claude/hooks/notify-stop.sh"}]
}
]
}
}
Run type-checking after TypeScript edits
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write",
"hooks": [
{
"type": "command",
"command": "if [[ \"$CLAUDE_TOOL_INPUT_FILE_PATH\" == *.ts || \"$CLAUDE_TOOL_INPUT_FILE_PATH\" == *.tsx ]]; then npx tsc --noEmit 2>&1 | head -20; fi"
}
]
}
]
}
}
This runs TypeScript type-checking after every .ts or .tsx file write and prints the first 20 lines of any errors to stderr. Claude can see stderr output from hooks, so it'll notice type errors and fix them before moving on — without you having to ask.
Combining multiple hooks
You can have multiple hooks in each array — they run in order:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write",
"hooks": [
{
"type": "command",
"command": "prettier --write \"$CLAUDE_TOOL_INPUT_FILE_PATH\" 2>/dev/null || true"
},
{
"type": "command",
"command": "git add \"$CLAUDE_TOOL_INPUT_FILE_PATH\" 2>/dev/null || true"
}
]
}
]
}
}
This formats, then stages. Order matters — format before staging so the staged version is the formatted version.
PreToolUse: intercepting tool calls
PostToolUse runs after the tool has already executed. PreToolUse runs before, and can block the tool call entirely.
A PreToolUse hook that logs what Claude is about to run before it runs it:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "echo \"[PreToolUse] About to run: $CLAUDE_TOOL_INPUT_COMMAND\" >&2"
}
]
}
]
}
}
To block a tool call, exit with a non-zero status code. A hook that prevents deleting the src/ directory:
#!/bin/bash
# ~/.claude/hooks/guard-bash.sh
if echo "$CLAUDE_TOOL_INPUT_COMMAND" | grep -qE "rm.*src/"; then
echo "ERROR: Refusing to run rm on src/ directory" >&2
exit 1
fi
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [{"type": "command", "command": "~/.claude/hooks/guard-bash.sh"}]
}
]
}
}
If the hook exits with status 1, Claude Code treats the tool call as blocked. Claude will see the error message from stderr and decide what to do next — usually trying a different approach.
Security considerations
Hooks run with full access to your system — the same permissions as your user account. A hook that runs rm -rf $CLAUDE_TOOL_INPUT_FILE_PATH would delete files. A malicious hook that leaked CLAUDE_TOOL_INPUT_COMMAND values could expose sensitive commands.
Practical security rules:
Don't commit hooks with hardcoded secrets. If a hook needs an API key (like the Slack webhook URL), use environment variables, not hardcoded values in the settings JSON.
Be careful with project-level hooks. If you commit .claude/settings.json with hooks to a shared repo, those hooks run for every developer using Claude Code in that repo. Make sure the hooks are safe to run on any developer's machine.
Validate input in hook scripts. A hook receiving $CLAUDE_TOOL_INPUT_COMMAND should treat that value as untrusted. Don't eval it or use it in ways that could cause injection.
Keep hooks minimal. Hooks that do a lot of work (complex scripts, network calls) slow down every tool use. If a hook takes 2 seconds and Claude makes 50 tool calls in a session, you've added 100 seconds of overhead.
Debugging hooks
If a hook isn't firing as expected:
- Check that the settings file is valid JSON — a syntax error silently breaks everything
- Verify the
matchervalue exactly matches the tool name (case-sensitive:"Write", not"write") - Run the hook command manually in your terminal to confirm it works outside of Claude Code
- Check stderr output from Claude Code — hook errors and output appear there
The simplest debugging hook:
{
"hooks": {
"PostToolUse": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "echo \"Hook fired: $CLAUDE_TOOL_NAME\" >&2"
}
]
}
]
}
}
Add this temporarily to verify hooks are running, then remove it.
Hooks work with the settings and permissions system covered in the settings and permissions lesson. Hooks run outside of Claude's tool permission model — a hook script can do things Claude itself wouldn't be allowed to do. That's intentional (hooks are for system-level automation you control) but worth understanding.