Key takeaways
- Hooks are configured in Claude Code’s settings files under a
"hooks"key: event, then matcher group, then handlers. UserPromptSubmitruns before Claude sees a prompt.PreToolUseruns before every tool call. Both can block.- Exit code 2 blocks. Exit code 1 doesn’t: it’s a non-blocking error and the action goes ahead.
- When a prompt is blocked, only you see the reason. When a tool call is denied, Claude sees the reason and can adjust.
- Hooks run with your full user permissions, on every matching event. Keep them short, fast and reviewed.
What Claude Code hooks are
A hook is a command that Claude Code runs for you when something specific happens in a session: a prompt is submitted, a tool is about to run, a tool just finished, the session starts or ends.
The command gets a JSON description of the event on stdin. It answers through its exit code, and optionally through JSON on stdout. Depending on the event, that answer can let the action continue, block it, or add context for Claude to read.
That makes hooks the right tool for rules you want enforced every time, not just most of the time. An instruction in CLAUDE.md is advice the model weighs. A PreToolUse hook that exits 2 is a gate: the tool call doesn’t happen.
This guide focuses on the two events that decide whether work happens at all: UserPromptSubmit and PreToolUse. Everything here comes from the official hooks reference, and every script was run against sample input before it went on this page.
Where hooks live
Hooks are defined in JSON settings files. The file you choose sets who the hook applies to.
| Location | Applies to | Shareable |
|---|---|---|
~/.claude/settings.json | All your projects | No |
.claude/settings.json | One project | Yes, can be committed |
.claude/settings.local.json | One project | No, gitignored |
| Managed policy settings | Your whole organization | Yes, admin-controlled |
Plugin hooks/hooks.json | While the plugin is enabled | Yes |
| Skill or subagent frontmatter | While that skill or subagent is active | Yes |
Hooks from different levels merge: a project hook doesn’t replace your user hook, both run. Two settings matter if you work in a company setup. disableAllHooks turns hooks off, but it can’t disable managed hooks from outside managed settings. Administrators can set allowManagedHooksOnly to ignore user, project, local and plugin hooks entirely.
Personal guardrails (working hours, “never rm -rf”) belong in ~/.claude/settings.json, so they follow you into every repo. Team conventions (run the formatter, protect a generated folder) belong in the project’s .claude/settings.json, so they’re reviewed like code.
The shape of a hook configuration
The configuration has three levels: the event, a list of matcher groups for that event, and the handlers each group runs.
~/.claude/settings.json
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "$HOME/.claude/hooks/block-rm-rf.sh",
"timeout": 10
}
]
}
]
}
}
Each handler needs a type. Most hooks are "command", which runs a shell command; Claude Code also supports http, mcp_tool, prompt and agent handlers. The fields you’ll use most:
| Field | What it does |
|---|---|
command | The command to run. Use an absolute path or a variable such as $HOME or ${CLAUDE_PROJECT_DIR}. |
timeout | Seconds before the hook is cancelled. Defaults to 600 for command hooks, lowered to 30 on UserPromptSubmit. |
if | An extra filter in permission-rule syntax, such as "Bash(git *)". Only evaluated on tool events. |
statusMessage | Spinner text shown while the hook runs. |
async | Run the command in the background instead of waiting for it. |
Two environment variables help hooks find their files. CLAUDE_PROJECT_DIR is the project root where the session started, and it stays fixed even when Claude moves into a worktree. Plugins get CLAUDE_PLUGIN_ROOT for their install folder.
Which events you can hook
Claude Code exposes more than thirty events. Most guides only need a handful:
| Event | When it fires | Can block? |
|---|---|---|
SessionStart | A session begins or resumes | No |
UserPromptSubmit | You submit a prompt, before Claude processes it | Yes |
PreToolUse | Before a tool call runs | Yes |
PermissionRequest | A tool call needs a permission decision | Through its decision object |
PostToolUse | After a tool call succeeds | No, the tool already ran |
Stop | Claude finishes responding | Exit 2 prevents stopping |
SessionEnd | A session ends | No |
For control over whether work happens, UserPromptSubmit and PreToolUse do almost everything. The prompt hook stops new work from starting. The tool hook stops work that’s already running at its next step, which matters because an agent can keep calling tools for a long time after your last prompt.
What your hook receives on stdin
Command hooks receive one JSON object on stdin. Every event includes common fields such as session_id, transcript_path, cwd, permission_mode and hook_event_name. Each event then adds its own.
UserPromptSubmit input
The prompt hook adds prompt, the text you submitted. Pasted text that was collapsed to a placeholder arrives expanded.
stdin · UserPromptSubmit
{
"session_id": "abc123",
"transcript_path": "/Users/you/.claude/projects/my-app/00893aaf.jsonl",
"cwd": "/Users/you/code/my-app",
"permission_mode": "default",
"hook_event_name": "UserPromptSubmit",
"prompt": "Write a function to calculate the factorial of a number"
}
PreToolUse input
The tool hook adds tool_name, tool_input and tool_use_id. The shape of tool_input depends on the tool: for Bash the command is in tool_input.command.
stdin · PreToolUse
{
"session_id": "abc123",
"cwd": "/Users/you/code/my-app",
"permission_mode": "default",
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": {
"command": "npm test",
"description": "Run test suite",
"timeout": 120000,
"run_in_background": false
},
"tool_use_id": "toolu_01ABC123"
}
Read stdin even when you don’t need it. A script that exits without reading its input is usually fine, but draining it (cat > /dev/null) makes the intent obvious and avoids surprises with large inputs.
Exit codes: 0, 2 and everything else
The exit code is the simplest way for a hook to answer.
| Exit code | Meaning | What happens |
|---|---|---|
| 0 | Success | The action continues. If stdout is a single JSON object, it’s parsed as a decision. On UserPromptSubmit, plain-text stdout is added to Claude’s context. |
| 2 | Blocking error | The action is blocked and stderr is the reason. On PreToolUse the tool call doesn’t run. On UserPromptSubmit the prompt never reaches Claude. |
| Anything else | Non-blocking error | The action continues, and the transcript shows a hook error notice. |
Most shell scripts fail with exit 1, and most people assume that stops the action. It doesn’t. Claude Code treats exit 1 as a non-blocking error and carries on. If your hook enforces a rule, make the failure path exit 2, and decide on purpose what should happen when the hook itself breaks.
Claude never sees stderr from a hook that exits 0. Write diagnostics there freely; they show up in the debug log, not in the conversation.
JSON output: decisions without exit 2
Exit codes give you two outcomes. JSON gives you more: a reason for the person, a reason for Claude, extra context, or an “ask me first”. To use it, print exactly one JSON object to stdout and exit 0. Exit 2 blocks even if you print JSON, so pick one approach per hook.
A few fields work on every event: continue: false stops Claude after the hook (with stopReason shown to you), and systemMessage shows you a warning.
Blocking a prompt with UserPromptSubmit
Return a top-level decision of "block" and a reason. Leave decision out to allow the prompt. The reason is shown to the person and isn’t added to Claude’s context.
stdout · block a prompt
{
"decision": "block",
"reason": "Prompts are paused until 9:00. Your agents clock back in tomorrow.",
"hookSpecificOutput": {
"hookEventName": "UserPromptSubmit",
"suppressOriginalPrompt": true
}
}
By default the block message ends with Original prompt: followed by your text. Setting suppressOriginalPrompt to true inside hookSpecificOutput leaves it out of the message. It doesn’t remove the text from the transcript file or your prompt history.
Adding context with UserPromptSubmit
To let a prompt through with extra information attached, return additionalContext. Claude reads it alongside your prompt. Keep it factual and short; it’s capped at 10,000 characters.
stdout · add context
{
"hookSpecificOutput": {
"hookEventName": "UserPromptSubmit",
"additionalContext": "The staging database is read-only this week."
}
}
Denying a tool call with PreToolUse
PreToolUse doesn’t use the top-level decision field. It returns its answer inside hookSpecificOutput:
stdout · deny a tool call
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Database writes are not allowed"
}
}
permissionDecision | Effect | Where the reason goes |
|---|---|---|
deny | The tool call doesn’t run | Shown to Claude |
ask | You’re asked to confirm | Shown to you in the permission prompt |
allow | Skips the permission prompt, with exceptions | Debug log only |
defer | Ends the turn so the tool can resume later; needs non-interactive -p mode | Debug log only |
When several PreToolUse hooks disagree, the strictest wins: deny beats defer, which beats ask, which beats allow. One hook saying no is enough.
Who sees what
This is the detail that shapes how you word your messages:
| Situation | You see | Claude sees |
|---|---|---|
Prompt blocked (exit 2 or decision: "block") | The reason | Nothing: the prompt never arrives |
Tool call denied (exit 2 or permissionDecision: "deny") | The denial in the transcript | The reason, as the result of its tool call |
Prompt allowed with stdout text or additionalContext | Nothing extra | The added context |
systemMessage | The warning | Nothing |
So a prompt-block message is written for a person: say what happened and when it will be allowed. A tool-denial message is written for the model: say what was refused and what it should do instead. “Don’t retry this; stop and tell the user” is a perfectly good instruction to put in a denial reason, because Claude reads it and usually follows it.
Matchers and the if filter
For tool events, matcher is compared against the tool name. The rules are simple once you’ve seen them:
| Matcher | Evaluated as | Example |
|---|---|---|
"*", "" or omitted | Matches everything | Every tool call |
Letters, digits, _, -, spaces, , and | only | Exact name, or a list | Edit|Write matches exactly those two tools |
| Any other character | A JavaScript regex, unanchored | ^Notebook, or Edit.* (which also matches NotebookEdit) |
MCP tools are named mcp__<server>__<tool>. Match one tool with mcp__memory__create_entities, a whole server with mcp__memory__.* (the .* is required), or a pattern across servers with mcp__.*__write.*.
UserPromptSubmit has no matcher support. If you add one, it’s ignored and the hook runs for every prompt.
The if filter
The handler-level if field narrows a tool hook further using permission-rule syntax. This hook only runs for git push commands:
~/.claude/settings.json
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"if": "Bash(git push *)",
"command": "$HOME/.claude/hooks/check-push.sh"
}
]
}
]
}
}
The documentation calls if best-effort. Use it to avoid running a hook needlessly, not as your only line of defence. Hard allow and deny rules belong in Claude Code’s permission settings, or in a hook that checks the input itself.
Example 1: block rm -rf with PreToolUse
This hook refuses any Bash command that calls rm with both a recursive and a force flag: -rf, -fr, -Rf, -r -f or --recursive --force. It needs no jq: sed pulls tool_input.command out of the JSON.
~/.claude/hooks/block-rm-rf.sh
#!/bin/sh
# block-rm-rf.sh: a PreToolUse hook for Claude Code.
# Refuses Bash commands that call rm with both a recursive and a force flag.
# No jq needed: sed pulls tool_input.command out of the JSON on stdin.
input=$(cat)
cmd=$(printf '%s' "$input" | sed -nE 's/.*"command"[[:space:]]*:[[:space:]]*"(([^"\\]|\\.)*)".*/\1/p')
# Look only at what follows the first "rm " in the command.
rest=$(printf '%s' "$cmd" | sed -nE 's/(^|.*[^[:alnum:]_-])rm[[:space:]]+(.*)/\2/p')
[ -z "$rest" ] && exit 0
recursive=no; force=no
printf '%s' "$rest" | grep -Eq -- '(^|[[:space:]])(-[[:alpha:]]*[rR]|--recursive)' && recursive=yes
printf '%s' "$rest" | grep -Eq -- '(^|[[:space:]])(-[[:alpha:]]*f|--force)' && force=yes
if [ "$recursive" = yes ] && [ "$force" = yes ]; then
echo "Blocked: rm with recursive and force flags. Delete specific files instead, or ask the user to run it." >&2
exit 2
fi
exit 0
Make it executable and register it for the Bash tool only, so it doesn’t run on every file read:
chmod +x ~/.claude/hooks/block-rm-rf.sh
~/.claude/settings.json
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "$HOME/.claude/hooks/block-rm-rf.sh",
"timeout": 10
}
]
}
]
}
}
How it behaved in testing
We fed it sample PreToolUse input on stdin, the way Claude Code does, and checked the exit codes:
tool_input.command | Exit | Result |
|---|---|---|
rm -rf build | 2 | Blocked |
rm -fr /tmp/x | 2 | Blocked |
rm -r -f dist | 2 | Blocked |
rm --recursive --force dist | 2 | Blocked |
cd app && rm -Rf node_modules | 2 | Blocked |
rm notes.txt | 0 | Allowed |
rm -r old | 0 | Allowed (no force flag) |
npm run format | 0 | Allowed |
echo "rm -rf" is dangerous | 2 | Blocked: a false positive |
It also worked with pretty-printed, multi-line JSON. The false positive in the last row is the honest cost of matching text instead of parsing shell: anything that mentions rm -rf is refused. For a safety rail that’s the right way to fail, and Claude reads the reason and works around it by deleting specific files.
Text matching can be dodged: a script file, find -delete, or a variable holding the command. This hook stops the common accident, not a determined process. If you need real isolation, run the agent in a container or a VM with only the files it needs.
If you’d rather parse the JSON properly, Python’s standard library can do it in one line, at the cost of interpreter startup on every Bash call. In our test, starting /usr/bin/python3 took about 57 ms against about 25 ms for the whole sh script.
Example 2: add git context with UserPromptSubmit
Not every hook blocks. This one tells Claude which branch you’re on and how many files are uncommitted, every time you submit a prompt. On UserPromptSubmit, plain text printed to stdout with exit 0 is added to Claude’s context, so there’s no JSON to build.
~/.claude/hooks/git-context.sh
#!/bin/sh
# git-context.sh: a UserPromptSubmit hook for Claude Code.
# Prints the current branch and uncommitted file count. On exit 0,
# plain stdout from a UserPromptSubmit hook is added to Claude's context.
cat > /dev/null # the prompt arrives as JSON on stdin; not needed here
dir=${CLAUDE_PROJECT_DIR:-.}
branch=$(git -C "$dir" rev-parse --abbrev-ref HEAD 2>/dev/null) || exit 0
changed=$(git -C "$dir" status --porcelain 2>/dev/null | wc -l | tr -d ' ')
echo "Git branch: $branch ($changed uncommitted files)"
~/.claude/settings.json
{
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "$HOME/.claude/hooks/git-context.sh",
"timeout": 5
}
]
}
]
}
}
Run against a test repository on a branch called feature/billing, it printed Git branch: feature/billing (1 uncommitted files) and exited 0. Outside a repository, or in a repository with no commits yet, git rev-parse fails, so the script prints nothing and exits 0: no context, no error.
Note the short timeout. A prompt hook sits between you pressing Enter and Claude starting, so anything slow here is felt on every message.
Example 3: working hours for Claude Code
This is the hook AgentUnion is built around, in its simplest form. It refuses new prompts and tool calls outside your working hours, so an agent you left running at 6 PM stops at its next step instead of working through the night.
~/.claude/hooks/working-hours.sh
#!/bin/sh
# working-hours.sh: refuse new prompts and tool calls outside working hours.
# Works as a Claude Code or Codex command hook. Exit 2 blocks; stderr is the reason.
START=0900 # 9:00, 24-hour HHMM
END=1800 # 18:00
DAYS="1 2 3 4 5" # 1 = Monday ... 7 = Sunday
cat > /dev/null # the hook's JSON input arrives on stdin; this script doesn't need it
now=$(date +%H%M)
today=$(date +%u)
for day in $DAYS; do
if [ "$day" = "$today" ] && [ "$now" -ge "$START" ] && [ "$now" -lt "$END" ]; then
exit 0 # on the clock: allow
fi
done
echo "Outside working hours (weekdays 9:00 to 18:00). Don't retry or work around this; stop and tell the user." >&2
exit 2
Register the same script for both events. UserPromptSubmit takes no matcher; PreToolUse uses "*" so every tool call is checked:
~/.claude/settings.json
{
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{ "type": "command", "command": "$HOME/.claude/hooks/working-hours.sh", "timeout": 10 }
]
}
],
"PreToolUse": [
{
"matcher": "*",
"hooks": [
{ "type": "command", "command": "$HOME/.claude/hooks/working-hours.sh", "timeout": 10 }
]
}
]
}
}
We tested the boundaries by substituting the clock: 8:59 on a Monday is blocked, 9:00 is allowed, 17:59 on a Friday is allowed, 18:00 is blocked, and Saturday and Sunday are blocked all day. Each blocked run exits 2 with the reason on stderr.
Because the same exit-2 message serves both events, it does double duty. When a prompt is blocked, you read it. When a tool call is blocked mid-task, Claude reads it, and the instruction to stop and tell you keeps it from trying another tool, then another.
Where this simple version stops
- Overnight shifts. A 22:00 to 06:00 shift needs the end compared on the next day; the script above only handles shifts within one day.
- Exceptions. There’s no way to say “just this once” without editing the script. A deliberate override, used by a person and not the agent, needs state somewhere.
- Breaks and limits. Hours of agent time per day, or a break every two hours, need a record of activity between hook calls.
- Codex. Codex supports the same two events with a similar
hooks.json, but it asks you to trust each hook first. See the Codex hooks guide.
The working hours guide walks through these cases in more depth.
Keep hooks fast
A PreToolUse hook with matcher "*" runs before every tool call: every file read, every search, every edit. A busy session makes hundreds of them. Small delays add up.
- Match narrowly. If a rule is about shell commands, use
"matcher": "Bash", not"*". - Avoid interpreter startup on hot paths. In our tests a plain
shscript ran in 15 to 25 ms; starting Python alone took about 57 ms. A compiled binary is faster still: AgentUnion’s native check takes about 10 ms. - Exit early. Decide as soon as you can. Most calls should hit the “allowed” path in a few lines.
- Set a timeout. The default for command hooks is 600 seconds. A guard hook that hangs for ten minutes is worse than no hook. Set something like 10 seconds, and make sure the script fails the way you want if it’s cut off.
- Don’t touch the network on every call unless the rule truly needs it.
Security: hooks run as you
Hooks run with your full user permissions, in the session’s working directory, with Claude Code’s environment. A hook can read your SSH keys and delete your home folder as easily as it can print a branch name. Treat them accordingly:
- Review project hooks before you trust a repository. A committed
.claude/settings.jsonis code that runs on your machine. Read the hook commands as carefully as you would apostinstallscript. - Use absolute paths or
${CLAUDE_PROJECT_DIR}for scripts, so a hook never runs whatever happens to be in the current directory. - Quote variables in shell scripts, and never pass tool input to
eval. - Keep secrets out of output. Anything you return as context becomes part of the conversation.
Hooks are also not a hard security boundary against the agent itself. Claude Code runs as your user, so a determined process could edit the settings file that registers the hook. Hooks keep honest work on the rails; they don’t contain hostile code.
Troubleshooting
The hook never runs
Run /hooks inside Claude Code. It opens a read-only browser of every configured hook, grouped by event, with its source (user, project, local, plugin or session) and the file it came from. If your hook isn’t listed, the settings file isn’t being read or the JSON is invalid. Check the file with python3 -m json.tool ~/.claude/settings.json. If it’s listed but doesn’t run, check the matcher, and restart the session to rule out stale configuration.
The JSON output is ignored
JSON decisions are only read when the hook exits 0 and stdout contains exactly one JSON object. A stray echo before it, or an exit code of 2, means the JSON isn’t used. Hook JSON that fails to parse shows a hook error notice in the transcript.
The hook “fails” but nothing is blocked
It’s exiting 1 (or something other than 2). Only exit 2 blocks. Look for commands at the end of the script whose failure becomes the script’s exit status.
Permission denied
The script isn’t executable. Run chmod +x on it, or call it through its interpreter: "command": "sh $HOME/.claude/hooks/working-hours.sh".
Works in the terminal, fails in the hook
The hook’s environment isn’t your interactive shell. Aliases, functions and anything added to PATH in .zshrc may be missing. Use full paths to tools like git, node or jq, or set PATH at the top of the script.
The hook runs on the wrong tools
Check the matcher against the regex rules. Edit.* also matches NotebookEdit; use ^Edit$ for an exact match. A matcher containing only letters and | is an exact list, not a regex. For MCP tools, remember the double underscores and the trailing .*.
See exactly what happened
Start Claude Code with claude --debug, or turn on logging mid-session with /debug. Hook stdout, stderr and errors go to the debug log.
Rather not maintain the scripts?
AgentUnion is a free Mac app that installs and maintains UserPromptSubmit and PreToolUse hooks for Claude Code and Codex. You set working hours, breaks and a daily limit in a menu bar app; it keeps your other hooks, backs up your settings before every change, and handles overnight shifts and overtime passes.
FAQ
Does exit code 1 block a Claude Code hook?
No. Only exit code 2 blocks. Exit 1, like any code other than 0 and 2, is a non-blocking error: the prompt or tool call goes ahead and the transcript shows a hook error notice.
Does Claude see the reason when a UserPromptSubmit hook blocks a prompt?
No. The reason, from stderr or the JSON reason field, is shown to you and is not added to Claude’s context. When a PreToolUse hook denies a tool call, Claude does see the reason.
Can I use a matcher on UserPromptSubmit?
No. UserPromptSubmit has no matcher support, so a matcher there is ignored and the hook runs for every prompt. Matchers apply to tool events such as PreToolUse, where they are compared with the tool name.
Where should I put a hook that applies to every project?
In ~/.claude/settings.json. Hooks in a project’s .claude/settings.json apply only to that project and can be committed for the team; .claude/settings.local.json is for personal, uncommitted project hooks. Hooks from all levels merge.
What is the default timeout for a Claude Code hook?
Command hooks time out after 600 seconds by default, lowered to 30 seconds on UserPromptSubmit. Set a short timeout on guard hooks, such as 10 seconds, so a stuck script can’t hold up your session.
Can a hook stop Claude Code from working at night?
Yes. A script registered on both UserPromptSubmit and PreToolUse that exits 2 outside your working hours blocks new prompts and stops running work at its next tool call. Example 3 in this guide does exactly that.


