Hooks: Run Your Rules on Every Action
Lint on every edit, guard dangerous commands, and wire Claude Code into your standards.
Last time, we turned repeated instructions into reusable skills. Now we lock down what Claude Code is actually allowed to do. Hooks are event handlers that run your own code at fixed points in every session, every turn and every tool call. They can block dangerous actions, format files, add context for the model, or change a tool's input before it runs.
The key idea is that hooks are not suggestions to the model. Claude Code itself runs them, outside the LLM (large language model) entirely. If the model proposes a harmful command, your PreToolUse hook can stop it, regardless of how confident the model sounds.
What exactly are hooks and why do I need them?
Hooks are handlers that Claude Code runs automatically when specific lifecycle events fire. You define them in a JSON (JavaScript Object Notation) settings file, and when an event fires, Claude Code hands your handler a JSON description of what is happening. Your handler can inspect that data, make a decision, and answer with an exit code, plus optional JSON, to let the action go ahead, block it, or change it.
This is a different kind of control from CLAUDE.md instructions or permission rules. Instructions are guidance the model usually follows but might not. Permission rules are fixed patterns matched against tool names, commands and paths. Hooks run real code at boundaries Claude Code enforces, so you can check the actual contents of a file about to be written, ask an internal policy service, or read an environment variable, and then make a decision that binds. That is what turns "an assistant you have to watch" into "an agent you can safely hand work to."
How do hooks differ from CLAUDE.md instructions or permission rules?
Instructions live inside the conversation. They are the text you put in your CLAUDE.md or a prompt to steer behavior, and Part 2 showed that Claude treats them as context, not enforced configuration. They can also fade: an instruction given only in chat, or one in a nested CLAUDE.md that has not reloaded yet, can be lost after the conversation is compacted (the project-root CLAUDE.md is re-read from disk after compaction). Permission rules add a declarative allow or deny on tools and paths, but they cannot look at run-time data or change a tool call before it goes through.
Hooks sit beside both. A PreToolUse hook sees the tool name and its arguments, which for a file write include the content about to be written. It can run a linter or a security scanner, and if those fail, the tool call never happens. A PostToolUse hook can log the result, add feedback for Claude, replace what Claude sees, or run a formatter. A UserPromptSubmit hook can add missing context before the model starts reasoning. The key difference is that hooks are code, not fixed patterns, and Claude Code enforces their outcome.
An analogy: if instructions are like telling your intern "please don't touch production," hooks are the locked door with an alarm system. The alarm doesn't care what was said. It cares what is about to happen.
How does the lifecycle work and when do hooks fire?
The hooks reference groups the main events into three cadences: once per session, once per turn, and on every tool call inside the agent loop.
Session-level events like SessionStart and SessionEnd fire when a session begins (or resumes) and when it ends. They suit loading environment details or cleaning up. Turn-level events fire around each turn: UserPromptSubmit fires when you submit a prompt, before Claude processes it, and Stop fires when Claude finishes responding (StopFailure when the turn ends because of an API error). Tool-level events surround every tool call: PreToolUse before it runs, PermissionRequest when it needs a permission decision, and PostToolUse or PostToolUseFailure after it succeeds or fails. There are more events for subagents, compaction, notifications and configuration changes; the reference lists them all.
Because a single turn can contain many tool calls, the tool events repeat inside it:
Each event can have matcher groups, and each group holds one or more handlers. When an event fires and a matcher matches, Claude Code passes the event's JSON to every matching handler: on stdin for a command hook, as the body of a POST request for an HTTP hook. All matching handlers run in parallel, not one after another, so a hook cannot rely on another hook's side effects. If the same handler is defined in more than one settings file, it runs once.
What types of handlers can I write?
There are five handler types.
A command hook runs a shell command or script. It reads the event's JSON from stdin and answers with its exit code and, optionally, JSON on stdout. This is the most common type, and the one to reach for first.
An HTTP hook sends the event's JSON as a POST request to a URL you control, and reads the answer from the response body in the same JSON format. It suits a central policy service.
An MCP tool hook calls a tool on one of your configured MCP servers and treats the tool's text output like a command hook's stdout.
A prompt hook sends the event to a Claude model for a single-turn judgment, which returns its decision as JSON. For a Stop event, you might ask "Is the task really complete?" and decide whether to let the agent stop.
An agent hook goes further: it starts a subagent that can read files and search the code to check a condition before it answers. Agent hooks are experimental, and the docs recommend command hooks for production workflows.
How does a hook actually make a decision?
Every handler receives JSON with some common fields, such as session_id, transcript_path, cwd, hook_event_name and, on many events, permission_mode, plus fields for the event itself. A PreToolUse hook gets tool_name and tool_input; a PostToolUse hook also gets tool_response, the result the tool returned.
The exit code is the simplest answer:
- Exit 0 means success. Claude Code reads stdout, and if it holds a JSON object, applies its fields. With no JSON, the action goes ahead normally.
- Exit 2 means block. On events that can block, such as
PreToolUse, the tool call is stopped, and whatever your hook printed to stderr is passed to Claude as the reason, so it can adjust. - Any other exit code is, by default, a non-blocking error: the action goes ahead and you see a "hook error" notice in the transcript. The hooks reference is explicit: "If your hook is meant to enforce a policy, use
exit 2." Since version 2.1.295 you can change this for a command or HTTP hook by setting"onFailure": "block", which makes a crash, a timeout or an unexpected exit code block the action instead.
For finer control, exit 0 and print JSON. A PreToolUse hook answers inside hookSpecificOutput: permissionDecision can be allow (skip the permission prompt), deny, ask (prompt the user) or defer, with a permissionDecisionReason; updatedInput replaces the tool's arguments before it runs; and additionalContext adds a note to Claude's context. A PostToolUse hook can return updatedToolOutput to replace what Claude sees, or a top-level decision of "block" with a reason to put feedback next to the result (the only value decision takes is "block"). Any hook can return "continue": false with a stopReason to stop Claude entirely.
How predictable is all this? A command hook is exactly as deterministic as your script: the same input gives the same answer, which makes it easy to test. Prompt and agent hooks ask a model, so they are not.
Here is the decision flow for a PreToolUse command hook:
What does a real hook look like in practice?
In our side project, we want to stop Claude Code from ever editing .env files, and we want to format any TypeScript or JavaScript file it changes.
Hooks live under a "hooks" key in a settings file. For hooks the whole team should share, that is .claude/settings.json in the project. Add this to it:
The matcher is a string, not an object: Write|Edit matches either tool by exact name. Matching both matters, because Edit is Claude's usual way to change an existing file, and a guard on Write alone would let edits through. $CLAUDE_PROJECT_DIR points at the project root, so the paths work wherever Claude happens to be running a command.
The guard script, saved as .claude/hooks/block-env.sh, reads the file path from the JSON on stdin:
The formatter, saved as .claude/hooks/format.sh, runs after the edit has landed:
Both scripts use jq to read JSON, so install it first, and on macOS or Linux make them executable with chmod +x .claude/hooks/*.sh. The guard exits 2 with a reason on stderr, so Claude sees why it was refused and can tell you. The formatter exits 0 and prints nothing to stdout, so nothing else changes; because it is a PostToolUse hook, the file is already on disk when it runs. Anthropic's hooks guide walks through the same two patterns.
These are command hooks, but you could swap in an HTTP hook that asks your team's policy service, or a prompt hook that checks whether a commit message follows your conventions. The structure stays the same: pick an event, match it, define a handler, and decide.
Where do hooks live and how do they compose across projects?
Hooks are configured under the "hooks" key of a settings file, and where you put them sets their scope:
~/.claude/settings.json: all your projects, on your machine only..claude/settings.json: one project, committed and shared with the team..claude/settings.local.json: one project, just for you.- Managed policy settings: set by your organization's administrators.
- A plugin's
hooks/hooks.json: whenever that plugin is enabled. - Skill or subagent frontmatter: while that skill or subagent is in use.
Hook entries from different levels merge rather than replace each other: your user, project and local hooks are added alongside your organization's managed hooks, and only managed settings can turn managed hooks off. This gives you composability. A global hook might block access to .env files in every repository, while the project adds its own formatter.
Hooks also reach subagents. Hooks from settings files, managed policy and plugins run inside subagents too, so the same PreToolUse guard fires when a subagent calls a tool, and the input carries agent_id and agent_type so a script can tell who called.
One warning belongs here. Command hooks run shell commands with your full user permissions. In an interactive session, Claude Code holds back hooks until you accept the workspace trust dialog for the folder. But a claude -p run never shows that dialog, so the hooks committed in a repository's .claude/settings.json run even in a folder you have never trusted. Before you run Claude Code, especially in scripts, on a repository you cloned, read its .claude/settings.json, or start the run with --bare (Part 9), which skips them.
Quick Reference
| Property | Value |
|---|---|
| Where hooks live | A "hooks" key in ~/.claude/settings.json, .claude/settings.json, .claude/settings.local.json or managed settings; also plugins and skill or subagent frontmatter |
| Block | Exit code 2 (stderr becomes the reason Claude sees) |
| Success | Exit code 0 (JSON on stdout, if any, is applied) |
| Other exit codes | Non-blocking error by default; "onFailure": "block" makes failures block |
| Handler types | command, http, mcp_tool, prompt, agent (experimental) |
| Key events | PreToolUse, PostToolUse, UserPromptSubmit, Stop, PermissionRequest, SessionStart |
| Key output fields | permissionDecision, updatedInput, additionalContext, updatedToolOutput, decision: "block" |
| Default timeout | 600 s for command, http and mcp_tool; 30 s for prompt; 60 s for agent |
| See what is registered | /hooks |
| Debug | claude --debug or claude --debug-file <path> |
The numbers worth remembering, all from the hooks reference:
Frequently Asked Questions
Q: Can a hook modify the model's prompt before it sees it?
Not the prompt itself, but it can add to it. A UserPromptSubmit hook can return additionalContext, such as the current git branch or recent test results, and Claude reads it alongside your prompt. It can also block the prompt with decision: "block".
Q: What happens if a hook takes too long to run?
Claude Code cancels it at its timeout: 600 seconds by default for command, HTTP and MCP tool hooks (30 seconds on UserPromptSubmit), 30 for prompt hooks and 60 for agent hooks. A timed-out PreToolUse command hook does not block the tool call; the call continues through the normal permission flow, unless the hook sets "onFailure": "block". You can change the limit with the timeout field on the handler.
Q: Can I share hooks with my team?
Yes. Commit the scripts under .claude/hooks/ and the configuration in .claude/settings.json, and refer to the scripts as "$CLAUDE_PROJECT_DIR"/.claude/hooks/..., so the paths work on every teammate's machine. An absolute path to your own home folder would break for everyone else. For rules owned centrally, an HTTP hook pointing at a shared service also works.
Q: Do hooks run for subagent tasks as well?
Yes. Your PreToolUse and PostToolUse hooks also fire for tools a subagent calls, and the input carries agent_type if you want different behavior per subagent. SubagentStart and SubagentStop mark when a subagent begins and ends.
Q: How do I debug a hook that isn't firing?
Run /hooks to see every registered hook and where it comes from. Then check the matcher: tool names are case-sensitive and must match exactly. For the details of each run, start Claude Code with claude --debug (or --debug-file <path>) and read the log, which records each hook, how it ended and how long it took. Watch for a "hook error" notice on the first run, since a mistyped script path means the hook never runs.
Test yourself
Your team wants to stop Claude Code from running any shell command that deletes recursively with rm -rf, but you still want it to be able to remove individual files with rm. How would you implement this with a hook?
Answer: Add a PreToolUse hook with the matcher Bash in .claude/settings.json, pointing at a script under .claude/hooks/. You can add "if": "Bash(rm *)" to the handler so the script only starts for rm commands. The script reads tool_input.command from the JSON on stdin. If the command is a recursive forced delete, it prints a reason to stderr (echo "Recursive rm is blocked" >&2) and exits 2, so the call is blocked and Claude sees why. Otherwise it exits 0, and a plain rm notes.txt goes through the normal permission flow. Make the check catch the variants: a plain substring test for rm -rf misses rm -fr and rm -r -f, so look for an rm whose flags include both r and f. Do not exit 1 to block, because that is only a non-blocking error.
Next: MCP Servers: Give Claude Code New Powers. It shows how to connect Claude Code to databases, browsers and other services through the Model Context Protocol.
If you want this kind of breakdown every week, how real systems actually work under the hood, subscribe to Internals Decoded at internalsdecoded.com.
Sources
- Hooks reference
- Automate actions with hooks
- Settings files and precedence
- Run Claude Code programmatically
- How Claude remembers your project
- jqlang.org