Skip to content

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:

  1. Type /hooks in Claude Code. The browser lists configured hooks by event, and yours should be under Notification.
  2. Press Esc, then Shift+Tab until the status bar reads manual mode.
  3. 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

PlatformCommand to useIf nothing appears
macOSosascript -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
Linuxnotify-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
Windowspowershell.exe -Command "..." showing a System.Windows.Forms message boxIt 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:

MatcherFires when
permission_promptAn approval (including a sandboxed command's network request) has waited about six seconds
idle_promptClaude finished around 60 seconds ago and you have not typed since
auth_successSign-in completes
elicitation_dialogAn MCP server shows an input form and you have been idle about six seconds
elicitation_url_dialogAn MCP server wants you to open a URL, idle about six seconds
elicitation_completeAn MCP server reports a URL-mode elicitation finished
elicitation_responseAn elicitation answer is sent back to the server
agent_needs_inputA 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_completedA background session finishes or fails, while agent view is open
quota_auto_resume_firedClaude 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_staleThe limit reset while the machine slept for more than about 30 minutes, so Claude Code waits for Enter
quota_auto_resume_disabledThe 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 PermissionRequest matchers tight. An empty matcher or .* would approve every prompt, shell commands and file writes included.

How hooks run

Events

EventFiresMatcher filters on
SessionStartSession begins or resumesstartup, resume, clear, compact, fork
Setup--init-only, or --init / --maintenance with -pinit, maintenance
UserPromptSubmitBefore a prompt (including self-started turns) reaches Claudenone
UserPromptExpansionA typed command expands into a prompt; can blockcommand name
PreToolUseBefore a tool runs; can blocktool name
PermissionRequestA tool call needs a permission decisiontool name
PermissionDeniedAuto mode denies a call; return hookSpecificOutput.retry: true to allow a retry (ignored without a classifier verdict)tool name
PostToolUseA tool call succeededtool name
PostToolUseFailureA tool call failedtool name
PostToolBatchA batch of parallel calls resolved, before the next model callnone
NotificationClaude Code raises a notificationnotification type
MessageDisplayAssistant text is being displayednone
SubagentStart / SubagentStopA subagent starts or finishesagent type (general-purpose, Explore, Plan, custom names)
TaskCreated / TaskCompletedA task is created via TaskCreate or marked completenone
StopClaude finishes respondingnone
StopFailureTurn ends on an API errorrate_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
TeammateIdleAn agent-team teammate is about to go idlenone
InstructionsLoadedA CLAUDE.md or .claude/rules/*.md file loadssession_start, nested_traversal, path_glob_match, include, compact
ConfigChangeA config file changes mid-sessionconfig source
CwdChangedWorking directory changesnone
DirectoryAddedA directory is added via /add-dir or the SDK register_repo_root requestslash_command, register_repo_root
FileChangedA watched file changes on diskliteral filenames
WorktreeCreate / WorktreeRemoveA worktree is created (via --worktree, isolation: "worktree" or a background session) or a hook-created one is removed; replaces default git behaviournone
PreCompact / PostCompactAround compactionmanual, auto
PreModelSwitchBefore a requested model switch; can blocktarget model name, e.g. .*opus.*
PostModelSwitchAfter the model changes for any reasontarget model name
Elicitation / ElicitationResultAn MCP server asks for input / the answer is about to be sentMCP server name
SessionEndSession terminatesclear, resume, logout, prompt_input_exit, other

Events with "none" ignore the matcher and fire every time.

Handler types

typeWhat runs
commandA shell command (the common case)
httpPOSTs the event JSON to a URL
mcp_toolCalls a tool on a configured MCP server
promptA single-turn model judgement
agentA 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 codeMeaning
0No 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
2Block. 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 elseIf 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:

  • allow skips the prompt. Deny and ask rules (managed ones included) still apply, as do prompts for MCP tools marked requiresUserInteraction and connector tools your organisation set to ask.
  • deny cancels the call and passes the reason to Claude.
  • ask shows the usual prompt.
  • defer (only with -p in 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: additionalContext or permissionDecision placed at the top level of the JSON is silently ignored. Both belong inside hookSpecificOutput.

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 Stop hook that scans the tree once per turn, or match Bash|PowerShell too and inspect git 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

LocationApplies toCommit it?
~/.claude/settings.jsonAll your projectsNo
.claude/settings.jsonOne projectYes
.claude/settings.local.jsonOne project, just youNo
Managed policy settingsWhole organisationAdmin-controlled
Plugin hooks/hooks.jsonWhile the plugin is enabledShips with the plugin
Skill frontmatterRest of the session after the skill is invokedIn the skill file
Subagent frontmatterWhile that subagent runsIn 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. Set continueOnBlock: true to 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: true feeds 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. additionalContext reaches Claude as a system reminder.
  • Default timeouts (override with timeout in seconds): command, http and mcp_tool 10 minutes, lowered to 30 seconds for UserPromptSubmit, PreModelSwitch and PostModelSwitch and 10 seconds for MessageDisplay; prompt 30 seconds; agent 60 seconds. All SessionEnd hooks share a 1.5-second budget, raised to match a longer per-hook timeout up to 60 seconds.
  • PostToolUse cannot undo what already happened.
  • PermissionRequest fires when Claude Code would prompt, or would auto-deny a call that cannot prompt. With -p it still runs outside dontAsk mode, and an undecided call is denied.
  • Stop fires at the end of every response, not just task completion, and not on interrupts. API errors fire StopFailure.

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.