Home / Blog / Claude Code

Claude Code hooks: a practical guide

Claude Code hooks are the difference between an agent you hope behaves and one you can prove won't run rm -rf on the wrong directory. They're shell commands that fire at fixed points in the agent's lifecycle, and a PreToolUse hook can block a tool call before it executes. I run hooks as a hard enforcement layer in production. This is how they actually work, with config you can copy.

What Claude Code hooks are

Claude Code hooks are user-defined shell commands that Claude Code runs automatically at specific points in a session, like before a tool runs (PreToolUse), after it runs (PostToolUse), or when the session starts. You configure them in settings.json. The hook reads a JSON description of what's happening on stdin and signals back through its exit code and stdout. They run as real OS processes with your permissions, so they execute deterministically every time, not when the model feels like it.

That last point is the whole reason to use them. If you put "always run the linter after editing a file" in CLAUDE.md, it's a suggestion the model follows most of the time. A PostToolUse hook runs the linter every time, because the harness runs it, not Claude. Anything you need to be guaranteed rather than likely belongs in a hook.

Hooks live at three scopes: ~/.claude/settings.json for everything you do, a project's .claude/settings.json committed to the repo, and .claude/settings.local.json for machine-local overrides you don't commit. Project hooks are the useful ones in a team, because they ship with the repo and apply to everyone who opens it.

The lifecycle events worth knowing

Claude Code fires hooks on a set of named events. The ones you'll reach for first are PreToolUse (before a tool call, and the only one that can block it cleanly via permissions), PostToolUse (after a tool succeeds), UserPromptSubmit (before Claude sees your prompt), SessionStart (session begins or resumes), and Stop (Claude finishes a turn). There are more, but these five cover almost every practical hook.

Here's the working subset, with what each one is actually good for:

EventFiresUse it for
PreToolUseBefore a tool call runsBlock or allow a call, validate the input, redirect a command
PostToolUseAfter a tool call succeedsFormat, lint, run tests, log what changed
UserPromptSubmitBefore Claude reads your promptInject context, block a prompt, add a timestamp
SessionStartSession starts, resumes, or clearsLoad recent state or open issues into context
StopClaude finishes respondingForce a final check; block the stop to keep it working
SubagentStopA subagent finishesSame as Stop, scoped to spawned subagents
PreCompactBefore context compactionSnapshot the transcript before it's summarized
SessionEndSession terminatesCleanup, final logging

The matcher on tool events keys off the tool name: Bash, Edit, Write, Read, or an MCP tool like mcp__github__create_issue. You can match several with a pipe (Edit|Write) or everything with *. Events that aren't tool calls, like SessionStart, ignore the matcher or use a source value instead.

The settings.json shape

Hooks go under a top-level "hooks" key in settings.json, keyed by event name. Each event holds an array of matcher groups, and each group has a matcher string and a list of hooks to run, where each hook is a command with a type of "command".

The nesting trips people up the first time, so here's the full structure with one PreToolUse matcher in place:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/guard.sh",
            "timeout": 10
          }
        ]
      }
    ]
  }
}

The matcher is the tool name. The inner command is what runs; pointing it at a script file rather than inlining shell keeps the JSON readable and the script testable. $CLAUDE_PROJECT_DIR is set by Claude Code to the project root, so the path resolves no matter which subdirectory the session is working in. timeout is in seconds and optional. That's the entire schema for a command hook.

How a hook gets data and answers back

Claude Code passes the hook a JSON object on stdin, and the hook answers two ways: through its exit code, or by printing JSON on stdout. Exit 0 means proceed. Exit 2 is the blocking signal: for PreToolUse it blocks the tool, and whatever the hook wrote to stderr is fed back to Claude as the reason. Any other non-zero code is a non-blocking error that gets logged and ignored.

The stdin payload carries the session context and, for tool events, the tool name and its input. A PreToolUse hook on a Bash call receives something like this:

{
  "session_id": "abc123",
  "cwd": "/Users/me/project",
  "hook_event_name": "PreToolUse",
  "tool_name": "Bash",
  "tool_input": { "command": "rm -rf build/" }
}

Exit codes are the simple path and cover most needs. The three that matter:

Exit codeMeaning
0Success. Proceed. Stdout JSON (if any) is parsed.
2Block. Stderr is sent to Claude as the reason. On PreToolUse the tool call is stopped.
otherNon-blocking error. Logged, shown to you, execution continues.

For finer control there's a JSON path: print an object on stdout and exit 0. A PreToolUse hook can return a permissionDecision of "allow", "deny", or "ask" under a hookSpecificOutput key, which is cleaner than exit 2 when you want to attach a reason the model reads. The exit-2 route is fine for a yes-or-no guard; reach for JSON when you want to deny with an explanation or rewrite the tool input.

A real guard: blocking a dangerous command

The highest-value hook is a PreToolUse guard on Bash that inspects the command string and exits 2 if it matches a destructive pattern. This is the pattern I lean on most: read the proposed command from stdin, check it against a denylist, block with a reason if it's dangerous, otherwise let it through.

Here's a complete guard script. It reads the JSON payload, pulls out the command with jq, and refuses recursive force-deletes and a couple of other footguns:

#!/usr/bin/env bash
# .claude/hooks/guard.sh  -  blocks destructive Bash commands
set -euo pipefail

payload=$(cat)
command=$(echo "$payload" | jq -r '.tool_input.command // ""')

# Patterns we never want an agent to run unattended.
if echo "$command" | grep -Eq 'rm[[:space:]]+-[a-z]*r[a-z]*f|:\(\)\{|mkfs|dd[[:space:]]+if='; then
  echo "Blocked: '$command' matches a destructive pattern. Ask the user to run it manually." >&2
  exit 2
fi

exit 0

Make it executable (chmod +x .claude/hooks/guard.sh), wire it to PreToolUse with the Bash matcher from the config above, and every Bash call now passes through it first. When it exits 2, Claude Code never runs the command and Claude sees the stderr line, so it knows why and can adjust instead of looping. The denylist here is deliberately small. In practice you tune the patterns to your repo, but the shape stays the same: parse, match, exit 2 with a reason.

Why a regex on the command and not on the tool: the matcher only tells you a Bash call is happening, not what it does. The command string is the thing that's dangerous, so the inspection has to read tool_input.command. Matching the tool name alone would block every Bash call, which is useless.

A PostToolUse formatter

The second hook most people want runs a formatter after Claude edits a file, so the code is always formatted without asking. A PostToolUse hook on Edit|Write fires after the file is written, reads the path from the payload, and runs your formatter on it.

This one doesn't need to block anything, so it can be a one-liner in the config. It runs Prettier on whatever file Claude just touched:

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

The xargs -r matters: if jq returns nothing, -r stops Prettier from running on an empty argument and erroring. Small detail, but without it the hook throws a non-blocking error on every tool call that has no file path, and your transcript fills with noise.

Hooks as enforcement, not convenience

In a multi-tenant agent system, hooks are the enforcement layer that keeps one tenant's agent inside its own boundary, not a productivity nicety. We run a PreToolUse guard that inspects every tool call against a per-tenant policy and blocks anything that reaches outside the allowed paths or touches a secret, before the call executes. The model's cooperation isn't part of the security model.

The pattern generalizes well beyond our setup. Keep a small policy file (we use a JSON registry that maps what each agent is allowed to touch) and have the guard load it, check the proposed tool call against it, and exit 2 on a violation. The model can be prompted to stay in bounds, and it usually does, but "usually" isn't a boundary. The hook is. It runs on every call, in process you control, with a default of deny for anything not explicitly allowed.

This is the same principle that holds when you build an MCP server that wraps a writable API: the safety check lives in code you control and runs before the side effect, not in the prompt. A hook is that idea applied one level up, at the agent's own tool calls rather than inside a single server. Both come down to enforcing the boundary where the action actually happens, with a deny default, instead of trusting the model to behave.

Gotchas that cost me time

  1. Hooks run with your full shell permissions. A hook is arbitrary code you wrote, running as you. A bug in a hook can do real damage, so treat hook scripts like any other code you'd let near your filesystem. Review them, keep them in version control, don't paste hooks from random gists without reading them.
  2. Exit 1 doesn't block. Only exit 2 blocks. I lost an hour wondering why my guard's exit 1 let commands through. One is a generic error and gets logged and ignored; two is the blocking signal. Use two.
  3. The matcher matches the tool, not the content. A matcher of Bash fires on every Bash call. To act on what the command does, you have to read tool_input inside the script. The matcher is a coarse filter, not the decision.
  4. Settings load at session start. Editing settings.json mid-session doesn't always pick up new hooks. Restart the session, or check /hooks to confirm what's actually registered before you assume a hook is live.
  5. Quote your variables. The command string can contain spaces, quotes, and shell metacharacters Claude generated. Unquoted $command in a guard is its own injection risk. Quote everything, and prefer matching with grep over eval.

Hooks are the part of Claude Code that turns it from a clever assistant into something you can put real guardrails around. The model is the flexible part. The hooks are where you make the non-negotiable things non-negotiable, in code that runs every time whether the model cooperates or not.

P

Pavle Lazic is the founder of Scalably, where he builds and runs multi-tenant Claude agent platforms in production for real businesses. He writes about the Claude Agent SDK, MCP servers, and what it actually takes to put AI agents to work. See the platform.