Claude Code hooks: a practical guide
Downshift by Tiago Vilas Boas. Updated
Downshift here is a Go tool for AI coding agents, not the React downshift autocomplete library and unrelated to any car transmission.
TL;DR
Claude Code hooks run your code at fixed points in a session. A PreToolUse hook sees every tool call before it runs and can allow, deny, ask or rewrite its input with updatedInput. Failures fail open by default. This guide covers the 33 events, config, exit codes, matchers and three safe examples, then shows how Downshift uses two PreToolUse hooks in production.
What are Claude Code hooks?
Claude Code hooks are your code, run by Claude Code at fixed points in a session: before a tool call, after it, when a prompt is submitted, when a subagent stops, and more. A hook is not a suggestion to the model. It is a handler that runs every time its event fires, so it is where you put the rules you never want the model to forget.
A handler is usually a shell command. The reference also lists http, mcp_tool, prompt and agent handler types. A command hook reads a JSON payload on stdin and answers with an exit code and, optionally, one JSON object on stdout.
Which hook events does Claude Code have?
The hooks reference lists 33 events. This is the list as published, one line each. PreToolUse, UserPromptExpansion and PreModelSwitch are the ones the table marks as able to block.
| Event | Fires when |
|---|---|
SessionStart | A session begins or resumes |
Setup | Claude Code starts with --init-only, or with --init or --maintenance in -p mode |
UserPromptSubmit | A prompt is submitted, before Claude processes it |
UserPromptExpansion | A command you typed expands into a prompt; can block the expansion |
PreToolUse | Before a tool call executes; can block it |
PermissionRequest | A tool call needs a permission decision |
PermissionDenied | Auto mode denies a tool call |
PostToolUse | After a tool call succeeds |
PostToolUseFailure | After a tool call fails |
PostToolBatch | After a batch of parallel tool calls resolves, before the next model call |
Notification | Claude Code sends a notification |
MessageDisplay | Assistant message text is displayed |
SubagentStart | A subagent is spawned |
SubagentStop | A subagent finishes |
TaskCreated | A task is being created via TaskCreate |
TaskCompleted | A task is being marked completed |
Stop | Claude finishes responding |
StopFailure | The turn ends because of an API error |
TeammateIdle | An agent team teammate is about to go idle |
InstructionsLoaded | A CLAUDE.md or .claude/rules/*.md file is loaded |
ConfigChange | A configuration file changes during a session |
CwdChanged | The working directory changes |
DirectoryAdded | A working directory is added mid-session |
FileChanged | A watched file changes on disk |
WorktreeCreate | A worktree is being created |
WorktreeRemove | A worktree created by a WorktreeCreate hook is being removed |
PreCompact | Before context compaction |
PostCompact | After context compaction completes |
PreModelSwitch | Before a requested model switch is applied; can block it |
PostModelSwitch | After the session's model changes |
Elicitation | An MCP server requests user input during a tool call |
ElicitationResult | After a user responds to an MCP elicitation |
SessionEnd | A session terminates |
Checked on 2026-10-24 against the Claude Code hooks reference. Events, defaults and fields change between releases; check your version.
Where do you configure Claude Code hooks?
Hooks live under a hooks key, and entries merge across levels instead of replacing each other.
~/.claude/settings.json: every project on your machine..claude/settings.json: one project, shareable through the repo..claude/settings.local.json: one project, gitignored.- Managed policy settings: organization-wide, set by an admin.
- A plugin's
hooks/hooks.json: active while the plugin is enabled. - Skill and subagent frontmatter: active while that skill or subagent is in play.
The shape is three levels deep: event, then matcher group, then handlers.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "~/.claude/hooks/check-bash.sh", "timeout": 10 }
]
}
]
}
}See Claude Code settings for which file wins on other keys, and the hooks guide for a walkthrough.
How does a hook talk to Claude Code? Input, output and exit codes
Every command hook gets JSON on stdin with common fields such as session_id, transcript_path, cwd, permission_mode and hook_event_name. Tool events add tool_name, tool_input and tool_use_id. Inside a subagent you also get agent_id and agent_type.
- Exit 0: success. If stdout is a single JSON object, Claude Code parses it as a decision; otherwise it is plain text.
- Exit 2: a blocking error on events that can block. Stderr becomes the reason. On
PostToolUseit only shows stderr, because the tool already ran. - Any other code: a non-blocking error. The action proceeds and a hook error notice appears. Exit 1 does not block, so use exit 2 or a JSON deny for policy.
Keep stdout clean. A stray echo before your JSON turns a decision into text.
How can a PreToolUse hook allow, deny, ask or rewrite a tool call?
This is the event that matters most. A PreToolUse hook answers with hookSpecificOutput carrying hookEventName: "PreToolUse", a permissionDecision and a permissionDecisionReason.
| permissionDecision | What it does |
|---|---|
allow | Skips the permission prompt. Deny and ask rules in your settings are still evaluated. |
deny | Prevents the tool call; the reason is shown to Claude. |
ask | Prompts you to confirm, with the reason in the prompt, even in auto mode. |
defer | Ends the turn so the tool can resume later; -p (non-interactive) only, ignored interactively. |
When several hooks disagree, the reference sets the precedence as deny > defer > ask > allow.
The fourth power is rewriting. updatedInput, under hookSpecificOutput, replaces the tool's whole input object before the tool runs. Pair it with allow to auto-approve, or with ask to show the user the modified input. Two rules bite people: send every unchanged field back, and remember that permission rules are evaluated against the input your hook returns, not the one Claude sent.
Checked on 2026-10-24 against the Claude Code hooks reference. Events, defaults and fields change between releases; check your version.
How do hook matchers work?
"*",""or no matcher: match everything.- Only letters, digits,
_,-, spaces, commas and pipes: an exact name, or a list such asEdit|Write. - Anything else: an unanchored JavaScript regular expression.
Edit.*also matchesNotebookEdit; write^Edit$for a whole-name match.mcp__memory__.*matches every tool of one MCP server.
On tool events a handler can add an if field in permission-rule syntax, such as Bash(git *), to run only for some calls. The reference calls that filter best-effort, so it narrows when a hook runs; it is not your security boundary.
What happens when a hook times out or fails?
Defaults from the reference: 600 seconds for command, http and mcp_tool handlers, 30 seconds for prompt, 60 for agent, and a shorter 30 seconds on UserPromptSubmit and the model-switch events. Set your own timeout, in seconds, per handler.
On PreToolUse, a hook that crashes or times out does not block the call. The call goes through the normal permission flow, as if the hook had said nothing. If you want failure to block, set "onFailure": "block"; the reference marks it as available from v2.1.295. A PreModelSwitch hook that hits its timeout blocks the switch.
That default is right for most hooks: a bug in your logger should never stop your session. It is also why a hook is a guard rail and not a lock.
Claude Code hooks examples: three safe, minimal hooks
Each script needs jq and chmod +x. Each one does one thing and stays quiet otherwise.
1. Block a destructive command
Deny rm -rf and let every other Bash call through. The if filter keeps the script from starting on unrelated commands.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "if": "Bash(rm *)", "command": "~/.claude/hooks/block-rm-rf.sh" }
]
}
]
}
}#!/bin/bash
# block-rm-rf.sh: deny recursive force deletes, stay silent otherwise
cmd=$(jq -r '.tool_input.command // ""')
if printf '%s' "$cmd" | grep -Eq 'rm[[:space:]]+-[a-zA-Z]*r[a-zA-Z]*f|rm[[:space:]]+-[a-zA-Z]*f[a-zA-Z]*r'; then
jq -n '{hookSpecificOutput: {hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: "rm -rf is blocked by a local hook"}}'
fi
exit 0For a hard rule, also add a deny rule to permissions in settings; the reference recommends the permission system for enforcement.
2. Log tool calls without their content
An audit trail of which tools ran and when, with no arguments, file paths or output. async keeps it off the critical path.
{
"hooks": {
"PostToolUse": [
{
"matcher": "*",
"hooks": [
{ "type": "command", "command": "~/.claude/hooks/log-tool.sh", "async": true }
]
}
]
}
}#!/bin/bash
# log-tool.sh: tool name, session and time only; never tool_input or output
jq -c '{ts: (now | todate), session: .session_id, tool: .tool_name}' \
>> ~/.claude/tool-calls.jsonl3. Rewrite a subagent's model
Send the original tool_input back with only model changed. Use a family name; on 2026-10-06 a full model id was refused and the spawn failed.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Agent|Task",
"hooks": [
{ "type": "command", "command": "~/.claude/hooks/subagent-model.sh" }
]
}
]
}
}#!/bin/bash
# subagent-model.sh: send the whole tool_input back, change only model
jq '{hookSpecificOutput: {hookEventName: "PreToolUse",
permissionDecision: "allow",
updatedInput: (.tool_input + {model: "haiku"})}}'This one always answers haiku, so it shows the mechanism, not a policy. The PreToolUse subagent model guide covers the details.
Are Claude Code hooks safe? Security notes
- Command hooks run with your user's permissions, with no sandbox of their own. Anything your shell can do, a hook can do.
- Review a third-party hook or plugin like code you are about to run, because it is. Read the script, the matcher and what it writes to disk or sends over the network.
- Project hooks from a cloned repo run only after you accept the workspace trust dialog. Do not accept it for a repo you have not read.
- Admins can set
allowManagedHooksOnlyto block user, project and plugin hooks; HTTP hooks can be limited withallowedHttpHookUrls. - Prefer the exec form (
args) to avoid shell interpretation of special characters. - A hook's silence never approves a call. Keep hard allow and deny rules in permissions; use hooks for logic that rules cannot express.
Worked example: how Downshift uses PreToolUse hooks
Downshift is an open-source Go binary that ships two PreToolUse hooks for Claude Code, both registered by its plugin.
The spawn hook: matcher Agent|Task
When Claude spawns a subagent, Downshift scores the task text locally, with no LLM call, and picks a tier. If it is confident, it returns updatedInput with the same fields and only model and reasoning_effort changed. If it is unsure, it holds and the spawn runs exactly as Claude asked.
// tool_input Claude sent
{ "description": "Rename variable", "prompt": "rename userId to accountId",
"subagent_type": "general-purpose", "model": "opus" }
// updatedInput Downshift returns: same fields, only model moved
{ "description": "Rename variable", "prompt": "rename userId to accountId",
"subagent_type": "general-purpose", "model": "haiku" }- Fails open. Stdin is capped at 1 MiB and read with a 2 second timeout, the whole hook has a 1.5 second deadline, and a panic leaves the spawn unchanged.
- Task text stays in memory. It is scored and dropped, never written to disk; telemetry keeps counts, hashes and timings.
- Sibling fields are untouched.
prompt,descriptionandsubagent_typego back as they came.
The Compactor: matcher Bash
When the agent runs an allowlisted test, build or status command (go test, git status, pytest, vitest, jest, tsc, cargo test and more), the hook rewrites command to run through downshift compact. Green runs come back as a summary line, failures stay whole, and the real exit code is kept.
# what the agent asked for
go test ./...
# what runs after the Bash PreToolUse hook (allowlisted, no shell metacharacters)
downshift compact -- go test ./...It rewrites only commands with no shell metacharacters, and a matching deny or ask rule in your settings stops the rewrite, so the hook never walks a command past your rules. "exec": false or DOWNSHIFT_EXEC=0 turns it off.
Frequently asked questions
What is a hook in Claude Code?
A handler, usually a shell command, that Claude Code runs at a fixed point in a session, such as before a tool call. A command hook reads a JSON payload on stdin and answers with an exit code and, optionally, one JSON object on stdout.
Can a PreToolUse hook change a tool's input?
Yes. Return updatedInput under hookSpecificOutput together with permissionDecision allow or ask. It replaces the whole input object, so send the unchanged fields back too.
Does a broken hook block Claude Code?
Not on PreToolUse by default. Exit 2 or a deny decision blocks. A crash, a timeout or any other exit code lets the call continue through the normal permission flow, unless the hook sets onFailure to block.
Does a hook's allow override my deny rules?
No. Per the hooks reference, deny and ask rules in your settings are still evaluated whatever the hook returns, and they are evaluated against the input the hook returns.
Does Downshift read or store my prompts?
It reads a spawn's task text to score it, in memory only, and never persists it. It changes only model and reasoning_effort, and leaves the spawn unchanged on any error.
Put a hook to work on every spawn
Install Downshift, preview every change with downshift setup --dry-run, and let the two PreToolUse hooks route subagents and compact test output. downshift setup --undo restores your backups.
curl -fsSL https://raw.githubusercontent.com/tiagovilasboas/downshift/main/install.sh | sh && downshift setupClaude Code plugin
/plugin install downshift --marketplace tiagovilasboas/downshiftRelated guides
Sources
- Anthropic docsverified
code.claude.com/docs/en/hooks
event list, input fields, exit codes, PreToolUse decisions, updatedInput, matchers, timeouts, security (fetched 2026-10-24) - Anthropic docsverified
code.claude.com/docs/en/hooks-guide
walkthrough of hook setup (linked, not quoted) - Anthropic docsverified
code.claude.com/docs/en/settings
settings files and scopes (linked, not quoted) - Downshift repoverified
docs/evidence/four-arm-mechanical-2026-10-21.md
four-arm battery figures, 2026-10-21 - Downshift repoverified
hooks/hooks.json
matchers Downshift registers for Claude Code