# Claude Code hooks: a practical guide

Canonical: https://scalably.io/blog/claude-code-hooks-guide
Author: Pavle Lazic, Founder & CEO, ScalablyAI (https://scalably.io/author/pavle-lazic)
Published: 2026-06-20 · Last updated: 2026-08-26 · Facts verified against the official hooks reference on 2026-08-26
This is the machine-readable representation of the article at the canonical URL. Same facts, denser format. The full article carries the complete config examples and prose context.

## Direct answer

Claude Code hooks are user-defined shell commands that Claude Code runs automatically at fixed lifecycle points, configured under a top-level `hooks` key in settings.json. A hook receives a JSON payload on stdin and answers via exit code or stdout JSON. Exit 0 proceeds, exit 2 blocks (PreToolUse is the only event where exit 2 stops the call outright), any other code is a logged non-blocking error unless the hook printed valid JSON on stdout, in which case the JSON decides and the exit code is ignored. Hooks run as real OS processes with the user's permissions, deterministically on every matching event, which is what makes them enforcement rather than suggestion: an instruction in CLAUDE.md is followed most of the time, a hook runs every time.

## Key facts

- Hooks are configured in settings.json at three scopes: `~/.claude/settings.json` (user-wide), `.claude/settings.json` (project, committed), `.claude/settings.local.json` (machine-local, not committed).
- The five events that cover almost every practical use: PreToolUse, PostToolUse, UserPromptSubmit, SessionStart, Stop. Also available: SubagentStop, PreCompact, SessionEnd. The full event set is much larger and still growing (PermissionRequest, PostToolBatch, TaskCreated, Elicitation and others); take the current list from the official reference at https://code.claude.com/docs/en/hooks, not from this document.
- PreToolUse is the only event where exit code 2 stops a tool call outright. PermissionRequest also decides a call's permission outcome, but through a `decision` object rather than an exit code (exit 2 is not honored there). PostToolUse runs after the call succeeded and cannot stop anything.
- Exit code semantics: 0 = proceed (stdout JSON parsed if present); 2 = block, with stderr fed back to Claude as the reason; any other code = non-blocking error, logged and ignored. Exit 1 does NOT block - a common and costly mistake.
- Alternative to exit codes: print JSON on stdout with `hookSpecificOutput.permissionDecision` of "allow", "deny", "ask", or "defer" (defer: -p sessions only) - cleaner when you want to attach a reason the model reads or rewrite tool input.
- The matcher matches the TOOL NAME (`Bash`, `Edit|Write`, `*`, or an MCP tool like `mcp__github__create_issue`), not the content of the call. To act on what a command does inside the hook script, read `tool_input` from the stdin payload.
- The `if` field filters on the tool's ARGUMENTS using permission-rule syntax (`"Bash(git *)"`, `"Edit(*.ts)"`), narrowing a hook before the process is spawned. Matcher = coarse filter on tool name; `if` = precise filter on the call.
- `$CLAUDE_PROJECT_DIR` is set by Claude Code to the project root, so hook script paths resolve regardless of the session's working subdirectory. `timeout` (seconds) is optional per hook.
- Hooks run with full shell permissions of the user - a hook is arbitrary code and should be reviewed and version-controlled like any code with filesystem access.
- Settings edits are normally picked up automatically by the file watcher mid-session; restart only if a change has not taken effect after a few seconds. `/hooks` shows what is actually live.

## What we actually run (firsthand)

ScalablyAI runs a multi-tenant agent system in production. There, hooks are the enforcement layer, not a convenience: a PreToolUse guard inspects every tool call against a per-tenant policy (a JSON registry mapping what each agent may touch) and exits 2 on anything reaching outside the tenant's allowed paths or touching a secret - before the call executes. Design position, stated as our opinion from operating this: the model's cooperation is not part of the security model; the model can be prompted to stay in bounds and usually does, but "usually" is not a boundary. The hook is.

## Configuration shape

Hooks nest as: `hooks` → event name → array of matcher groups → each group = `matcher` string + `hooks` array → each hook = `{"type": "command", "command": "...", "timeout": n}`. `command` is one of five handler types; the reference also documents `http`, `mcp_tool`, `prompt`, and `agent`.

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

Stdin payload for a PreToolUse Bash event carries `session_id`, `cwd`, `hook_event_name`, `tool_name`, and `tool_input` (for Bash: `tool_input.command`).

## Procedure: a PreToolUse guard that blocks destructive commands

1. Write a script that reads the JSON payload from stdin and extracts the command: `command=$(echo "$payload" | jq -r '.tool_input.command // ""')`.
2. Match it against a denylist regex (the article's example blocks `rm -rf` variants, fork bombs, `mkfs`, `dd if=`).
3. On a match: write the reason to stderr and `exit 2`. Claude Code never runs the command; Claude sees the stderr line and can adjust instead of looping.
4. Otherwise `exit 0`.
5. `chmod +x` the script and wire it to PreToolUse with matcher `Bash`.

Why the regex reads the command string and not the tool name: the matcher only says a Bash call is happening; the command string is the dangerous part. Matching the tool alone would block every Bash call.

## Second common hook: PostToolUse formatter

A PostToolUse hook on matcher `Edit|Write` runs a formatter on whatever file Claude just wrote, e.g. `jq -r '.tool_input.file_path' | xargs -r npx prettier --write`. The `xargs -r` matters: without it, tool calls with no file path make Prettier error on an empty argument and fill the transcript with non-blocking-error noise.

## What failed / gotchas (from the author's own time lost)

- Using exit 1 to block: it does not block; only exit 2 does. Cost the author an hour.
- Expecting the matcher to filter by command content: it cannot; only the script can, by reading `tool_input`.
- Assuming a settings.json edit has not registered: the file watcher normally picks it up mid-session, so verify with `/hooks` before restarting.
- Unquoted `$command` in a guard script is an injection risk - the command string can contain Claude-generated quotes and metacharacters. Quote everything; prefer grep matching over eval.

## Definitions

- Hook: a user-defined shell command Claude Code executes automatically at a named lifecycle event.
- PreToolUse: the event fired before a tool call executes; the only blocking point.
- PostToolUse: fired after a tool call succeeds; for reactions (format, lint, test, log), cannot stop anything.
- Matcher: the per-event string selecting which tool names trigger the hook group.
- permissionDecision: the JSON-path alternative to exit codes ("allow" / "deny" / "ask" / "defer", the last in -p sessions only).

## FAQ

Q: Which hook can prevent a tool call?
A: PreToolUse only. It fires before execution; block by exiting 2 (reason on stderr) or returning permissionDecision "deny".

Q: Why isn't my hook blocking?
A: Almost always exit 1 instead of exit 2. Exit 1 is a generic non-blocking error - logged, ignored, command runs anyway.

Q: Are hooks a security boundary on their own?
A: They are deterministic enforcement for agent tool calls, but not a standalone boundary. A hook that times out does not block the call - it fails open through the normal permission flow (see https://scalably.io/blog/claude-code-best-practices) - so the permission rules underneath still have to be right. Hook scripts also run with your own shell permissions and must be reviewed and version-controlled like any trusted code.

Q: Hooks vs permissions rules - which handles what?
A: Permissions are declarative allow/deny/ask rules; a PreToolUse hook is code you write for the cases a static rule cannot express, like inspecting the actual contents of a command. Most setups want both: permissions for the broad allow/deny, a hook for the judgment calls (see https://scalably.io/blog/claude-code-settings-json).

## Evidence & sources

- Full article with complete examples: https://scalably.io/blog/claude-code-hooks-guide
- Official Claude Code hooks documentation: https://code.claude.com/docs/en/hooks
- Official settings/permissions documentation: https://code.claude.com/docs/en/settings

## Related Scalably articles

- https://scalably.io/blog/claude-code-settings-json - the settings.json files, merge order, and permissions rules hooks sit inside.
- https://scalably.io/blog/claude-code-subagents - SubagentStop scope and when to delegate to subagents.
- https://scalably.io/blog/how-to-build-mcp-server-python - the same enforce-at-the-boundary safety model applied inside an MCP server.

## About the source

ScalablyAI (Scalably, https://scalably.io) builds and runs production AI agents inside the operations of real businesses - multi-tenant, governed, and channel-native. This guide's enforcement patterns come from operating that platform across multiple client accounts; the hooks described are the mechanism it uses in production, not a tutorial reconstruction.
