← Research
Developer Tools

Claude Code Hooks: Automate Guardrails, Logging, and Workflow Enforcement

By 8bitconcepts  ·  May 2026  ·  10 min read

Most developers use Claude Code interactively — send a task, review the output, move on. Hooks unlock a different mode: deterministic behavior enforcement that runs regardless of what Claude decides to do. Block a class of commands. Log every file write to an audit trail. Trigger a notification when a long task finishes. Run your linter automatically after every code edit.

Hooks are shell scripts (or any executable) that Claude Code calls at specific lifecycle events. They run outside the model — no tokens, no latency, no prompt needed. The model cannot override them.

What hooks actually are

A hook is a shell command wired to a Claude Code lifecycle event. When the event fires, Claude Code runs your command, inspects the exit code, and proceeds accordingly.

Exit code behavior:

Your hook can also write JSON to stdout to communicate back to Claude — providing additional context, modifying inputs, or setting a permission decision. This makes hooks composable with Claude's reasoning, not just blunt gate-checks.

Where hooks are configured

Hook configuration lives in settings.json at one of three scopes:

FileScopeCommitted?
~/.claude/settings.jsonAll projects (user-level)No
.claude/settings.jsonThis project (shared)Yes — team hooks go here
.claude/settings.local.jsonThis project (local only)No — gitignored

User-level hooks apply globally. Project-level hooks let teams enforce consistent behavior — everyone on the project gets the same pre-commit check, the same audit log, the same blocked commands — automatically, from the first checkout.

The structure inside settings.json:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "/path/to/your/hook.sh"
          }
        ]
      }
    ]
  }
}

The matcher field filters which tool triggers the hook. Use "Bash" to catch shell commands, "Write" to catch file writes, "*" to match all tools.

Hook events

The events that matter most for real workflows:

EventFires whenCan block?
PreToolUseBefore any tool runsYes (exit 2)
PostToolUseAfter any tool completesNo
SessionStartSession beginsNo
StopClaude stops (task done or interrupted)No
UserPromptSubmitUser sends a messageYes
SubagentStartSub-agent spawnedNo
SubagentStopSub-agent completesNo
FileChangedFile written or modifiedNo
PreCompactBefore context compactionNo

PreToolUse is where most safety guardrails live. PostToolUse is where audit logging and automatic follow-up actions go. Stop is useful for notifications and handoff files. SessionStart lets you inject dynamic context that doesn't belong in a static CLAUDE.md.

What your hook receives

When Claude Code fires a hook, it passes context as environment variables and/or stdin JSON. For PreToolUse on Bash, your script receives the command that Claude is about to run. You can inspect it, reject it, or pass it through.

Example: Claude Code calls your hook with something like:

{
  "tool_name": "Bash",
  "tool_input": {
    "command": "rm -rf /tmp/build",
    "description": "Clean build directory"
  }
}

Your hook reads this from stdin (or env), checks the command, and exits 0 or 2.

Four practical examples

PreToolUse Block destructive shell commands

The most common hook: refuse any rm -rf that Claude tries to run without an explicit path you approve.

#!/usr/bin/env bash
# ~/.claude/hooks/block-destructive.sh
set -euo pipefail

INPUT=$(cat)
COMMAND=$(echo "$INPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('tool_input',{}).get('command',''))" 2>/dev/null || echo "")

if echo "$COMMAND" | grep -qE 'rm\s+-rf\s+(/|~|\$HOME|\./)'; then
  echo '{"decision":"block","reason":"Destructive rm -rf on root/home path blocked by safety hook."}'
  exit 2
fi
exit 0

Wire it into ~/.claude/settings.json:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "~/.claude/hooks/block-destructive.sh"
          }
        ]
      }
    ]
  }
}

Claude gets a clear reason for the block and can propose an alternative. You don't have to monitor every command — the hook does it.

PostToolUse Audit log every file write

For compliance-sensitive projects or debugging, log every file Claude writes — path, timestamp, size — to a local audit file.

#!/usr/bin/env bash
# .claude/hooks/audit-writes.sh
set -euo pipefail

INPUT=$(cat)
FILE=$(echo "$INPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('tool_input',{}).get('file_path',''))" 2>/dev/null || echo "")
TOOL=$(echo "$INPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('tool_name',''))" 2>/dev/null || echo "")

if [ -n "$FILE" ]; then
  SIZE=$(wc -c < "$FILE" 2>/dev/null || echo 0)
  echo "$(date -Iseconds) | $TOOL | $FILE | ${SIZE}B" >> "${CLAUDE_PROJECT_DIR}/.claude/audit.log"
fi
exit 0

The CLAUDE_PROJECT_DIR environment variable is set by Claude Code to the project root — use it instead of hardcoding paths. The hook exits 0 (PostToolUse hooks can't block anyway), but the audit trail accumulates silently.

Stop Notify when a long task finishes

Long tasks run while you switch focus. A Stop hook fires when Claude is done — you can send a desktop notification, write a handoff file, or ping a webhook.

#!/usr/bin/env bash
# ~/.claude/hooks/notify-done.sh
# macOS notification
osascript -e 'display notification "Claude Code task complete" with title "Claude Code" sound name "Ping"'

# Write handoff file
echo "$(date -Iseconds): Session complete" >> ~/.foundry/runs/handoff-latest.md
exit 0

Wire to the Stop event:

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "~/.claude/hooks/notify-done.sh"
          }
        ]
      }
    ]
  }
}

PostToolUse Auto-run linter after file edits

Every time Claude writes to a .py or .ts file, run the linter and surface failures immediately — before Claude moves on to the next file.

#!/usr/bin/env bash
# .claude/hooks/lint-on-write.sh
set -euo pipefail

INPUT=$(cat)
FILE=$(echo "$INPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('tool_input',{}).get('file_path',''))" 2>/dev/null || echo "")

case "$FILE" in
  *.py)
    ruff check "$FILE" --quiet || {
      echo "{\"additionalContext\": \"ruff found issues in $FILE — fix before continuing\"}"
      exit 2
    }
    ;;
  *.ts|*.tsx)
    npx eslint "$FILE" --quiet || {
      echo "{\"additionalContext\": \"ESLint found issues in $FILE — fix before continuing\"}"
      exit 2
    }
    ;;
esac
exit 0

This hook uses additionalContext in the JSON output to pass the linter message back to Claude. Claude reads this and incorporates it into its next response — it knows what failed and why, without you having to re-explain.

JSON output fields

Your hook can write JSON to stdout to communicate with Claude beyond a bare exit code:

FieldUse
decision"allow" or "block" — explicit permission decision
reasonHuman-readable reason shown to Claude when blocking
additionalContextExtra info passed to Claude's context (non-blocking)
updatedInputModified version of the tool input (PreToolUse only)

Using additionalContext turns your hooks from blunt blockers into context injectors — the hook can say "this file was last modified by the deploy pipeline" or "this test suite takes 4 minutes" and Claude reasons with that information.

Team hooks via project settings

Hooks in .claude/settings.json are committed to source control. Every developer who checks out the repo gets the same hook enforcement automatically — no per-developer setup required.

Useful patterns for teams:

The .claude/settings.local.json file (gitignored) is for hooks that shouldn't be shared — personal notification preferences, local-only paths, developer-specific secrets for hook scripts.

Discovering configured hooks

Run /hooks inside any Claude Code session to see all hooks currently active for that session — which events are wired, which scripts run, and at which scope (user vs. project). This is the fastest way to debug a hook that isn't firing or a permission decision you don't understand.

You can also check the config directly:

# User-level hooks
cat ~/.claude/settings.json | python3 -m json.tool | grep -A20 '"hooks"'

# Project-level hooks
cat .claude/settings.json 2>/dev/null | python3 -m json.tool | grep -A20 '"hooks"'

What hooks can't do

Hooks run per-session and don't share state between sessions. A hook that writes to a temp file in /tmp/ won't find that file in the next session. For cross-session memory — tracking what was done in prior runs, persisting decisions — you need to write to a stable path and read from it explicitly.

Hooks also can't modify Claude's base behavior: they operate on specific tool events, not on Claude's planning or reasoning. You can't hook into "Claude is about to make a bad architectural decision" — you can only hook into the discrete tool calls that result from that decision.

For teams using hooks in CI or automated pipelines, keep hook scripts fast. A hook that takes 10 seconds on every Bash call will make Claude Code unusable for rapid iteration. Cache expensive checks where you can; fail fast where you can't.

Hooks and session persistence

One pattern that doesn't work the way developers expect: using a Stop hook to "save state" that a SessionStart hook restores. The Stop hook fires reliably, but the context it saves is only useful if your SessionStart hook loads it and injects it into Claude's initial prompt — which requires the hook to produce output in the right format.

Claude Code doesn't have native cross-session memory. Each session starts fresh. Hooks can paper over this at the edges — injecting a handoff file at SessionStart, writing a summary at Stop — but the session boundary problem remains. Every new session, the model has no memory of what it did last time unless you explicitly hand that context back in.

The session boundary problem, solved

Hooks help at the edges, but they don't solve the fundamental issue: Claude Code loses all context at session end. Beyond Your Ability (BYA) gives Claude persistent project memory across sessions — no handoff files, no re-explaining your stack every morning.

Learn how BYA works →

Related reading