The gist
You call skills yourself, when you need them. Hooks work without you, all the time. They're like the rules in an employment contract: an employee follows them automatically without being reminded every time. A hook fires on a specific event (before an action, after it, on an error, on completion) and does what you told it to do.
This lesson gives you the full picture: about 30 event types, 5 handler types, the exact data format. Let's start with the main thing.
Key concepts
- Hook: an automatic rule that fires on a specific event in Claude Code
- Event: a moment in Claude Code's lifecycle: session start, a tool call, completion, and so on
- Handler: what exactly runs on the event: a bash script, an HTTP request, an MCP tool, a prompt or an agent
- Matcher: a filter for which tools or events to react to
- settings.json: the hook configuration file (
.claude/settings.jsonfor a project,~/.claude/settings.jsonglobally) - Exit code: how a hook reports its decision:
0= OK,2= block
Theory
Skills vs. hooks: what's the difference
They don't compete. They're different tools.
| Skills | Hooks | |
|---|---|---|
| Activation | You call them explicitly | Automatically on an event |
| Scope | Project or global | Project or global |
| Where they live | .claude/skills/<name>/SKILL.md |
.claude/settings.json or ~/.claude/settings.json |
| Purpose | Instructions for how to do a task | Safety and automation rules |
| Analogy | A recipe | The rules in an employment contract |
Event types: when hooks fire
Claude Code supports about 30 event types (the exact list grows from version to version; see the official documentation). To start, you need the 6 main ones. The rest are for advanced scenarios.
The 6 main events (80% of use)
| Event | When | Why |
|---|---|---|
| PreToolUse | BEFORE a tool runs | Blocking dangerous actions, checking conditions |
| PostToolUse | AFTER it runs successfully | Logging, auditing, notifications |
| Stop | Claude finished its response | A "done" notification, cleanup, running tests |
| Notification | Claude sends a notification | Reacting to in-between events |
| SessionStart | A session starts or resumes | Loading context, checking the environment |
| UserPromptSubmit | The user sent a request | Validation, adding context before processing |
Advanced events (for when you outgrow the main ones)
| Event | When | Example |
|---|---|---|
| SubagentStart | A subagent starts | Logging which agents get started |
| SubagentStop | A subagent finished its work | Checking the subagent's result |
| PostToolUseFailure | A tool ended with an error | Sending an alert on an error |
| PostToolBatch | A batch of parallel calls finished | Checks after batch operations |
| FileChanged | A file changed on disk | Reloading .env when it changes |
| ConfigChange | The configuration changed | Reacting to a settings update |
| PreCompact | Before the context is compressed | Saving what matters before compaction |
| SessionEnd | The session is ending | Final cleanup, saving state |
| StopFailure | A response was cut off by an API error | An alert on a rate limit or billing error |
| PermissionRequest | A permission prompt appeared | Auto-approving certain operations |
| CwdChanged | The working folder changed | Switching environments |
| Setup | Launch with --init or --maintenance |
Installing dependencies on initialization |
A closer look: the 4 main events
⚠️ A note on how current this is: early material about Claude Code mentions "4 types of hooks" (Pre-tool / Post-tool / Stop / Need you). That's the basic model from the old documentation. By October 2026 the ecosystem had grown to about 30 lifecycle events (PreToolUse, PostToolUse, UserPromptSubmit, Stop, SessionStart, SubagentStop, Notification, PreCompact, PostCompact and others) and 5 handler types (command, http, mcp_tool, prompt, agent). These 4 basic scenarios still cover most tasks. The other events are for fine-tuning on top. More in the Hook-Deny-By-Design lesson, which puts the advanced events to work.
Mapping the old "big four" to today's events:
| Old category | Today's events, 2026 |
|---|---|
| Pre-tool | PreToolUse + PreCompact + UserPromptSubmit |
| Post-tool | PostToolUse + PostCompact + SessionStart |
| Stop | Stop + SubagentStop |
| Need you | Notification + UserPromptSubmit |
1. PreToolUse: a check before the action
When it fires: before Claude runs any tool (writing a file, reading, a bash command, etc.)
Why: to block dangerous actions, check conditions, protect sensitive files.
Practical scenarios:
- Keep Claude from editing the
.envfile with your API keys - Check that code doesn't contain hardcoded secrets
- Block writes to the production database
- Check the budget before expensive operations
{
"hooks": {
"PreToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "/path/to/check-secrets.sh"
}
]
}
]
}
}If the script returns exit code 2 → Claude Code stops and doesn't perform the action. The message from stderr is passed to Claude.
2. PostToolUse: an action after it runs
When it fires: after Claude successfully runs a tool.
Why: to log what changed, build an audit trail, send notifications about specific changes.
Practical scenarios:
- Write to a log file which files Claude changed and when
- Send a notification to Slack or Telegram when a critical file changes
- Update an operations counter for budget control
- Create a git commit after changes
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "/path/to/audit-log.sh"
}
]
}
]
}
}3. Stop: when the response is done
When it fires: when Claude Code finishes responding and completes the task.
Why: to let you know the work is done, clean up, kick off the next step.
Practical scenarios:
- A macOS notification "Claude finished the task", so you can work on something else in the meantime
- Sending a final report to a messaging app
- Running tests after Claude has written code
- An automatic git commit at the end
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "osascript -e 'display notification \"Claude finished the task\" with title \"Claude Code\"'"
}
]
}
]
}
}4. Notification: keeping you informed
When it fires: when Claude Code sends the user a notification (other than Stop).
Why: to react to Claude's in-between messages, not just to completion.
How it differs from Stop: Stop is the full completion of a task. Notification is Claude telling you something along the way.
Matcher options: permission_prompt, idle_prompt, auth_success
Practical scenarios:
- Log all of Claude's in-between messages
- Get notified when Claude hits an error and keeps working
- Track the progress of long tasks
5 handler types: HOW a hook does its job
The event is WHEN. The handler is HOW. Claude Code supports 5 handler types:
| Type | What it does | When to use it |
|---|---|---|
command |
Runs a bash script | 90% of cases: checks, logs, notifications |
http |
Sends an HTTP POST request | A webhook to Slack, Telegram or an external service |
mcp_tool |
Calls a tool on an MCP server | When an MCP server is already connected |
prompt |
Sends text to a fast model | An AI check of a request before it runs |
agent |
Starts a subagent (experimental) | Complex checks that need reasoning |
The command handler (a bash script): the main one
The simplest and most common. It runs a shell script.
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/check-secrets.sh",
"timeout": 30
}The http handler (a webhook): for external services
Sends the hook's data as a POST request. The request body is the same JSON a command hook gets through stdin.
{
"type": "http",
"url": "http://localhost:8080/hooks/validate",
"headers": {
"Authorization": "Bearer $MY_TOKEN"
},
"allowedEnvVars": ["MY_TOKEN"],
"timeout": 30
}The server's JSON response is handled the same way as a command hook's stdout.
The prompt handler: a quick AI check
Sends text to a fast model. Useful for judging whether a request is safe.
{
"type": "prompt",
"prompt": "Is this bash command safe? Command: $ARGUMENTS\nRespond with JSON: {\"decision\": \"allow\"} or {\"decision\": \"deny\"}",
"timeout": 30
}The mcp_tool and agent handlers: advanced
mcp_tool calls a tool on a connected MCP server. agent starts a subagent to do the check (the documentation marks it as experimental). Both are for complex scenarios, not for getting started.
Matcher: the "what to react to" filter
The matcher defines which SPECIFIC tools to react to. Without a matcher, the hook fires on EVERYTHING.
| Matcher value | What it does | Example |
|---|---|---|
"Bash" |
Bash commands only | The hook fires on npm test, git push |
"Write|Edit" |
Writing or editing files | A hook that checks for secrets |
"mcp__memory__.*" |
All tools of the memory MCP server | Auditing MCP operations |
"*" or missing |
All tools | A universal log |
A matcher is a regular expression if it contains special characters, or an exact match if it's only letters.
An extra "if" filter lets you filter by arguments (for example, Bash(git *) or Edit(*.ts)):
{
"matcher": "Bash",
"hooks": [{
"type": "command",
"if": "Bash(rm *)",
"command": "echo 'rm is blocked' >&2 && exit 2"
}]
}Here the hook fires only for Bash, and only if the command starts with rm. The full if syntax is in the official hooks reference.
The structure of settings.json (official format)
All hooks live in settings.json. There are three levels of files:
| File | Scope | Share it? |
|---|---|---|
~/.claude/settings.json |
All projects (global) | No |
.claude/settings.json |
This project | Yes (commit it to git) |
.claude/settings.local.json |
This project (local) | No (in .gitignore) |
The structure: 3 levels of nesting
hooks → Event → [{ matcher, hooks: [{ type, command, ... }] }]A full example with 3 hooks:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/check-secrets.sh",
"timeout": 30
}
]
}
],
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/audit-log.sh"
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "osascript -e 'display notification \"Claude finished\" with title \"Claude Code\"'"
}
]
}
]
}
}Key rules:
- Event names are CamelCase:
PreToolUse, notpre_tool_use - Each event holds an array of groups with
matcherandhooks - Each group holds an array of
hookshandlers matcherfilters by tool (not needed for Stop/SessionStart)- You can turn off all hooks entirely:
"disableAllHooks": true
How a hook gets its data (the JSON protocol)
Claude Code passes data to the hook through stdin (for command hooks) or the POST body (for http hooks), in JSON format.
What a PreToolUse hook gets
{
"session_id": "abc123",
"cwd": "/Users/me/project",
"hook_event_name": "PreToolUse",
"tool_name": "Write",
"tool_input": {
"file_path": "/project/config.py",
"content": "API_KEY = 'sk-proj-abc123...'"
}
}What a PostToolUse hook gets
{
"session_id": "abc123",
"cwd": "/Users/me/project",
"hook_event_name": "PostToolUse",
"tool_name": "Bash",
"tool_input": { "command": "npm test" },
"tool_response": "All tests passed"
}What a SessionStart hook gets
{
"session_id": "abc123",
"cwd": "/Users/me/project",
"hook_event_name": "SessionStart",
"source": "startup",
"model": "<model identifier>"
}How a hook answers Claude Code (the JSON response)
A hook can return JSON through stdout to control behavior:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"permissionDecisionReason": "Safe command",
"additionalContext": "A hint for Claude"
}
}Decisions for PreToolUse: "allow" (allow without asking), "deny" (block), "ask" (ask the user); newer versions also have "defer".
If several hooks give different decisions, the priority is: deny > ask > allow.
Exit codes: how a hook reports its decision
| Exit code | Result |
|---|---|
| 0 | Success. Claude Code parses stdout as JSON |
| 2 | Block. Stderr is passed to Claude as the reason |
| 1 or anything else | A non-blocking error: it gets logged and work continues |
Important: exit code 2 (not 1!) blocks the action. Exit code 1 just means an error: the hook "broke," but Claude keeps working.
How to add a hook: two ways
Way 1: Ask Claude Code (recommended to start)
I want to make a hook: when Claude finishes a response, send me a macOS notification
Claude Code will ask clarifying questions, create a bash script and add the entry to settings.json.
Way 2: Through /hooks in the terminal
claude
# In the Claude Code interface:
/hooks
# Opens the list of configured hooks (view only)
# To edit, change settings.json directlyIt shows the current hooks: the handler type ([command], [http], [prompt]), the source ([User], [Project], [Local]) and the matcher.
Global vs. project hooks
Put safety hooks (secrets, budget) in the global file (~/.claude/settings.json). They protect you in every project.
Put project-specific hooks (linter, tests, deployment) in the project file (.claude/settings.json). You can commit them to git and share them with your team.
~/.claude/settings.json ← Safety (all projects)
└── PreToolUse: no-secrets
└── PreToolUse: budget-check
.claude/settings.json ← Project (this project)
└── PostToolUse: run-linter
└── Stop: run-testsAll levels are combined. Global + project + local hooks work together.
Environment variables in hooks
Inside a command hook you have access to:
| Variable | What it holds |
|---|---|
$CLAUDE_PROJECT_DIR |
The project root (wrap it in quotes!) |
$CLAUDE_ENV_FILE |
A path for saving env variables for the whole session |
Example:
#!/bin/bash
# Run a script from the project folder
"$CLAUDE_PROJECT_DIR"/.claude/hooks/my-check.shPractice
Task: get to know the structure of settings.json
- Open or create the file
.claude/settings.json - Ask Claude Code:
Show me the current hook settings - Ask it to create the simplest possible hook:
Create a hook: when Claude finishes a response, show a "Done" notification, and click yes - Check that settings.json was updated: look for the new entry in the
Stopsection (CamelCase!) - Test it: ask Claude any simple question, and the notification should appear
- Type
/hooksin Claude Code and make sure the hook shows up in the list
Goal: understand that settings.json is the single place where all hooks are configured, and that hooks work automatically without you
Tools and resources
.claude/settings.json: the project hooks file (commit it to git)~/.claude/settings.json: the global hooks file (all projects)/hooks: the command for viewing configured hooks in Claude Codejq: a tool for parsing JSON in bash scripts (needed for command hooks)osascript: the macOS command for sending native notifications- Official documentation (current reference): https://code.claude.com/docs/en/hooks — all lifecycle events, JSON formats, exit codes
- Advanced events: the Hook-Deny-By-Design lesson, with SubagentStop, PreCompact and PermissionRequest in practice
Sources
- https://code.claude.com/docs/en/hooks — official reference (checked in October 2026)
- The Hook-Deny-By-Design lesson: advanced events in practice
Key takeaways
Hooks ≠ skills. You call skills yourself. Hooks work automatically on an event, and you set them up once.
There are about 30 event types, but start with the 4-6 main ones: PreToolUse, PostToolUse, Stop, Notification, SessionStart, UserPromptSubmit.
5 handler types: command (bash), http (webhook), mcp_tool, prompt (an AI check), agent (a subagent). For getting started, command is enough.
The matcher filters by tool:
"Write|Edit"means file operations only,"Bash"means commands only.
Exit code 2 = block, exit code 0 = allow. It's 2, not 1, that blocks!
Hooks live in settings.json at three levels: global, project and local. They all get combined.
What's next
The mark stays in this browser only and is never sent anywhere. My progress