Claude Code Hooks / Track 03
Track 03 · Automation
Four ready-to-paste hooks: format on save, notify when finished, log every command, and inject context as soon as the session opens.
Lessons in this path
PostToolUse picks up the newly edited file and runs the formatter—prettier, black, gofmt—so you don't have to remember.
Stop and Notification trigger the right alert at the right time — without watching the terminal and waiting for Claude to finish.
PreToolUse on Bash logs every command with a timestamp—a simple audit journal.
SessionStart provides the branch, git status, and project rules as soon as the session opens. Trail capstone.
Track 03 · Lesson 1 · Intermediate
After Claude edits a file, a hook runs the formatter on it — no need to remember, no exceptions.
↓ scroll to study
The hook lives in the event PostToolUse: it fires after that Claude finishes editing or writing a file—the action has already happened, so it’s time to clean up.
O matcher filters which tools the hook applies to: "Edit|Write" handles both — editing an existing file or writing a new one.
{ "hooks": { "PostToolUse": [ {
"matcher": "Edit|Write",
"hooks": [ { "type": "command",
"command": "f=$(jq -r .tool_input.file_path); prettier --write \"$f\"" } ]
} ] } }
Read from the inside out: in the event PostToolUse, when the tool is Edit or Write, run the command. Simple—and out of your mind forever.
Test yourself
Which event runs AFTER a tool finishes running?
The hook doesn’t know in advance which file was touched — it reads it from the stdin, field tool_input.file_path. With jq, this becomes a shell variable:
# reads the file that was just edited
file=$(jq -r '.tool_input.file_path')
It's this $file that goes to the formatter in the next section. Without it, the hook wouldn’t know which file to act on.
With the path in hand, choose the formatter based on the file extension. A case works for any stack:
case "$file" in *.js|*.ts|*.jsx|*.tsx) prettier --write "$file" ;; *.py) black -q "$file" ;; *.go) gofmt -w "$file" ;; esac
Replace these with your project's formatters. The point isn't which tool — it's that it runs always, in every file, with no human exception.
Test yourself
To format a file .py, which line of the case comes into action?
A formatting hook shouldn't block nothing: always exit 0. And watch out for a gotcha—with PostToolUse, a exit 2 doesn't undo the edit (the tool has already run); it only reports an error.
To avoid cluttering the context when everything works, use suppressOutput:
{ "suppressOutput": true }
And the formatter needs to be idempotent: running twice on the same file must not break anything—same input, same result, every time.
Only PreToolUse e Stop can block something with exit 2. In PostToolUse, that same code is treated as a non-blocking error—the action has already happened, and there's no way to undo it.
Test yourself
If a hook PostToolUse exits with exit 2, what happens?
Practice now 0/4 done
Have Claude automatically format every file it edits. ~5 min, safe: it only formats; it doesn’t block anything.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "f=$(jq -r '.tool_input.file_path'); command -v prettier >/dev/null && prettier --write \"$f\" 2>/dev/null; exit 0" }
]
}
]
}
}✓ Wrap-up: the code always comes out clean — the formatter has become part of the workflow, not a manual task you could forget.
Track 03 · Lesson 2 · Intermediate
Two events signal things at the right time: Stop when Claude finishes responding, Notification when it needs you.
↓ scroll to study
Two events signal different things. Stop triggers when Claude finishes responding — the turn is over. You’ve already Notification triggers when it needs you: it’s been idle for 60s+, requested permission, or an MCP is waiting for a response.
The difference matters: Stop means "I'm done"; Notification means "I'm waiting for you." Each calls for a different kind of notification.
Test yourself
Which event fires when Claude needs your permission (or is idle for 60s+)?
Attach a desktop notification to the event that matters. On Linux, notify-send works:
# Stop → desktop notification (linux)
notify-send "Claude Code" "terminou de responder"
A short sound (paplay, afplay) also works, and it’s more subtle than a popup. The important thing: you stop watching the terminal.
The event Notification triggers for several reasons — notification_type says which one. If you notify in all, becomes spam. Filter for what actually needs your attention:
type=$(jq -r '.notification_type') [ "$type" = "permission_prompt" ] || exit 0 notify-send "Claude Code" "precisa de permissão" --urgency=critical
This way, only the permission request triggers the critical alert — you decide whether you want the rest (idle, elicitation) or not, without constantly touching everything.
Test yourself
To notify only when a permission request is made (and ignore the rest), filter by:
The notification command changes depending on the system. A case on top of uname covers all three:
case "$(uname)" in Linux) notify-send "Claude Code" "terminou" ;; Darwin) osascript -e 'display notification "terminou" with title "Claude Code"' ;; *) powershell.exe -Command "New-BurntToastNotification -Text 'Claude Code','terminou'" ;; esac
On Windows (via WSL), calling the powershell.exe from inside Linux is usually the most direct route.
Prefer sound to a popup? Change the notify-send/osascript by paplay algum.wav (Linux) or afplay algum.wav (macOS)—more subtle, works the same way as a "finished" signal.
Practice now 0/4 done
Have Claude show you a popup whenever it finishes responding. ~5 min, safe (it only observes; it doesn’t block).
{
"hooks": {
"Stop": [
{
"hooks": [
{ "type": "command", "command": "command -v notify-send >/dev/null && notify-send 'Claude Code' 'terminou de responder'; exit 0" }
]
}
]
}
}✓ Wrap-up: you no longer have to watch the terminal — the notification comes to you.
Track 03 · Lesson 3 · Intermediate
PreToolUse on Bash logs every command with a timestamp—a record of everything the session ran, without blocking anything.
↓ scroll to study
The event PreToolUse triggers before any tool to run. With a matcher "Bash", it only catches terminal commands—before they actually run.
Here, we won’t block anything: the hook only observe and lets it proceed. It’s a record, not a block.
Test yourself
A hook PreToolUse with a matcher "Bash" triggers when?
Each log line carries the time when the command was going to run. Build the timestamp with date:
# .claude/hooks/log-cmd.sh (PreToolUse, matcher "Bash")
ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)
On its own, this doesn't do anything yet — you still need to capture the command itself, which comes in the next section.
The command text is in the stdin JSON, field tool_input.command. Combine it with the timestamp and append it to a file:
cmd=$(jq -r '.tool_input.command') printf '%s\t%s\n' "$ts" "$cmd" >> "$CLAUDE_PROJECT_DIR/.claude/cmd.log" exit 0
$CLAUDE_PROJECT_DIR ensures the log always goes to the same place, no matter where the command was run from. And exit 0 — the hook never blocks, it only logs.
Test yourself
Where does the text of the command Bash will run come from?
O cmd.log becomes a log: every command, timestamped and in order. Useful for reviewing what a session did, investigating a strange command, or reconstructing a timeline afterward.
One caution: hooks run with full permissions of your user. If a command has a token or password embedded in it, it will end up in the log — don’t let that file leave the .gitignore or circulate around.
The log is read-only for you later — the hook itself should never block based on it. What blocks is a separate guard (the previous lesson in the track); this hook here only observes.
Test yourself
Should this logging hook block the command before it runs?
Practice now 0/4 done
Log every Bash command Claude runs in this session, with a timestamp. ~5 min, safe (only observes, always exits with exit 0).
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "jq -r '(now|gmtime|strftime(\"%Y-%m-%d %H:%M:%S\")) + \" \" + .tool_input.command' >> \"$CLAUDE_PROJECT_DIR/.claude/cmd.log\"; exit 0" }
]
}
]
}
}✓ Wrap-up: there’s now a dated log of everything the session ran — auditable, without blocking anything.
Track 03 · Lesson 4 · Intermediate
SessionStart and UserPromptSubmit provide context directly to Claude — branch, git status, project rules — without you having to paste anything.
↓ scroll to study
SessionStart triggers once, when the session opens. UserPromptSubmit triggers the each message that you send. Neither is a tool event—they don’t use a matcher; they simply fire at the right moment.
Both can return extra context to Claude. The difference is frequency: session context (branch, project rules) goes in SessionStart; something that changes with each message would go in the UserPromptSubmit — sparingly, so you don't repeat it all the time.
Test yourself
Which event fires ONCE, at the start of the session?
To provide real context, the hook returns Structured JSON in stdout, with the text inside hookSpecificOutput, field additionalContext:
{
"hookSpecificOutput": {
"hookEventName": "SessionStart",
"additionalContext": "Branch: main\nMudanças: 2 arquivos"
}
}
This is the text—and only this—that reaches Claude as extra context. Don't just print it to stdout and expect it to "show up"; the format matters.
Test yourself
Where is the text that the hook injects as context for Claude?
Combine the sources that matter: the current branch, what changed, perhaps a line saying that there’s a CLAUDE.md with rules. Build the string and escape it with jq -Rs to turn it into a valid JSON value:
# .claude/hooks/session-context.sh (SessionStart)
branch=$(git branch --show-current)
status=$(git status --short)
ctx="Branch: $branch"$'\n'"Mudanças:"$'\n'"$status"
printf '{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":%s}}' \
"$(printf '%s' "$ctx" | jq -Rs .)"
O jq -Rs . reads plain text (not JSON) and returns a JSON string already escaped — including line breaks and quotes. Without that, a \n loose would break the output JSON.
The temptation is to put the entire project in the additionalContext. Resist: too much context pollutes and wastes space Claude could use to think about your request. Inject only what changes the decision of it now — branch, what’s dirty, one or two lines of rules — not the whole history.
Rule of thumb: if you wouldn’t paste it manually into a message, don’t inject it automatically either.
Combine the trail’s four hooks and the environment takes care of itself: format on save, notify you when it finishes, log what ran, and provide the right context as soon as the session opens.
Test yourself
Why not put the entire project contents in additionalContext every time?
Practice now 0/4 done
Have the session open already aware of the branch and what changed in the project — without you having to explain anything. ~8 min.
{
"hooks": {
"SessionStart": [
{
"hooks": [
{ "type": "command", "command": "bash .claude/hooks/session-context.sh" }
]
}
]
}
}✓ Capstone: the session opens already knowing about the project — branch, changes, all of it without you pasting anything. End of the automation track.