Skip to content

Your First Hook

A hook runs a script automatically when something happens in Claude Code — like before Claude runs a bash command, or after Claude edits a file. Hooks let you enforce policies, log activity, validate changes, or integrate with external systems.


Block Claude from running dangerous shell commands, even if you accidentally approve them:

  1. Create the hook script

    Create .claude/hooks/check-bash.sh:

    #!/bin/bash
    # Read the hook input (JSON with tool name and command details)
    INPUT=$(cat)
    # Extract the command being run
    COMMAND=$(echo "$INPUT" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('tool_input', {}).get('command', ''))")
    # Block dangerous patterns
    if echo "$COMMAND" | grep -qE '(rm -rf /|DROP TABLE|> /dev/sda)'; then
    echo "Blocked: dangerous command detected" >&2
    exit 2
    fi
    # Allow everything else
    exit 0
  2. Register the hook in settings

    Add to .claude/settings.json:

    {
    "hooks": {
    "PreToolUse": [
    {
    "matcher": "Bash",
    "handler": {
    "type": "command",
    "command": ".claude/hooks/check-bash.sh"
    }
    }
    ]
    }
    }
  3. Claude now checks every bash command

    Every time Claude wants to run a bash command, your script runs first. If it exits with code 2, Claude stops. If it exits with code 0, Claude proceeds.


The exit code from your script is the decision:

Exit codeMeaning
0Proceed normally — Claude runs the tool
2Block — Claude stops and does not run the tool
Any otherIgnored — Claude proceeds (treat as 0)

This is how you’d prevent Claude from running rm -rf commands, accidentally overwriting production configs, or making network requests to internal services.


  1. Claude decides to run a tool — e.g. rm -rf old_files/

  2. Claude Code finds matching hooks — checks PreToolUse handlers in settings.json for matchers that match "Bash".

  3. Your script runs with JSON on stdin — Claude Code runs check-bash.sh and pipes the tool call JSON to its stdin. Your script parses the command and checks it against your deny list.

  4. Your script exits with a decision:

    • Exit 0 (safe): Claude Code proceeds and runs the Bash command. The output is returned to Claude.
    • Exit 2 (dangerous): Claude Code stops. Whatever your script wrote to stderr becomes the block message shown to Claude.

When Claude Code calls your hook, it passes JSON to your script’s stdin:

{
"event": "PreToolUse",
"tool_name": "Bash",
"tool_input": {
"command": "rm -rf old_files/",
"description": "Remove old files"
},
"session_id": "abc123"
}

For PostToolUse, you also get tool_result with the output.

Parse it with python3 -c "import json,sys; d=json.load(sys.stdin); ..." or jq if you prefer.


The most useful events for beginners:

EventFires whenBlockable?
PreToolUseBefore any tool runsYes — exit 2 to block
PostToolUseAfter a tool succeedsYes — exit 2 to undo/flag
SessionStartWhen a session beginsNo
StopWhen Claude finishes a responseYes — exit 2 to continue
FileChangedWhen a watched file changesNo

For the full list of 26 events, see Hooks/event-reference.md.


A complete walkthrough: logging all file edits

Section titled “A complete walkthrough: logging all file edits”

Here’s a hook that logs every file Claude edits to a file, which you can review later:

.claude/hooks/log-edits.sh

#!/bin/bash
INPUT=$(cat)
FILE=$(echo "$INPUT" | python3 -c "
import json, sys
d = json.load(sys.stdin)
print(d.get('tool_input', {}).get('file_path', 'unknown'))
")
echo "$(date '+%Y-%m-%d %H:%M:%S') Claude edited: $FILE" >> .claude/edit-log.txt
exit 0

.claude/settings.json

{
"hooks": {
"PostToolUse": [
{
"matcher": "Write",
"handler": {
"type": "command",
"command": ".claude/hooks/log-edits.sh"
}
}
]
}
}

This hook runs after every Write tool call (not before, so it can’t block — it just logs).


{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"handler": {
"type": "command",
"command": "path/to/script.sh",
"timeout": 5000
}
}
]
}
}
FieldRequiredWhat it does
matcherNoFilter by tool name (e.g., "Bash", "Write"). Omit to match all tools for this event.
handler.typeYes"command" (shell script), "http" (webhook), "prompt" (ask Claude), "agent" (subagent)
handler.commandYes (for type: command)Path to your script. Relative to project root.
handler.timeoutNoMilliseconds before the hook is killed (default: 60000)

Hook never fires

  • Check JSON syntax in settings.json — a typo breaks all hooks
  • Make sure the event name is spelled correctly (PreToolUse, not pre-tool-use)
  • Check that matcher matches the tool name exactly ("Bash" not "bash")

Hook fires but doesn’t block

  • Exit code 2 blocks; anything else (including exit 1) does not block
  • Make sure your script actually exits — infinite loops hang the session

Script can’t find my file

  • Hooks run with the project root as the working directory
  • Use relative paths from the project root, or absolute paths


← Back to GettingStarted/README.md