PTENES
INEMA.CLUBPROClaude Code Hooks

Claude Code Hooks / Track 01

Trail 01 · Fundamentals

Fundamentals of hooks

What a hook is, the events that trigger each one, where everything lives in settings.json, the JSON that arrives—and how your hook decides what to respond with.

5lessons
~45minutes
beginnerlevel

Trail 01 · Lesson 1 · Beginner

Hooks:
o essential

A hook is an automatic instruction: "whenever this happens, run that". It's how Claude Code follows your rules on its own — without relying on you to remember.

↓ scroll to study

01 What a hook is

A hook is an automatic order that you give Claude Code: "every time this, run that". O this is a moment (an event); the that is a command of yours—a script, a formatter, a notification. Claude runs it automatically, without you having to ask again.

Without a hook, you repeat the same instructions ("format the code," "don't run this") every session and hope they're followed. With a hook, the rule becomes part of the environment: automatic, and the same behavior every time.

Test yourself

In practice, a hook is:

02 The moments (events)

Each hook attaches to an event — a moment in Claude’s work. The most important ones to start with:

  • PreToolUse — before for Claude to use a tool (e.g., before running a command in the terminal).
  • PostToolUse — after (e.g., format the file as soon as it’s edited).
  • UserPromptSubmit — when you sends a message.
  • Stop — when Claude ends to respond.
  • SessionStart — when the session starts (great for injecting project context).

Each event provides your command with the details of what’s happening (which tool, which file) as JSON through standard input. Your command reads it and decides what to do.

03 Where they live: settings.json

Hooks live in settings.json — in the project (.claude/settings.json) or global (~/.claude/settings.json). You specify the event, a matcher (which tool it applies to) and the command:

// .claude/settings.json — formats every edited file
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          { "type": "command", "command": "prettier --write $file" }
        ]
      }
    ]
  }
}

Read from the inside out: in the event PostToolUse, when the tool is Edit or Write, run the command. That's it — three layers: event → matcher → command.

Test yourself

O matcher "Edit|Write" is for:

04 A hook that protects

In PreToolUse, your command can block the action before it runs. If the command exits with code 2, Claude doesn't execute it—and the message you print becomes the reason shown to it. That's how you block the dangerous one (a rm -rf, a push to main) before it happens.

# blocks rm -rf; receives the command via stdin (JSON)
grep -q "rm -rf" && { echo "bloqueado: comando destrutivo" >&2; exit 2; }
exit 0

Exit 0 = you can proceed. Exit 2 = block and return the reason. Simple and powerful: you turn "please don’t do that" into a real blocker.

Test yourself

For the PreToolUse hook block a command, it should exit with code:

05 Ideas to get started

You don't need many. Start with a hook that takes a recurring worry off your mind:

  • Format on save — PostToolUse on Edit/Write runs the formatter. No more messy code.
  • Notify when finished — Stop triggers a notification or a sound.
  • Log commands — PreToolUse on Bash writes everything that ran to a log.
  • Inject the project rules — SessionStart prints your CLAUDE.md, and Claude starts out aligned.

The best hook is the most tedious to do by hand. Automate this first.

Going deeper

In addition to the exit code, hooks can return JSON based on the output (a decision to approve/block with a reason), use matchers more granular, and there are other events: SubagentStop, Notification, PreCompact, SessionEnd. More lessons are coming up.

Practice now 0/4 done

Your first hook: log commands

Have Claude log every command to a file—and watch it work. About 5 minutes, and safe: the hook only observe (exits with exit 0), it doesn’t block anything.

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": "cat >> .claude/cmd.log" }
        ]
      }
    ]
  }
}

✓ Done: your first hook ran on its own. That’s exactly it — when this → run that. In the next lessons, it gets smarter: formatting, notifying, blocking dangerous actions.

Trail 01 · Lesson 2 · Beginner

settings.json
in depth

Where hooks live, how each one is assembled, and what Claude passes to your command when it runs.

↓ scroll to study

01 Three places for hooks

A hook can live in three files, and they all apply at the same time:

  • Global — ~/.claude/settings.json. Applies to all your projects.
  • Project — .claude/settings.json. It goes to Git; the whole team inherits.
  • Local — .claude/settings.local.json. Stay in .gitignore; é only yours.

When there’s a conflict, the the more specific one wins: local overrides project, which overrides global. Practical rule: what belongs to the team goes in .claude/settings.json; what’s a matter of personal preference goes in the .local.json.

Test yourself

Which file is personal and does NOT go into Git?

02 Anatomy of a hook

Every hook has the same Russian-doll structure: the event holds a list; each item has a matcher and a list of hooks; each hook is a command.

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": "./guarda.sh" }
        ]
      }
    ]
  }
}

There may be several hooks in the same matcher — they run in order. And several matchers in the same event. Start with one; the structure can grow.

03 The matcher: where the hook catches

For PreToolUse e PostToolUse, the matcher matches tool name. Three ways:

  • Exact name — "Bash" handles only the terminal.
  • Toggle — "Edit|Write" handles edits or file writing.
  • Everything — "*" (or empty) matches any tool.

Events that aren’t tool events (such as SessionStart or Stop) don’t use a tool matcher—they simply fire at that moment.

Test yourself

For a hook to catch both Edit and Write, the matcher is:

04 What the hook receives

When it runs, Claude passes your command a JSON via standard input (stdin) with the context: which event, which tool, and its input.

# reads the command Bash was about to run
cmd=$(jq -r '.tool_input.command')
# reads the file that was edited
file=$(jq -r '.tool_input.file_path')

There's also the variable $CLAUDE_PROJECT_DIR pointing to the project root—useful for finding your scripts. With stdin + jq, your hook knows exactly what’s about to happen.

Going deeper

The JSON includes more fields (session_id, cwd, hook_event_name…). You rarely need all of them— tool_name e tool_input handle most hooks. Lesson 4 of this track breaks down that JSON field by field.

Practice now 0/4 done

See what the hook receives

Log the JSON Claude sends to the hook — and read the tool_input. ~5 min, safe (observation only).

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          { "type": "command", "command": "cat >> .claude/edits.log" }
        ]
      }
    ]
  }
}

✓ You saw the real payload. The matcher Edit|Write and stdin are no longer abstractions: this is the JSON the hook reads.

Trail 01 · Lesson 3 · Beginner

The events,
a for a

From the start of a session to the end of a task: the moments when a hook can fire—and which of them can actually block something.

↓ scroll to study

01 A task’s lifecycle (timeline)

A Claude Code session has a life cycle, and each turn opens a window for a hook. In order, the session starts, you sends a message, Claude eventually uses tools (one at a time, before and after each one), it ends to respond, and — sooner or later — the session ends.

Thinking of hooks as points on a timeline helps more than memorizing isolated names: each event is just “this just happened”—and the rest of the lesson looks at what each point lets you do, one by one.

02 Before and after the tool

Every time Claude uses a tool — Bash, Edit, Write, whatever — two events surround it:

  • PreToolUse — triggers before any tool to run. It can block (exit 2); it only receives tool_name e tool_input, because the tool hasn't run yet.
  • PostToolUse — triggers after that the tool finished successfully. It also receives the tool_response (the result). An exit 2 here doesn't block — the tool has already run.

It's the same tool, but with different powers at two different moments: Pre decides if runs; the Post reacts to what ran.

Test yourself

Why can only PreToolUse prevent a tool from running?

03 You speak, Claude stops

Not every event is about a tool. Some mark the back-and-forth of a conversation:

  • UserPromptSubmit — triggers when you sends a message.
  • Stop — triggers when Claude ends a turn. You can also block with exit 2—not to block a tool, but to require Claude to continue (e.g., "PLAN.md still needs updating").
  • SubagentStop — same as Stop, but runs when there’s a subagent that finishes. It doesn’t block.

Notice the asymmetry: Stop is one of the two gates that actually block (the other is PreToolUse); UserPromptSubmit e SubagentStop don't block — they observe or inject context.

Test yourself

Which event fires when YOU send a message, before Claude processes it?

04 Session and maintenance

The last four handle the session environment, not a specific tool or message:

  • SessionStart — when the session starts. No matcher; great for injecting context (branch, commits, rules).
  • SessionEnd — when the session ends normally. It doesn't block or modify anything — it's for cleanup, metrics, or archiving something.
  • PreCompact — before the conversation history is compacted (summarized to fit the context). It’s the least documented event—you can use it to save something before compaction, but don’t count on fine-grained details.
  • Notification — when Claude needs you (it got stuck, asks for permission, an MCP asks something). The only one in this group with matcher (permission_prompt, idle_prompt, auth_success, elicitation_dialog)—and you can even customize the message shown.
Going deeper

SubagentStop e PreCompact are the two events with the least complete official documentation — they work, but expect less fine-grained predictability than with PreToolUse or Stop. Always test them before trusting them with anything critical.

05 Who can block

With eight names in mind, it’s easy to think any of them can block something from going out with exit 2. Not like this:

  • PreToolUse — exit 2 blocks the tool before it runs.
  • Stop — exit 2 blocks the end of the turn (forces Claude to continue).
  • All the others — PostToolUse, Notification, SessionStart, SessionEnd, SubagentStop, PreCompact, UserPromptSubmit—a non-zero exit code is just non-blocking error: the action proceeds normally (fail-open). For PostToolUse in particular, blocking wouldn't even make sense: the tool has already run.

Remember this rule: only Pre and Stop actually block. This avoids the trap of writing a guard in an event that will never prevent anything.

Test yourself

A hook PostToolUse exits with exit 2. What happens?

Practice now 0/4 done

A SessionStart that reports the date and branch

Have the session print where and when it started—the first thing that runs, before any tool. ~5 min, safe (observation only).

{
  "hooks": {
    "SessionStart": [
      {
        "hooks": [
          { "type": "command", "command": "{ date; echo branch: $(git branch --show-current); } >> .claude/session.log" }
        ]
      }
    ]
  }
}

✓ SessionStart is the first window in the cycle — no matcher, no dependence on any tool. It’s the right place for any context Claude should know from the very first second.

Trail 01 · Lesson 4 · Beginner

What the
hook receives

Every hook starts the same way: JSON comes in through standard input. Learn to read this payload — the fields that are always there and those that change with the event.

↓ scroll to study

01 Everything comes through stdin (JSON)

Regardless of the event: when a hook fires, Claude Code builds a JSON with the context from that moment and delivers it through the standard input (stdin) from your command. Your script reads this text—with cat, jq, a json.load(sys.stdin) in Python — and decides what to do.

One practical consequence: hooks can't be interactive. They receive everything at once, at the start, and respond all at once, at the end (with an exit code or printed JSON). You can't "ask" anything along the way.

02 The common fields

Four fields appear in every hook, regardless of the event:

  • session_id — identifies the current session.
  • cwd — the current working directory.
  • transcript_path — path to the file with the conversation history.
  • hook_event_name — says which event this is (useful if the same script handles more than one event).

Think of them as the envelope: always arrives and guides you before you even look at the rest of the content.

Test yourself

Which of these fields appears in ANY hook, regardless of the event?

03 tool_input and tool_response

Beyond the common envelope, the rest of the JSON changes with the event. For tool events, the most important pair is:

  • tool_input — what the tool will receive. Present in PreToolUse e PostToolUse.
  • tool_response — the result after the tool runs. Only exists in PostToolUse — on PreToolUse the tool hasn't even run yet.

Other events bring their own fields: Notification comes with notification_type e notification_message; SessionStart doesn't include any tool data (there's nothing to include — nothing has run yet). Always check the hook_event_name before assuming a field will be there.

Test yourself

Why does the field tool_response doesn't appear in the JSON for a PreToolUse?

04 Read with jq

In practice, three commands cover almost everything:

# tool name
jq -r '.tool_name'
# the command Bash was about to run
jq -r '.tool_input.command'
# the file that was edited
jq -r '.tool_input.file_path'

To explore an entire payload while you're learning, jq . (without a filter) prints everything in a readable, formatted way—useful for debugging a new hook before writing the right filter.

Going deeper

Without jq Once installed, you can read the same JSON in Python: import sys, json; data = json.load(sys.stdin), and then data.get("tool_name"). More verbose, but it runs on any machine with Python.

Practice now 0/4 done

Log tool_name + command with jq

Record in full which tool ran and which command it received — straight from the stdin JSON. ~5 min, safe (only observes).

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": "jq -r '.tool_name + \" \" + .tool_input.command' >> .claude/tool.log" }
        ]
      }
    ]
  }
}

✓ You went from raw payload to a readable log with a one-line filter. That’s the real distance between “a JSON arrived” and “my hook makes a decision with it.”

Trail 01 · Lesson 5 · Beginner

How the hook
responds

An exit code is the simple signal. But a hook can also return JSON—and explain the decision it made with a reason.

↓ scroll to study

01 Two ways to respond

A hook communicates back to Claude Code in two ways, which can be combined:

  • Exit code — the simplest signal: a number. Quick to write, covers most cases.
  • JSON on stdout — when a number isn’t enough. You print an object with an explicit decision, a reason in text, and fine-tune behavior.

Always start with the exit code — it’s the quick path from the earlier lessons. Switch to JSON when you need nuance: asking for confirmation instead of blocking or explaining why in a richer way than a line in stderr.

02 The exit codes

Three code ranges, three very different behaviors:

  • 0 — on success, continue as usual.
  • exit 2 — blocks, but only in PreToolUse e Stop. What you print to stderr becomes the reason.
  • 1, 3 or more — error non-blocking: the action proceeds (fail-open). Your hook broke, but Claude doesn't get blocked because of it.

That last point is intentional: a malformed hook shouldn’t be able to freeze all of Claude Code. Prefer fail open — if your script has a bug, the worst-case scenario is that the hook does nothing, not that Claude gets stuck.

Going deeper

This principle is called fail-open: in your script, always have a guaranteed output path (a trap in the shell, a try/except in Python) that returns exit 0 if something unexpected happens. A safety hook that locks up on its own is worse than no hook at all.

Test yourself

A hook Notification exits with exit 1 because of a bug in the script. What happens?

03 Output JSON

Instead of just an exit code, the hook can print a JSON in stdout with a more expressive decision:

{
  "decision": "approve|block",
  "reason": "string",
  "suppressOutput": false,
  "continue": true
}

Each field has a purpose:

  • decision — "approve" or "block", the explicit version of the exit code.
  • reason — the reason, in text—what previously went only through stderr.
  • suppressOutput — hides the hook’s output from the transcript when nobody needs to see it.
  • continue — if false, for the entire session, not just the current action. Use with care.

Test yourself

Which output JSON field explains the reason for a decision in text?

04 permissionDecision and extra context

For tool decisions, the JSON can be even more concise with its own block:

{
  "hookSpecificOutput": {
    "permissionDecision": "allow|deny|ask"
  },
  "reason": "confirmar antes de rodar em produção"
}

Three approaches, not two: allow (allows), deny (block—equivalent to exit 2) and ask (pauses and asks you, instead of deciding on its own). This third option is what a plain exit code doesn't provide — not everything is black and white, and sometimes the right thing is just to give a warning.

Some events also accept a additionalContext inside the JSON — extra text that goes into Claude’s context, in addition to the decision itself. It’s like inserting a footnote into what it’s processing.

Practice now 0/4 done · capstone

A PreToolUse that asks instead of blocking

Wrap up the track by making a hook respond with permissionDecision: "ask" and a reason. It’s the same

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command",
            "command": "grep -q PEDECONFIRMACAO && printf '{\"hookSpecificOutput\":{\"permissionDecision\":\"ask\"},\"reason\":\"confirmar antes de rodar\"}'; exit 0" }
        ]
      }
    ]
  }
}

✓ End of the track: your hook doesn’t just decide (exit code) — it explains the decision in JSON, with permissionDecision and a list of when this → run that from Lesson 1, except now Claude understands exactly why.