AI Catchup

Claude Code Hooks: A Practical Guide With Six Recipes

By 13 min read

Claude Code hooks run your own shell commands, HTTP endpoints, MCP tools, prompts, or agents at fixed points in a session. You define them in settings.json, narrow them with matchers, and block actions with exit code 2 or JSON. Exit 0 is not approval, and a hook allow never loosens your deny rules.

Claude Code hooks are the deterministic layer of Claude Code: commands that always run at a given point in a session instead of waiting for the model to remember. Use them to format every edit, block risky commands, re-inject context after compaction, and skip approval prompts you would always accept. This guide condenses Anthropic's hooks guide and the hooks reference into what you need to configure them correctly.

If you use Codex as well, its hooks follow a similar idea with a different trust model; see Codex hooks and programmatic access tokens. For TypeScript plugins that change Claude Code's interface as well as its behavior, see Claude Code mods.

Key Takeaways

  • Hooks live in settings files. User, project, local, managed policy, plugin, skill frontmatter, and subagent frontmatter are all valid locations. Run /hooks to see what is configured and where it came from.
  • The reference lists 33 hook events. Most setups need five: PreToolUse, PostToolUse, PermissionRequest, SessionStart, and Stop.
  • There are five hook types: command, http, mcp_tool, prompt, and agent. Agent hooks are experimental.
  • exit 0 is not approval. It means "no objection", and the normal permission flow still runs. exit 2 blocks on events that can block. exit 1 does not block.
  • When hooks disagree on a PreToolUse call, the strictest answer wins: deny, then defer, then ask, then allow.
  • A hook deny beats bypassPermissions, but a hook allow can't loosen your deny rules. For hard policy, use permission rules; the if filter is best-effort.
  • Keep auto-approve matchers narrow. A PermissionRequest hook on ExitPlanMode is safe; an empty matcher approves everything.

What Hooks Are and Where They Live

A hook is a handler that Claude Code runs automatically when a lifecycle event fires, such as before a tool call or when Claude finishes responding. The reference describes hooks as user-defined shell commands, HTTP endpoints, MCP tool calls, LLM prompts, or subagents. The same events fire in the terminal, IDE extensions, the Desktop app, and cloud sessions.

Configuration has three levels: an event (PreToolUse), a matcher group that filters it (Bash), and one or more handlers that run when it matches. Where you put that block sets its scope:

LocationScopeShareable
~/.claude/settings.jsonAll your projectsNo, local to your machine
.claude/settings.jsonSingle projectYes, commit it to the repo
.claude/settings.local.jsonSingle projectNo, gitignored when Claude Code saves a setting to it
Managed policy settingsOrganization-wideYes, admin-controlled
Plugin hooks/hooks.jsonWhile the plugin is enabledYes, bundled with the plugin
Skill frontmatterRest of the session once the skill is invokedYes
Subagent frontmatterWhile that subagent runsYes

Type /hooks at the prompt to open a read-only browser of every configured hook, labeled with its source. To pause all hooks without deleting them, set "disableAllHooks": true; it can't switch off managed hooks unless it is set in managed settings. Hook entries merge across levels instead of replacing each other, so a project hook adds to your user hooks.

Hooks from settings files also fire inside subagents, with agent_id and agent_type in the input so your script can tell them apart. That makes hooks a natural companion to reusable subagent definitions.

One security point before you start: command hooks run with your full user permissions. In an interactive session Claude Code holds hooks back until you accept the workspace trust dialog, but a -p or SDK session treats the folder as trusted, so hooks committed in a repository's .claude/settings.json run in a folder you never trusted. Review a repo's .claude/ directory before you script claude -p over it.

Your First Hook

The fastest useful hook is a desktop notification when Claude is waiting on you. Add a Notification hook to ~/.claude/settings.json:

{
  "hooks": {
    "Notification": [
      {
        "matcher": "",
        "hooks": [
          { "type": "command", "command": "osascript -e 'display notification \"Waiting on you\" with title \"Claude Code\"'" }
        ]
      }
    ]
  }
}

On Linux, swap the command for notify-send; on Windows, use a PowerShell message box. If your settings file already has a hooks key, add Notification beside the existing events rather than replacing the object. Then run /hooks and confirm the entry appears under Notification.

The empty matcher fires on every notification type. To fire on permission prompts alone, set the matcher to permission_prompt, which fires when a prompt has waited about six seconds. idle_prompt fires when Claude finished responding about 60 seconds ago and you haven't typed since.

On macOS, osascript sends notifications through Script Editor. If nothing appears, run the command once in Terminal, then turn on notifications for Script Editor in System Settings.

The Events That Matter Most

The hooks reference lists 33 hook events. You don't need most of them; grouped by when they fire, here is the full set and where to start:

GroupEventsStart with
Tool loopPreToolUse, PermissionRequest, PermissionDenied, PostToolUse, PostToolUseFailure, PostToolBatchPreToolUse to block, PostToolUse to format or lint
TurnUserPromptSubmit, UserPromptExpansion, Stop, StopFailure, MessageDisplayStop to check work before Claude finishes
Session and environmentSessionStart, Setup, SessionEnd, InstructionsLoaded, ConfigChange, CwdChanged, DirectoryAdded, FileChangedSessionStart to load context or environment
Agents, tasks, worktreesSubagentStart, SubagentStop, TaskCreated, TaskCompleted, TeammateIdle, WorktreeCreate, WorktreeRemoveSubagentStop to verify delegated work
Context and modelPreCompact, PostCompact, PreModelSwitch, PostModelSwitchSessionStart with a compact matcher instead
Notifications and MCPNotification, Elicitation, ElicitationResultNotification for alerts

Three cadences matter for performance. SessionStart and SessionEnd run once per session, UserPromptSubmit and Stop run once per turn, and PreToolUse and PostToolUse run on every tool call. Keep per-tool-call hooks fast.

Five Hook Types: command, http, mcp_tool, prompt, agent

Use command for almost everything; reach for the other four when the logic lives elsewhere or needs judgment.

TypeWhat runsUse it forDefault timeout
commandA shell command that reads JSON on stdinFormatting, blocking, logging, context injection10 minutes
httpA POST of the same JSON to a URLA shared team audit or policy service10 minutes
mcp_toolA tool on a configured MCP serverReusing a scanner you already expose over MCP10 minutes
promptA single-turn Claude evaluationJudgment calls on the hook input alone30 seconds
agentA subagent with tools such as Read and GrepChecking the codebase before allowing a stop60 seconds

HTTP hooks can't block through status codes. To deny a tool call, the endpoint returns a 2xx response with the decision in a JSON body. Header values can interpolate environment variables, but only those listed in allowedEnvVars.

MCP tool hooks take a server, a tool, and an optional input whose strings can pull from the hook input, such as ${tool_input.file_path}. Claude Code skips them on SessionStart at launch because MCP servers aren't connected yet, so use a command hook there.

Prompt hooks send your prompt and the hook input to a Claude model, which returns {"ok": true} or {"ok": false, "reason": "..."}. On Stop, a false result feeds the reason back to Claude so it keeps working.

Agent hooks spawn a subagent that can read files and run commands, for up to 50 tool-use turns. Anthropic marks them experimental and recommends command hooks for production workflows.

Exit Codes and JSON Output

A command hook talks back through its exit code and stdout. The rule to remember: exit 0 means "no objection", exit 2 means "block", and anything else is a non-blocking error.

ExitWhat Claude Code does
exit 0, no JSONProceeds. On PreToolUse this is not an approval: the normal permission flow still runs. On UserPromptSubmit, UserPromptExpansion, SessionStart, and PostModelSwitch, plain stdout becomes context for Claude
exit 0 with JSONThe JSON decides the outcome, for example allow, deny, or ask
exit 2Blocks on events that can block, and the stderr text becomes the reason. Even a JSON allow can't override it
exit 1 or otherNon-blocking error unless stdout holds valid JSON. The action proceeds

The exit 1 row is the classic mistake. It is the conventional Unix failure code, yet Claude Code treats it as a non-blocking error and continues, so a policy script that exits 1 enforces nothing. Use exit 2.

Not every event can block. PreToolUse, UserPromptSubmit, Stop, SubagentStop, PreCompact, and ConfigChange can; PostToolUse can't, because the tool already ran, so exit 2 there just shows stderr to Claude. PermissionRequest ignores exit 2 entirely; deny through its JSON decision object instead.

For finer control, exit 0 and print one JSON object. On PreToolUse the decision sits inside hookSpecificOutput:

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "Run the migration through make db-migrate instead"
  }
}

permissionDecision takes four values. allow skips the prompt, deny cancels the call and shows Claude the reason, ask shows the normal prompt, and defer (non-interactive -p runs) exits so an Agent SDK wrapper can resume later. When several PreToolUse hooks answer differently, precedence is deny, then defer, then ask, then allow. All matching hooks still run to completion, so one hook's deny doesn't stop a sibling's side effects.

Other events use different shapes. PostToolUse and Stop use a top-level "decision": "block" with a reason, and PermissionRequest uses hookSpecificOutput.decision.behavior. Each additionalContext, systemMessage, and plain stdout string is capped at 10,000 characters; anything longer goes to a file with a preview.

Matchers and the if Field

A matcher filters a group by one field, usually the tool name, and the if field filters a single handler by tool name and arguments together. Both are filters for efficiency, not security boundaries.

Matcher evaluation depends on the characters you use:

MatcherEvaluated asExample
"", "*", or omittedMatch everythingFires on every occurrence
Letters, digits, _, -, spaces, ,, |Exact names, separated by | or ,Edit|Write matches those two tools exactly
Anything elseUnanchored JavaScript regexEdit.* also matches NotebookEdit; write ^Edit$ for an exact match

Every matcher is case-sensitive. MCP tools are named mcp__<server>__<tool>, and a whole-server matcher needs the trailing .*: mcp__github__.* works, while a bare mcp__github matches no tool. Events without matcher support, such as Stop and UserPromptSubmit, silently ignore a matcher you add.

The if field takes one permission rule, such as "Bash(git *)" or "Edit(*.ts)", so the hook process spawns just for matching calls. Claude Code checks each subcommand, including commands inside $() and backticks, and runs your hook anyway when it can't tell what a Bash command runs. Use if on tool events alone: on any other event, a hook with if set never runs.

Because the filter is best-effort, Anthropic's docs recommend the permission system rather than a hook for a hard allow or deny. Treat a PreToolUse guard as a second line behind a permissions.deny rule, not a replacement for one.

Six Recipes Worth Copying

These six cover the hooks most teams end up writing. Project recipes go in .claude/settings.json; personal ones go in ~/.claude/settings.json. Each snippet below goes inside the top-level hooks object. Every Bash recipe here parses input with jq.

Auto-Format After Every Edit

Run Prettier on every file Claude edits or creates, with a PostToolUse hook matched to Edit|Write:

"PostToolUse": [
  { "matcher": "Edit|Write",
    "hooks": [{ "type": "command", "command": "jq -r '.tool_input.file_path' | xargs npx prettier --write" }] }
]

A successful run prints nothing; check the file to confirm. Files Claude changes through Bash skip this hook. To reformat a specific file however it changes, use FileChanged; to catch every change, add a Stop hook that scans the working tree once per turn.

Protect Sensitive Files

Block edits to .env, lockfiles, or .git/ with a PreToolUse hook on Edit|Write that calls a script. The script reads tool_input.file_path, compares it with your protected patterns, and on a match writes a reason to stderr and runs exit 2. Claude gets the reason and changes course.

"PreToolUse": [
  { "matcher": "Edit|Write",
    "hooks": [{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-files.sh" }] }
]

Run chmod +x on the script. The same shape blocks shell commands: match Bash, read tool_input.command, and exit 2 on a pattern like drop table.

Re-Inject Context After Compaction

Compaction summarizes the conversation and can drop details Claude needs. A SessionStart hook with the compact matcher puts them back, because plain stdout from SessionStart becomes context:

"SessionStart": [
  { "matcher": "compact",
    "hooks": [{ "type": "command", "command": "cat .claude/conventions.txt; git log --oneline -5" }] }
]

For context you want in every session, put it in CLAUDE.md instead. Our context management guide covers when compaction kicks in.

Reload direnv When the Directory Changes

Claude's Bash tool doesn't pick up direnv on its own. Pair a SessionStart hook and a CwdChanged hook that both run direnv export bash > "$CLAUDE_ENV_FILE". Claude Code runs that file as a preamble before each Bash command, so the variables follow Claude into each directory. Run direnv allow once per directory first. To react to edits of .envrc itself, use FileChanged with the matcher .envrc|.env.

Keep an Audit Log of Config Changes

ConfigChange fires when a settings or skills file changes during a session. Append each change to a log in ~/.claude/settings.json:

"ConfigChange": [
  { "matcher": "",
    "hooks": [{ "type": "command", "command": "jq -c '{at: now | todate, source: .source, file: .file_path}' >> ~/claude-config-audit.log" }] }
]

Set the matcher to project_settings or skills to narrow it. To stop a change from taking effect, exit 2 or return {"decision": "block"}, though policy settings changes can't be blocked.

Auto-Approve Plan Mode, and Nothing Else

To stop confirming every finished plan, add a PermissionRequest hook matched to ExitPlanMode alone that prints an allow decision:

"PermissionRequest": [
  { "matcher": "ExitPlanMode",
    "hooks": [{ "type": "command", "command": "echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PermissionRequest\",\"decision\":{\"behavior\":\"allow\"}}}'" }] }
]

Claude Code exits plan mode, restores the mode you had before, and the transcript shows "Allowed by PermissionRequest hook". The hook path always keeps the current conversation; it can't clear context the way the dialog can. Add an updatedPermissions entry with setMode to switch to acceptEdits for the session. Never widen the matcher to .* or leave it empty, because that approves every prompt, including shell commands.

Hooks vs Permission Modes

Hooks in settings files can tighten restrictions, but they can't loosen them past what your permission rules allow. PreToolUse hooks fire before any permission-mode check, in every mode, so a hook deny blocks the tool even in bypassPermissions mode or with --dangerously-skip-permissions. That makes a hook the place for policy users can't switch off by changing modes.

SituationWho wins
Hook returns deny, session in bypassPermissionsThe hook: the tool is blocked
Hook returns allow, a settings deny rule matchesThe deny rule: allow doesn't bypass it
Hook returns allow for an MCP tool marked requiresUserInteractionThe prompt still appears
Hook returns ask, session in auto modeA prompt appears; the classifier can deny but can't approve silently
A mod handling tool.check approves a call your PreToolUse hook blockedThe mod, unless the hook is in managed settings

The last row matters if you install mods. A mod that handles tool.check can approve a call a non-managed PreToolUse hook blocked, so an organization that needs a hook to hold should ship it in managed settings. If you plan to write one, start with our guide to building a Claude Code mod.

Timeouts and the Stop-Hook Cap

Default timeouts depend on the hook type, and you override them per hook with timeout in seconds:

HookDefault timeout
command, http, mcp_tool600 seconds
Those three on UserPromptSubmit, PreModelSwitch, PostModelSwitch30 seconds
Those three on MessageDisplay10 seconds
prompt hooks30 seconds
agent hooks60 seconds
All SessionEnd hooksA shared 1.5-second budget, raised to match a longer per-hook timeout, up to 60 seconds

A timed-out command, HTTP, or MCP tool hook on PreToolUse doesn't block the call; it falls through to the normal permission flow. Don't rely on a stalled hook as a gate.

Stop hooks have a loop guard. Claude Code overrides a Stop hook after it blocks eight times in a row with no tool call from Claude in between, and the count resets whenever Claude calls a tool. Read stop_hook_active from the input and exit 0 when it is true, so your hook allows the stop once it has already forced a continuation. If a hook genuinely needs more rounds, raise the cap with CLAUDE_CODE_STOP_HOOK_BLOCK_CAP. For a goal-driven loop without configuration, /goal is a built-in session-scoped prompt Stop hook.

Troubleshooting

Most hook failures come from one of three causes: the hook never fires, it fires but its JSON is ignored, or it errors and the action proceeds anyway.

The hook isn't firing. Run /hooks and confirm it sits under the right event. Check the matcher's case, since matchers are case-sensitive. Make sure you picked the right event: PreToolUse runs before the tool, PostToolUse after. An if field on a non-tool event stops the hook from ever running, and if /hooks says only managed hooks run, your organization set allowManagedHooksOnly.

The JSON has no effect. Usually something printed before the JSON. A shell profile with an unconditional echo (Git Bash, or BASH_ENV pointing at ~/.bashrc) prepends text, so stdout no longer starts with { and Claude Code treats it as plain text without reporting an error. Wrap profile output in if [[ $- == *i* ]] so it runs in interactive shells alone. The other cause is a field at the wrong level: permissionDecision and additionalContext belong inside hookSpecificOutput, and at the top level they are silently ignored. Start claude --debug and search the log for Hook JSON output had unrecognized keys.

The hook errors and the action proceeds. A mistyped script path exits with a code like 127, which is a non-blocking error, so your policy gate is silently off. Test the script by piping sample JSON into it, use "$CLAUDE_PROJECT_DIR" or absolute paths, and chmod +x it. For full detail, run claude --debug-file /tmp/claude.log or /debug mid-session and read the hook's exit code, stdout, and stderr.

Hooks intercept what Claude does; when you want to hand work off instead, our comparison of subagents, agent teams, and workflows covers which delegation tool fits.

Sources

More skills and workflows

Frequently Asked Questions

How do I auto-approve a permission prompt with a Claude Code hook?

Add a PermissionRequest hook whose matcher names one tool, such as ExitPlanMode, and print JSON with hookSpecificOutput.decision.behavior set to allow. Keep the matcher narrow: an empty matcher or .* would auto-approve every prompt, including file writes and shell commands.

How do I block a command with a PreToolUse hook?

Match the Bash tool, read the command from tool_input on stdin, and exit with code 2 while writing the reason to stderr. Claude sees that reason as the denial. Exit 1 does not block: Claude Code treats it as a non-blocking error and runs the command anyway.

Does a hook that exits 0 approve the tool call?

No. Exit 0 from a PreToolUse hook means no objection, and the normal permission flow still decides whether you get a prompt. To approve a call from a hook, exit 0 and print JSON with permissionDecision set to allow; your deny and ask rules still apply.

Can a hook block a tool in bypassPermissions mode?

Yes. PreToolUse hooks fire before any permission-mode check, so a hook that returns deny blocks the tool even in bypassPermissions mode or with --dangerously-skip-permissions. The reverse does not hold: a hook allow cannot override deny rules from your settings.

Why does my Stop hook keep Claude working forever?

Your hook blocks on a condition that never resolves. Claude Code overrides a Stop hook after it blocks eight times in a row with no tool call in between. Read stop_hook_active from the input and exit early when it is true to avoid the loop.

Get the weekly AI Catchup

Tools, practices, and what matters, in your inbox every week.