Claude Code has 4 hook handler types (command, prompt, agent, http) and 21 lifecycle events. Most developers default to command hooks on PreToolUse. This decision guide helps you pick the right type for the right event, and tells you which 3 to implement first.
Two configs. Same goal: block a force push to main. Different reliability:
# Command hook (deterministic, <5ms)
COMMAND=$(jq -r '.tool_input.command // empty' < /dev/stdin)
if echo "$COMMAND" | grep -qE 'git push.*(--force|-f).*main'; then
echo "BLOCKED: force push to main" >&2
exit 2
fi
// Prompt hook (non-deterministic, 300-2000ms)
{
"type": "prompt",
"prompt": "Block this if it looks like a force push to a production branch"
}
The command hook is 5 lines of bash. It runs in under 5ms. It catches every git push --force main without exception.
The prompt hook calls an LLM. It takes 300-2000ms. It might decide --force-with-lease is safe enough to allow.
Both are "hooks." Choosing the wrong type turns a guardrail into a suggestion. CLAUDE.md instructions achieve 70-90% compliance. Hooks achieve 100% — but only when you pick the right one.
What are the 4 Claude Code hook handler types?
Each type trades speed for intelligence differently. Pick the wrong type and your 100% guardrail drops to a probabilistic suggestion.
| Handler | Speed | Deterministic? | Codebase Access? | Best For |
|---|---|---|---|---|
| command | <5ms | Yes | No (stdin only) | Guardrails, formatting, logging |
| prompt | 300-2000ms | No | No | Nuanced decisions on Stop |
| agent | 2-10s | No | Yes (full tools) | Deep verification, architecture |
| http | 50-500ms | Yes (your server) | No | Team policies, centralized audit |
Command hooks are shell scripts. They read JSON from stdin, run fast, and return deterministic results. Use them for anything you can express as a string match, path check, or regex.
Prompt hooks call an LLM to make a judgment call. Only use them when the decision genuinely requires reasoning, like evaluating subagent output quality on SubagentStop.
Agent hooks spawn a full Claude Code session that can read files, search code, and run tools. Reserve them for verification tasks that need codebase context.
HTTP hooks POST to your server. Useful for centralized team policies and audit logging.
The critical rule: never use prompt-based hooks for safety boundaries. Prompt hooks involve LLM judgment, and LLMs can be wrong. Safety boundaries need deterministic command hooks.
When should you use CLAUDE.md vs a hook vs both?
Use CLAUDE.md for conventions the agent should follow. Use hooks for rules the agent must never break. Use both when you want the agent to understand WHY while the hook enforces WHAT.
Is this a HARD constraint (must NEVER be violated)?
├── YES → Can you test it with a string/path/regex check?
│ ├── YES → Command hook (PreToolUse)
│ └── NO → Does it need codebase context?
│ ├── YES → Agent hook
│ └── NO → Prompt hook or HTTP hook
└── NO → Is it a preference or convention?
├── YES → CLAUDE.md (~70-90% compliance)
└── NO → Is it a repeatable workflow?
├── YES → Skill or .claude/commands/
└── NO → You probably don't need it
When should you use both? When the constraint is structural (hook enforces it) but the agent also benefits from understanding the reasoning:
-
Hook: PreToolUse blocks
git push --forceto main -
CLAUDE.md: "We use
--force-with-leaseinstead of--forcebecause a force push overwrote a teammate's commits in March 2026"
The hook prevents the bad action. The CLAUDE.md helps the agent choose the right alternative.
Which hook events should you implement first?
Start with 3 events in this order:
| Priority | Event | Handler | What It Does | Setup Time |
|---|---|---|---|---|
| 1st | PreToolUse | command | Block dangerous actions | 15 min |
| 2nd | PostToolUse | command | Auto-format, log actions | 20 min |
| 3rd | Stop | agent | Verify work before done | 30 min |
| 4th | SessionStart | command | Load env vars, context | 10 min |
| 5th | SubagentStop | prompt | Validate subagent output | 20 min |
| 6th | PermissionRequest | command | Auto-approve safe patterns | 15 min |
| 7th | PreCompact | command | Preserve context on compact | 15 min |
Your first hook — a PreToolUse command hook that blocks force pushes:
#!/bin/bash
# .claude/hooks/block-force-push.sh
# Blocks git push --force and -f to main/master/production
COMMAND=$(jq -r '.tool_input.command // empty' < /dev/stdin)
if echo "$COMMAND" | grep -qE 'git push.*(--force|-f)' && \
echo "$COMMAND" | grep -qE '(main|master|production)'; then
echo "BLOCKED: force push to protected branch" >&2
exit 2
fi
exit 0
Register it in .claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "bash .claude/hooks/block-force-push.sh"
}
]
}
]
}
}
How do you handle multiple hooks on the same event?
Hooks on the same event run in definition order. For PreToolUse, the strictest decision wins: deny beats defer, defer beats ask, ask beats allow. If any hook denies, the action is blocked regardless of what other hooks return.
Chain hooks from fastest to slowest to minimize latency:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "bash .claude/hooks/block-force-push.sh" },
{ "type": "command", "command": "bash .claude/hooks/validate-paths.sh" },
{ "type": "command", "command": "bash .claude/hooks/log-action.sh" }
]
}
]
}
}
Decision precedence hierarchy:
deny → Action blocked. Feedback sent to model.
defer → Action paused (headless mode). External UI resumes.
ask → User prompted for confirmation.
allow → Action proceeds. Skips built-in permission check.
(none) → Default behavior. Built-in permission check runs.
What are the most common hook mistakes?
Three mistakes account for most "my hook doesn't work" reports:
Exit code cheat sheet
| Exit Code | Meaning | Model Sees Feedback? |
|---|---|---|
| 0 | Success (parse JSON from stdout) | Yes, if JSON provided |
| 2 | Block action (stderr becomes feedback) | Yes |
| Any other | Silent error (logged in verbose only) | No |
The exit 1 vs exit 2 distinction is the #1 gotcha. Exit 1 means "my hook crashed." Claude Code logs it quietly and continues. Exit 2 means "I'm deliberately blocking this action."
Debug workflow
Test any hook manually:
echo '{"tool_name":"Bash","tool_input":{"command":"git push --force main"}}' \
| bash .claude/hooks/block-force-push.sh
echo "Exit code: $?"
If the hook doesn't run at all, check:
- Path correct? Command path is relative to project root, not the hooks directory
-
Matcher correct?
"matcher": "Bash"matches the tool name, not the command content -
Settings level? Project
.claude/settings.jsonoverrides user~/.claude/settings.json -
File executable? Run
chmod +x .claude/hooks/your-hook.sh - JSON valid? A syntax error in settings.json silently disables all hooks
FAQ
What are the 4 Claude Code hook handler types?
Command (shell scripts, <5ms, deterministic), prompt (LLM judgment, 300-2000ms), agent (multi-turn verification with codebase access, 2-10s), and http (webhooks, 50-500ms). Use command hooks for guardrails and formatting. Use prompt or agent hooks for nuanced decisions that require reasoning.
Should I use CLAUDE.md or a hook for security rules?
Hooks. CLAUDE.md instructions achieve 70-90% compliance because they compete with 200K tokens of context. A PreToolUse command hook achieves 100% compliance because it runs outside the LLM's reasoning chain. Use CLAUDE.md to explain WHY. Use hooks to enforce WHAT.
What is the difference between PreToolUse and PostToolUse hooks?
PreToolUse runs BEFORE a tool executes and can block it (exit code 2) or modify its input. PostToolUse runs AFTER execution and cannot undo the action, but it can auto-format code, log what happened, or inject feedback. PreToolUse for prevention, PostToolUse for reaction.
Can Claude Code hooks run in headless mode?
Yes. All hook types work in headless mode (claude -p). PreToolUse hooks can return permissionDecision: "defer" to pause execution for external UI collection. This makes hooks fully compatible with CI/CD pipelines and SDK-based workflows.
Try it now: Copy the force-push blocker script into .claude/hooks/block-force-push.sh, register it in .claude/settings.json, make it executable with chmod +x, and test it with the debug command above. Verify exit code 2. You now have one production-ready guardrail.
Which hook event would you implement first? Drop it in the comments.
Originally published on ShipWithAI. I write about Claude Code workflows, AI-assisted development, and shipping software faster with structured AI.
Top comments (0)