PTENES
INEMA.CLUBPROClaude Code Hooks

Claude Code Hooks / Track 03

Track 03 · Automation

Rules that run
on their own

Four ready-to-paste hooks: format on save, notify when finished, log every command, and inject context as soon as the session opens.

4lessons
~36minutes
intermediatelevel

Track 03 · Lesson 1 · Intermediate

Format
when save

After Claude edits a file, a hook runs the formatter on it — no need to remember, no exceptions.

↓ scroll to study

01 PostToolUse on Edit|Write

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?

02 Catch the file

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.

03 Run the formatter

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?

04 Quiet and idempotent

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.

Going deeper

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

Prettier on the newly edited file

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

Notify when
end

Two events signal things at the right time: Stop when Claude finishes responding, Notification when it needs you.

↓ scroll to study

01 Stop and Notification

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+)?

02 Trigger notify-send / a sound

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.

03 Only when it matters

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:

04 OS variations

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.

Going deeper

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

Desktop notification on the Stop event

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

Log
the commands

PreToolUse on Bash logs every command with a timestamp—a record of everything the session ran, without blocking anything.

↓ scroll to study

01 PreToolUse on Bash

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?

02 Append to a log with a timestamp

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.

03 Extract the command with jq

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?

04 Simple audit of what ran

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.

Going deeper

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

Dated log of every command

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

Inject context
on the start

SessionStart and UserPromptSubmit provide context directly to Claude — branch, git status, project rules — without you having to paste anything.

↓ scroll to study

01 SessionStart and UserPromptSubmit

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?

02 additionalContext inside hookSpecificOutput

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?

03 Inject git status / CLAUDE.md / TODO

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.

04 Right-sized context

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.

Going deeper

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

SessionStart injects branch + git status · capstone

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.