Claude Code Hooks / Track 01
Trail 01 · Fundamentals
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.
Lessons in this path
What a hook is, the events, where to configure them, how to block dangerous actions, and ideas for getting started.
The three places for hooks, the event→matcher→command anatomy, matchers, and what the hook receives.
The full lifecycle of a task, the events that exist, and which of them can actually block it.
The JSON that arrives via stdin: common fields, tool_input/tool_response, and how to read it with jq.
Exit code or JSON: decision, reason, and permissionDecision, which asks for confirmation instead of blocking.
Trail 01 · Lesson 1 · Beginner
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
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:
Each hook attaches to an event — a moment in Claude’s work. The most important ones to start with:
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.
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:
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:
You don't need many. Start with a hook that takes a recurring worry off your mind:
The best hook is the most tedious to do by hand. Automate this first.
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
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
Where hooks live, how each one is assembled, and what Claude passes to your command when it runs.
↓ scroll to study
A hook can live in three files, and they all apply at the same time:
~/.claude/settings.json. Applies to all your projects..claude/settings.json. It goes to Git; the whole team inherits..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?
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.
For PreToolUse e PostToolUse, the matcher matches tool name. Three ways:
"Bash" handles only the terminal."Edit|Write" handles edits or file writing."*" (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:
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.
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
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
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
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.
Every time Claude uses a tool — Bash, Edit, Write, whatever — two events surround it:
tool_name e tool_input, because the tool hasn't run yet.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?
Not every event is about a tool. Some mark the back-and-forth of a conversation:
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?
The last four handle the session environment, not a specific tool or message:
permission_prompt, idle_prompt, auth_success, elicitation_dialog)—and you can even customize the message shown.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.
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:
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
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
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
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.
Four fields appear in every hook, regardless of the event:
cwd — the current working directory.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?
Beyond the common envelope, the rest of the JSON changes with the event. For tool events, the most important pair is:
PreToolUse e PostToolUse.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?
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.
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
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
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
A hook communicates back to Claude Code in two ways, which can be combined:
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.
Three code ranges, three very different behaviors:
0 — on success, continue as usual.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.
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?
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.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?
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
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.