Run your own commands automatically with Claude Code hooks
Hooks are shell commands that Claude Code runs at fixed moments, such as before a command or after an edit. Log what Claude runs, block what it must never run, check every file it edits, and get told when it is waiting for you.
Checked against the official documentation on
On this page9 sections
You can write "always check the PHP syntax after editing a file" in your CLAUDE.md, and Claude will usually do it. Usually. A hook makes it certain. It is a command of your own that Claude Code runs every time a given moment arrives, whatever Claude decides.
This guide explains how hooks work, then builds four useful ones step by step: a log of every command Claude runs, a block on force pushes, a syntax check after every PHP edit, and a notification when Claude is waiting for you. All four were tested in real sessions.
What a hook is
A hook has three parts:
- An event: the moment it runs. For example
PreToolUse(before Claude uses a tool),PostToolUse(after a tool succeeds) orNotification(when Claude Code needs your attention). - A matcher: which cases it applies to. For the tool events, that is the tool name, such as
BashorEdit|Write. - A command: what runs. Usually a shell command or a script.
When the event fires, Claude Code sends your command the details as JSON on standard input. When the tool is Bash, that includes the command Claude wants to run:
{
"session_id": "abc123",
"cwd": "/home/you/project",
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": {
"command": "npm test"
}
}Your command reads what it needs (the examples here use jq), does its work, and answers with its exit code:
- Exit 0: no objection. Claude Code carries on as normal. For
PreToolUsethis does not approve anything: the usual permission rules still apply. - Exit 2: a blocking error. What you write to standard error goes back as the reason. A
PreToolUsehook blocks the tool call, and Claude reads your message. APostToolUsehook cannot undo anything, because the tool already ran, but Claude still reads your message and can fix the problem. - Any other code: a non-blocking error. The action goes ahead, and you see a short
hook errornotice.
The events you will use most:
PreToolUse: before a tool call. The only one of these that can stop a tool call before it runs.PostToolUse: after a tool call succeeds.Notification: when Claude Code is waiting for your permission or your input.SessionStart: when a session starts or resumes. Plain text the command prints is added to Claude's context.UserPromptSubmit: when you send a prompt, before Claude sees it.Stop: when Claude finishes its answer.
The full list, with more than 30 events, is in the hooks reference linked at the end.
Where hooks live
Hooks go in the same settings files as permission rules, under a hooks key:
~/.claude/settings.json: every project on your machine..claude/settings.jsonin a project: shared with everyone, if you commit it..claude/settings.local.jsonin a project: only you.
Type /hooks in a session to see every hook in force, grouped by event, and the file it comes from. The menu only shows them. To add or change a hook, edit the settings file, or ask Claude to do it. Claude Code usually notices the change on its own, without a restart.
Each example below is a complete settings file. If yours already has a hooks key, add the new event next to the ones already there instead of replacing the whole block.
Example 1: keep a log of every command
This hook appends every shell command Claude runs to a file in the project:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.command' >> \"$CLAUDE_PROJECT_DIR\"/.claude/command-log.txt"
}
]
}
]
}
}$CLAUDE_PROJECT_DIR is set by Claude Code to the project folder, so the log lands in the same place wherever Claude happens to be working.
Note
PostToolUse runs only after a tool call succeeds. In our test, a command that another hook blocked never reached the log. To record failed calls too, add the same hook under PostToolUseFailure.
Example 2: block force pushes
A PreToolUse hook runs before the permission check, so it can stop a command even in modes that would otherwise let it run.
Write the script
.claude/hooks/block-force-push.sh#!/bin/bash # Block force pushes. Claude sees the message and can choose another way. command=$(jq -r '.tool_input.command') if [[ "$command" =~ git[[:space:]]+push.*(--force|[[:space:]]-f([[:space:]]|$)) ]]; then echo "Blocked by hook: force push is not allowed in this project. Push normally or ask the user." >&2 exit 2 fi exit 0Make it executable
Shell · your userchmod +x .claude/hooks/block-force-push.shTest it by hand
Feed it the same kind of JSON Claude Code would send, and check the exit code:
Shell · your userecho '{"tool_input":{"command":"git push --force origin main"}}' | .claude/hooks/block-force-push.sh; echo "exit=$?"Output
Blocked by hook: force push is not allowed in this project. Push normally or ask the user. exit=2A normal
git push origin mainshould print onlyexit=0.Register it
.claude/settings.json{ "hooks": { "PreToolUse": [ { "matcher": "Bash", "hooks": [ { "type": "command", "if": "Bash(git push *)", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-force-push.sh" } ] } ] } }
The if field uses the same patterns as permission rules. With it, the script starts only for git push commands instead of for every shell command.
When Claude tried git push --force origin master in our test, the command never ran, and Claude received:
Output
PreToolUse:Bash hook error: ["$CLAUDE_PROJECT_DIR"/.claude/hooks/block-force-push.sh]: Blocked by hook: force push is not allowed in this project. Push normally or ask the user.A hook that blocks works in every permission mode, including Bypass permissions (bypassPermissions). The reverse is not true: a hook that answers "allow" cannot override a deny rule.
Warning
Like a Bash permission rule, this script only reads the command text. A command written in an unusual way can slip past a pattern. Use hooks like this one to catch mistakes. For a limit that must hold, combine a deny rule with the sandbox (/sandbox).
Example 3: check PHP syntax after every edit
This hook runs php -l on every PHP file Claude creates or edits. If the check fails, Claude reads the error and fixes the file straight away.
Write the script
.claude/hooks/php-lint.sh#!/bin/bash # After Claude edits a PHP file, check its syntax and report errors back to Claude. file=$(jq -r '.tool_input.file_path') if ! output=$(php -l "$file" 2>&1); then echo "PHP syntax check failed for $file:" >&2 echo "$output" >&2 exit 2 fi exit 0Make it executable
Shell · your userchmod +x .claude/hooks/php-lint.shRegister it for both Write and Edit
.claude/settings.json{ "hooks": { "PostToolUse": [ { "matcher": "Edit|Write", "hooks": [ { "type": "command", "if": "Write(*.php)", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/php-lint.sh" }, { "type": "command", "if": "Edit(*.php)", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/php-lint.sh" } ] } ] } }
Warning
An if condition covers one tool. Our first version had only Edit(*.php), and when Claude created a new file with the Write tool, the check never ran. The debug log said Skipping hook due to if condition "Edit(*.php)" not matching. Claude creates new files with Write and changes existing ones with Edit, so you need one handler for each.
With both handlers in place, we asked Claude to create a file with a missing semicolon. The hook answered:
Output
PHP syntax check failed for /home/you/project/hello.php:
PHP Parse error: syntax error, unexpected end of file, expecting "," or ";" in /home/you/project/hello.php on line 1
Errors parsing /home/you/project/hello.phpClaude added the semicolon with the Edit tool, the hook ran again and stayed silent, and the file passed.
The same pattern works for any checker or formatter: python -m py_compile, shellcheck, prettier --write, and so on.
Example 4: get told when Claude is waiting
When Claude needs your permission and you are in another window, a notification saves you from checking the terminal every few minutes. Put this one in your user settings, so it works in every project.
On a Linux desktop:
{
"hooks": {
"Notification": [
{
"matcher": "permission_prompt",
"hooks": [
{
"type": "command",
"command": "notify-send 'Claude Code' 'Claude Code needs your attention'"
}
]
}
]
}
}On a Mac, use this command instead:
osascript -e 'display notification "Claude Code needs your attention" with title "Claude Code"'permission_promptfires when a permission prompt has waited about six seconds.idle_promptfires when Claude finished answering about a minute ago and you have not typed since.- An empty matcher,
"", fires on every kind of notification.
Note
We tested the event on a server, which has no desktop, so the test hook wrote to a file instead of showing a notification. It fired while the permission prompt was waiting, and the JSON it received carried the message Claude needs your permission. The notify-send and osascript commands are the ones in the official documentation. notify-send needs a desktop session, so it does nothing over SSH. On a Mac, the first notification may need you to allow notifications for Script Editor in System Settings.
Hooks run with your permissions
A command hook runs as you, with access to everything your user account can reach. Before you add one, read what it does.
Hooks in a project's .claude/settings.json come from whoever wrote the repository, and two things from our tests are worth knowing:
- In an interactive session, Claude Code holds back hooks until you trust the folder. But the trust dialog we saw listed the folder's pre-approved permission rule and said nothing about its hooks. Before you trust a repository you did not write, open its
.claude/settings.jsonand read thehookssection yourself. claude -pnever shows the trust dialog, and it treats the folder as trusted for hooks. In our test, it ignored the project's allow rule because the folder was not trusted, but it still ran the project's hooks. To runclaude -pon someone else's repository without its hooks, add--setting-sources user. Claude Code then reads only your own settings, so the project's hooks and rules are both skipped. We tested it: with the flag, the project's logging hook did not run.
When a hook does not work
- Test the script by hand first, with a line of JSON piped in, as in example 2. Check the exit code with
echo $?. - Make scripts executable with
chmod +x. A script without it does not run. - Check the matcher. Matchers are case sensitive:
bashis notBash. jq: command not found: installjq, or write the hook in Python.- Look at what happened. Press
Ctrl+Ofor the transcript view, where blocking messages and hook errors appear. A hook that succeeds shows nothing there. - Read the debug log. Start Claude Code with
claude --debug-file /tmp/claude.log, and the log shows which hooks matched, which were skipped and why, and what each one printed.
Sources
- Automate actions with hooks, Claude Code documentation
- Hooks reference, Claude Code documentation
- Configure permissions, Claude Code documentation
- jq
Checked on
Claude Code 2.1.280 on AlmaLinux 9.8, with jq 1.6 and PHP 8. All four hooks were tested in a throwaway git project: examples 1 to 3 in claude -p runs, and example 4 and the trust dialog in an interactive session. The messages above were copied from those sessions, with the project path replaced by /home/you/project.