Claude Code Hooks That Cut Token Burn: 5 Real Configs (2026)
A hook runs before the tool call, so it can stop the expensive read from ever entering your context.
Hooks are the only part of Claude Code that runs before a tool call. That makes them the only lever that stops a 40,000-token file read instead of paying for it and regretting it. Most hook examples online play a sound when the agent finishes. That is the least useful thing hooks do.
This guide gives five configurations you can paste into settings.json today. Every event name, field, and matcher below is checked against the official hooks reference.
Why a blocked tool call saves so much
Every request re-sends the whole conversation. A tool result you accept once keeps billing on every later turn in that session. So the cost of reading a 300KB lockfile is not one read. It is that read multiplied by the rest of the session. We covered the same mechanism in restarting sessions at 200K tokens.
A PreToolUse hook that returns deny costs you a short denial string instead. That is the whole trade.
The schema, once
Hooks live under hooks in ~/.claude/settings.json (user), .claude/settings.json (project), or .claude/settings.local.json (personal, untracked).
{
"hooks": {
"EventName": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "/path/to/script.sh", "timeout": 10 }
]
}
]
}
}
Three things people get wrong:
| Field | What it actually does |
|---|---|
matcher | Matches the tool name on tool events. Plain names and | or , lists are exact matches (Edit|Write). Anything containing other characters is treated as an unanchored JavaScript regex (mcp__.*). Omitting it, or "*", matches everything. |
if | Filters on the tool arguments using permission-rule syntax, like "Bash(git *)" or "Read(**/*.lock)". Only evaluated on PreToolUse, PostToolUse, PostToolUseFailure, PermissionRequest, and PermissionDenied. On any other event a hook with if set never runs at all. |
| exit code | 0 means the hook succeeded and its stdout is parsed as JSON if it starts with {. 2 is a hard block on events that support blocking. Any other code is a non-blocking error and the action proceeds, unless the hook still printed valid decision JSON, which is honored. |
Matcher narrows the tool. if narrows the argument. Use both, or your hook fires on every call and you pay for the process spawn instead.
1. Stop reads of files nobody needs
Lockfiles, minified bundles, dist/, .min.js, and CSV dumps are the classic burn. The agent reads one to “understand the project” and you carry it for the rest of the session.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Read",
"hooks": [
{
"type": "command",
"if": "Read(**/*.lock)",
"command": "~/.claude/hooks/deny-read.sh",
"timeout": 5
},
{
"type": "command",
"if": "Read(**/dist/**)",
"command": "~/.claude/hooks/deny-read.sh",
"timeout": 5
},
{
"type": "command",
"if": "Read(**/*.min.js)",
"command": "~/.claude/hooks/deny-read.sh",
"timeout": 5
}
]
}
]
}
}
deny-read.sh:
#!/usr/bin/env bash
cat <<'JSON'
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Generated or vendored file. Do not read it. Use Grep for a specific symbol, or ask the user what you need from it."
}
}
JSON
Note the path patterns follow gitignore semantics. A bare filename matches at any depth, so Read(.env) and Read(**/.env) are the same rule. A single-segment relative pattern like secrets/** matches at any depth in deny rules, but only at the current directory in allow rules. The full table is in the permissions reference.
Savings: the size of the file, times the number of turns left in the session.
2. Block reads above a size threshold
Pattern matching only catches files you predicted. A size gate catches the rest. The hook gets the tool call as JSON on stdin, so it can inspect tool_input.file_path.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Read",
"hooks": [
{
"type": "command",
"command": "~/.claude/hooks/size-gate.sh",
"timeout": 5
}
]
}
]
}
}
size-gate.sh (needs jq):
#!/usr/bin/env bash
input=$(cat)
path=$(printf '%s' "$input" | jq -r '.tool_input.file_path // empty')
offset=$(printf '%s' "$input" | jq -r '.tool_input.offset // empty')
# An explicit offset means the agent is already paging. Let it through.
[ -n "$offset" ] && exit 0
[ -z "$path" ] || [ ! -f "$path" ] && exit 0
bytes=$(wc -c < "$path" | tr -d ' ')
if [ "$bytes" -gt 120000 ]; then
jq -n --arg b "$bytes" '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: ("File is " + $b + " bytes. Read it with an offset and limit, or Grep it first.")
}
}'
fi
exit 0
120KB is roughly 30K tokens of code. Tune it. The offset escape hatch matters: without it the agent cannot page through a large file even when that is the correct move, and it will waste turns retrying.
3. Push shell searches into the structured tools
find and grep -r in a Bash call return unbounded output straight into context. Grep and Glob return capped, structured results. Steering the agent costs one denial string.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"if": "Bash(grep -r *)",
"command": "~/.claude/hooks/use-grep-tool.sh",
"timeout": 5
},
{
"type": "command",
"if": "Bash(find *)",
"command": "~/.claude/hooks/use-grep-tool.sh",
"timeout": 5
}
]
}
]
}
}
The script is the same deny shape as above, with the reason set to “Use the Grep tool for content search and the Glob tool for filenames. They cap output; a raw shell search does not.”
Bash if matching is smarter than a plain string compare. A rule must match each subcommand independently, and the recognized separators are &&, ||, ;, |, |&, &, and newlines. A fixed wrapper list is stripped before matching (timeout, time, nice, nohup, stdbuf, command, builtin, noglob, and bare xargs), so Bash(grep *) also matches xargs grep pattern. Deny and ask rules match past any leading variable assignment, so Bash(rm *) still matches FOO=bar rm -rf tmp/.
Do not mistake that for a security boundary. The docs are explicit that Bash patterns constraining arguments are fragile: URL=http://x && curl $URL and similar indirection get past them. Two gaps that matter for this hook specifically: find with -exec or -delete is not covered by a Bash(find *) rule, and environment runners like npx or docker exec are not stripped. This hook is a steering nudge to save tokens, not a guard.
4. Format on write so the agent never lints
Every lint round trip is a full-context request: the agent writes a file, something complains, the agent reads, edits, re-runs. Running the formatter yourself deletes that loop.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/format.sh",
"async": true,
"timeout": 30
}
]
}
]
}
}
async: true runs it in the background so the agent is not waiting on Prettier. ${CLAUDE_PROJECT_DIR} resolves to the project root, so the config works no matter where the session started.
One detail worth knowing: on PostToolUse the exit code is ignored. A formatter that fails will not block anything and will not tell the agent. If you want the agent to know something, print the documented PostToolUse output shape on stdout instead:
{
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"additionalContext": "Formatter failed. Fix it before continuing."
}
}
5. Gate expensive agent spawns
Subagents get their own context window, and an Opus subagent on a task Haiku could do is pure waste. The Agent tool’s parameters are matchable with the Tool(param:value) form, and the same syntax works in a hook’s if.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Agent",
"hooks": [
{
"type": "command",
"if": "Agent(model:opus)",
"command": "~/.claude/hooks/confirm-opus.sh",
"timeout": 5
}
]
}
]
}
}
Have the script return permissionDecision: "escalate" rather than "deny", so you get a permission prompt rather than a wall. The three valid PreToolUse values are allow, deny, and escalate. There is no "ask". One rule matches one parameter, so gate model and isolation with two separate entries.
Things that will bite you
- A hook with
ifon a non-tool event never runs. No error, no output. It is silently dead.ifonly works on the five tool events. - Exit code 2 is not universal. It blocks on
PreToolUse,UserPromptSubmit,Stop,SubagentStop,PreCompact,PostToolBatch, and others. It is ignored onPostToolUse,PermissionRequest,PermissionDenied,SessionStart,SessionEnd, andNotification. Check the table before relying on it. - A schema-invalid JSON output is a non-blocking error. The action proceeds and you see a
hook errornotice. Test your script by piping it a sample stdin payload before you trust it. - Timeouts default to 600 seconds for command, HTTP, and MCP hooks (
UserPromptSubmitdrops to 30s,MessageDisplayto 10s; prompt hooks 30s, agent hooks 60s). Set a short explicittimeouton anything in the hot path. On timeout the hook is cancelled, its output is discarded, and no decision is rendered, which means a slow gate is an open gate. - Denials are not free. The reason string enters context. Keep it to one sentence. A verbose denial fired fifty times is its own problem.
- Hooks execute with your user permissions. Read a script before you paste it, including the ones above.
What to install first
Start with hook 2 (the size gate) and hook 4 (format on write). Between them they cover the two most common burns: an oversized read you carry all session, and the lint loop. Add hook 1 once you know which generated files your repo keeps handing the agent.
Then measure. Run /usage and /context in Claude Code, or npx ccusage@latest, and see whether the number actually moved. The full list of free levers, including the ones that need no scripting at all, is in stop hitting Claude Code usage limits. If you are running a local model harness instead, the constraint is different and smaller: see context window traps for local agents.
Need OpenClaw fixed live?
Remote rescue sessions for gateway, auth, tunnel, VPS, and model access problems.
See Rescue Session