Use when authoring a Claude Code hook -- writing a PreToolUse/PostToolUse/SessionStart/UserPromptSubmit checker -- to get the fail-open guard skeleton and advisory-vs-blocking recipes so a hook reports without ever breaking the user's flow.
Scanned 9/6/2026
Install to Claude Code
npx -y skills add avmnu-sng/sutra --skill hooks-cookbook --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Hooks Cookbook?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/avmnu-sng-hooks-cookbook)More formats (shields.io, HTML) on the badges page.
---
description: Use when authoring a Claude Code hook -- writing a PreToolUse/PostToolUse/SessionStart/UserPromptSubmit checker -- to get the fail-open guard skeleton and advisory-vs-blocking recipes so a hook reports without ever breaking the user's flow.
---
# Hooks cookbook
A hook runs on every matching event, in the user's critical path. The one rule
that makes hooks safe to ship: **a hook reports, but it never breaks the flow
when its tooling is absent.** No interpreter, no `jq`, wrong tool, wrong target,
unreadable file -- every one of those is a clean `exit 0`, not an error. Earn the
right to block only for a rule you are certain about, and even then translate
the failure into a structured response instead of a raw crash.
Build every hook in this order: parse the event, early-return unless it is
relevant, run the checker, translate the result into an advisory or a block.
Sections 1-3 are the contract; the recipe gallery applies it.
## When to use
- Authoring a new hook (PreToolUse, PostToolUse, SessionStart, UserPromptSubmit,
Stop, or a git pre-commit script).
- Turning an ad-hoc checker script into something safe to run on every edit.
- Deciding whether a check should warn or hard-block, and wiring the response.
## When not to use
- Wiring an existing, finished hook -- that is a settings.json edit, not
authoring. See `examples/hooks/README.md`.
- A one-off manual check you run by hand. Hooks are for the automatic path.
---
## 1. The contract you are coding against
A hook is a command. Claude Code sends it a JSON event on **stdin** and reads
its **exit code** and **stdout**.
Stdin fields you will use (all events carry `session_id`, `cwd`,
`hook_event_name`):
| Event | Key fields on stdin |
|--------------------|------------------------------------------------------|
| `PreToolUse` | `tool_name`, `tool_input` (args the tool will run) |
| `PostToolUse` | `tool_name`, `tool_input`, `tool_response` |
| `UserPromptSubmit` | `prompt` (the text the user just submitted) |
| `SessionStart` | `source` (`startup`\|`resume`\|`clear`\|`compact`) |
Exit codes -- the coarse channel:
| Exit | Meaning | Effect |
|------|------------------------|--------------------------------------------------------|
| `0` | success | flow proceeds; stdout is consumed (see below) |
| `2` | blocking error | stderr is fed back to the agent; the action is stopped |
| other| non-blocking error | stderr is shown to the user; flow proceeds |
Stdout -- the structured channel (a single JSON object on exit 0):
- `{"systemMessage": "..."}` -- a non-blocking note shown to the user.
- `hookSpecificOutput.additionalContext` -- text injected into the model's
context. Valid for `UserPromptSubmit` and `SessionStart`.
- `hookSpecificOutput.permissionDecision` = `"allow"|"deny"|"ask"` with
`permissionDecisionReason` -- a `PreToolUse`-only structured verdict.
- `{"suppressOutput": true}` -- hide stdout from the transcript.
The two ways to stop an action: **exit 2** (works everywhere; stderr is the
message) or, for `PreToolUse` only, **`permissionDecision: "deny"`** (a clean
structured refusal). Everything else is advisory.
---
## 2. The fail-open guard skeleton
Every hook starts the same way. Copy this shape; fill in step 4.
```bash
#!/usr/bin/env bash
set -u # NOT -e: a stray nonzero must not abort the hook mid-way
# 0. No stdin (someone ran it by hand)? Nothing to check.
[ -t 0 ] && exit 0
payload="$(cat 2>/dev/null || true)"
[ -n "$payload" ] || exit 0
# 1. Parse the event. Prefer jq; fall back to sed so a missing jq is a no-op,
# not a crash. (For a checker binary you REQUIRE, `command -v it || exit 0`.)
json_get() { # json_get <jq-path> -- e.g. json_get '.tool_input.file_path'
if command -v jq >/dev/null 2>&1; then
printf '%s' "$payload" | jq -r "$1 // empty" 2>/dev/null
else
key="${1##*.}" # last dotted segment; forgiving, good enough for a path
printf '%s' "$payload" \
| sed -n "s/.*\"$key\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p" \
| head -n1
fi
}
# 2. Early-return unless THIS event is relevant.
file="$(json_get '.tool_input.file_path')"
[ -n "$file" ] && [ -f "$file" ] || exit 0
case "$file" in *.rb|*.go|*.ts) ;; *) exit 0 ;; esac # only files we handle
# 3. The checker it needs is absent? No-op -- never block on a tooling gap.
command -v mychecker >/dev/null 2>&1 || exit 0
# 4. Run the checker. Clean result -> succeed silently.
report="$(mychecker "$file" 2>&1)" && exit 0
# 5. Translate the nonzero result into a response (advisory here).
msg="$(printf '%s' "$report" | tail -n 3 | sed 's/\\/\\\\/g; s/"/\\"/g')"
printf '{"systemMessage":"mychecker flagged %s:\\n%s"}\n' "$file" "$msg"
exit 0
```
Why each guard exists:
1. `set -u` without `-e` -- catch unset vars, but never let an ordinary nonzero
command abort the hook and surface as a spurious blocking error.
2. `[ -t 0 ]` / empty payload -- the hook is runnable by hand for testing; do
not hang on a terminal or choke on empty input.
3. `jq`-or-`sed` -- degrade, do not depend. A machine without `jq` still gets a
silent no-op, never a stack trace in the user's face.
4. The `case` and `command -v` early-returns -- do the cheapest disqualifying
check first. A hook that fires on every edit must cost near-zero on the 99%
of edits it does not care about.
---
## 3. Advisory or blocking -- pick deliberately
Default to advisory. Reach for a block only when a false positive costs less
than letting the violation through, and the rule is mechanical enough to trust.
| Intent | How |
|--------------------------------|---------------------------------------------------------|
| Non-blocking warning | `exit 0` + `{"systemMessage": "..."}` |
| Feed a hint into model context | `exit 0` + `hookSpecificOutput.additionalContext` |
| Hard stop (any event) | write the reason to stderr, `exit 2` |
| Hard stop (`PreToolUse`) | `permissionDecision: "deny"` + `permissionDecisionReason` |
Advisory is the safe default because a warning at the wrong moment is merely
noise; a block at the wrong moment is lost work. Escalate from warn to block
only after the rule has run in warn mode long enough to trust its precision.
---
## Recipe gallery
Each recipe is a pattern plus a sketch. Runnable, tunable templates live in
`examples/hooks/`; adapt them to your stack before relying on them.
### A. Fail-open guard wrapper
The skeleton in section 2 *is* this recipe: parse, early-return unless relevant,
run the checker, warn or block on failure. Every recipe below is that shape with
step 2 (relevance) and step 4-5 (checker + verdict) specialized. Start here.
### B. Advisory post-edit CI-parity
Run the **cheap** CI subset on **only the edited file** and warn. Do not run the
slow full build in a hook -- lint one file, never compile the world.
```bash
# PostToolUse on Write|Edit. Lint the one file CI would lint; warn, never block.
case "$file" in *.rb) ;; *) exit 0 ;; esac
command -v rubocop >/dev/null 2>&1 || exit 0
report="$(rubocop --force-exclusion --format simple "$file" 2>&1)" && exit 0
tail="$(printf '%s' "$report" | tail -n 3 | sed 's/\\/\\\\/g; s/"/\\"/g')"
printf '{"systemMessage":"CI-parity lint on %s:\\n%s"}\n' "$file" "$tail"
exit 0
```
**Auto-format-then-warn variant.** A `PostToolUse` hook MAY mutate the file it
just saw -- run the formatter, then validate. This is qualitatively different
from a warn-only hook: it *changes the user's file behind the edit*. Only do it
when the formatter is deterministic and idempotent, and always announce the
mutation so the model re-reads before its next edit.
```bash
command -v prettier >/dev/null 2>&1 || exit 0
before="$(cksum "$file" 2>/dev/null)"
prettier --write "$file" >/dev/null 2>&1 || exit 0 # mutate in place
[ "$before" = "$(cksum "$file" 2>/dev/null)" ] && exit 0 # unchanged -> quiet
printf '{"systemMessage":"auto-formatted %s (prettier); re-read before editing further."}\n' "$file"
exit 0
```
### C. Newly-introduced-only guard
Warn only when **this edit** introduced the violation. Do not nag about
pre-existing debt -- compare the working file against its committed `HEAD`.
```bash
git rev-parse --is-inside-work-tree >/dev/null 2>&1 || exit 0
pattern='TODO|FIXME|binding\.pry|console\.log'
rel="$(git ls-files --full-name "$file" 2>/dev/null)"
now="$(grep -Ec "$pattern" "$file" 2>/dev/null)"; now="${now:-0}"
was="$(git show "HEAD:$rel" 2>/dev/null | grep -Ec "$pattern")"; was="${was:-0}"
[ "$now" -gt "$was" ] || exit 0 # count did not rise -> not this edit's doing
printf '{"systemMessage":"this edit added a debug/TODO marker to %s (%s -> %s)."}\n' \
"$file" "$was" "$now"
exit 0
```
A count delta is coarse but cheap. For line-precise attribution, diff the added
lines (`git diff -U0 -- "$file" | sed -n 's/^+//p'`) and grep only those.
### D. Structural-ledger validator (blocking)
Parse a Markdown ledger and require every **newly-added** entry to cite a
concrete `` `backticked` `` identifier and a bold **Fix:** line. A finding with
no anchor is not actionable, so this one blocks.
```bash
# PostToolUse on Edit of the ledger. Inspect only the lines this edit added.
case "$file" in */LEDGER.md) ;; *) exit 0 ;; esac
git rev-parse --is-inside-work-tree >/dev/null 2>&1 || exit 0
added="$(git diff -U0 -- "$file" | sed -n 's/^+//p')"
bad=""
while IFS= read -r line; do
case "$line" in
'- '*) # a new entry bullet must name a concrete identifier
printf '%s' "$line" | grep -q '`[^`]\+`' || bad="missing backticked id" ;;
esac
done <<EOF
$added
EOF
printf '%s' "$added" | grep -q '\*\*Fix:\*\*' || bad="missing **Fix:** line"
[ -z "$bad" ] && exit 0
echo "LEDGER: new entry needs a \`backticked\` identifier and a **Fix:** line ($bad)." >&2
exit 2 # hard stop; stderr becomes the agent's feedback
```
### E. Repo-freshness / staleness (SessionStart, always exits 0)
Fetch, compute ahead/behind versus upstream, inject the result as context. A
`SessionStart` hook must never gate a session -- offline, detached HEAD, no
upstream all fall through to `exit 0`.
```bash
#!/usr/bin/env bash
set -u
git rev-parse --is-inside-work-tree >/dev/null 2>&1 || exit 0
git fetch --quiet 2>/dev/null || exit 0 # offline? never block.
upstream="$(git rev-parse --abbrev-ref --symbolic-full-name '@{u}' 2>/dev/null)"
[ -n "$upstream" ] || exit 0
set -- $(git rev-list --left-right --count "$upstream"...HEAD 2>/dev/null)
behind="${1:-0}"; ahead="${2:-0}"
[ "$behind" = "0" ] && exit 0
printf '{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"Branch is %s behind / %s ahead of %s. Consider rebasing before you start."}}\n' \
"$behind" "$ahead" "$upstream"
exit 0
```
### F. Session-close checklist (UserPromptSubmit)
Grep the submitted prompt for a trigger phrase; on a match, inject a close-out
checklist as context. No match -> silent no-op.
```bash
#!/usr/bin/env bash
set -u
[ -t 0 ] && exit 0
payload="$(cat 2>/dev/null || true)"
prompt="$(printf '%s' "$payload" \
| sed -n 's/.*"prompt"[[:space:]]*:[[:space:]]*"\(.*\)".*/\1/p')"
printf '%s' "$prompt" | grep -Eiq 'wrap up|close out|ship it|end of session' || exit 0
printf '{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"Session close-out checklist:\\n- [ ] tests green\\n- [ ] lint clean\\n- [ ] changelog / notes updated\\n- [ ] no debug markers left behind\\n- [ ] retro captured"}}\n'
exit 0
```
### G. Planning-doc commit guard (denylist, blocking)
Keep internal-only files out of commits: match a filename denylist against the
staged set, and refuse the commit if any are staged. Wire it as a `PreToolUse`
hook on `Bash` (gate `git commit`) or as a git `pre-commit` script.
```bash
#!/usr/bin/env bash
set -u
command -v git >/dev/null 2>&1 || exit 0
git rev-parse --is-inside-work-tree >/dev/null 2>&1 || exit 0
# Only act on a commit command (PreToolUse fires on every Bash call).
payload="$(cat 2>/dev/null || true)"
cmd="$(printf '%s' "$payload" | sed -n 's/.*"command"[[:space:]]*:[[:space:]]*"\(.*\)".*/\1/p')"
[ -n "$cmd" ] && ! printf '%s' "$cmd" | grep -q 'git commit' && exit 0
DENY_REGEX="${DENY_REGEX:-(^|/)(docs/private|\.notes|scratch)/|(^|/)PLANNING\.md$}"
hit="$(git diff --cached --name-only 2>/dev/null | grep -E "$DENY_REGEX" || true)"
[ -n "$hit" ] || exit 0
files="$(printf '%s' "$hit" | tr '\n' ' ' | sed 's/\\/\\\\/g; s/"/\\"/g')"
printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"Internal-only paths are staged: %s. Unstage them before committing."}}\n' \
"$files"
exit 0
```
As a git `pre-commit` script instead, drop the command-parsing and swap the JSON
tail for `echo "..." >&2; exit 2`.
---
## Wiring and opt-in
Enforcement belongs to the user, not the plugin. A hook that fires unasked -- even
one that only warns at the wrong moment -- is a surprise, and surprises erode
trust in the tool. So:
- **sutra core ships only advisory hooks.** Anything that mutates or blocks is
opt-in, wired by the user in their own project or user `settings.json`.
- **Escalate through the profiles.** Model an opt-in blocking rule on the sutra
profile ladder: silent under `lite`/`standard`, active under `strict` (see
`plugins/sutra/profiles/`). Read the profile at run time and no-op unless the
user chose the stricter tier.
- **One wiring per hook.** Do not register the same script as both a git
`pre-commit` and a Claude Code hook -- it will run twice.
- **Start runnable templates from `examples/hooks/`**
(`source-change-requires-test.sh`, `empty-test-detector.sh`). They already
encode the fail-open contract and expose every knob as an env var.
---
## Checklist
- [ ] `set -u` (not `-e`); no ordinary nonzero can abort the hook mid-run.
- [ ] Empty/absent stdin, missing `jq`, missing checker, wrong tool, wrong
target, unreadable file -- every one is a silent `exit 0`.
- [ ] Cheapest disqualifying check runs first; the hot path is near-zero cost.
- [ ] Advisory by default: `systemMessage` or `additionalContext`, `exit 0`.
- [ ] Any block is deliberate -- `exit 2` (stderr message) or `PreToolUse`
`permissionDecision: "deny"` -- and reserved for a rule you trust.
- [ ] A file-mutating hook announces the mutation and uses only an idempotent
formatter.
- [ ] "Newly introduced" checks compare against `HEAD`; they never nag about
pre-existing issues.
- [ ] Blocking enforcement is opt-in (profile-gated or user-wired), never turned
on by sutra core.
- [ ] The JSON emitted on stdout is a single valid object; interpolated values
are escaped for backslash and double-quote.
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!