Claude Code hooks: run scripts before and after agent actions
Hooks run your scripts on Claude Code events: format code after edits, block risky commands, send notifications. A walkthrough of the configuration based on the official docs.
6 min · · development · автоматизация
Claude Code hooks are for the moments when asking the agent to "remember to run the formatter" isn't enough. A hook fires on its own at a given event, such as before a tool call or after a file edit. Below are the configuration schema, the exit codes, and three working examples. Everything here was checked against the official Claude Code documentation (the Hooks reference and the Automate actions with hooks guide).
What Claude Code hooks are
A hook is a handler that Claude Code calls at a specific point in the session lifecycle. The handler receives JSON describing the event and can do nothing, run a side effect (format a file, send a notification), or return a decision such as denying a tool call.
The main events:
| Event | When it fires |
|---|---|
PreToolUse | before a tool call; can block it |
PostToolUse | after a tool call succeeds |
PostToolUseFailure | after a tool call fails |
PermissionRequest | when a tool call needs a permission decision |
UserPromptSubmit | when you submit a prompt, before it is processed |
Stop | when Claude finishes responding |
SubagentStart / SubagentStop | subagent spawned / finished |
Notification | when Claude Code sends a notification |
SessionStart / SessionEnd | session begins / ends |
PreCompact / PostCompact | before / after context compaction |
There are no separate "before file edit" or "before terminal command" events. A file edit is a call to the Edit or Write tool, and a command is a call to Bash (or PowerShell on Windows). You pick the tool with a matcher on PreToolUse and PostToolUse. The full event list (more than thirty) is in the hooks reference.
Where hooks live and how the config is shaped
Hooks are defined in JSON settings files:
~/.claude/settings.json— all your projects (on Windows,~means%USERPROFILE%);.claude/settings.jsonin the project — shared with the team, can be committed;.claude/settings.local.json— local project settings, kept out of git.
The config has three levels: the event, a matcher group, and a list of handlers:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs npx prettier --write"
}
]
}
]
}
}A matcher can be a tool name (Bash), a |-separated list (Edit|Write), or a regular expression (mcp__.* for every MCP tool). An empty matcher, "*", or no matcher at all fires on every occurrence of the event.
Handler types (type):
command— a shell command; the event JSON arrives on stdin;http— a POST request with the event JSON as the body;mcp_tool— calls a tool on a configured MCP server;prompt— a single-turn check by a Claude model;agent— a subagent that can read files before deciding (marked experimental).
Optional common fields: timeout in seconds (600 by default for command and http), statusMessage for the spinner text, and if, a filter in permission-rule syntax such as "Bash(rm *)".
What a hook receives and how it answers
For PreToolUse on the Bash tool, stdin carries JSON like this:
{
"session_id": "abc123",
"cwd": "/home/user/my-project",
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": { "command": "npm test" }
}For Edit and Write, tool_input contains file_path. There are no special environment variables carrying the file path or the command text. You read the data from the JSON, usually with jq. Scripts can use CLAUDE_PROJECT_DIR, the project root.
Exit codes:
0— success. For most events stdout goes to the debug log, but if it starts with{and ends with}, it is parsed as a JSON decision;2— blocking error. InPreToolUsethe tool call is cancelled and Claude receives your stderr. InPostToolUsethe action has already happened, so stderr is just shown to Claude;- anything else (including
1) — non-blocking error: the action proceeds and the transcript shows a hook error notice.
This trips a lot of people up: exit 1 does not block anything. Use exit 2 or a JSON decision to deny.
Example: format after every edit
The config above is the example from the official guide. After every Edit or Write, the command pulls the file path out of the JSON and passes it to Prettier. For Python, swap in something like jq -r '.tool_input.file_path' | xargs ruff format. You need jq and the formatter installed.
Example: block rm -rf
Save this as .claude/hooks/block-rm.sh and make it executable (chmod +x):
#!/bin/bash
command=$(jq -r '.tool_input.command')
if echo "$command" | grep -q 'rm -rf'; then
echo "Blocked: rm -rf is not allowed in this project" >&2
exit 2
fi
exit 0Wire it up:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-rm.sh" }
]
}
]
}
}Instead of exit 2, the script can print JSON with hookSpecificOutput.permissionDecision: "deny" and a reason in permissionDecisionReason. The docs stress that hook filtering is best-effort. Use permission rules (permissions.deny) for hard restrictions and treat the hook as an extra layer.
Example: an HTTP hook
{
"hooks": {
"PostToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "http",
"url": "http://localhost:8080/hooks/post-tool-use",
"timeout": 30,
"headers": { "Authorization": "Bearer $MY_TOKEN" },
"allowedEnvVars": ["MY_TOKEN"]
}
]
}
]
}
}Claude Code POSTs the event JSON. Header variables are expanded only if they appear in allowedEnvVars, so the token never has to sit in the settings file.
Hooks and token spend when you work through Zukko
command and http hooks run locally and never call the model. Only prompt and agent hooks spend tokens, because they call the model through the same gateway as the main session. The other indirect cost is text a hook hands back to Claude (stderr on exit 2, additionalContext). It lands in the context, so keep it short.
Claude Code itself connects to Zukko through environment variables. Set them in your shell or in the env block of the same settings.json:
{
"env": {
"ANTHROPIC_BASE_URL": "https://api.zukko.pro",
"ANTHROPIC_AUTH_TOKEN": "fetch_your_key",
"ANTHROPIC_MODEL": "claude-sonnet-5"
}
}The base URL has no /v1 because Claude Code adds it itself. In Zukko, claude-sonnet-5 costs $0.3 per 1M input tokens and $0.3 per 1M output tokens. One caveat about caching: vendors charge about 10% of the input price for prompt-cache reads, while Zukko bills cached tokens at the normal input price. For an agent with a large, repeated context, the real gap versus official pricing is smaller than a straight price comparison suggests. For the current model list, call GET https://api.zukko.pro/v1/models or open the Models page in your dashboard. The step-by-step setup is in the Claude Code guide.
Debugging
/hooksinside Claude Code opens a read-only list of configured hooks, labelled with the settings file each one came from.Ctrl+Oopens the transcript view, where blocks and hook errors show up.claude --debug-file /tmp/claude.logwrites a detailed log: which hooks matched, exit codes, stdout and stderr."disableAllHooks": trueturns every hook off temporarily.
FAQ
Why doesn't my hook with exit 1 block the command?
Exit code 1 is a non-blocking error, so the action proceeds. Use exit 2, or JSON with permissionDecision: "deny" in PreToolUse.
How do I run a hook only for certain files?
Narrow the matcher to Edit|Write and check the path in your script: jq -r '.tool_input.file_path', then test the extension. The if field is another option, for example "Edit(*.ts)".
Do hooks run inside subagents?
Yes. Hooks from settings files also fire on subagent tool calls, and the input JSON then includes agent_id and agent_type.
How long does Claude Code wait for a hook?
By default up to 600 seconds for command and http, and 30 seconds for prompt. Change it with timeout. If a PreToolUse command hook times out, the call is not blocked and goes through the normal permission flow.
zukko
Pay 25×
less
for Claude Code & ChatGPT
One fetch_* key and base URL api.zukko.pro — for Cursor, Claude Code, Codex, and any SDK.
Related articles
- Translating subtitles with a neural network: SRT without broken timecodesThe main risk when translating SRT with a neural network is a broken file structure. A scheme where timecodes never reach the model, and a Python script with validation.
- Editing texts with a neural network: proofreading, style and cuttingDifferent kinds of editing need different prompts. Wording for proofreading, style and cutting, how to get edits with explanations and how not to lose the author's voice.
- A neural network for writing scripts: Bash, PowerShell and PythonA model writes scripts for routine fast, but running them blindly is risky. How to state the task, demand a safe dry-run mode and check the result before launch.
- Dify in Docker: installation, first-run setup and connecting modelsInstall Dify with Docker Compose, create the admin account and connect Claude, GPT and DeepSeek models on one key. Plus what to do when containers will not start.