Builder6 min readFree

Hooks for beginners

Add three hooks that always run, format files after every edit, trim test output to failures, and block a risky command.

You can ask Claude to run Prettier after every edit, and most of the time it will. A hook makes it happen every time. This guide is for people comfortable with Claude Code and a terminal who want a few rules that never depend on Claude remembering them. You will set up three hooks and learn how to test and debug them.

What you will have at the end

  • A hook that formats every file Claude edits.
  • A hook that cuts test output down to the failures before Claude reads it.
  • A hook that blocks a dangerous shell command and tells Claude why.
  • A way to check each hook loaded and to debug one that does not fire.

Before you start

  • Claude Code installed and signed in.
  • jq installed. The examples use it to read the JSON that Claude Code passes to hooks. Install it with brew install jq on macOS or apt-get install jq on Debian and Ubuntu (hooks guide).
  • For Step 1, a JavaScript or TypeScript project where npx prettier works.

How hooks work in one minute

Hooks are shell commands that Claude Code runs at fixed points in a session (hooks guide). The docs call this "deterministic control", and that is the reason to use them: the action always happens rather than relying on the model to choose it.

Three events cover most beginner needs (how hooks work):

  • PreToolUse fires before a tool call runs, and can block it.
  • PostToolUse fires after a tool call succeeds.
  • Notification fires when Claude Code sends a notification, for example when it waits for you.

When an event fires, Claude Code sends JSON about it to your command on stdin. For a Bash call, that includes tool_name and tool_input.command. Your command answers with its exit code: 0 means no objection, 2 means block, with the reason on stderr passed to Claude (read input and return output).

Hooks live in a hooks block in a settings file. The file decides the scope (hook location):

  • ~/.claude/settings.json: all your projects, only on your machine.
  • .claude/settings.json: this project, and you can commit it.
  • .claude/settings.local.json: this project, just for you.

Step 1: format every file after an edit

Add this to .claude/settings.json in your project root (auto-format):

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

The matcher limits the hook to the Edit and Write tools. The command pulls the edited file's path out of the JSON with jq and hands it to Prettier.

A successful hook shows nothing in the conversation. To test it, ask Claude to add a line with single-quoted strings to a JavaScript file. With Prettier's defaults, the file should come back with double quotes.

Step 2: show Claude only the failing tests

A full test run can flood Claude's context with passing lines. This hook from the costs docs rewrites test commands so only failures come back (costs page).

Create the folder and the script:

mkdir -p ~/.claude/hooks

Save this as ~/.claude/hooks/filter-test-output.sh:

#!/bin/bash
input=$(cat)
cmd=$(echo "$input" | jq -r '.tool_input.command')

# If running tests, filter to show only failures
pattern='^(npm test|pytest|go test)( [^;&|<>`$#\\[:cntrl:]]*)?$'
if [[ "$cmd" =~ $pattern ]]; then
  filtered_cmd="$cmd 2>&1 | grep -A 5 -E '(FAIL|ERROR|error:)' | head -100"
  echo "$input" | jq --arg filtered "$filtered_cmd" \
    '{hookSpecificOutput: {hookEventName: "PreToolUse", updatedInput: (.tool_input + {command: $filtered})}}'
else
  echo "{}"
fi

Make it executable:

chmod +x ~/.claude/hooks/filter-test-output.sh

Register it in ~/.claude/settings.json:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "~/.claude/hooks/filter-test-output.sh"
          }
        ]
      }
    ]
  }
}

This one does not block anything. When the command is npm test, pytest or go test, the script returns JSON with an updatedInput that pipes the output through grep and head. Any other command passes through untouched.

If your settings file already has a hooks key, add the new event as a sibling inside the same hooks object rather than replacing it (first hook).

Step 3: block a risky command

Save this as .claude/hooks/block-drop-table.sh in your project. It is the blocking example from the hooks guide (hook output):

#!/bin/bash
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command')

if echo "$COMMAND" | grep -q "drop table"; then
  echo "Blocked: dropping tables is not allowed" >&2  # stderr becomes Claude's feedback
  exit 2                                               # exit 2 = block the action
fi

exit 0  # exit 0 = no decision; the normal permission flow applies

Make it executable with chmod +x .claude/hooks/block-drop-table.sh, then register it in .claude/settings.json next to the formatter:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-drop-table.sh"
          }
        ]
      }
    ]
  }
}

Change the grep pattern to whatever you never want run. Note that exit 0 does not approve the call. It only means the hook has no objection, and your normal permission rules still apply (hook output).

Two limits to keep in mind. When several hooks match, they run in parallel, and the most restrictive answer wins (combine results). And for a hard allow or deny, the docs recommend the permission system rather than a hook, because hook filtering is best effort (filter by arguments). Use hooks for guard rails and feedback, and permissions for the rules that must hold.

Check it worked

Type /hooks in a session. It opens a browser of every configured hook grouped by event (hook location). You should see your formatter under PostToolUse and two hooks under PreToolUse.

Then test each one:

  • Formatter: ask for a small edit with single quotes in a JavaScript file and open the file.
  • Test filter: start Claude Code with claude --debug-file ./claude-debug.txt and ask it to run npm test. When the hook rewrites the command, the log contains a modified tool input keys line (costs page).
  • Blocker: ask Claude to run a command containing drop table. It should stop and report the Blocked: message.

You can also test a script on its own by piping sample JSON into it and printing the exit code (hook error in output):

echo '{"tool_name":"Bash","tool_input":{"command":"ls"}}' | ./my-hook.sh
echo $?  # Check the exit code

Common problems

The hook never fires. Run /hooks and confirm it is under the right event. Matchers are case sensitive, so edit will not match Edit (hook not firing).

/hooks shows nothing. Check the JSON is valid, with no trailing commas or comments, and that the file is in the right place. Edits are normally picked up on their own; if not, restart the session (no hooks configured).

"command not found". Use absolute paths or $CLAUDE_PROJECT_DIR for scripts. If the missing command is jq, install it (hook error in output).

The script does not run at all. It is probably not executable. Run chmod +x on it.

Hooks stopped running everywhere. Look for "disableAllHooks": true in your settings files. A project settings file can set it and override yours (hook location).

Next

Sources

Last checked: 2026-11-06

Next

Read next