Lesson 08: Hooks
What Are Hooks?
Hooks are shell commands (or LLM prompts) that Claude Code runs automatically at specific points in its lifecycle. They give you deterministic control over Claude's behavior — ensuring certain actions always happen rather than relying on the LLM to choose them.
Use hooks to:
- Auto-format code after every edit
- Block dangerous commands before they execute
- Send notifications when Claude needs your input
- Inject context at session start or after compaction
- Enforce rules like "never modify
.envfiles" - Audit every tool call for compliance logging
- Verify task completion with LLM-powered checks
Where Hooks Live
Hooks are defined in JSON settings files. Where you put them determines their scope:
| Location | Scope | Shareable? |
|---|---|---|
~/.claude/settings.json |
All your projects | No (local to your machine) |
.claude/settings.json |
Single project | Yes (commit to repo) |
.claude/settings.local.json |
Single project | No (gitignored) |
| Managed policy settings | Organization-wide | Yes (admin-controlled) |
Plugin hooks/hooks.json |
When plugin is enabled | Yes (bundled with plugin) |
| Skill or agent frontmatter | While component is active | Yes (in the component file) |
Project-level hooks (.claude/settings.json) are great for team workflows — everyone on the project gets the same hooks automatically.
The `/hooks` Command
The fastest way to create, view, and manage hooks is the interactive /hooks menu inside Claude Code. Type /hooks and you will see:
- A list of all hook events
- Your currently configured hooks (labeled
[User],[Project],[Local], or[Plugin]) - Options to add new hooks, delete existing ones, or disable all hooks
The menu walks you through choosing an event, setting a matcher, entering a command, and picking a storage location — no manual JSON editing required.
Tip: Hooks added through
/hookstake effect immediately. If you edit settings files manually while Claude Code is running, changes require a review in/hooksor a session restart.
Hook Events — The Complete List
Claude Code fires 17 hook events across the session lifecycle. Here is every event:
Session Lifecycle
| Event | When It Fires | Can Block? |
|---|---|---|
SessionStart |
Session begins or resumes | No |
SessionEnd |
Session terminates | No |
PreCompact |
Before context compaction | No |
ConfigChange |
A settings or skills file changes mid-session | Yes |
User Input
| Event | When It Fires | Can Block? |
|---|---|---|
UserPromptSubmit |
You submit a prompt, before Claude processes it | Yes |
Tool Execution
| Event | When It Fires | Can Block? |
|---|---|---|
PreToolUse |
Before a tool call executes | Yes |
PermissionRequest |
When a permission dialog is about to appear | Yes |
PostToolUse |
After a tool call succeeds | No (already ran) |
PostToolUseFailure |
After a tool call fails | No (already failed) |
Agent Completion
| Event | When It Fires | Can Block? |
|---|---|---|
Stop |
Claude finishes responding | Yes (force continue) |
SubagentStart |
A subagent is spawned | No |
SubagentStop |
A subagent finishes | Yes (force continue) |
Notification |
Claude sends a notification | No |
Teams & Tasks
| Event | When It Fires | Can Block? |
|---|---|---|
TeammateIdle |
An agent-team teammate is about to go idle | Yes |
TaskCompleted |
A task is being marked complete | Yes |
Worktrees
| Event | When It Fires | Can Block? |
|---|---|---|
WorktreeCreate |
A worktree is being created (replaces default git behavior) | Yes |
WorktreeRemove |
A worktree is being removed | No |
Hook Configuration Format
The configuration has three levels of nesting:
- Hook event — which lifecycle point to respond to
- Matcher group — filter when it fires (e.g., "only for the Bash tool")
- Hook handlers — one or more commands/prompts to run when matched
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/validate-bash.sh"
}
]
}
],
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs npx prettier --write"
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "prompt",
"prompt": "Check if all tasks are complete: $ARGUMENTS"
}
]
}
]
}
}Hook Types
There are three types of hook handlers:
Command Hooks (`type: "command"`)
Run a shell command. The script receives JSON on stdin and communicates results through exit codes and stdout.
| Field | Required | Description |
|---|---|---|
type |
Yes | "command" |
command |
Yes | Shell command to execute |
timeout |
No | Seconds before canceling (default: 600) |
async |
No | If true, runs in the background without blocking |
statusMessage |
No | Custom spinner message while running |
Prompt Hooks (`type: "prompt"`)
Send a prompt to a Claude model for single-turn evaluation. The model returns a yes/no decision as JSON. Good for decisions requiring judgment rather than deterministic rules.
| Field | Required | Description |
|---|---|---|
type |
Yes | "prompt" |
prompt |
Yes | Prompt text. Use $ARGUMENTS for the hook input JSON |
model |
No | Model to use (defaults to a fast model like Haiku) |
timeout |
No | Seconds before canceling (default: 30) |
The model must respond with:
{ "ok": true }or:
{ "ok": false, "reason": "Explanation for why the action should be blocked" }Agent Hooks (`type: "agent"`)
Spawn a subagent that can use tools (Read, Grep, Glob) to verify conditions before returning a decision. Use when verification requires inspecting actual files.
| Field | Required | Description |
|---|---|---|
type |
Yes | "agent" |
prompt |
Yes | Prompt describing what to verify. Use $ARGUMENTS placeholder |
model |
No | Model to use (defaults to a fast model) |
timeout |
No | Seconds before canceling (default: 60) |
Uses the same ok/reason response format as prompt hooks.
Not all events support all types. Events like
PreToolUse,PostToolUse,Stop,UserPromptSubmit,PermissionRequest,PostToolUseFailure,SubagentStop, andTaskCompletedsupport all three types. Events likeSessionStart,SessionEnd,Notification,SubagentStart,PreCompact,ConfigChange,TeammateIdle,WorktreeCreate, andWorktreeRemoveonly supporttype: "command".
Matchers — Filtering When Hooks Fire
The matcher field is a regex string that controls which specific occurrences trigger the hook. Omit it, use "", or use "*" to match everything.
Each event type matches on a different field:
| Event | What Matcher Filters | Example Values |
|---|---|---|
PreToolUse, PostToolUse, PostToolUseFailure, PermissionRequest |
Tool name | Bash, Edit|Write, mcp__.* |
SessionStart |
How session started | startup, resume, clear, compact |
SessionEnd |
Why session ended | clear, logout, prompt_input_exit |
Notification |
Notification type | permission_prompt, idle_prompt |
SubagentStart, SubagentStop |
Agent type | Bash, Explore, Plan |
PreCompact |
Compaction trigger | manual, auto |
ConfigChange |
Config source | user_settings, project_settings, skills |
Some events (UserPromptSubmit, Stop, TeammateIdle, TaskCompleted, WorktreeCreate, WorktreeRemove) do not support matchers — they fire on every occurrence.
Since matchers are regex, Edit|Write matches either tool, and mcp__memory__.* matches all tools from the memory MCP server.
Matching MCP Tools
MCP tools follow the naming pattern mcp__<server>__<tool>:
mcp__memory__create_entities— Memory server's create entities toolmcp__filesystem__read_file— Filesystem server's read file toolmcp__github__.*— All tools from the GitHub server
The JSON Input/Output System
Hooks communicate with Claude Code through stdin (JSON input), stdout (JSON or text output), stderr (error messages), and exit codes.
Input: What Hooks Receive
Every hook receives a JSON payload on stdin with common fields plus event-specific data:
{
"session_id": "abc123",
"transcript_path": "/home/user/.claude/projects/.../transcript.jsonl",
"cwd": "/home/user/my-project",
"permission_mode": "default",
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": {
"command": "npm test"
}
}Common fields on every event: session_id, transcript_path, cwd, permission_mode, hook_event_name.
Event-specific fields vary — PreToolUse adds tool_name and tool_input, UserPromptSubmit adds prompt, Stop adds stop_hook_active and last_assistant_message, SessionStart adds source and model, and so on.
Output: Exit Codes
| Exit Code | Meaning |
|---|---|
0 |
Success — action proceeds. Stdout is parsed for JSON output |
2 |
Blocking error — action is prevented. Stderr is fed back to Claude |
| Any other | Non-blocking error — action proceeds. Stderr shown in verbose mode |
Output: JSON for Fine-Grained Control
Instead of just exit codes, exit 0 and print a JSON object to stdout for richer control:
Universal fields (work on all events):
| Field | Default | Description |
|---|---|---|
continue |
true |
If false, Claude stops entirely |
stopReason |
none | Message shown to user when continue is false |
suppressOutput |
false |
If true, hides stdout from verbose mode |
systemMessage |
none | Warning message shown to the user |
Decision control varies by event:
For PreToolUse, use hookSpecificOutput:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Destructive command blocked"
}
}permissionDecision can be "allow" (bypass permission prompt), "deny" (block the call), or "ask" (show the permission dialog). You can also include updatedInput to modify tool parameters before execution.
For Stop, PostToolUse, SubagentStop, UserPromptSubmit, ConfigChange, use top-level decision:
{
"decision": "block",
"reason": "Tests must pass before stopping"
}For PermissionRequest, use hookSpecificOutput with a decision object:
{
"hookSpecificOutput": {
"hookEventName": "PermissionRequest",
"decision": {
"behavior": "allow"
}
}
}Important: Choose one approach per hook — either exit codes alone, or exit
0with JSON. Claude Code ignores JSON on exit2.
Practical Examples
1. Auto-Format Code After Edits
Run Prettier on every file Claude writes or edits:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs npx prettier --write"
}
]
}
]
}
}2. Block Dangerous Commands
Prevent rm -rf from ever running:
#!/bin/bash
# .claude/hooks/block-rm.sh
COMMAND=$(jq -r '.tool_input.command')
if echo "$COMMAND" | grep -q 'rm -rf'; then
jq -n '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: "Destructive rm -rf command blocked by hook"
}
}'
else
exit 0
fiConfiguration:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/block-rm.sh"
}
]
}
]
}
}3. Desktop Notifications
Get notified when Claude needs your input so you can switch to other tasks:
macOS:
{
"hooks": {
"Notification": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "osascript -e 'display notification \"Claude needs your attention\" with title \"Claude Code\"'"
}
]
}
]
}
}Linux:
{
"hooks": {
"Notification": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "notify-send 'Claude Code' 'Claude needs your attention'"
}
]
}
]
}
}4. Protect Sensitive Files
Block edits to .env, lock files, and .git/:
#!/bin/bash
# .claude/hooks/protect-files.sh
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
PROTECTED_PATTERNS=(".env" "package-lock.json" ".git/")
for pattern in "${PROTECTED_PATTERNS[@]}"; do
if [[ "$FILE_PATH" == *"$pattern"* ]]; then
echo "Blocked: $FILE_PATH matches protected pattern '$pattern'" >&2
exit 2
fi
done
exit 0{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-files.sh"
}
]
}
]
}
}5. Re-Inject Context After Compaction
When compaction summarizes the conversation, critical details can be lost. Re-inject them automatically:
{
"hooks": {
"SessionStart": [
{
"matcher": "compact",
"hooks": [
{
"type": "command",
"command": "echo 'Reminder: use Bun, not npm. Run bun test before committing. Current sprint: auth refactor.'"
}
]
}
]
}
}Any text your SessionStart hook prints to stdout is added to Claude's context.
6. LLM-Powered Completion Check
Use a prompt hook to verify Claude actually finished all requested tasks before stopping:
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "prompt",
"prompt": "You are evaluating whether Claude should stop working. Context: $ARGUMENTS\n\nAnalyze the conversation and determine if:\n1. All user-requested tasks are complete\n2. Any errors need to be addressed\n3. Follow-up work is needed\n\nRespond with JSON: {\"ok\": true} to allow stopping, or {\"ok\": false, \"reason\": \"what remains\"} to continue working.",
"timeout": 30
}
]
}
]
}
}7. Audit Logging
Log every tool call for compliance:
{
"hooks": {
"PostToolUse": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_name' >> ~/.claude/logs/tool-audit.log"
}
]
}
]
}
}8. Run Tests in the Background
Use async hooks to run tests after file changes without blocking Claude:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/run-tests-async.sh",
"async": true,
"timeout": 300
}
]
}
]
}
}The async: true flag lets Claude continue working while tests run. When they finish, results are delivered on the next conversation turn via systemMessage.
Async Hooks
By default, hooks block Claude until they complete. For long-running tasks, set "async": true to run in the background:
{
"type": "command",
"command": "/path/to/slow-script.sh",
"async": true,
"timeout": 120
}Async hooks cannot block or control Claude's behavior — by the time they finish, the triggering action has already proceeded. Only type: "command" hooks support async. Results are delivered on the next conversation turn.
Environment Variables
Hook scripts have access to these environment variables:
| Variable | Description |
|---|---|
$CLAUDE_PROJECT_DIR |
The project root directory |
${CLAUDE_PLUGIN_ROOT} |
The plugin's root directory (for plugin hooks) |
$CLAUDE_CODE_REMOTE |
Set to "true" in remote web environments |
$CLAUDE_ENV_FILE |
Path to write persistent env vars (SessionStart only) |
Use $CLAUDE_PROJECT_DIR to reference scripts with absolute paths:
{
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/my-script.sh"
}Persisting Environment Variables
In SessionStart hooks, write export statements to $CLAUDE_ENV_FILE to set environment variables for all subsequent Bash commands in the session:
#!/bin/bash
if [ -n "$CLAUDE_ENV_FILE" ]; then
echo 'export NODE_ENV=production' >> "$CLAUDE_ENV_FILE"
echo 'export DEBUG_LOG=true' >> "$CLAUDE_ENV_FILE"
fi
exit 0Hooks in Skills and Agents
Hooks can be defined directly in skill and subagent YAML frontmatter. These hooks are scoped to the component's lifecycle — they only run while that skill or agent is active.
---
name: secure-operations
description: Perform operations with security checks
hooks:
PreToolUse:
- matcher: "Bash"
hooks:
- type: command
command: "./scripts/security-check.sh"
---For subagents, Stop hooks are automatically converted to SubagentStop.
Debugging Hooks
Verbose Mode
Toggle verbose mode with Ctrl+O to see hook output in the transcript. On exit 0, stdout is shown in verbose mode. On exit 2, stderr is fed back to Claude.
Debug Flag
Run claude --debug to see detailed hook execution:
[DEBUG] Executing hooks for PostToolUse:Write
[DEBUG] Found 1 hook matchers in settings
[DEBUG] Matched 1 hooks for query "Write"
[DEBUG] Hook command completed with status 0: Test Hooks Manually
Pipe sample JSON into your script to test it outside of Claude Code:
echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf /"}}' | ./my-hook.sh
echo $? # Check exit codeCommon Issues
Hook not firing?
- Run
/hooksto confirm it appears under the correct event - Check that matcher patterns match exactly (they are case-sensitive)
- Verify you are triggering the right event type
JSON validation failed?
- If your shell profile (
.zshrc,.bashrc) prints text on startup, it interferes with JSON parsing. Wrap echo statements so they only run in interactive shells:
if [[ $- == *i* ]]; then
echo "Shell ready"
fiStop hook runs forever?
- Check
stop_hook_activein the input and exit early if it istrue:
INPUT=$(cat)
if [ "$(echo "$INPUT" | jq -r '.stop_hook_active')" = "true" ]; then
exit 0 # Allow Claude to stop — hook already triggered a continuation
fijq: command not found?
- Install jq:
brew install jq(macOS),apt-get install jq(Linux), or use Python/Node for JSON parsing instead.
Best Practices
Keep hooks fast. Hooks run synchronously by default — slow hooks block Claude. For expensive operations, use "async": true or run tasks in the background.
Handle failures gracefully. Only exit 2 when you intentionally want to block. Unexpected blocking will frustrate you.
Use absolute paths. Hooks may run from varying working directories. Use $CLAUDE_PROJECT_DIR or full paths.
Quote shell variables. Always use "$VAR" not $VAR to prevent word splitting.
Validate inputs. Never trust input data blindly — check for path traversal (..), skip sensitive files, and sanitize before acting.
Choose the right hook type. Use command for deterministic rules (formatting, blocking patterns). Use prompt for judgment calls (is the task really done?). Use agent when you need to inspect files to verify conditions.
Practical Exercise
Build a PreToolUse hook that:
- Matches only
Bashtool calls - Reads the JSON input from stdin with
jq - Checks if the command contains
rm— if so, logs it to~/.claude/logs/deletions.logwith a timestamp - Blocks
rm -rf /andrm -rf ~with a JSONpermissionDecisionof"deny" - Allows all other commands to proceed
Then add a PostToolUse hook with matcher Edit|Write that runs your project's linter on the edited file.