Claude Code Hooks — Controlling Agent Tool Execution in Code with PreToolUse·PostToolUse
Based on official documentation | Claude Code hooks · PreToolUse · PostToolUse
Not long after adopting Claude Code, I felt a familiar anxiety: "What if Claude decides to run rm -rf on its own?" Back when we first attached an AI agent to the team, I spent a long time walking the tightrope between trust and tension. Then I found Hooks in the official documentation, and everything changed.
By the end of this article, you'll know how to inject your own logic immediately before and after Claude executes any tool. Blocking dangerous commands, auto-formatting, audit logs, CI integration — this article walks through the patterns for blocking tool execution ahead of time with PreToolUse, and building reactive automation with PostToolUse, all based on the official Claude Code documentation.
Hooks look like simple event listeners on the surface. But the key is timing. Can you intervene before execution, or can you only react after? That difference determines "what you can prevent" versus "what you cannot." There is a world of difference in reliability between asking an LLM via prompt "please don't do this" and using a hook to block the execution outright.
Core Concepts
The Exact Timing of Hook Intervention
The Claude Code agent loop flows like this:
SessionStart → PreToolUse → (tool execution) → PostToolUse → ... → StopPreToolUse fires immediately before Claude executes a tool. Nothing has happened yet. If the hook returns deny at this point, the tool call itself is cancelled. You can even modify the input values being passed to the tool.
PostToolUse fires immediately after tool execution completes. Honestly, at first I hoped I could use this to block things too — but the file has already been written by then. Rollback is not possible. Instead, it's perfect for "automation that reacts to what has already happened," such as auto-formatting, running tests, or recording audit logs.
Agent Loop: The internal cycle in which Claude Code repeatedly selects and executes tools to handle a user's request. Hooks inject user-defined logic at specific points in this loop.
Registering Hooks and Basic Configuration
The configuration file can live in two places. For global settings: ~/.claude/settings.json; for project-specific settings: .claude/settings.json. The structure looks like this:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "bash /path/to/validator.sh" }
]
}
],
"PostToolUse": [
{
"matcher": "Write",
"hooks": [
{ "type": "command", "command": "prettier --write $TOOL_INPUT_FILE_PATH" }
]
}
]
}
}The matcher accepts a regex pattern for the tool name. It is case-sensitive by default, and tool names must match exactly (Bash, not bash). You can target multiple tools at once with | (e.g., Write|Edit), or use .* to target all tools.
There are five handler types:
| Type | Stability | Description |
|---|---|---|
command |
GA | Execute a shell command or script |
http |
GA | Call an external HTTP endpoint |
mcp_tool |
GA | Call a tool on a connected MCP server |
prompt |
GA | Use a Claude model as a single-turn judge |
agent |
Experimental | Run a Claude Code sub-agent |
The Data Structure Hooks Receive via stdin
Hook scripts receive tool call information as JSON via stdin. For the Bash tool, it looks like this:
{
"tool_name": "Bash",
"tool_input": {
"command": "rm -rf /tmp/build",
"description": "Clean up build directory"
}
}This is why you use jq -r '.tool_input.command' to extract the actual command. Because the key structure of tool_input differs per tool type, it's a good habit to check this schema first when writing a hook targeting a new tool.
Decisions are returned to stdout in this format:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Dangerous command detected"
}
}permissionDecision accepts one of allow, deny, or ask (prompt the user for confirmation).
Exit Codes — The Confusing Part at First
I struggled with this for a while, but once you sort it out, it becomes easy.
| Exit code | Meaning |
|---|---|
0 |
Success. If stdout contains JSON, it is parsed and used. |
2 |
Blocking error. In PreToolUse, the operation is halted and the stderr content is fed back to Claude. |
| Any other non-zero | Non-blocking error. Execution continues and stderr is only shown in verbose mode. |
Blocking error (exit 2): This signals not just that the script failed, but that "this operation must not proceed." Claude reads the stderr content and recalibrates its judgment.
Since a deny decision itself is successfully processed, ending with exit 0 is correct. exit 0 does not mean "allow the tool" — the JSON in stdout carries the actual decision.
Practical Application
The examples are arranged from simple to complex. If you're introducing hooks for the first time, starting with Example 1 keeps the barrier to entry low.
Example 1: Auto-Format on File Save (PostToolUse)
Auto-formatting with PostToolUse is the best place to start. It sets up Prettier to automatically run every time Claude writes a file, so you'll never hear "why isn't this formatted?" in a PR review again.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [{
"type": "command",
"command": "prettier --write \"$TOOL_INPUT_FILE_PATH\" 2>/dev/null || true"
}]
}
]
}
}$TOOL_INPUT_FILE_PATH is an environment variable automatically injected by Claude Code when running a PostToolUse hook. You can reference it directly without a separate export. The || true is a safety measure to prevent the hook itself from being treated as an error when Prettier encounters unsupported file types (e.g., .sh, .md).
Example 2: Automatically Block Dangerous Commands (PreToolUse)
This pattern blocks commands like rm -rf or force-push before Claude can execute them.
This example uses jq. If it's not installed, you can install it with brew install jq on macOS or apt install jq on Ubuntu/Debian. It's worth confirming it's included in your team's development environment beforehand.
#!/bin/bash
# pre-bash-guard.sh
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command')
BLOCKED_PATTERNS=("rm -rf /" "git push --force" ":(){:|:&};:" "chmod 777")
for pattern in "${BLOCKED_PATTERNS[@]}"; do
if echo "$COMMAND" | grep -qF -- "$pattern"; then
echo '{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Dangerous command detected — execution blocked"
}
}'
exit 0
fi
done{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [{ "type": "command", "command": "bash pre-bash-guard.sh" }]
}
]
}
}| Code point | Description |
|---|---|
INPUT=$(cat) |
Reads the entire tool input JSON arriving via stdin |
jq -r '.tool_input.command' |
Extracts the actual command string from the Bash tool |
grep -qF -- "$pattern" |
Searches with a fixed string (-F). Works safely even if the pattern contains spaces or special characters, without word splitting |
permissionDecision: "deny" |
Tells Claude to "cancel this tool call" |
exit 0 |
Signals that the deny decision itself was handled successfully — not that it was allowed |
Example 3: Asynchronous Audit Logging (PostToolUse, async)
If you want to log all tool calls without slowing down the agent loop because of logging overhead, you can use async: true. On our team, this pattern was invaluable for understanding which tools the agent actually called and how often. Having visible data makes it much easier to refine policy.
{
"hooks": {
"PostToolUse": [
{
"matcher": ".*",
"hooks": [{
"type": "command",
"command": "bash audit-logger.sh",
"async": true
}]
}
]
}
}Adding async: true runs the hook in the background without blocking Claude's loop. You can also add the asyncRewake: true option to wake Claude back up after logging completes so it can continue with follow-up processing. Note that async: true is only supported for type: "command". It does not apply to prompt or agent type hooks.
Example 4: Trigger CI via HTTP Hook
When you need to integrate with an external CI system, you can use the type: "http" handler. My first reaction to this feature was "so you can attach a webhook this simply?" — and in practice, the configuration really is that straightforward.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Bash",
"hooks": [{
"type": "http",
"url": "https://ci.example.com/webhook",
"headers": { "Authorization": "Bearer ${CI_TOKEN}" },
"allowedEnvVars": ["CI_TOKEN"]
}]
}
]
}
}${CI_TOKEN} inside headers is not shell interpolation. It is Claude Code's own syntax for referencing environment variables listed in allowedEnvVars using the ${VARNAME} format inside the JSON configuration. If the variable name is not listed in allowedEnvVars, the value will not be injected for security reasons.
Example 5: Protect Production Paths (PreToolUse + Prompt Hook)
The type: "prompt" handler uses the Claude model itself as a single-turn judge. Instead of coding complex conditions in a shell script, you can define them in natural language, which offers great flexibility.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Write",
"hooks": [{
"type": "prompt",
"prompt": "If this file path contains a /prod/ or /release/ directory, respond with only 'deny'. Otherwise, respond with only 'allow'."
}]
}
]
}
}However, this approach has practical limitations. The LLM must return exactly one word — deny or allow — but it occasionally responds in natural language like "it should be deny" or "allow is correct," which causes parsing to fail. For clear path patterns, shell scripts are more reliable; use the prompt hook as a supplementary tool for semantic judgments that are hard to express as rules. Also factor in the latency and cost of LLM calls.
Pros and Cons Analysis
Advantages
| Item | Description |
|---|---|
| Deterministic control | Tool execution can be guaranteed by rules rather than relying on LLM judgment |
| Security gate | PreToolUse can block dangerous operations entirely from outside the agent loop |
| Automation consistency | Formatting, testing, notifications, etc. are applied uniformly across all sessions |
| Handler composition | command, http, mcp_tool, prompt, and agent can be mixed and combined |
| Scope separation | Global and project-level settings can be managed independently |
Drawbacks and Caveats
| Item | Description | Mitigation |
|---|---|---|
| No rollback in PostToolUse | Triggered after the file is already written; changes cannot be undone | Use PreToolUse to control dangerous file writes |
| stdin parsing dependency | Hook scripts must parse JSON from stdin directly, requiring tools like jq |
Confirm jq is included in the team development environment beforehand |
| Synchronous hook performance impact | Slow synchronous hooks block every tool call, degrading UX | Apply async: true for long-running operations |
| async type restriction | Async execution is only supported for type: "command"; not available for prompt or agent hooks |
Limit prompt hooks to lightweight judgment logic |
| Unstable prompt hook parsing | Parsing may fail if the LLM responds in a format other than deny/allow |
Handle clear conditions in shell scripts; use prompt hooks only as a supplement |
| Debugging complexity | When multiple hooks are layered, tracing execution order and exit codes can be difficult | Use verbose mode (--verbose) to check stderr output |
The Most Common Mistakes in Practice
-
Attempting to block file deletion or DB changes with PostToolUse — it has already executed by then. If you want to block it, you must use PreToolUse.
-
Confusing exit code 0 and 2 — it's easy to think "the script exited normally, so the tool will be allowed," but the
denydecision is delivered via JSON in stdout, not determined by the exit code.exit 2is a separate signal meaning "something went wrong, stop." -
Writing tool names in lowercase in the matcher — writing
bashwill not match. It is recommended to write tool names with correct casing:Bash,Write,Edit.
Closing Thoughts
Claude Code Hooks are the most reliable way to embed "our team's rules" as code into an AI agent. And once you start combining these building blocks, a picture emerges that goes beyond simple blocking rules. Auto-managing code quality with formatting hooks, blocking dangerous commands with security hooks, and understanding the agent's behavior patterns through audit logs — gradually, Claude feels like it's being trained to match the way your team works.
Three steps you can start with right now:
-
You can add just one line for Prettier auto-formatting to
~/.claude/settings.json. You'll immediately see formatting applied every time Claude writes a file, which makes it the best starting point for getting a feel for how hooks actually behave. -
You can add a dangerous command blocking script to your team repository's
.claude/settings.jsonvia PreToolUse. At first, setting it toaskinstead ofdenylets you operate gently, with Claude prompting the user for one additional confirmation. -
You can attach audit logging with
async: true. Usingmatcher: ".*"to capture all tools and write to a local file lets you see which tools the agent actually calls and how often, so you can refine your policy with real data.
As you add hooks one by one, you'll find yourself trusting Claude more. Because trust isn't blind optimism — it comes from structures like this.
References
Official Documentation
- Hooks Reference — Claude Code official docs
- Automate Workflows with Hooks — Claude Code official guide
Community Guides
- Claude Code Hooks: Complete Guide to All 12 Lifecycle Events — claudefa.st (community)
- Claude Code Hooks: 6 Production Patterns (2026) — Pixelmojo (community)
- Claude Code Hooks: A Practical Guide to Workflow Automation — DataCamp
- Claude Code Hooks Complete Guide (March 2026 Edition) — SmartScope (community)
- Implementing PostToolUse Hooks — CodeSignal
- Claude Code Hooks: Security Gates for Agent Workflows — DEV Community
- Claude Code Hook Control Flow — Steve Kinney
- Claude Code Hooks: Complete Reference (32+ Events) — The Prompt Shelf (community)
- GitHub - claude-code-hooks-mastery
- Understanding Claude Code Hooks Documentation — PromptLayer Blog