Automating with hooks
A practical guide to Claude Code hooks, with worked examples for notifications, formatting, guardrails, context injection and environment reloading.
Instructions in CLAUDE.md are suggestions: Claude usually follows them, but nothing forces it to. Hooks are the opposite. A hook is a handler (usually a shell command) that Claude Code itself runs at a fixed point in its lifecycle, every time, regardless of what the model decides. If something must always happen, such as formatting after an edit or refusing to touch .env, make it a hook.
This page is the hands-on guide. Event schemas, every output field and the edge cases live in the hooks reference.
Your first hook: a ping when Claude is waiting
The hook I install on every new machine is a desktop alert when Claude needs me, so I can work in another window. Hooks live under a hooks key in a settings file. Put this in ~/.claude/settings.json (create the file if needed):
{
"hooks": {
"Notification": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "osascript -e 'display notification \"Waiting on you\" with title \"Claude Code\" sound name \"Glass\"'"
}
]
}
]
}
}
The structure is always the same three levels: event name, then a list of matcher groups, then a list of handlers inside each group. If your file already has a hooks object, add Notification beside the existing event keys rather than replacing the object.
To check it:
- Type
/hooksin Claude Code. The browser lists configured hooks by event, and yours should be underNotification. - Press
Esc, thenShift+Tabuntil the status bar reads manual mode. - Ask for something that needs permission and switch to another app. The alert should appear.
You can also just describe the hook you want and let Claude write the settings block.
Notification commands for other platforms
| Platform | Command to use | If nothing appears |
|---|---|---|
| macOS | osascript -e 'display notification "..." with title "..."' | Notifications are delivered via Script Editor. Run any osascript notification once in Terminal, then enable Allow Notifications for Script Editor in System Settings > Notifications. It fails silently until you do |
| Linux | notify-send 'Claude Code' 'Waiting on you' | Needs a notification daemon, which SSH sessions, servers and most containers lack. Install libnotify-bin (Debian/Ubuntu) or your distro's equivalent |
| Windows | powershell.exe -Command "..." showing a System.Windows.Forms message box | It is a dialog, so it can hide behind the terminal. Under WSL, powershell.exe must be reachable on PATH via interop |
Narrowing notifications
An empty matcher fires for every notification. Set the matcher to one of these types to be choosier:
| Matcher | Fires when |
|---|---|
permission_prompt | An approval (including a sandboxed command's network request) has waited about six seconds |
idle_prompt | Claude finished around 60 seconds ago and you have not typed since |
auth_success | Sign-in completes |
elicitation_dialog | An MCP server shows an input form and you have been idle about six seconds |
elicitation_url_dialog | An MCP server wants you to open a URL, idle about six seconds |
elicitation_complete | An MCP server reports a URL-mode elicitation finished |
elicitation_response | An elicitation answer is sent back to the server |
agent_needs_input | A background session needs you while agent view is open; also a teammate's terminal setup question in agent teams, or auto mode's classifier billing notice, after about six idle seconds |
agent_completed | A background session finishes or fails, while agent view is open |
quota_auto_resume_fired | Claude Code resumes your task after a claude.ai usage limit (at reset, or earlier if you add credit, upgrade or switch model) |
quota_auto_resume_stale | The limit reset while the machine slept for more than about 30 minutes, so Claude Code waits for Enter |
quota_auto_resume_disabled | The usage-limit wait ended without continuing (for example autoContinueAtUsageLimit turned off, the reset moved over 24 hours away, or the resumed task kept hitting the limit). Not fired when you press Esc, Ctrl+C or choose Don't continue automatically |
Version notes: the three quota_auto_resume_* matchers need v2.1.234+, permission_prompt for sandbox network requests in a terminal needs v2.1.246+, and agent_needs_input for teammate setup questions needs v2.1.248+. Timing of permission_prompt differs between the terminal and SDK-based hosts such as the desktop app and VS Code.
Recipes
The examples below assume jq is installed (brew install jq or apt-get install jq), because hooks receive their input as JSON on stdin.
Format every file Claude edits
Project-level .claude/settings.json, so the whole team gets it:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "f=$(jq -r '.tool_input.file_path'); case \"$f\" in *.py) ruff format \"$f\" ;; *.ts|*.tsx) npx prettier --write \"$f\" ;; esac"
}
]
}
]
}
}
A successful hook prints nothing in the conversation; the evidence is the reformatted file. Edit|Write only catches the editing tools, so if a shell command rewrites a file the hook will not see it. For "whenever this file changes, however", use a FileChanged hook instead.
Refuse edits to sensitive files
A script is easier to maintain than a one-liner. Save as .claude/hooks/guard-paths.sh:
#!/usr/bin/env bash
target=$(jq -r '.tool_input.file_path // empty')
target="${target//\\//}" # treat Windows separators like forward slashes
for blocked in ".env" "secrets/" "terraform.tfstate" ".git/"; do
if [[ "$target" == *"$blocked"* ]]; then
echo "Refused: $target is on the protected list ($blocked). Ask the user to change it by hand." >&2
exit 2
fi
done
exit 0
Make it executable with chmod +x .claude/hooks/guard-paths.sh, then register it:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/guard-paths.sh" }
]
}
]
}
}
Exit code 2 blocks the call and hands your stderr to Claude as feedback, so it changes approach instead of retrying blindly. $CLAUDE_PROJECT_DIR keeps the path correct whatever directory Claude is in.
Put key facts back after compaction
When the context fills, compaction summarises the conversation and details get lost. A SessionStart hook with the compact matcher runs straight after, and plain stdout from that event is added to Claude's context:
{
"hooks": {
"SessionStart": [
{
"matcher": "compact",
"hooks": [
{
"type": "command",
"command": "echo 'Package manager is pnpm. Never edit generated/ by hand.'; git log --oneline -3"
}
]
}
]
}
}
For context that every session needs from the start, CLAUDE.md is the better home. For environment variables, write to CLAUDE_ENV_FILE (below).
Log configuration changes
ConfigChange fires when something outside Claude Code edits a settings or skills file mid-session. In ~/.claude/settings.json:
{
"hooks": {
"ConfigChange": [
{
"matcher": "project_settings|local_settings",
"hooks": [
{
"type": "command",
"command": "jq -c '{at: (now|todate), kind: .source, path: .file_path}' >> ~/.claude/config-changes.jsonl"
}
]
}
]
}
}
The matcher values are user_settings, project_settings, local_settings, policy_settings and skills. To stop the change applying, exit 2 or print {"decision": "block"}.
Keep direnv (or devbox) in sync
Claude's Bash tool does not run your shell's directory hooks, so tools like direnv go stale. Write their output to CLAUDE_ENV_FILE, which Claude Code sources before every Bash command, both at startup and whenever the working directory changes:
{
"hooks": {
"SessionStart": [
{ "hooks": [ { "type": "command", "command": "direnv export bash > \"$CLAUDE_ENV_FILE\"" } ] }
],
"CwdChanged": [
{ "hooks": [ { "type": "command", "command": "direnv export bash > \"$CLAUDE_ENV_FILE\"" } ] }
]
}
}
Run direnv allow once per directory with an .envrc. Devbox users can swap in devbox shellenv (or devbox global shellenv).
To reload only when particular files change, use FileChanged with a matcher such as ".envrc|.env.local". For this event the matcher is split on | into literal filenames to watch, not treated as a regular expression.
Skip the "proceed with this plan?" prompt
PermissionRequest hooks run when Claude Code is about to ask you something, and can answer on your behalf by printing JSON:
{
"hooks": {
"PermissionRequest": [
{
"matcher": "ExitPlanMode",
"hooks": [
{
"type": "command",
"command": "echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PermissionRequest\",\"decision\":{\"behavior\":\"allow\"}}}'"
}
]
}
]
}
}
When it approves, plan mode ends and the previous permission mode is restored; the transcript says "Allowed by PermissionRequest hook". The hook path always continues in the same conversation; it cannot clear context the way the dialog can.
To pick a specific mode, add an updatedPermissions entry to the decision:
{
"hookSpecificOutput": {
"hookEventName": "PermissionRequest",
"decision": {
"behavior": "allow",
"updatedPermissions": [
{ "type": "setMode", "mode": "acceptEdits", "destination": "session" }
]
}
}
}
mode can be any permission mode. bypassPermissions only takes effect if bypass was already available when the session started (via --dangerously-skip-permissions, --permission-mode bypassPermissions, --allow-dangerously-skip-permissions, or permissions.defaultMode in user, --settings or managed settings), and not if permissions.disableBypassPermissionsMode is set or the session runs in restricted mode. It is never saved as defaultMode.
Warning: Keep
PermissionRequestmatchers tight. An empty matcher or.*would approve every prompt, shell commands and file writes included.
How hooks run
Events
| Event | Fires | Matcher filters on |
|---|---|---|
SessionStart | Session begins or resumes | startup, resume, clear, compact, fork |
Setup | --init-only, or --init / --maintenance with -p | init, maintenance |
UserPromptSubmit | Before a prompt (including self-started turns) reaches Claude | none |
UserPromptExpansion | A typed command expands into a prompt; can block | command name |
PreToolUse | Before a tool runs; can block | tool name |
PermissionRequest | A tool call needs a permission decision | tool name |
PermissionDenied | Auto mode denies a call; return hookSpecificOutput.retry: true to allow a retry (ignored without a classifier verdict) | tool name |
PostToolUse | A tool call succeeded | tool name |
PostToolUseFailure | A tool call failed | tool name |
PostToolBatch | A batch of parallel calls resolved, before the next model call | none |
Notification | Claude Code raises a notification | notification type |
MessageDisplay | Assistant text is being displayed | none |
SubagentStart / SubagentStop | A subagent starts or finishes | agent type (general-purpose, Explore, Plan, custom names) |
TaskCreated / TaskCompleted | A task is created via TaskCreate or marked complete | none |
Stop | Claude finishes responding | none |
StopFailure | Turn ends on an API error | rate_limit, overloaded, authentication_failed, oauth_org_not_allowed, account_on_hold, billing_error, invalid_request, model_not_found, server_error, max_output_tokens, cloud_credential_error, unknown |
TeammateIdle | An agent-team teammate is about to go idle | none |
InstructionsLoaded | A CLAUDE.md or .claude/rules/*.md file loads | session_start, nested_traversal, path_glob_match, include, compact |
ConfigChange | A config file changes mid-session | config source |
CwdChanged | Working directory changes | none |
DirectoryAdded | A directory is added via /add-dir or the SDK register_repo_root request | slash_command, register_repo_root |
FileChanged | A watched file changes on disk | literal filenames |
WorktreeCreate / WorktreeRemove | A worktree is created (via --worktree, isolation: "worktree" or a background session) or a hook-created one is removed; replaces default git behaviour | none |
PreCompact / PostCompact | Around compaction | manual, auto |
PreModelSwitch | Before a requested model switch; can block | target model name, e.g. .*opus.* |
PostModelSwitch | After the model changes for any reason | target model name |
Elicitation / ElicitationResult | An MCP server asks for input / the answer is about to be sent | MCP server name |
SessionEnd | Session terminates | clear, resume, logout, prompt_input_exit, other |
Events with "none" ignore the matcher and fire every time.
Handler types
type | What runs |
|---|---|
command | A shell command (the common case) |
http | POSTs the event JSON to a URL |
mcp_tool | Calls a tool on a configured MCP server |
prompt | A single-turn model judgement |
agent | A subagent with tool access (experimental) |
Input
Every event delivers JSON on stdin with shared fields such as session_id, cwd and hook_event_name, plus event-specific ones. A PreToolUse for Bash looks roughly like:
{
"session_id": "7f3c9e",
"cwd": "/home/cam/projects/invoice-router",
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": { "command": "pnpm run migrate" }
}
UserPromptSubmit carries prompt; SessionStart carries source; and so on. The reference lists every schema.
Output: exit codes
| Exit code | Meaning |
|---|---|
0 | No objection. For PreToolUse this is not an approval: normal permission rules still apply. For UserPromptSubmit, UserPromptExpansion, SessionStart and PostModelSwitch, plain stdout is added to Claude's context |
2 | Block. stderr becomes the reason; depending on the event it goes to Claude, to you, or (for ConfigChange, Elicitation) nowhere. Non-blockable events such as SessionStart just show stderr and carry on |
| Anything else | If stdout is valid JSON that passes validation, the JSON decides and the exit code is ignored (with per-event exceptions such as WorktreeCreate, which fails on any non-zero code). Invalid JSON gives a non-blocking error. Plain or empty stdout gives a non-blocking <hook name> hook error notice showing the first stderr line after Failed with non-blocking status code: |
Output: structured JSON
For richer control, exit 0 and print JSON. Pick one style per hook: exit 2 with stderr, or exit 0 with JSON.
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Run migrations through `make migrate` so the lock is taken."
}
}
permissionDecision values on PreToolUse:
allowskips the prompt. Deny and ask rules (managed ones included) still apply, as do prompts for MCP tools markedrequiresUserInteractionand connector tools your organisation set toask.denycancels the call and passes the reason to Claude.askshows the usual prompt.defer(only with-pin headless mode) exits with the tool call preserved so an SDK wrapper can gather input and resume.
PreModelSwitch uses the same field: allow proceeds, deny cancels, and ask prompts only when you ran /model interactively (elsewhere it counts as a refusal).
Other events differ: PostToolUse and Stop use a top-level decision: "block", PermissionRequest uses hookSpecificOutput.decision.behavior. To add context from UserPromptSubmit, use hookSpecificOutput.additionalContext:
{
"hookSpecificOutput": {
"hookEventName": "UserPromptSubmit",
"additionalContext": "On-call this week: Priya. Staging is frozen until Thursday."
}
}
Note:
additionalContextorpermissionDecisionplaced at the top level of the JSON is silently ignored. Both belong insidehookSpecificOutput.
When several hooks match
All matching handlers run in parallel and to completion before results are merged, so one hook's deny does not stop another hook's side effects. For PreToolUse decisions, the strictest wins in the order deny, defer, ask, allow. Every hook's additionalContext is kept. If more than one hook rewrites a tool's input with updatedInput, whichever finishes last wins, which is effectively random, so avoid it.
A logging hook and a guard hook on the same Bash matcher therefore both run: the log line is written even when the guard blocks the command.
Matchers
Tool-event matchers take tool names: Bash, Edit|Write (a comma works too: "Edit, Write"), or regular expressions. MCP tools are named mcp__<server>__<tool>, so mcp__github__.* covers one server and mcp__.*__delete.* spans servers. Plugin-bundled servers use a scoped segment such as mcp__plugin_my-plugin_db__query. Matchers are case-sensitive.
Note: Claude can also change files with shell commands. For full coverage (compliance, audit), add a
Stophook that scans the tree once per turn, or matchBash|PowerShelltoo and inspectgit status --porcelain.
The if field
if filters a single handler by tool and arguments using permission rule syntax, so the process only spawns when relevant:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"if": "Bash(terraform *)",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/tf-policy.sh"
}
]
}
]
}
}
With Bash(terraform *): terraform plan runs the hook; make lint && terraform apply runs it (each subcommand is checked); echo $(terraform output) runs it (substitutions are checked); echo $(date) does not. A pattern that specifies more than the command name, such as Bash(terraform apply *), runs anyway when it sees $(), backticks or $VAR. If Claude Code cannot work out what a command runs, the hook runs. Because this is best effort, enforce hard rules with permissions, not if.
if only works on PreToolUse, PostToolUse, PostToolUseFailure, PermissionRequest and PermissionDenied. On any other event it stops the hook running at all. To match several tools, use separate handlers or pipe alternation in matcher.
Where hooks can live
| Location | Applies to | Commit it? |
|---|---|---|
~/.claude/settings.json | All your projects | No |
.claude/settings.json | One project | Yes |
.claude/settings.local.json | One project, just you | No |
| Managed policy settings | Whole organisation | Admin-controlled |
Plugin hooks/hooks.json | While the plugin is enabled | Ships with the plugin |
| Skill frontmatter | Rest of the session after the skill is invoked | In the skill file |
| Subagent frontmatter | While that subagent runs | In the agent file |
"disableAllHooks": true switches hooks off, evaluated after settings precedence (so a project can override you). Managed hooks keep running unless managed settings also set it. Edits to settings files are normally picked up live.
Model-powered hooks
Prompt hooks
type: "prompt" sends your prompt plus the event JSON to a Claude model (override with model) which must answer {"ok": true} or {"ok": false, "reason": "..."}.
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "prompt",
"prompt": "Did the assistant update CHANGELOG.md for the user-facing change it made? Reply {\"ok\": false, \"reason\": \"...\"} if not."
}
]
}
]
}
}
What ok: false does:
Stop,SubagentStop: the reason becomes Claude's next instruction, unless the reply also sets"impossible": true, which lets the turn end.PreToolUse: the call is denied, the turn ends and the reason appears as a warning. SetcontinueOnBlock: trueto return it to Claude as a tool error instead. (Before v2.1.210 the reason always went back to Claude.)PostToolUse: turn ends with a warning by default;continueOnBlock: truefeeds it back instead.PostToolBatch,UserPromptSubmit,UserPromptExpansion: turn ends with a warning.
Agent hooks
type: "agent" (experimental) spawns a subagent that can read files and run tools before deciding, so it can check real state. Same ok/reason contract, default timeout 60 seconds, up to 50 tool turns, no impossible field, and ok: false behaves like a prompt hook with continueOnBlock: true. $ARGUMENTS in the prompt is replaced with the event JSON.
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "agent",
"prompt": "Run `pnpm typecheck`. Return ok:false with the first errors if it fails. Context: $ARGUMENTS",
"timeout": 180
}
]
}
]
}
}
Rule of thumb: prompt hooks when the event data is enough to judge; agent hooks when you need to look at the codebase. For anything production-critical, prefer command hooks.
HTTP hooks
type: "http" POSTs the same JSON a command would get on stdin, and reads the same JSON format back from the response body. Useful for a shared team audit service.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "http",
"url": "https://hooks.internal.example/claude/bash",
"headers": { "X-Api-Key": "${AUDIT_KEY}" },
"allowedEnvVars": ["AUDIT_KEY"]
}
]
}
]
}
}
Status codes cannot block on their own: return a 2xx with the decision fields in the body. Headers interpolate $VAR or ${VAR}, but only for names listed in allowedEnvVars; others resolve empty.
Limits worth knowing
- Command hooks talk only through stdout, stderr and exit codes; they cannot run slash commands or tools.
additionalContextreaches Claude as a system reminder. - Default timeouts (override with
timeoutin seconds):command,httpandmcp_tool10 minutes, lowered to 30 seconds forUserPromptSubmit,PreModelSwitchandPostModelSwitchand 10 seconds forMessageDisplay;prompt30 seconds;agent60 seconds. AllSessionEndhooks share a 1.5-second budget, raised to match a longer per-hooktimeoutup to 60 seconds. PostToolUsecannot undo what already happened.PermissionRequestfires when Claude Code would prompt, or would auto-deny a call that cannot prompt. With-pit still runs outsidedontAskmode, and an undecided call is denied.Stopfires at the end of every response, not just task completion, and not on interrupts. API errors fireStopFailure.
Hooks and permission modes
PreToolUse runs before any permission-mode logic, in every mode including dontAsk and bypassPermissions. A hook deny therefore wins even under --dangerously-skip-permissions, which makes hooks good for policy users cannot switch off. The reverse does not hold: a hook allow cannot override deny rules or the mandatory prompts mentioned above. Settings and plugin hooks can tighten but never loosen. One exception: an installed mod handling tool.check can approve a call your PreToolUse hook blocked, unless that hook lives in managed settings.
Troubleshooting
The hook never fires. Check /hooks lists it under the right event, that the matcher matches the exact, case-sensitive tool name, and that you picked the right event (PreToolUse is before, PostToolUse after).
"... hook error" in the transcript. Run the script by hand with sample input:
echo '{"tool_name":"Edit","tool_input":{"file_path":"src/app.ts"}}' | ./.claude/hooks/guard-paths.sh; echo "exit=$?"
Then: use absolute paths or ${CLAUDE_PROJECT_DIR} if you see "command not found" (or add "args": [] for exec form, which skips the shell entirely); install jq if that is missing; chmod +x the script; and if the notice mentions JSON validation or parsing, build output with jq rather than string concatenation.
/hooks is empty. Wait a few seconds or restart the session; check the JSON for trailing commas or comments; check the file path. If the menu says Only hooks from managed settings run here, your organisation set allowManagedHooksOnly.
A Stop hook loops. Claude Code overrides a Stop hook after eight consecutive blocks with no tool call in between. Check stop_hook_active in the input and exit 0 when it is true. Raise the cap with CLAUDE_CODE_STOP_HOOK_BLOCK_CAP if you genuinely need more rounds.
Valid JSON, no effect. Usually either a field at the wrong nesting level, or something printing before the JSON. Shell-form hooks run via sh -c (Git Bash or PowerShell on Windows), and some setups, such as BASH_ENV pointing at ~/.bashrc, source your profile. A banner echo there gets prepended, the output no longer starts with {, and the JSON is treated as plain text. Guard profile output with if [[ $- == *i* ]]; then ... fi. Run claude --debug and search for Hook JSON output had unrecognized keys to spot misplaced fields.
Seeing what happened. Ctrl+O opens the transcript view: successful hooks are silent unless they emit something like systemMessage; blocking errors show the reason or stderr; non-blocking errors show a <hook name> hook error notice. For full detail start with claude --debug-file /tmp/claude-hooks.log and tail -f it, or run /debug mid-session to turn logging on. More in Debug your configuration.