Mascot Logo
ai-agents-tutorial

Part 2 · Lesson 9 of 16

Automate Claude Code with Hooks

Event-driven automation. One real example end-to-end.

10 min

Step 1 of 7 · What are hooks?

A hook is a command that Claude Code runs automatically when a specific event happens in your session — before it runs a tool, after it edits a file, when it finishes responding, and more.

Hooks turn "please remember to run the formatter every time" into a guarantee. Instead of asking the agent to behave, you wire a shell command to an event, and the agent's harness runs it deterministically. Two of the most useful events for beginners are:

  • PreToolUse — fires before a tool call runs, so you can inspect and even block it (great as a safety guard).
  • PostToolUse — fires after a tool call succeeds, so you can react to it (great for auto-formatting a file Claude just edited).

Step 2 of 7 · Where hooks are configured

Hooks live in your settings.json, not in a special "hooks file." The same settings files you'd use for permissions or environment variables hold your hooks:

  • ~/.claude/settings.json — applies to all your projects (user scope).
  • .claude/settings.json — applies to this project and can be committed so your team shares it.
  • .claude/settings.local.json — project scope, but git-ignored (personal overrides).

Step 3 of 7 · The shape of a hook config

Every hook config follows the same nested shape: a top-level "hooks" object, keyed by the event name, containing an array of matchers. Each matcher has a "matcher" (which tools it applies to) and a "hooks" array of commands to run:

{
"hooks": {
  "PreToolUse": [
    {
      "matcher": "Bash",
      "hooks": [
        { "type": "command", "command": "./.claude/hooks/guard-bash.sh" }
      ]
    }
  ]
}
}

The "matcher" is matched against the tool name (e.g. Bash, Edit, Write). Use "Edit|Write" to match several, or "*" (or an empty string) to match every tool. Each entry in the inner "hooks" array runs a "command" — and Claude Code passes the event's JSON (tool name, inputs, cwd, and more) to that command's stdin.

Step 4 of 7 · Event 1 — a PreToolUse guard

Let's block a dangerous command before it ever runs. Create the guard script under your project's .claude/hooks/ folder:

mkdir -p .claude/hooks
cat > .claude/hooks/guard-bash.sh <<'EOF'
#!/bin/sh
# Read the event JSON from stdin and pull out the command.
command=$(jq -r '.tool_input.command' < /dev/stdin)

case "$command" in
*"rm -rf"*)
  echo "Blocked: 'rm -rf' is not allowed by the hook." >&2
  exit 2  # exit code 2 tells Claude Code to BLOCK the tool call
  ;;
esac
exit 0      # exit 0 = allow, fall back to normal permission flow
EOF
chmod +x .claude/hooks/guard-bash.sh

The PreToolUse event passes the proposed Bash command in tool_input.command. Exiting with code 2 tells the harness to deny the call and feed your stderr message back to Claude; exiting 0 lets the normal permission flow continue.

Step 5 of 7 · Event 2 — a PostToolUse formatter

Now add an event that runs after Claude edits a file, so your code is always formatted. Add a PostToolUse matcher for the Edit and Write tools to your .claude/settings.json:

{
"hooks": {
  "PreToolUse": [
    {
      "matcher": "Bash",
      "hooks": [
        { "type": "command", "command": "./.claude/hooks/guard-bash.sh" }
      ]
    }
  ],
  "PostToolUse": [
    {
      "matcher": "Edit|Write",
      "hooks": [
        { "type": "command", "command": "./.claude/hooks/format.sh" }
      ]
    }
  ]
}
}

The PostToolUse event passes the path of the file Claude touched in tool_input.file_path. The script formats just that file:

cat > .claude/hooks/format.sh <<'EOF'
#!/bin/sh
file=$(jq -r '.tool_input.file_path' < /dev/stdin)
case "$file" in
*.ts|*.tsx|*.js|*.json) npx prettier --write "$file" ;;
esac
exit 0
EOF
chmod +x .claude/hooks/format.sh

Step 6 of 7 · Bonus — a Git pre-commit hook

Claude Code hooks fire inside a session. But you can also use Claude from a Git hook, which fires on git commit regardless of any session. Git looks for a script at .git/hooks/pre-commit and aborts the commit if it exits non-zero.

The key is headless mode: claude -p "<prompt>" runs a single prompt non-interactively and prints the result, which is perfect for scripts and CI.

cat > .git/hooks/pre-commit <<'EOF'
#!/bin/sh
# Run Claude Code headlessly to sanity-check staged files.
claude -p "Review the staged git changes for obvious typos or
secrets. Reply with only the word OK, or a one-line problem." \
--allowedTools "Read,Bash(git diff:*)"
EOF
chmod +x .git/hooks/pre-commit

Step 7 of 7 · Checkpoint & Recap

Recap

  • Hooks are commands Claude Code runs automatically on session events, configured in settings.json (user, project, or local scope).
  • PreToolUse fires before a tool runs; exit code 2 blocks the call. Great as a safety guard.
  • PostToolUse fires after a tool succeeds; use it to auto-format or lint the file Claude just changed.
  • The config nests "hooks" → event name → "matcher" + "hooks" array of { "type": "command", "command": ... }, and the event JSON arrives on the command's stdin.
  • Git pre-commit hooks (.git/hooks/) are separate — call claude -p "<prompt>" for a non-interactive check at commit time.

Frequently asked questions

Where do I configure Claude Code hooks?

In a settings.json file under the "hooks" key. Use ~/.claude/settings.json to apply hooks to every project, .claude/settings.json to share them with your team via the repo, or .claude/settings.local.json for personal, git-ignored overrides. The in-session /hooks command only views hooks — it can't edit them, so change the JSON directly or ask the agent to.

How do I make a hook block a command?

Use a PreToolUse hook. Your command reads the event JSON from stdin (for Bash, the proposed command is in tool_input.command), and if you exit with code 2, Claude Code denies the tool call and shows the agent your stderr message. Exiting 0 allows it and falls back to the normal permission flow.

What's the difference between PreToolUse and PostToolUse?

PreToolUse runs before a tool call, so it can inspect or block it — ideal for safety guards. PostToolUse runs after a tool call succeeds, so it can react to the result — ideal for auto-formatting or linting a file Claude just edited (the path arrives in tool_input.file_path).

Are Claude Code hooks the same as Git hooks?

No. Claude Code hooks live in settings.json and fire on events inside a Claude Code session (like PreToolUse and PostToolUse). Git hooks live in .git/hooks/ and fire on Git events like pre-commit, independent of any session. You can combine them by having a Git pre-commit hook call Claude in headless mode with claude -p "<prompt>".