The Hook That Saved My Database

Claude had been drafting a migration for twenty minutes when it produced a Bash(rm -rf ~/work/api/data) call to clean up what it thought was a stale export. My terminal sat there asking for permission. Two months earlier I had wired a hook that pattern-matches rm -rf and exits with code 2 before the tool call ever executes, so the call never reached my shell. Claude saw “deletion blocked by policy” as tool output, paused, and rewrote the command to target a single file it had actually produced. No lost data.

That hook is twelve lines of bash. It has been sitting in .claude/settings.json since January.

What Hooks Are

Hooks are shell commands (or HTTP calls, or short prompts) that Claude Code runs at defined points in a session. They are the only way to deterministically intervene in the agentic loop. Every other mechanism asks the model to behave and hopes it complies.

Configuration lives in .claude/settings.json (project), ~/.claude/settings.json (user), or .claude/settings.local.json (gitignored overrides). The shape is:

{
  "hooks": {
    "<EventName>": [
      {
        "matcher": "<optional filter>",
        "hooks": [
          {
            "type": "command",
            "command": "/absolute/path/to/hook.sh",
            "timeout": 30
          }
        ]
      }
    ]
  }
}

Each event carries an array of matcher groups. Each matcher group carries an array of handlers. Handlers come in four flavours: command (shell), http (POST to a URL), prompt (a one-shot LLM evaluation against a built-in fast model), and agent (a full subagent invocation).

The Event Lifecycle

Early documentation for hooks listed four events. The current set in Anthropic’s hooks reference is over twenty, grouped by where they fire:

Session boundary

  • SessionStart — new session, resume, clear, or post-compaction restart. Matcher selects the start reason.
  • SessionEnd — session terminates. Matcher selects the exit reason.
  • InstructionsLoaded — CLAUDE.md or .claude/rules/*.md loads into context.
  • UserPromptSubmit — before the model sees a new user prompt.
  • PreCompact / PostCompact — before and after context compaction.

Tool call

  • PreToolUse — parameters created, before execution.
  • PostToolUse — tool succeeded.
  • PostToolUseFailure — tool errored.
  • PermissionRequest — Claude Code is about to show a permission dialog.
  • PermissionDenied — auto-mode classifier declined a call.

Agentic machinery

  • Stop / StopFailure — main agent finished responding or crashed.
  • SubagentStart / SubagentStop — subagent spawned or finished.
  • TaskCreated / TaskCompleted — TaskCreate / TaskUpdate fired.
  • Notification — permission prompts, idle warnings, auth events.
  • ConfigChange / CwdChanged / FileChanged — session-state mutations.
  • WorktreeCreate / WorktreeRemove — worktree orchestration.
  • Elicitation / ElicitationResult — MCP server asking the user a question mid-task.
  • TeammateIdle — multi-agent teammate about to idle out.

Most real configs use five or six of these: PreToolUse, PostToolUse, UserPromptSubmit, SessionStart, Stop, and PreCompact. The rest become useful once you need something specific — MCP elicitation flows, multi-agent teams, or fine-grained observability.

Input Is JSON on stdin, Not an Env Var

The single most common mistake when writing a hook is reaching for $CLAUDE_FILE_PATH. That variable does not exist in the current contract. Claude Code delivers every event payload as a JSON object on stdin:

{
  "session_id": "abc123",
  "transcript_path": "/Users/me/.claude/projects/.../transcript.jsonl",
  "cwd": "/Users/me/work/api",
  "permission_mode": "default",
  "hook_event_name": "PostToolUse",
  "tool_name": "Write",
  "tool_input": { "file_path": "/Users/me/work/api/src/route.ts", "content": "..." },
  "tool_response": { "success": true }
}

Your hook reads stdin, parses JSON, acts on it:

#!/usr/bin/env bash
payload=$(cat)
file=$(echo "$payload" | jq -r '.tool_input.file_path')
[[ "$file" == *.ts ]] && npx eslint --fix "$file"

The environment variables that are set for the hook process:

  • $CLAUDE_PROJECT_DIR — project root. Always quote it.
  • $CLAUDE_PLUGIN_ROOT / $CLAUDE_PLUGIN_DATA — plugin paths, per plugin.
  • $CLAUDE_CODE_REMOTE"true" in the remote web environment, unset locally.
  • $CLAUDE_ENV_FILE — only for SessionStart, CwdChanged, and FileChanged. Write export FOO=bar lines into this path and Claude Code picks them up for subsequent Bash tool calls. This is the current idiomatic way to inject environment variables into a session.

Everything else lives in the stdin payload.

Exit Codes Are Load-Bearing

Hooks signal back to Claude Code through exit codes plus optional JSON on stdout.

  • Exit 0 — success. Stdout, if it contains JSON, is parsed. For UserPromptSubmit and SessionStart, stdout also gets injected as context the model will actually read.
  • Exit 2 — blocking error. Stderr is fed back to the model or user depending on the event. On PreToolUse the call is cancelled and stderr becomes model feedback. On Stop the agent is forced to continue. On PostToolUse the tool already ran, so stderr is shown to the model as retrospective feedback.
  • Any other non-zero — non-blocking error. Logged, not enforced. exit 1 does not block anything, which catches people used to Unix conventions.

For richer control, exit 0 with a JSON payload:

{
  "continue": true,
  "systemMessage": "Linter auto-fixed 3 issues",
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "rm -rf against home directory blocked by policy"
  }
}

permissionDecision accepts allow, deny, ask, or defer. When multiple hooks fire on the same PreToolUse event, the precedence is deny > defer > ask > allow.

Other events use a simpler top-level decision: "block" plus reason. PreToolUse is the one that uses hookSpecificOutput because it carries the most nuanced permission state.

Matcher Semantics

Matchers look like regex. Mostly they aren’t.

  • Plain letters, digits, underscores, and | → exact string, or |-separated exact strings. "Bash" matches Bash. "Write|Edit" matches either.
  • Any other character → the matcher is treated as a JavaScript regex. "^Notebook" matches any tool name starting with Notebook.
  • "*", "", or omitted → match everything.

One trap worth calling out: "mcp__memory" is pure letters and underscores, so it’s compared as an exact string. It matches zero tools because the real names are mcp__memory__read, mcp__memory__write, and so on. To match every tool from an MCP server use "mcp__memory__.*".

FileChanged is the one exception where the matcher is a literal pipe-separated filename list (.env|.envrc), not a regex.

For finer filtering inside a tool event, each handler can carry an if field using Claude Code’s permission-rule syntax: "Bash(git *)", "Edit(*.ts)".

Four Hooks I Actually Use

Block rm -rf before it runs

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "if": "Bash(rm -rf *)",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-rm.sh",
            "timeout": 5
          }
        ]
      }
    ]
  }
}
#!/usr/bin/env bash
# block-rm.sh
payload=$(cat)
cmd=$(echo "$payload" | jq -r '.tool_input.command')
cat >&2 <<EOF
Blocked: $cmd
Policy: rm -rf must be run manually, not via agent tool calls.
EOF
exit 2

Auto-lint after Write and Edit

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "if": "Edit(*.ts)",
            "command": "jq -r '.tool_input.file_path' | xargs -I{} npx eslint --fix {}",
            "async": true,
            "timeout": 60
          }
        ]
      }
    ]
  }
}

The async: true flag runs the linter in the background so Claude is not held up by a slow ESLint pass. Background hooks cannot block, but they can surface output on the next turn.

Load sprint context at session start

{
  "hooks": {
    "SessionStart": [
      {
        "matcher": "startup",
        "hooks": [
          { "type": "command", "command": "cat .claude/context/current-sprint.md" }
        ]
      }
    ]
  }
}

Stdout from SessionStart is injected as system context. The sprint brief shows up without a manual prompt on every new session.

Inject env vars from .envrc

{
  "hooks": {
    "SessionStart": [
      {
        "hooks": [
          { "type": "command", "command": "direnv export bash > \"$CLAUDE_ENV_FILE\"" }
        ]
      }
    ]
  }
}

$CLAUDE_ENV_FILE is the only hook variable that persists env mutations into the session’s Bash tool calls.

Security

Command hooks run with your full user permissions. There is no sandbox beyond whatever limits you set on the hook process yourself. Anthropic’s security guidance in the hooks reference maps to five rules worth adopting verbatim:

  • Always quote shell variables: "$VAR", never bare $VAR.
  • Reject path traversal — check for .. in any file path pulled from the JSON payload before opening it.
  • Use absolute paths, and reference hook scripts via "$CLAUDE_PROJECT_DIR" so you do not accidentally run something from $PWD.
  • Treat tool_input as untrusted input. The model’s output is effectively user input for shell purposes.
  • Skip sensitive files by default (.env, .git/, credentials, private keys) in anything that iterates over filesystem paths.

For enterprise settings there are three settings keys worth knowing: allowManagedHooksOnly (blocks user- and project-level hooks), allowedHttpHookUrls (allowlist for HTTP handler targets), and httpHookAllowedEnvVars (restricts which env vars can be interpolated into outbound headers).

Why Bother

Hooks bolt determinism onto a non-deterministic loop. Linting fires on every matching Write, whether or not Claude remembered to run it. Dangerous commands get intercepted by a script you wrote once and forgot about. Sprint context loads the same way every Monday morning.

Most of my hook files run ten to thirty lines of bash. The interesting engineering is picking the event and writing a plain shell script. Claude Code handles the rest.