Rules are requests. Hooks are code. A hook is a script Claude Code runs at a fixed point, like right before a tool call, outside the model. The model can't skip it, forget it, or talk its way past it.
If something must never happen in your repo, a prompt is the wrong tool. A PreToolUse hook is the right one.
What are Claude Code hooks?
Claude Code hooks are commands you register in settings.json. Claude Code runs them when a lifecycle event fires. The two that matter most:
PreToolUseruns before a tool call. It can block the call, rewrite it, or let it through.PostToolUseruns after a tool call succeeds. It can't undo anything. It can report a problem to Claude, or change what Claude sees.
There are many more events. These are the ones you are likely to use:
| Event | Fires |
|---|---|
SessionStart | When a session starts or resumes |
UserPromptSubmit | When you send a prompt, before Claude reads it |
PreToolUse | Before a tool call. Can block it |
PermissionRequest | When a tool call needs a permission decision |
PostToolUse | After a tool call succeeds |
PostToolUseFailure | After a tool call fails |
Stop | When Claude finishes responding |
SubagentStop | When a subagent finishes |
PreCompact | Before the context is compacted |
Notification | When Claude Code sends a notification |
Most hooks are "type": "command": a shell command. Claude Code also supports http, mcp_tool, prompt and agent hooks. This page sticks to commands.
How a hook works: JSON in, exit code out
Claude Code sends the hook a JSON payload on stdin. For a tool event it looks roughly like this:
{
"session_id": "abc123",
"hook_event_name": "PreToolUse",
"cwd": "/Users/you/my-app",
"tool_name": "Bash",
"tool_input": { "command": "rm -rf dist" }
}
PostToolUse also gets tool_response. The shape of tool_input changes per tool. Bash has command. Edit and Write have file_path.
The hook answers with an exit code:
| Exit code | What happens |
|---|---|
0 | No objection. For PreToolUse this is not an approval: the normal permission flow still runs |
2 | Block. On PreToolUse the call never runs, and stderr goes to Claude as the reason |
| Anything else | A non-blocking error. The action goes ahead and the transcript shows a hook error |
Exit 2 means different things per event. On PostToolUse the tool already ran, so stderr just goes to Claude. On Stop it keeps Claude working instead of stopping.
Answering with JSON instead
For more control, exit 0 and print JSON to stdout. On PreToolUse:
permissionDecision:"allow","deny"or"ask", plus apermissionDecisionReason.updatedInput: replace the tool's input before it runs.additionalContext: text Claude reads alongside the result.
On PostToolUse, updatedToolOutput replaces what Claude sees, and additionalContext adds a note. Pick one style per hook: exit 2 with stderr, or exit 0 with JSON.
Claude Code hooks in settings.json
Hooks live under a hooks key. Each event holds a list of groups. Each group has a matcher and the commands to run:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/no-rm-rf.sh",
"timeout": 30
}
]
}
]
}
}
Where that block can live:
| File | Scope |
|---|---|
~/.claude/settings.json | All your projects |
.claude/settings.json | This project. Commit it |
.claude/settings.local.json | This project, just you |
| Managed policy settings | The whole organization |
A plugin's hooks/hooks.json | Wherever the plugin is on |
| Skill or subagent frontmatter | While that skill or subagent is active |
How matchers read:
"Bash"or"Edit|Write": plain names are an exact list.Edit|Writedoes not fire forBash, even when Bash writes a file.- Anything with other characters is a JavaScript regex:
"mcp__github__.*"matches every tool from that MCP server. "*",""or no matcher matches everything.- Matching is case-sensitive.
Three more facts that save you an afternoon:
- Use
$CLAUDE_PROJECT_DIR. It points at the project root, even after Claude runscd. A relative path breaks the moment it does. - All matching hooks run in parallel. If any one denies, the call is blocked.
timeoutis in seconds. Command hooks default to 10 minutes. Keep yours far below that.
Edits to settings files are normally picked up while Claude Code runs. Type /hooks to see what is registered.
Copy this: a PreToolUse hook that blocks rm -rf
A generic example, not from the generator. Save as .claude/hooks/no-rm-rf.sh and run chmod +x on it:
#!/usr/bin/env bash
# Minimal example. It catches the obvious case and misses the clever ones.
input=$(cat)
cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // empty')
if printf '%s' "$cmd" | grep -Eq 'rm +-(rf|fr)'; then
echo "Blocked: recursive force delete. Remove the specific files you mean, or ask the user." >&2
exit 2
fi
exit 0
Wire it with the settings.json block above. Test it without Claude by piping a payload in:
echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf dist"}}' | .claude/hooks/no-rm-rf.sh
echo $? # 2 means blocked
It works. It is also easy to get past.
Why grep is not a guard
That script misses all of these:
rm -r -f distandrm --recursive --force dist/bin/rm -rf distbash -c "rm -rf dist"andsudo rm -rf distfind . -name dist -exec rm -rf {} +ls | xargs rm -rf
And it blocks things it shouldn't, like a commit message that mentions rm -rf. A guard that cries wolf gets turned off within a week. Then you have nothing.
A real guard parses the shell line. Here is the top of block-destructive, the guard every generated repo ships:
#!/usr/bin/env bun
/**
* block-destructive: PreToolUse / Bash
*
* Reads the Claude Code hook payload on stdin. Exit 0 allows the tool call,
* exit 2 blocks it and shows stderr to the agent.
*
* The command is parsed, not grepped: quotes, `&&`, pipes, heredocs, `$(...)`,
* `bash -c`, `xargs` and `find -exec` are all followed, so a commit message
* that mentions "truncate" passes and `/bin/rm -rf` does not.
*/
import { spawnSync } from "node:child_process";
import { existsSync, readFileSync, statSync } from "node:fs";
import { homedir } from "node:os";
import { isAbsolute, join, resolve } from "node:path";
const ALLOW = 0;
const BLOCK = 2;
interface Payload {
tool_name?: string;
cwd?: string;
tool_input?: Record<string, unknown>;
}
interface Finding {
title: string;
detail: string;
instead: string;
}
function readPayload(): Payload | null {
try {
const raw = readFileSync(0, "utf8");
if (raw.trim() === "") return null;
const parsed: unknown = JSON.parse(raw);
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
return parsed as Payload;
} catch {
return null;
}
}
First 42 of 770 lines of .claude/hooks/block-destructive.ts.
The rest of the file follows quotes, &&, pipes, heredocs, $(...), bash -c, xargs and find -exec. It unwraps sudo, env, timeout and package runners like npx. It tracks cd through the line, so a relative path resolves where it will actually run.
When it blocks, it prints three parts to stderr: BLOCKED by block-destructive:, what it caught and why, and Do this instead:. Then it exits 2. If the payload is unreadable, it allows the call and says so. A guard that bricks every tool call is worse than the risk it covers.
The wiring in a real repo
This is .claude/settings.json from a generated Next.js repo, quoted in full:
{
"$schema": "https://json.schemastore.org/claude-code-settings.json",
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write|MultiEdit",
"hooks": [
{
"type": "command",
"command": "bun \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/auto-lint.ts"
},
{
"type": "command",
"command": "bun \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/enforce-doc-meta.ts"
}
]
},
{
"matcher": "Edit|Write|MultiEdit|Bash|Read|Grep",
"hooks": [
{
"type": "command",
"command": "bun \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/env-leak-detector-write.ts"
}
]
}
],
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "bun \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-destructive.ts"
},
{
"type": "command",
"command": "bun \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/enforce-typecheck.ts"
},
{
"type": "command",
"command": "bun \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/guard-neon-sql.ts"
}
]
},
{
"matcher": "Bash|Read|Grep",
"hooks": [
{
"type": "command",
"command": "bun \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/env-leak-detector.ts"
}
]
}
]
},
"extraKnownMarketplaces": {
"compound-engineering-plugin": {
"source": {
"source": "github",
"repo": "EveryInc/compound-engineering-plugin",
"ref": "compound-engineering-v3.28.2"
}
}
},
"enabledPlugins": {
"compound-engineering@compound-engineering-plugin": true
}
}
.claude/settings.json, as generated.
Four groups, 7 scripts:
- Before
Bash:block-destructive,enforce-typecheck, andguard-neon-sql(Neon battery only). - Before
Bash|Read|Grep:env-leak-detector. It stops a read of.env.local, anecho $DATABASE_URL, or a live key in a command. - After
Edit|Write|MultiEdit:auto-lintruns Biome on the one file that changed.enforce-doc-metachecks frontmatter on solution docs and plans. - After
Edit|Write|MultiEdit|Bash|Read|Grep:env-leak-detector-write. It swaps live secrets in output for[redacted: KEY].
Every command starts from "$CLAUDE_PROJECT_DIR", so the guards still fire after the agent runs cd src. The file also enables the Compound Engineering plugin, pinned to a release tag.
The full list in this repo:
auto-lint.tsblock-destructive.tsenforce-doc-meta.tsenforce-typecheck.tsenv-leak-detector-write.tsenv-leak-detector.tsguard-neon-sql.ts
Team mode adds two more: enforce-git-tracked (no commit while untracked files exist) and plan-gate (no writes under src/ until a plan says status: approved).
Block, deny, rewrite, redact
A hook can do more than say no. The generated guards use four moves:
| Move | How | Example |
|---|---|---|
| Block | Exit 2, reason on stderr | block-destructive |
| Deny | Exit 0, permissionDecision: "deny" | guard-neon-sql |
| Rewrite | Exit 0, updatedInput | enforce-typecheck |
| Redact | PostToolUse, updatedToolOutput | env-leak-detector-write |
The deny style, from guard-neon-sql:
#!/usr/bin/env bun
/**
* guard-neon-sql: PreToolUse / Bash
*
* Reads the tool call on stdin, decides, and prints a Claude Code hook
* decision. Uses only APIs shared by Bun and Node so it works whichever
* runtime the generated repo uses.
*/
import { existsSync, readFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
interface ToolCall {
tool_name?: string;
cwd?: string;
tool_input?: { command?: string };
}
function deny(reason: string): never {
process.stdout.write(
JSON.stringify({
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: reason,
},
}),
);
process.exit(0);
}
First 29 of 537 lines of .claude/hooks/guard-neon-sql.ts.
The rewrite style, from enforce-typecheck. A bare tsc reads the nearest tsconfig.json and reports different errors from the build. So the hook swaps it for the project's script before it runs:
#!/usr/bin/env bun
/**
* enforce-typecheck: PreToolUse / Bash
*
* Rewrites a bare `tsc` into the project's typecheck script before it runs,
* using the hook's `updatedInput`. Prints nothing and exits 0 when there is no
* bare `tsc` in the command. Exit 2 only for a `tsc` it cannot rewrite safely.
*/
First 8 of 457 lines of .claude/hooks/enforce-typecheck.ts.
One catch: if two PreToolUse hooks both return updatedInput for the same call, the last one to finish wins. They run in parallel, so that order is random. Let one hook own each rewrite.
Hooks and permission modes
PreToolUse hooks run before any permission check, in every permission mode. A hook that denies still blocks in bypassPermissions mode or with --dangerously-skip-permissions.
It doesn't work the other way. A hook that returns "allow" does not override deny rules in your settings. Hooks can tighten permissions. They can't loosen them.
They also run inside subagents. When a subagent calls a tool, the same PreToolUse and PostToolUse hooks fire, with agent_id and agent_type in the payload. Your guards cover the whole team of agents, not just the main thread.
Prove your hooks still work
Guards die quietly. Someone edits settings.json and drops the wiring. A script stops parsing after a refactor. Nothing tells you.
Two habits fix that:
- Pipe test payloads into each script, like the
echo ... | scriptcheck above. Check the exit code. - Run a self-test after any change to
.claude/. Every generated repo shipsbun run verify:hooks(or thepnpmornpmequivalent). It runs each hook the way Claude Code does, from the command in.claude/settings.json, against cases it must block and cases it must allow. Block cases run again fromsrc/. One wrong answer fails the script.
Claude Code hooks best practices
- Block only what you can't undo. Deleted files, dropped tables, force pushes, leaked keys. Let the rest through.
- Parse, don't grep. Split on
&&,|,;and followbash -c, or a chained command smuggles anything past. - Fail open on bad input. Empty stdin or broken JSON should allow, with a note on stderr.
- Check
tool_namefirst. Then read thetool_inputfields that tool actually has. - Name the hook in the message. Say what was blocked and what to do instead. A bare "no" makes the agent try a variation.
- Stay fast. A hook runs on every matching call.
- Never scan your own hook folder. A guard that greps
.claude/hooks/trips on its own patterns. - Commit the shared ones. Team guards go in
.claude/settings.json. Personal ones go in.claude/settings.local.json. - Change a guard in its own commit, with the reason. Never loosen one in passing.
Common mistakes
- Exit 1 to block. Only exit 2 blocks. Exit 1 is a non-blocking error, and the command runs.
- Expecting
PostToolUseto stop anything. The tool already ran. The file is already written. - Relative script paths. They break after
cd. Use"$CLAUDE_PROJECT_DIR"/.claude/hooks/.... - Matching
Edit|Writeand calling it file protection. Claude can also write files throughBash. - Half-JSON on stdout. Output that looks like a JSON object but isn't valid becomes a hook error.
- No timeout on a network call. A hung hook stalls the session.
- Assuming other tools run them. The generated repo wires hooks for Claude Code only. Codex runs none of them out of the box, so its
AGENTS.mdrules are all it has. See AGENTS.md examples.
Get the guards without writing them
The generator writes these hooks, their wiring and the verify:hooks self-test into every repo. Neon adds guard-neon-sql (Neon battery). Pick your stack in the builder or run:
npm create agentic-boilerplate@latest my-app
The full rundown of each guard, with the right and wrong way to write your own, is in the cookbook: what each guard hook blocks. For how hooks fit with rules, skills and subagents, read the agentic layer and the Claude Code setup for Next.js.
FAQ
Can a Claude Code hook block a command?
Yes. A PreToolUse hook that exits with code 2 blocks the tool call, and its stderr goes to Claude as the reason. A hook can also exit 0 and print JSON with permissionDecision: "deny".
What is the difference between PreToolUse and PostToolUse?
PreToolUse runs before the tool call and can block or rewrite it. PostToolUse runs after the call succeeds, so it can't undo it, but it can report a problem or change what Claude sees.
Where do Claude Code hooks go in settings.json?
Under a top-level hooks key, keyed by event name, as a list of groups with a matcher and a hooks array. Put shared hooks in .claude/settings.json and personal ones in .claude/settings.local.json or ~/.claude/settings.json.
Why is my Claude Code hook not firing?
Run /hooks and check it is listed under the right event. Check the matcher matches the tool name exactly, since matching is case-sensitive. Then pipe a sample payload into the script and check the exit code.
Do hooks still run with --dangerously-skip-permissions?
Yes. PreToolUse hooks run before any permission check, and a hook that denies still blocks in bypass mode.
Do Claude Code hooks work in Codex or Cursor?
The generated repo writes no Codex or Cursor hook config, so Codex runs none of these guards out of the box. Cursor can load Claude Code hooks from .claude/settings.json through its third-party hooks setting, but the guards are only tested under Claude Code.
Sources
Tool behavior is from the official docs, checked on October 4, 2026. Tools change. The docs win.
Keep reading
- CLAUDE.md template for Next.js, from a real repoA CLAUDE.md template for Next.js you can copy, a real one from a generated repo, where the file goes, what belongs in it, and the mistakes that get it ignored.
- AGENTS.md examples, from a real Next.js repoWhat AGENTS.md is, a template to copy, real root and nested AGENTS.md files from a generated Next.js repo, and how Codex, Cursor and Claude Code load them.
- CLAUDE.md vs AGENTS.md: which one you needCLAUDE.md is for Claude Code. AGENTS.md is the shared file Codex, Cursor and others read. Which tool reads what, and how to run both without them drifting.
- Cursor rules for Next.js, with real .mdc examplesHow Cursor rules work: the .mdc format, globs, alwaysApply and the four rule types, plus real Next.js rules from a generated repo that you can copy.