You told Claude three times not to touch .env. It's in CLAUDE.md too. On the fourth task it edits .env anyway, because instructions are suggestions to a model. A hook is not. It's a command that Claude Code runs at a fixed point, every time, and it can say no. This guide covers how hooks work today and gives you seven recipes you can paste and test.
Quick answer
- Hooks are commands Claude Code runs at fixed moments: before a tool runs (
PreToolUse), after it runs (PostToolUse), when Claude needs you (Notification), when it finishes (Stop), when a session starts (SessionStart) and about 30 other events.They live in settings files:
~/.claude/settings.json(you),.claude/settings.json(project, shared),.claude/settings.local.json(project, private), managed policy, plugins, and skill or subagent frontmatter.Input is JSON on stdin. Output is the exit code, stdout and stderr.
Exit 0 = no objection. Exit 2 = block, and stderr is the reason. Other codes are non-blocking errors.
For finer control, exit 0 and print JSON, such as
permissionDecision: "deny"onPreToolUseordecision: "block"onStop.
/hooksshows every hook that's configured and where it came from.Hooks run with your user's permissions. Read them before you add them.
What hooks are
A hook is a handler attached to a lifecycle event. When the event fires, Claude Code runs every matching hook in parallel, waits for them, and merges the results. Most hooks are shell commands ("type": "command"). There are four other types:
http: POST the event JSON to a URL.mcp_tool: call a tool on an MCP server you already configured.prompt: ask a Claude model for a yes/no decision.agent: spawn a subagent that can read files before deciding (experimental).
The point is determinism. A formatter you put in CLAUDE.md runs when the model remembers. A formatter in a PostToolUse hook runs after every edit.
Where hooks live
| Location | Scope | Shared? |
|---|---|---|
~/.claude/settings.json |
All your projects | No |
.claude/settings.json |
One project | Yes, commit it |
.claude/settings.local.json |
One project | No, gitignored |
| Managed policy settings | Whole organization | Admin-controlled |
Plugin hooks/hooks.json |
While the plugin is enabled | Yes |
| Skill frontmatter | Rest of the session after the skill runs | Yes |
| Subagent frontmatter | While that subagent runs | Yes |
Hooks from different levels add up. They don't replace each other. To turn them all off without deleting anything, set "disableAllHooks": true.
The shape is always the same: hooks → event name → list of matcher groups → each group has a matcher and a hooks list.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/check.sh", "timeout": 30 }
]
}
]
}
}
If you edit the file while a session is running, Claude Code normally picks up the change by itself.
The events
These are the ones you'll use most:
| Event | Fires | Matcher filters on | Can block? |
|---|---|---|---|
SessionStart |
Session starts or resumes | startup, resume, clear, compact, fork |
No |
UserPromptSubmit |
You send a prompt, before Claude sees it | (none) | Yes |
PreToolUse |
Before a tool call | Tool name | Yes |
PermissionRequest |
Claude Code is about to ask you for permission | Tool name | Via JSON |
PostToolUse |
After a tool call succeeds | Tool name | Feedback only |
Notification |
Claude needs you, and other alerts | permission_prompt, idle_prompt, agent_needs_input… |
No |
Stop |
Claude finishes responding | (none) | Yes, keeps it working |
SubagentStop |
A subagent finishes | Agent type | Yes |
PreCompact / PostCompact |
Around compaction | manual, auto |
Pre only |
SessionEnd |
Session ends | clear, resume, logout, prompt_input_exit, other |
No |
The full list today also has Setup, UserPromptExpansion, PermissionDenied, PostToolUseFailure, PostToolBatch, MessageDisplay, SubagentStart, TaskCreated, TaskCompleted, StopFailure, TeammateIdle, InstructionsLoaded, ConfigChange, CwdChanged, DirectoryAdded, FileChanged, WorktreeCreate, WorktreeRemove, PreModelSwitch, PostModelSwitch, Elicitation and ElicitationResult. New ones show up often, so check the reference when you need something unusual.
Matchers and the if field
"*",""or no matcher: match everything.- Only letters, digits,
_,-, spaces,,and|: exact names.Edit|WriteandEdit, Writeare the same. - Any other character makes it a JavaScript regex:
mcp__github__.*,^Notebook. - MCP tools are named
mcp__<server>__<tool>.mcp__memoryalone matches nothing. You needmcp__memory__.*. - Matchers are case-sensitive.
For tool events, the if field narrows by arguments too, using permission-rule syntax: "if": "Bash(git *)" runs the hook only when a subcommand is git. It's best-effort, so the docs tell you to use permission rules, not hooks, for a hard allow or deny.
Input: JSON on stdin
Every hook gets common fields: session_id, transcript_path, cwd, hook_event_name, permission_mode, and a few more. Each event adds its own. A PreToolUse for a Bash call looks like this:
{
"session_id": "abc123",
"cwd": "/Users/sarah/myproject",
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": { "command": "npm test" }
}
Other events you'll meet in the recipes: PostToolUse adds tool_response, Notification has message, title and notification_type, Stop has stop_hook_active and last_assistant_message, UserPromptSubmit has prompt, SessionStart has source.
Output: exit codes
- Exit 0. No objection. On
PreToolUsethat does not approve the call: the normal permission flow still runs. OnSessionStart,UserPromptSubmit,UserPromptExpansionandPostModelSwitch, plain stdout is added to Claude's context. Elsewhere it goes to the debug log. - Exit 2. Block. stderr is the reason. On
PreToolUsethe call is cancelled and Claude reads why. OnStopClaude keeps working. OnPostToolUsenothing can be undone, so stderr becomes feedback for Claude. OnNotificationandSessionStartit can't block anything. - Any other code (including 1): non-blocking error. The action goes ahead and the transcript shows the first line of stderr.
Output: JSON
Exit 0 and print one JSON object for finer control. Use either exit 2 or JSON in a hook, not both.
- Universal fields:
continue(falsestops Claude entirely),stopReason,systemMessage(a warning shown to you). PreToolUse:hookSpecificOutput.permissionDecisionisallow,deny,askordefer(deferonly with-p). AddpermissionDecisionReason, andupdatedInputto rewrite the tool's arguments.PostToolUse,Stop,UserPromptSubmit: top-leveldecision: "block"with areason.PermissionRequest:hookSpecificOutput.decision.behaviorisallowordeny.additionalContext(insidehookSpecificOutput) injects text for Claude. At the top level it's silently ignored.
Two traps. A "deny" from a hook blocks even in bypassPermissions mode, but an "allow" never overrides your deny rules. And if your shell profile prints anything, your JSON no longer starts with { and is read as plain text.
The /hooks command
Type /hooks in Claude Code. It's a read-only browser: each hook is labeled with its source (user, project, local, plugin, session), and selecting one shows the full command and the file that defines it. If a hook doesn't seem to fire, start there. Then claude --debug-file /tmp/claude.log and tail -f the log.
7 recipes
All Bash recipes use jq (brew install jq). Scripts go in .claude/hooks/ and need chmod +x. Test any script before wiring it up:
echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf build"}}' | .claude/hooks/block-rm-rf.sh; echo "exit: $?"
1. macOS notification that says which project needs you
The parallel sessions guide has the one-line version. This one uses the event's own message and the folder name, so with five sessions you know which one is asking. Save as ~/.claude/hooks/notify.sh:
#!/bin/bash
INPUT=$(cat)
MSG=$(echo "$INPUT" | jq -r '.message // "Claude Code needs you"')
PROJECT=$(basename "$(echo "$INPUT" | jq -r '.cwd')")
osascript -e 'on run argv' \
-e 'display notification (item 1 of argv) with title (item 2 of argv) sound name "Glass"' \
-e 'end run' "$MSG" "Claude Code · $PROJECT"
{
"hooks": {
"Notification": [
{
"matcher": "permission_prompt|idle_prompt",
"hooks": [{ "type": "command", "command": "~/.claude/hooks/notify.sh" }]
}
]
}
}
Passing the text as argv avoids quoting bugs when the message has quotes in it. If nothing appears, run osascript -e 'display notification "test"' once and allow Script Editor in System Settings → Notifications.
2. Run Prettier after every edit
Project .claude/settings.json:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "jq -r '.tool_input.file_path' | xargs npx prettier --write --ignore-unknown" }
]
}
]
}
}
--ignore-unknown keeps Prettier quiet on files it can't format. Files Claude changes through Bash won't pass through this hook. For those, use a FileChanged hook.
3. Block edits to .env and other protected files
.claude/hooks/protect-files.sh:
#!/bin/bash
FILE_PATH=$(jq -r '.tool_input.file_path // empty')
for pattern in ".env" ".git/" "package-lock.json"; do
if [[ "$FILE_PATH" == *"$pattern"* ]]; then
echo "Blocked: $FILE_PATH is protected ($pattern). Ask the user to change it." >&2
exit 2
fi
done
exit 0
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-files.sh" }]
}
]
}
}
Claude reads the stderr and changes course. This only guards the edit tools. Add a Read(./.env) / Edit(./.env) deny rule in permissions if you also want reads blocked.
4. Block rm -rf, with a JSON reason
This one uses JSON instead of exit 2. .claude/hooks/block-rm-rf.sh:
#!/bin/bash
CMD=$(jq -r '.tool_input.command // empty')
if echo "$CMD" | grep -Eiq '(^|[;&|[:space:]])rm[[:space:]]+-[a-zA-Z]*(rf|fr)'; then
jq -n --arg cmd "$CMD" '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: ("rm -rf is blocked by a hook: " + $cmd + ". Delete specific files instead.")
}
}'
fi
exit 0
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-rm-rf.sh" }]
}
]
}
}
A regex won't catch every variant (rm -r -f, a script that deletes). Treat it as a seatbelt and keep real deny rules in permissions.
5. Log every shell command
~/.claude/settings.json, one JSON line per command:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "jq -c '{time: (now | todate), session: .session_id, cwd: .cwd, cmd: .tool_input.command}' >> ~/.claude/bash-log.jsonl" }
]
}
]
}
}
tail -f ~/.claude/bash-log.jsonl shows what every session is running. Use PreToolUse instead if you also want commands that were then blocked.
6. Don't let Claude stop while tests fail
.claude/hooks/tests-on-stop.sh:
#!/bin/bash
INPUT=$(cat)
# Already continuing because of this hook? Let it stop.
if [ "$(echo "$INPUT" | jq -r '.stop_hook_active')" = "true" ]; then
exit 0
fi
cd "$CLAUDE_PROJECT_DIR" || exit 0
if ! OUTPUT=$(npm test 2>&1); then
jq -n --arg out "$(echo "$OUTPUT" | tail -n 30)" \
'{decision: "block", reason: ("Tests are failing. Fix them before finishing:\n" + $out)}'
fi
exit 0
{
"hooks": {
"Stop": [
{ "hooks": [{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/tests-on-stop.sh", "timeout": 300 }] }
]
}
}
Stop fires after every response, not only at the end of a task, so keep the test command fast. The stop_hook_active check matters: Claude Code overrides a Stop hook after eight blocks in a row anyway, but you don't want to get there.
7. Give Claude the repo state at session start
{
"hooks": {
"SessionStart": [
{
"matcher": "startup|compact",
"hooks": [
{ "type": "command", "command": "echo \"Branch: $(git branch --show-current)\"; echo 'Uncommitted:'; git status --short | head -20; echo 'Recent commits:'; git log --oneline -5" }
]
}
]
}
}
Plain stdout from SessionStart goes straight into Claude's context. The compact matcher re-injects it after compaction, when details tend to get lost. For static rules, CLAUDE.md is still the right place.
Security: hooks run as you
The reference is blunt: command hooks run with your full user permissions and can read, change or delete anything your account can. Practical rules:
- Read a repo's
.claude/settings.jsonbefore you trust the folder. In an interactive session Claude Code holds back hooks until you accept the workspace trust dialog. Withclaude -por the SDK there's no dialog, and committed hooks run. For a repo you didn't write, use--settings '{"disableAllHooks": true}'or--bare. - Quote every variable (
"$VAR"), use absolute paths or$CLAUDE_PROJECT_DIR, and reject..in file paths. - Treat stdin as untrusted. It holds whatever Claude decided to run or write.
- Keep auto-approve hooks narrow. A
PermissionRequesthook with an empty matcher approves everything. - Don't rely on hooks alone for hard limits. Matchers and
ifare best-effort, and Claude can reach a file through Bash instead ofEdit. Pair hooks with deny rules.
Hooks in Codex
Codex has hooks now, with a very similar format. They're on by default (turn them off with hooks = false under [features] in config.toml). They live in ~/.codex/hooks.json or config.toml, the same files under <repo>/.codex/, managed requirements.toml, and plugins. Events: SessionStart, SessionEnd, PreToolUse, PermissionRequest, PostToolUse, PreCompact, PostCompact, UserPromptSubmit, SubagentStart, SubagentStop, Stop and Interrupt.
Differences worth knowing: new or changed hooks must be reviewed and trusted in /hooks before they run (trust is tied to a hash of the hook). PreToolUse doesn't support ask yet. Prompt and agent handlers are parsed but skipped. Exit 2 blocks on PreToolUse, UserPromptSubmit and Stop, as in Claude Code.
Where Aki fits
The most common hook is "tell me when a session needs me". If you'd rather see it than get a notification, Aki shows it without any hook. It's a free, source-available macOS app (>github.com/muriloprataviera/aki) with a sidebar on the edge of the screen: one ring per Claude Code or Codex session, showing its state, context use and pending marks, and a ✓ when the agent resolved everything. You see which session is waiting at a glance.
Its main job is the other direction. You point at something on screen (an element in Chrome, an area, a terminal error), write what should change, and Aki sends it to the right session. A page on localhost:3000 goes to the worktree running port 3000. Mac only (Apple Silicon, macOS 14+), no account. Hooks and Aki work fine together.
FAQ
What are Claude Code hooks?
Commands, HTTP calls, MCP tool calls or model checks that Claude Code runs automatically at fixed points, such as before a tool runs, after a file edit, or when Claude finishes. They can block actions and add context.
Where do I put Claude Code hooks?
In a settings file: ~/.claude/settings.json for all projects, .claude/settings.json for a shared project config, or .claude/settings.local.json for a private one. Plugins, skills and subagents can also define them.
What does exit code 2 do in a hook?
It blocks the action and uses stderr as the reason. On PreToolUse the tool call is cancelled, on Stop Claude keeps working, on UserPromptSubmit the prompt is blocked. Exit 1 does not block.
How do I see which hooks are active?
Type /hooks in Claude Code. It lists every configured hook with its source and the full command.
Why isn't my hook firing?
Check /hooks first. Then check that the matcher matches the tool name exactly (it's case-sensitive), that the script is executable, and that your JSON has no trailing commas. Run with claude --debug-file /tmp/claude.log to see each run.
Can a hook approve a tool call automatically?
Yes. permissionDecision: "allow" on PreToolUse or decision.behavior: "allow" on PermissionRequest. Deny rules in your settings still win.
Does Codex support hooks?
Yes. Codex reads hooks.json or config.toml with a similar event list, and asks you to trust new hooks in /hooks first.
Sources
- https://code.claude.com/docs/en/hooks
- https://code.claude.com/docs/en/hooks-guide
- https://code.claude.com/docs/en/permissions
- https://learn.chatgpt.com/docs/hooks
- https://github.com/muriloprataviera/aki
- https://useaki.vercel.app