Claude Code Hooks and Skills
October 20, 2026
In the last article I set up CLAUDE.md and permissions for Claude Code with setting.json.
This one covers the next two tools in the box:
- Hooks are things that happen every time, automatically.
- Skills are procedures I want done the same way every time I ask.
Hooks
A hook is my shell command, run by Claude Code (not by the model) when an event fires. The model can’t skip it, forget it, or talk its way around it.
The events I care about:
| Event | Fires | Can block? |
|---|---|---|
PreToolUse | Before a tool runs | Yes |
PostToolUse | After a tool succeeds | No |
Stop | When Claude finishes | Can make it keep going |
SessionStart | Session opens | No |
The contract is simple:
- Input: JSON on stdin.
tool_input.file_pathfor edits,tool_input.commandfor Bash. Parse it withjq. - Output: the exit code.
0is fine.2blocks the action, and whatever you print to stderr is sent to Claude as the reason.
Hook 1: Block .env in shell commands
This closes the gap from last time:
#!/usr/bin/env bash
# .claude/hooks/block-env.sh
# PreToolUse (Bash): refuse shell commands that mention .env files.
set -euo pipefail
cmd=$(jq -r '.tool_input.command // empty')
if printf '%s' "$cmd" | sed 's/\.env\.example//g' | grep -q '\.env'; then
echo "Blocked by hook: commands that touch .env files are not allowed. Use .env.example." >&2
exit 2
fi
To be honest, this is string matching. It stops accidents, not a determined attacker (cat .e* walks right past it). The real fix is not keeping secrets where they don’t need to be.
It’s also strict. Right after I turned it on, it blocked Claude’s own find . -name '.env*', which was only checking whether a secret file existed. Later it blocked writing a .dockerignore, because the file contains .env to keep secrets out of the image. Claude used the Write tool instead and told me why.
I kept it strict anyway. A false positive costs one rephrased command. A false negative leaks a secret.
Hook 2: Format every file Claude edits
A PR once failed CI on prettier --check because of a single trailing space in a README. One space. My editor formats on save for me, so this hook does the same for Claude:
#!/usr/bin/env bash
# .claude/hooks/format.sh
# PostToolUse (Edit|Write): run Prettier on the file Claude just changed.
set -euo pipefail
file=$(jq -r '.tool_input.file_path // empty')
[ -n "$file" ] || exit 0
cd "$CLAUDE_PROJECT_DIR"
node_modules/.bin/prettier --write --ignore-unknown "$file" >/dev/null
Wiring them up
Both go in .claude/settings.json:
"hooks": {
"PreToolUse": [
{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-env.sh" }] }
],
"PostToolUse": [
{ "matcher": "Edit|Write", "hooks": [{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/format.sh" }] }
]
}
matcher is a regex on the tool name.
The hook that did nothing
I tested the format hook by having Claude write a badly formatted .ts file. The file came out unchanged, and there was no error anywhere. Great.
Running the script by hand with the same JSON showed the problem:
env: node: No such file or directory
I manage Node with fnm, which is set up in ~/.bash_profile. Only login shells read that file. Hooks don’t run in a login shell, so node wasn’t on the PATH. Any exit code other than 0 or 2 is a non-blocking error, so the edit went through unformatted and nobody complained.
The fix is to load fnm in the hook if needed, and fail loudly if Node still isn’t there:
if ! command -v node >/dev/null 2>&1 && command -v fnm >/dev/null 2>&1; then
eval "$(fnm env --shell bash)"
fi
if ! command -v node >/dev/null 2>&1; then
echo "format hook: node not found on PATH; file not formatted: $file" >&2
exit 1
fi
Lessons:
Hooks don’t run in the same environment as your terminal.
Test a hook by piping JSON into it with a minimal environment:
echo '{"tool_input":{"file_path":"src/app.ts"}}' \ | env -i HOME=$HOME PATH=/usr/bin:/bin CLAUDE_PROJECT_DIR=$PWD .claude/hooks/format.shA guardrail that silently does nothing is worse than no guardrail, because you think you’re protected.
One more thing: hooks run automatically, with your user’s permissions. A repo you clone can ship hooks in its committed .claude/settings.json. Read them before you trust a repo, the same way you would npm install scripts.
Skills
A skill is a reusable procedure in a folder: .claude/skills/<name>/SKILL.md. It has YAML frontmatter with a name and description, then Markdown instructions. It can include scripts or templates too.
At the start of a session, Claude Code only loads each skill’s name and description. The full instructions load when the skill is used. So you can have plenty of skills without filling up the context.
That’s also the rule of thumb for CLAUDE.md vs. skills:
- CLAUDE.md: facts and rules that always apply.
- Skills: procedures that only matter sometimes.
You run a skill by typing /name, or Claude picks one when the description fits. For anything with side effects, like creating branches, I turn off automatic use so only I can trigger it.
/start-issue
Every issue I work on starts the same way:
- Read the GitHub issue.
- Make sure the working tree is clean.
- Update
devand create a branch named<issue#>--short-slug. - Plan before writing code.
So that’s a skill:
---
name: start-issue
description: "Start work on a GitHub issue in this repo: read it, create the branch, and plan before coding."
disable-model-invocation: true
---
1. Read the issue with `gh issue view <number>`.
2. Run `git status --short`. If anything is uncommitted, list it and stop.
3. Update `dev` and create a branch named `<number>--<short-slug>`.
4. Gather context, write a plan, and wait for my approval before writing code.
Now /start-issue 3 does all of that.
Testing it
I ran /start-issue 3 on purpose with uncommitted work in the tree. The skill read the issue, ran git status, listed the four uncommitted files, and stopped. No stash, no commit, no branch switch. A procedure that stops and tells you beats one that “helpfully” tidies up your work.
With a clean tree, it created 3--fix-bug working branch and gathered context. Then it stopped with a plan, as it was told to.
Summary
- Hooks are for things that must happen every time: blocking, formatting, checking. Exit
2to block. Test them in a clean environment, and make them fail loudly. - Skills are for procedures you repeat. Keep the description short, quote it, and turn off automatic use for anything with side effects.
- Both can fail silently. Check that they actually ran before you trust them.
CLAUDE.md asks nicely. Settings and hooks don’t ask.
