NEW: Claude Code Security — research preview →

Hooks

Deterministic automation at Claude Code lifecycle events — enforce rules regardless of what Claude decides

NEWRead time: 8 min

title: "Hooks" description: "Deterministic automation at Claude Code lifecycle events — enforce rules regardless of what Claude decides" section: "Core" readTime: "8 min" badge: "NEW"

Hooks

Hooks execute shell commands at fixed lifecycle events in Claude Code. Unlike CLAUDE.md instructions (which Claude reads and tries to follow), hooks are deterministic — they run regardless of what Claude decides to do.

Use hooks when you need guaranteed behavior: run a formatter after every file edit, block dangerous commands before they execute, post to Slack when a session ends.


Hook Events

EventWhen it fires
SessionStartWhen a session begins or resumes
UserPromptSubmitWhen a prompt is submitted, before Claude processes it
PreToolUseBefore a tool call executes. Can block it
PermissionRequestWhen a tool call needs a permission decision
PostToolUseAfter a tool call succeeds
PostToolUseFailureAfter a tool call fails
NotificationWhen Claude Code sends a notification
SubagentStart / SubagentStopWhen a subagent is spawned / finishes
StopWhen Claude finishes responding
InstructionsLoadedWhen a CLAUDE.md or .claude/rules/*.md file is loaded into context
PreCompact / PostCompactBefore / after context compaction
SessionEndWhen a session terminates

This is a subset. See the official hooks reference for the full, current list of events.

There are no dedicated events for git commits or file deletion. To react to them, use PreToolUse with a Bash matcher and an if filter such as Bash(git commit *) or Bash(rm *).


Configuration

Hooks live in .claude/settings.json (project), .claude/settings.local.json (local, gitignored), or ~/.claude/settings.json (personal).

The hooks setting is an object with three levels of nesting:

  1. A hook event (such as PreToolUse)
  2. A matcher group that filters when it fires (such as "only the Bash tool")
  3. One or more hook handlers that run when matched
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "if": "Edit(*.ts)",
            "command": "npx prettier --write \"$(jq -r '.tool_input.file_path')\""
          },
          {
            "type": "command",
            "if": "Write(*.ts)",
            "command": "npx prettier --write \"$(jq -r '.tool_input.file_path')\""
          }
        ]
      }
    ],
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "if": "Bash(rm *)",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/block-rm.sh"
          }
        ]
      }
    ]
  }
}

The matcher is a string. A value made only of letters, digits, _, -, spaces, | and , (like Bash or Write|Edit) is an exact tool-name match, or a list of exact names; anything else is an unanchored JavaScript regular expression (so Edit.* also matches NotebookEdit — use ^Edit$ for a whole-name match). Omit it or use "*" to match every occurrence.

The optional if field on a handler narrows further using permission rule syntax, such as "Bash(git *)" or "Edit(*.ts)". It holds exactly one rule — there is no &&, ||, or | list syntax — so define a separate handler for each condition. if is only evaluated on tool events (PreToolUse, PostToolUse, PostToolUseFailure, PermissionRequest, PermissionDenied); on any other event a handler with if set never runs.


Handler Types and Decisions

Each handler has a type: command (shell command), http (POST to an endpoint), mcp_tool, prompt, or agent.

A hook doesn't use an action field. It controls Claude Code through its exit code or JSON output:

OutcomeBehavior
Exit code 0Success; stdout may contain a JSON decision
Exit code 2Blocking error; stderr is fed back to Claude (for PreToolUse, the tool call is blocked)
Any other non-zero exitNon-blocking error; the action proceeds. Use exit 2, not exit 1, to enforce a policy
permissionDecision: "deny"PreToolUse: cancel the tool call and show permissionDecisionReason
permissionDecision: "ask"PreToolUse: pause and ask the user for confirmation
permissionDecision: "allow"PreToolUse: allow the call without prompting

Deny and ask rules from your permission settings are still evaluated regardless of what a hook returns. When several PreToolUse hooks disagree, deny wins over ask, which wins over allow.

For example, block-rm.sh can ask for confirmation before a recursive delete:

#!/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: "ask",
      permissionDecisionReason: "Recursive delete requested"
    }
  }'
fi

Hook Input

Command hooks receive the event context as JSON on stdin, not as environment variables. Common fields include session_id, cwd, and hook_event_name; tool events add tool_name and tool_input (and tool_response for PostToolUse).

jq -r '.tool_input.file_path'   # path of the file a Write/Edit call touched
jq -r '.tool_input.command'     # command of a Bash call

To reference scripts regardless of the working directory, use ${CLAUDE_PROJECT_DIR} (the project root). Plugins can use ${CLAUDE_PLUGIN_ROOT}.


Common Patterns

Auto-format on write

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": "f=$(jq -r '.tool_input.file_path'); case \"$f\" in *.ts|*.tsx) npx prettier --write \"$f\" ;; esac"
          }
        ]
      }
    ]
  }
}

Because if accepts only one rule, this example filters by extension inside the command instead of listing four if rules.

Block secrets from being committed

There is no commit event, so intercept the git commit Bash call and exit with code 2 to block it:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "if": "Bash(git commit *)",
            "command": "if git diff --cached | grep -qE '(sk-[A-Za-z0-9]|AKIA[0-9A-Z]{16})'; then echo 'Possible secret detected in staged changes' >&2; exit 2; fi"
          }
        ]
      }
    ]
  }
}

Log which instruction files were loaded (debugging)

{
  "hooks": {
    "InstructionsLoaded": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "jq -r '\"[\" + (now | todate) + \"] Loaded: \" + .file_path' >> ~/.claude/instruction-log.txt"
          }
        ]
      }
    ]
  }
}

The InstructionsLoaded event is particularly useful for debugging why a CLAUDE.md rule or path-scoped rule isn't being applied — you can see exactly which files were discovered and when.

Slack notification on session end

{
  "hooks": {
    "SessionEnd": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "curl -s -X POST \"$SLACK_WEBHOOK\" -d \"{\\\"text\\\":\\\"Claude session $(jq -r .session_id) finished\\\"}\""
          }
        ]
      }
    ]
  }
}

Hooks in Skills

Hooks can be scoped to a skill's lifecycle using the hooks frontmatter field, which uses the same event-keyed format as settings. They are registered when the skill is invoked and stay active for the rest of the session; add once: true to a handler to remove it after its first successful run. Subagents support the same hooks frontmatter, but those hooks run only while that subagent is running.

---
name: deploy
description: Deploy to production
disable-model-invocation: true
hooks:
  PreToolUse:
    - matcher: Bash
      hooks:
        - type: command
          if: "Bash(*production*)"
          command: "${CLAUDE_PROJECT_DIR}/.claude/hooks/confirm-deploy.sh"
---
 
Deploy the application to production...

Hooks vs CLAUDE.md Instructions

CLAUDE.mdHooks
EnforcementClaude reads and tries to followExecuted by the runtime, always
Best forStyle guidance, conventionsSecurity policies, formatters, notifications
Can be overridden by ClaudeYes (it's context)No
Runs shell commandsNoYes

If an instruction must run at a specific lifecycle point — before every commit, after every file write — use a hook. If it's guidance Claude should follow while coding, use CLAUDE.md.


Security Considerations

  • In an interactive session, Claude Code holds back hooks from settings files (project and even your own ~/.claude/settings.json) until you accept the workspace trust dialog for the folder
  • Review project hooks before trusting a repository — they can run arbitrary shell commands
  • Managed hooks (deployed by IT/DevOps) cannot be overridden by individual settings: disableAllHooks in user or project settings doesn't turn them off, and managed allowManagedHooksOnly blocks user, project, local, and plugin hooks
  • Run /hooks in a session to browse every configured hook (read-only) along with where it comes from