GuideCoding & dev tools
Claude Code hooks: run formatters, tests and guards automatically
Set up Claude Code hooks that format files after edits, run tests and block risky commands, using today's event names, JSON input and exit codes.
Claude Code hooks are shell commands that Claude Code runs at fixed points in a session, such as before a tool call or after a file edit. You define them in a hooks block in a settings file, and they run every time their event fires, whether or not Claude would have remembered to. Because a hook is a shell command with your full user permissions, review every one before you add it.
What hooks are and which events exist
The hooks guide (opens in a new tab) describes hooks as deterministic control. An instruction in CLAUDE.md or a skill is something Claude may follow. A hook is something Claude Code runs. When an event fires, all matching hooks run in parallel.
The reference currently documents 33 events. These are the ones most projects start with:
| Job | Event | Matcher filters on | Can it block? |
|---|---|---|---|
| Stop a command or edit before it runs | PreToolUse | Tool name, such as Bash or Edit|Write | Yes |
| Format or check a file after Claude edits it | PostToolUse | Tool name | No, the tool already ran |
| Add context or reject a prompt | UserPromptSubmit | No matcher support | Yes |
| Keep Claude working until a check passes | Stop | No matcher support | Yes |
| Restore key context after compaction | SessionStart | startup, resume, clear, compact, fork | No |
| Alert you when Claude waits for input | Notification | Notification type, such as permission_prompt | No |
| Audit settings edits during a session | ConfigChange | Settings source | Yes, except managed policy |
The full list in the hooks reference (opens in a new tab) also covers subagents, tasks, worktrees, model switches, MCP elicitation and file watching. Most hooks use "type": "command". The other handler types are http, mcp_tool, prompt (a one-turn model decision) and agent, which the docs mark as experimental.
Where Claude Code hooks live
Hooks go in the same JSON files as other Claude Code settings (opens in a new tab). The file you choose sets who gets the hook:
| File | Applies to | Shared |
|---|---|---|
~/.claude/settings.json | All your projects | No |
.claude/settings.json | One project | Yes, commit it to the repo |
.claude/settings.local.json | One project | No, kept out of git when Claude Code writes to it |
| Managed policy settings | The whole organization | Set by admins |
A plugin's hooks/hooks.json | Wherever the plugin is enabled | Yes |
| Skill or subagent frontmatter | While that skill or subagent is active | Yes |
Hook entries merge across levels instead of replacing each other. Your user, project and local hooks add to managed ones without removing them.
Every config has the same three layers: the event name, a matcher group, and a list of handlers. If a hooks key already exists in the file, add the new event as a sibling key inside it. Run /hooks in Claude Code to see every configured hook grouped by event. Claude Code watches settings files, so edits normally apply without a restart.
Recipe 1: run a formatter after file edits
This hook from the official guide runs Prettier on every file Claude writes or edits. Add it to .claude/settings.json:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs npx prettier --write"
}
]
}
]
}
}
The Edit|Write matcher skips Bash, Read and every other tool. The command pulls the edited path out of the JSON input with jq and hands it to Prettier. A successful run prints nothing in the conversation, so check that the file changed.
One gap to know about: Claude can also change files through shell commands, and a PostToolUse hook on Edit|Write does not see those. For full coverage, the docs suggest a FileChanged hook for specific files, or a Stop hook that scans the working tree once per turn.
Recipe 2: a guard that blocks a command
A PreToolUse hook runs before the tool does, so it can stop the call. Save this illustrative script as .claude/hooks/block-dangerous.sh:
#!/bin/bash
# Reads the hook input from stdin and blocks a short list of commands.
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
case "$COMMAND" in
*"rm -rf"*|*"git push --force"*|*"git push -f"*)
echo "Blocked by project hook: ask the user to run this command." >&2
exit 2
;;
esac
exit 0
Make it executable with chmod +x .claude/hooks/block-dangerous.sh, then register it:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-dangerous.sh"
}
]
}
]
}
}
Exit code 2 cancels the tool call and sends the stderr text to Claude, so Claude can change course. A PreToolUse deny holds in every permission mode, including bypassPermissions and --dangerously-skip-permissions.
Narrow a guard with the if field
To spawn the script only for some commands, add an if field with permission-rule syntax, such as "if": "Bash(git *)". Claude Code checks each subcommand, including ones inside $() and backticks. The docs call this filter best-effort and say a hard allow or deny belongs in the permission system. A text match like the script above catches the obvious spellings only, so pair it with a permissions.deny rule for anything that must never run.
Run tests in the background
Tests take longer than formatting. The reference shows a PostToolUse hook with "async": true that runs npm test after each edit while Claude keeps working. When the script finishes, its JSON additionalContext reaches Claude on the next turn. Async hooks cannot block anything, because the action has already happened.
Reading the JSON input in a script
Claude Code passes each event's data to the hook as JSON on stdin. Every event includes common fields such as session_id, cwd, transcript_path and hook_event_name. Tool events add tool_name and tool_input, and PostToolUse also gets tool_response. File paths in tool_input are always absolute.
A PreToolUse call for a Bash command looks like this (trimmed):
{
"session_id": "abc123",
"cwd": "/home/user/my-project",
"permission_mode": "default",
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": {
"command": "npm test",
"description": "Run test suite"
}
}
Bash plus jq works for short checks. For anything longer, a Python script is easier to read and needs no extra tools. This illustrative example blocks edits to protected paths by printing a JSON decision instead of using exit code 2:
#!/usr/bin/env python3
import json
import sys
data = json.load(sys.stdin)
path = data.get("tool_input", {}).get("file_path", "").replace("\\", "/")
if any(part in path for part in (".env", ".git/", "secrets/")):
print(json.dumps({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": f"{path} is a protected file.",
}
}))
sys.exit(0)
Register it under PreToolUse with an Edit|Write matcher. Exec form avoids shell quoting problems with the path:
{
"type": "command",
"command": "python3",
"args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/protect_files.py"]
}
When args is present, Claude Code runs the command directly, without a shell, and substitutes ${CLAUDE_PROJECT_DIR} in each argument. For JSON output, the docs give one rule: pick exit 2 with a stderr message or exit 0 with JSON, and do not mix them in one hook. Nest additionalContext and decisions inside hookSpecificOutput, because a top-level additionalContext on UserPromptSubmit is silently ignored.
Exit codes and what they do
Exit codes are where most hook bugs come from. Exit 1, the usual Unix failure code, does not block anything.
| Hook result | What Claude Code does |
|---|---|
| Exit 0, no output | Nothing. On PreToolUse this is not an approval: normal permission checks still apply |
| Exit 0, plain text on stdout | Added to Claude's context for UserPromptSubmit, UserPromptExpansion, SessionStart and PostModelSwitch. Debug log only for other events |
| Exit 0, valid JSON on stdout | The JSON fields decide the outcome, for example permissionDecision: "deny" |
| Exit 2 | Blocks the action on events that can block, with stderr as the reason. On PostToolUse, Claude sees stderr but the tool already ran |
| Exit 1 or any other code, no valid JSON | Non-blocking error. The action goes ahead and the transcript shows a hook error notice |
| Timeout | The hook is cancelled and its output discarded. A timed-out PreToolUse command does not block the call |
Command hooks time out after 600 seconds by default. That drops to 30 seconds on UserPromptSubmit, and SessionEnd hooks share a 1.5-second budget unless you raise it. Set a per-hook timeout in seconds to change it.
To debug a script outside Claude Code, pipe sample JSON into it and print the exit code:
echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf build"}}' | .claude/hooks/block-dangerous.sh
echo $?
Inside a session, claude --debug or /debug shows full stderr from failing hooks.
Security review of hooks
The reference states it plainly: command hooks run shell commands with your full user permissions and can modify, delete or read any file your account can reach. Treat a hook like a script you are about to run by hand.
Workspace trust decides when a repository's hooks run. In an interactive session, Claude Code holds back hooks from every settings file until you accept the trust dialog for the folder. In claude -p and Agent SDK sessions there is no dialog, so hooks committed in a repo's .claude/settings.json run even in a folder you never trusted.
Before you script claude -p over someone else's repository, read its .claude/ settings, or turn hooks off for that run with --settings '{"disableAllHooks": true}'. A disableAllHooks set in user or project files cannot switch off managed hooks.
Copy this checklist for reviewing a hook, yours or a teammate's:
- To do: Which event and matcher does it use, and could the matcher be wider than intended?
- To do: Does it run a script from the repo? Read the script, not only the JSON.
- To do: Are shell variables quoted (
"$VAR") and file paths checked for..? - To do: Does it read or send
.env, keys or anything under.git/? - To do: Is any network call or HTTP hook going to a URL you recognize?
- To do: Does a guard rely on exit 1 or a long timeout, neither of which blocks?
- To do: Is a hard rule also backed by a permission deny rule?
Hooks from settings files and plugins also run inside subagents, so a guard covers delegated work too. Administrators can set allowManagedHooksOnly to block user, project, local and plugin hooks, and allowedHttpHookUrls to limit where HTTP hooks may send data. A ConfigChange hook can log or block settings edits made during a session.
Frequently asked questions
Can a hook approve a tool call automatically?
A `PreToolUse` hook can return `permissionDecision: "allow"` to skip the permission prompt. Deny and ask rules from settings still apply, including managed deny lists. Hooks can tighten restrictions but cannot loosen them past what your permission rules allow.
Do hooks work on Windows?
Yes. Shell-form hooks run through Git Bash on Windows, or through PowerShell when Git Bash is not installed. You can set `"shell": "powershell"` on a hook to pick PowerShell explicitly, and exec form with `args` skips the shell entirely.
How do I share hooks with my team without forcing them on everyone?
Put team hooks in `.claude/settings.json` and commit it. Personal hooks belong in `.claude/settings.local.json` or `~/.claude/settings.json`. A teammate can set `disableAllHooks` in their own local settings, though that does not affect hooks an administrator deploys through managed settings.
Can a hook use a model instead of a script?
Yes. A `"type": "prompt"` hook sends your prompt and the event data to a Claude model, which returns a decision as JSON, with a default timeout of 30 seconds. A `"type": "agent"` hook can also use tools such as Read and Grep before deciding, but the docs label agent hooks experimental.
Sources
- 1.Hooks reference - Claude Code DocsOfficial docsPrimarycode.claude.com
- 2.Automate actions with hooks - Claude Code DocsOfficial docsPrimarycode.claude.com
- 3.Settings files and precedence - Claude Code DocsOfficial docsPrimarycode.claude.com
Spotted an error? See our corrections policy.
Enjoyed this? Get the weekly brief.
New guides, Project & Tool of the Week and the AI developments that matter. One email, no spam.
Read next
GuideCoding & dev toolsPHP critical security update CVE-2026-109618: Versions and patch steps
Check the PHP critical security update CVE-2026-109618, identify affected servers, install fixed PHP versions, and apply temporary settings if needed.
12 min read
- GuideCoding & dev tools
Sandbox AI coding agent setup: permissions, containers and network limits
Set up a sandbox AI coding agent in Claude Code, Codex, Copilot or Docker, see what each boundary blocks, and spot the gaps it leaves open.
7 min read
- NewsCoding & dev tools
QGIS bans LLM, AI and agent-assisted code in its main repository
QGIS approved a policy that bans LLM, AI and agent-assisted code in qgis/qgis. What changed for contributors, how the vote went and what is still open.
5 min read