Claude Code Hooks Tutorial 2026: Deterministic Guardrails for Teams
Set up Claude Code hooks (PreToolUse, PostToolUse, Stop, SessionStart) for non-hallucinating guardrails — veto risky Bash, scan writes for secrets, govern headless CI runs.
Claude Code hooks are user-defined shell commands that Claude Code runs automatically at fixed points in its lifecycle — before it runs a tool, after it edits a file, when a session starts, when it finishes a turn. Because a hook is real code with a real exit code, not a polite instruction the model might ignore, it is the one mechanism in Claude Code that gives you deterministic, non-hallucinating guardrails. A PreToolUse hook can flatly refuse to let Claude run rm -rf /; a PostToolUse hook can scan every file Claude writes for an API key; a SessionStart hook can pin your org's coding standards into context before the agent does anything.
This claude code hooks tutorial covers the lifecycle events, the exit-code and JSON semantics that decide allow/deny/warn, three worked examples you can paste into settings.json, how hooks behave in headless CI runs, and an enterprise/India angle for distributing a standard hooks kit across a regulated team. All version-specific claims below were verified against the official Claude Code changelog and hooks reference as of 7 June 2026 (latest release at time of writing: v2.1.168).
Why hooks instead of "just telling Claude"?
If you write "never force-push" in CLAUDE.md, you are relying on the model to remember and obey it on every turn forever. That is a probabilistic guarantee — fine for style preferences, dangerous for production safety. A hook is a contract enforced by the harness. The model's cooperation is irrelevant: the command runs, the exit code or JSON is read, and the action is blocked or allowed mechanically.
That distinction is the whole reason hooks exist, and it is what makes them the foundation of any serious team or enterprise setup.
The hook lifecycle events
Hooks fire at events that fall into a few cadences: once per session, once per turn, and on every tool call inside the agent loop. The events you will use most:
SessionStart
Fires when a session begins, and again on resume (with source set to resume) so it can refresh context. The stdin payload includes the common fields (session_id, transcript_path, cwd, hook_event_name, permission_mode) plus the session source. SessionStart is special: its stdout is injected into Claude's context (along with UserPromptSubmit), so it is the natural place to load org rules, current sprint info, or a "you are working in a PCI-scoped repo" banner.
PreToolUse
Fires before any tool executes — Bash, Edit, Write, WebFetch, an MCP tool, anything. Its payload adds tool_name and tool_input (e.g. { "command": "npm test" } for Bash). This is the only event that can veto an action before it happens, which makes it the core of every guardrail.
PostToolUse
Fires after a tool succeeds. The payload includes tool_name, tool_input, and the tool_response. It cannot un-run the tool (the file is already written), but it can inject feedback, surface a warning, or — with the continueOnBlock option (added in v2.1.139) set to true — feed its rejection reason back to Claude and keep the turn going so the model can self-correct.
Stop and SubagentStop
Stop fires when Claude finishes responding; SubagentStop when a spawned subagent finishes. Historically a Stop hook could only block (which surfaces as an error). Since v2.1.163, both can return hookSpecificOutput.additionalContext to feed feedback back to Claude and continue the turn without it being labelled a hook error — ideal for "tests are still running, check again" loops.
MessageDisplay
Fires while assistant message text is being displayed to the user. It receives the common payload plus the message content being shown. It is an observation/side-effect event (logging, desktop notifications, redaction in the displayed transcript) — it is not a place to veto tool calls; that is PreToolUse's job.
Other events exist (UserPromptSubmit, Notification, SessionEnd, PreCompact, SubagentStart, and more), but the five above cover the guardrail-and-context use cases that matter for teams.
Exit codes and the allow / deny / warn decision
Every hook communicates back to Claude Code in two ways.
By exit code:
0— success. Claude Code parses stdout for JSON output fields. (ForSessionStartandUserPromptSubmit, stdout is also added to context.)2— blocking error. stderr is fed back to Claude as an error, and the action is blocked according to the event. JSON output is ignored on exit 2.- anything else — non-blocking error: a notice appears, execution continues.
By JSON on stdout (the cleaner path):
For PreToolUse, print:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Destructive command blocked by org policy"
}
}
permissionDecision can be:
allow— approve the tool call (skips the normal prompt)deny— block it; the reason is shown to Claudeask— escalate to the humandefer— let the normal permission flow decide
That is your allow / deny / warn spectrum: deny is a hard block, ask is a soft warn that hands control to a human, and a systemMessage field (or PostToolUse additionalContext) is how you surface a non-blocking warning.
Configuring hooks: shell form vs exec form
Hooks live in a hooks block in settings.json. The classic shell form passes a string to sh -c, so pipes and && work:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": ".claude/hooks/guard-bash.sh", "timeout": 10 }
]
}
]
}
}
Since v2.1.139, there is also an exec form using an args string array that spawns the executable directly with no shell — each array element is passed verbatim, with no glob/quote/variable interpretation:
{
"type": "command",
"command": "node",
"args": ["${CLAUDE_PROJECT_DIR}/scripts/scan-secrets.js", "--strict"]
}
Prefer the exec form whenever a path placeholder or untrusted value is involved — it removes an entire class of shell-injection footguns, which matters a lot when a hook is enforced fleet-wide.
Worked example 1 — PreToolUse blocks rm -rf and force-push
.claude/hooks/guard-bash.sh:
#!/usr/bin/env bash
# Reads the PreToolUse payload on stdin, denies dangerous commands.
payload=$(cat)
cmd=$(printf '%s' "$payload" | jq -r '.tool_input.command // ""')
deny() {
jq -nc --arg r "$1" '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: $r
}
}'
exit 0
}
case "$cmd" in
*"rm -rf "*|*"rm -fr "*) deny "Blocked: recursive force-delete is not allowed." ;;
*"git push"*"--force"*|*"git push"*" -f"*) deny "Blocked: force-push is disabled. Open a PR instead." ;;
esac
exit 0 # allow everything else
Now, even if the model is convinced that rm -rf node_modules is harmless, the harness reads the deny and the command never runs. Pair this matcher ("Bash") with the config block shown above.
Worked example 2 — PostToolUse scans Write/Edit content for secrets
A PostToolUse matcher on Write|Edit lets you scan what Claude just wrote. With continueOnBlock: true, a hit is fed back so Claude can remove the secret itself.
.claude/hooks/scan-secrets.sh:
#!/usr/bin/env bash
payload=$(cat)
file=$(printf '%s' "$payload" | jq -r '.tool_input.file_path // ""')
[ -f "$file" ] || exit 0
# crude but illustrative patterns: AWS keys, generic API keys, private keys
if grep -nE 'AKIA[0-9A-Z]{16}|sk-[A-Za-z0-9]{20,}|-----BEGIN (RSA|EC|OPENSSH) PRIVATE KEY-----' "$file" >/tmp/hit 2>/dev/null; then
jq -nc --arg f "$file" --arg h "$(cat /tmp/hit)" '{
decision: "block",
reason: ("Possible secret committed to " + $f + ":\n" + $h + "\nRemove it and use an env var or secret manager.")
}'
exit 0
fi
exit 0
Config:
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{ "type": "command", "command": ".claude/hooks/scan-secrets.sh", "continueOnBlock": true }
]
}
]
In production, replace the regex with a real scanner (gitleaks, trufflehog) called from the script. The hook is the enforcement point; the scanner is the detector.
Worked example 3 — SessionStart injects org context
"SessionStart": [
{
"hooks": [
{ "type": "command", "command": ".claude/hooks/org-context.sh" }
]
}
]
.claude/hooks/org-context.sh:
#!/usr/bin/env bash
cat <<'CTX'
ORG RULES (auto-loaded):
- This repo is in the DPDP/PII compliance scope. Never log customer PII.
- All DB migrations require a reviewer. Do not run `dotnet ef database update` against prod.
- Prefer the exec form for any new hook. No secrets in code — use the vault.
CTX
exit 0
Because SessionStart stdout is injected into context, every session starts already knowing the rules — no reliance on the developer remembering to paste them. If the hook installs or updates Skills, return {"hookSpecificOutput":{"hookEventName":"SessionStart","reloadSkills":true}} (supported from v2.1.152) so the new Skills are usable in the same session.
Hooks in headless mode: guardrails for CI and overnight jobs
The most important property for teams: hooks fire identically in headless mode (claude --print / -p), which is how Claude Code runs inside CI/CD pipelines, scheduled jobs, and overnight batch agents. There is no human in those runs to click "deny" — so the PreToolUse veto of rm -rf or force-push, and the PostToolUse secret scan, are the only thing standing between an autonomous agent and a bad outcome. Put your guardrail hooks in committed .claude/settings.json (and enforced managed settings) and they protect every pipeline run automatically.
Enterprise and India angle: a standard hooks kit for regulated teams
For an Indian IT services firm, a BFSI engineering org, or any team under the Digital Personal Data Protection (DPDP) Act, hooks turn AI-assisted coding from a liability into a controllable, auditable process.
Distribute a kit via managed settings. Place the guardrail hooks in the system-managed enterprise settings.json — the layer local user and project settings cannot override. Ship it through your MDM/golden-image to every developer laptop and CI runner. Now the rm -rf block, the force-push block, and the secret scanner are enforced fleet-wide and a developer cannot quietly delete them from their personal ~/.claude/settings.json.
Pair PreToolUse/PostToolUse with DPDP and PII obligations. A PostToolUse secret/PII scanner that blocks writes containing Aadhaar-like numbers, PAN, card numbers, or API keys gives you a deterministic technical control you can point auditors to — "no AI-generated change can introduce a hard-coded secret without being blocked." Log every deny (the script can append to a tamper-evident audit file) to evidence the control for BFSI/RBI-style reviews. This is exactly the kind of "human-meaningful, code-enforced" guardrail regulated teams need before they let agents touch a repo.
Standardise context, not just blocks. A SessionStart hook that injects the team's data-handling rules and the repo's compliance scope means every engineer — junior or senior, in Bengaluru or a client site — starts from the same baseline, with no training drift.
Putting it together: a minimal team starter
Drop this in .claude/settings.json, commit it, and you have a baseline every teammate inherits:
{
"hooks": {
"SessionStart": [
{ "hooks": [ { "type": "command", "command": ".claude/hooks/org-context.sh" } ] }
],
"PreToolUse": [
{ "matcher": "Bash", "hooks": [ { "type": "command", "command": ".claude/hooks/guard-bash.sh", "timeout": 10 } ] }
],
"PostToolUse": [
{ "matcher": "Write|Edit", "hooks": [ { "type": "command", "command": ".claude/hooks/scan-secrets.sh", "continueOnBlock": true } ] }
]
}
}
Make the scripts executable (chmod +x .claude/hooks/*.sh), keep them fast, and prefer the exec form for anything that interpolates a path. From there, layer on Stop-hook test loops, telemetry via async, and richer scanners as your needs grow.
Key takeaways
- Hooks are deterministic code at lifecycle events — the right tool when "the model usually remembers" is not good enough.
- PreToolUse is the only event that can veto an action; use
permissionDecision: "deny"(or exit 2) to block dangerous Bash before it runs. - PostToolUse scans results after the fact;
continueOnBlock: true(v2.1.139) feeds findings back so Claude self-corrects. - SessionStart injects context and can
reloadSkills(v2.1.152); Stop/SubagentStop can returnadditionalContext(v2.1.163) to keep turns going. - The exec form with
args(v2.1.139) spawns without a shell — safer for fleet-distributed hooks. - Hooks run in headless mode, so they protect CI and overnight agents; enforce them via managed enterprise settings so developers cannot bypass them — the backbone of a DPDP/BFSI-ready AI coding setup.
Verify version-specific features against the official Claude Code hooks reference and changelog, as Claude Code ships frequently. Facts here were checked on 7 June 2026 (v2.1.168 latest).
Community Questions
0No questions yet. Be the first to ask!