Claude Code hooks: a practical guide with examples
What Claude Code hooks are, every event, matchers, the JSON on stdin, exit codes and JSON output, plus 7 copy-paste examples you can use today.
Updated
Claude Code hooks are shell commands that Claude Code runs at fixed points in a session: before a tool runs, after it runs, when Claude needs you, when it stops, and more. You define them in a settings.json file. Unlike an instruction in CLAUDE.md, a hook always runs, so it’s the right place for rules and automation that must not depend on the model remembering.
This guide follows the official hooks reference and hooks guide as of October 2026. Claude Code ships often, so check there for anything newer.
Where hooks live
Hooks go in a hooks object inside a settings file. Where you put it sets the scope:
| File | Scope | Shared? |
|---|---|---|
~/.claude/settings.json | All your projects | No, only your machine |
.claude/settings.json | One project | Yes, commit it |
.claude/settings.local.json | One project | No, gitignored |
| Managed policy settings | Your organization | Set by admins |
A plugin’s hooks/hooks.json | While the plugin is on | With the plugin |
Hooks from every level merge. A project hook doesn’t replace your user hook: both run. To turn every hook off for a while, set "disableAllHooks": true.
The shape is always the same: an event name, a list of matcher groups, and the handlers in each group.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "echo edited >> ~/hook.log" }
]
}
]
}
}
Most hooks are "type": "command". There are also http (POST the event to a URL), mcp_tool, prompt (ask a model a yes or no question) and agent (experimental).
The events
These are the events most people use. The reference lists more, including PostToolBatch, TaskCreated, ConfigChange, CwdChanged, FileChanged, WorktreeCreate, PostCompact, Elicitation and the model switch events.
| Event | When it fires | Matcher filters | Can block? |
|---|---|---|---|
SessionStart | A session starts or resumes | startup, resume, clear, compact, fork | No, but can add context |
UserPromptSubmit | You send a prompt, before Claude sees it | none | Yes |
PreToolUse | Before a tool call runs | Tool name | Yes |
PermissionRequest | Claude Code is about to ask you for permission | Tool name | Answers it with JSON |
PostToolUse | After a tool call succeeds | Tool name | Feeds back to Claude |
PostToolUseFailure | After a tool call fails | Tool name | Feeds back to Claude |
Notification | Claude Code sends a notification | Notification type | No |
SubagentStart / SubagentStop | A subagent starts or finishes | Agent type | Stop: yes |
Stop | Claude finishes responding | none | Yes, keeps it working |
StopFailure | A turn ends on an API error | Error type | No |
PreCompact | Before context compaction | manual, auto | Yes |
SessionEnd | A session ends | clear, logout, prompt_input_exit, other and more | No |
For Notification, useful matchers are permission_prompt (a permission prompt has waited about six seconds) and idle_prompt (Claude finished about 60 seconds ago and you haven’t typed).
Matchers
"*",""or no matcher: fires every time.- Only letters, digits,
_,-, spaces,,and|: an exact name or a list, likeBashorEdit|Write. - Anything else: an unanchored JavaScript regex.
Edit.*also matchesNotebookEdit, so use^Edit$when you mean one tool. - MCP tools are named
mcp__<server>__<tool>. Match a whole server withmcp__github__.*. - Matchers are case-sensitive. A matcher on an event that doesn’t support one is ignored.
On tool events you can also add an if field with permission rule syntax, like "if": "Bash(git *)", so the hook only spawns for git commands. The docs call it best-effort, so don’t rely on it for a hard security rule.
What your hook receives
Each hook gets one JSON object on stdin. Common fields are session_id, transcript_path, cwd, permission_mode and hook_event_name. Tool events add tool_name and tool_input. A PreToolUse for Bash looks like this:
{
"session_id": "abc123",
"cwd": "/Users/you/code/hawkins",
"permission_mode": "default",
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": { "command": "npm test", "description": "Run test suite" },
"tool_use_id": "toolu_01ABC123"
}
$CLAUDE_PROJECT_DIR points at the project root, so use it for script paths. The examples below parse stdin with jq (brew install jq on a Mac).
Exit codes and JSON output
- Exit 0: success. Claude Code reads any JSON you print. For
SessionStartandUserPromptSubmit, plain stdout is added to Claude’s context. - Exit 2: a blocking error. Your stderr goes to Claude as the reason. It blocks
PreToolUse,UserPromptSubmitandPreCompact, and makesStopkeep working.PermissionRequestignores exit 2: answer it with JSON instead. - Any other code: a non-blocking error. The action goes ahead and the transcript shows a hook error notice.
For finer control, exit 0 and print JSON. Common fields are continue (false stops Claude entirely), stopReason and systemMessage (a warning shown to you). Event-specific fields go inside hookSpecificOutput:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Database writes are not allowed"
}
}
permissionDecision takes allow, deny, ask or defer. SessionStart and UserPromptSubmit take additionalContext. PostToolUse and Stop use a top-level "decision": "block" with a reason. A field at the wrong level is ignored without an error.
Seven examples you can paste
Put each hooks block in ~/.claude/settings.json or the project’s .claude/settings.json. If you already have a hooks object, add the event as a sibling key.
1. Desktop notification when Claude needs you
{
"hooks": {
"Notification": [
{
"matcher": "permission_prompt|idle_prompt",
"hooks": [
{
"type": "command",
"command": "osascript -e 'display notification \"Claude Code needs you\" with title \"Claude Code\"'"
}
]
}
]
}
}
On Linux, use notify-send 'Claude Code' 'Claude Code needs you'. On a Mac, if nothing shows, allow notifications for Script Editor in System Settings. More options in Claude Code notifications.
2. A sound when Claude finishes
{
"hooks": {
"Stop": [
{
"hooks": [
{ "type": "command", "command": "afplay /System/Library/Sounds/Glass.aiff" }
]
}
]
}
}
Stop fires each time Claude finishes responding, not only at the end of a task, and not when you interrupt it.
3. Block edits to .env files
Save this as .claude/hooks/protect-env.sh and run chmod +x on it:
#!/bin/bash
FILE_PATH=$(jq -r '.tool_input.file_path // empty')
case "$(basename "$FILE_PATH")" in
.env|.env.*)
echo "Blocked: $FILE_PATH is a secrets file. Ask me to change it." >&2
exit 2
;;
esac
exit 0
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-env.sh" }
]
}
]
}
}
Claude sees the message and changes course. Claude can still touch the file through Bash (cat >, sed -i), so pair this with a deny rule in your permissions if it matters.
4. Format after every edit
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "jq -r '.tool_input.file_path' | xargs npx prettier --write" }
]
}
]
}
}
Swap in gofmt -w or ruff format for your stack.
5. Log every Bash command
{
"hooks": {
"PostToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "jq -r '\"\\(now | todate) \\(.cwd) \\(.tool_input.command)\"' >> ~/.claude/bash-log.txt"
}
]
}
]
}
}
6. Give Claude context at session start
{
"hooks": {
"SessionStart": [
{
"matcher": "startup|compact",
"hooks": [
{ "type": "command", "command": "git status --short && git log --oneline -5" }
]
}
]
}
}
What it prints goes into Claude’s context, which helps most after a compaction.
7. Approve one safe permission prompt
{
"hooks": {
"PermissionRequest": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "jq -e '.tool_input.command == \"npm test\"' >/dev/null && echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PermissionRequest\",\"decision\":{\"behavior\":\"allow\"}}}' || true"
}
]
}
]
}
}
It approves exactly npm test and nothing else: anything longer, like npm test && rm -rf build, still asks you. Keep these matches exact. A loose pattern approves more than you meant. See Claude Code permission prompts for the other ways to cut prompts.
Debugging hooks
- Run
/hooksin Claude Code. It lists every configured hook by event and shows which file it came from. - Press ⌃O to open the transcript view. A blocked action shows its message, and a failed hook shows a hook error notice.
- Start with
claude --debug, orclaude --debug-file /tmp/claude.logandtail -fit, to see exit codes, stdout and stderr./debugturns logging on mid-session. - Test a script by hand:
echo '{"tool_name":"Bash","tool_input":{"command":"ls"}}' | ./my-hook.sh; echo $? - JSON ignored? Something printed before the
{, often anechoin your shell profile. Or a field sits outsidehookSpecificOutput. - A
Stophook that blocks forever: checkstop_hook_activein the input and exit 0 when it’strue. Claude Code overrides a Stop hook after 8 blocks in a row.
Security: read hooks before you install them
A hook runs with your user account’s permissions, with no prompt, every time its event fires. A project’s .claude/settings.json comes with the repo, so read it before you trust a repo you cloned. Read any hook script or plugin before installing it, the same as any shell script from the internet. Hooks fail open by default: if one crashes or times out, the action usually goes ahead.
Codex has hooks too
The Codex CLI runs hooks as well, on by default. They live in ~/.codex/hooks.json, a project’s .codex/hooks.json, or [hooks] tables in config.toml. Events include SessionStart, PreToolUse, PermissionRequest, PostToolUse, UserPromptSubmit, Stop and more, and exit 2 with a reason on stderr blocks like in Claude Code. Codex asks you to review non-managed hooks with /hooks before they run. Details are in the Codex hooks docs.
How Coral uses hooks
Coral is built on these same hooks. When a coding agent asks for permission, has a question or finishes, the hook tells Coral, and the prompt shows up in your Mac’s notch and on your iPhone, where you answer with one key or tap.
Coral asks first. During setup, each agent gets one row that connects it (for Claude Code, its hooks), and the row says exactly which files change and where the backups go. You can choose Later and skip it. When Coral updates, it keeps its own hooks current but never replaces one you changed; Settings → Agents tells you once, with a way to put Coral’s back. Coral’s hooks sit in your settings file next to your own, so /hooks shows them like any other. Details are in Get started and Privacy and security.
Removing Coral’s hooks
In the island menu, open Agent Hooks and choose Remove Claude Code Hooks. Codex, Copilot CLI and OpenCode have their own items in the same menu (Remove Codex Hooks, Remove Copilot Hooks, Remove OpenCode Plugin). Choose Add Claude Code Hooks… there to put them back.
- Only Coral’s entries go. Your own hooks and every other setting stay as they were. A Coral hook you edited is removed too.
- A backup first. Before it writes, Coral copies the file next to it, for example
~/.claude/settings.json.coral-backup-20261021-143000. - Quitting Coral is safe. If Coral isn’t running, its hook exits at once without output, so your agents ask in the terminal as usual.
- On a server, Remove… in the Servers window takes Coral’s hooks and its
coral-hookprogram back off. See Servers.
Questions
What are hooks in Claude Code?
User-defined commands that Claude Code runs at set points: before and after tools, when you send a prompt, when it needs you, and when it stops. They run every time, unlike instructions the model may forget.
What’s the difference between PreToolUse and PermissionRequest?
PreToolUse runs before every matching tool call and can block it with exit 2. PermissionRequest runs only when Claude Code is about to show you a permission prompt, and answers it with a JSON decision.
Why isn’t my hook firing?
Run /hooks to check it’s listed under the right event. Then check the matcher: it’s case-sensitive, and the script must be executable. Start with claude --debug to see what ran.
Can a hook stop Claude from finishing?
Yes. A Stop hook that exits 2, or returns "decision": "block" with a reason, makes Claude keep working. Check stop_hook_active so it doesn’t loop.