Claude Code Hooks: Deterministic Control (Not Just Shell Scripts)
Hooks are the part of Claude Code that does not depend on the model remembering anything. They are configured shell commands (and now HTTP calls, MCP tool calls, and model-judgment handlers) that the harness runs at named points in a session, whether or not the assistant decides to. That guarantee is the whole point. This guide covers what hooks are, the lifecycle events they fire on, the five handler types that make them more than shell scripts, and how they now relate to skills after Anthropic merged custom slash commands into the skills system.
What hooks actually are
The official one-line definition is worth reading carefully: hooks are user-defined shell commands that execute at specific points in Claude Code's lifecycle, providing deterministic control so certain actions always happen rather than relying on the LLM to choose to run them. Every word in that sentence is doing work.
Deterministic is the key. If you put "always run prettier after editing a file" in your project instructions or a CLAUDE.md, the model may or may not do it depending on context, how full the window is, and whether it got distracted mid-task. If you put it in a PostToolUse hook, the harness runs it after every matching edit, full stop. The model is not in the loop, so it cannot forget.
Configuration lives in a settings JSON file under a single top-level hooks object. Each key is an event name that maps to an array of entries shaped like { matcher, hooks: [{ type, command }] }. There are three scopes you will actually use: ~/.claude/settings.json for every project, .claude/settings.json for a project you want to share with the team, and .claude/settings.local.json for a project setup you keep private. Managed policy, plugin hooks, and skill or agent frontmatter can add hooks on top of those. If you are new to the tool overall, our Claude Code walkthrough covers where these files live.
The lifecycle events
Most older blog posts list about nine "classic" events. That number is now stale. The official Hooks reference documents roughly thirty lifecycle events as of the current docs, including newer and version-gated ones such as SessionStart, Setup, PermissionRequest, PostToolBatch, SubagentStart, TaskCompleted, FileChanged, PreCompact and SessionEnd. Treat "~30" as a current-docs figure, not a stable promise; some events require specific Claude Code versions.
For everyday work you can anchor on a small, stable core. Here is the sequence most sessions actually hit, with what each one lets you do:
That last point is a common trap. Exit code 2 blocks a PreToolUse call, erases a UserPromptSubmit prompt, and prevents Stop from ending the turn, but it does nothing on PostToolUse because the tool has already executed. There is no blanket "exit 2 blocks everything" rule.
Beyond shell scripts
The single most outdated claim in circulating explainers is "hooks are just shell commands." Today there are five handler type values:
- command — a shell command. It receives a JSON event on stdin and returns results via exit code and stdout. This is the workhorse.
- http — POST the JSON event to a URL. Useful for audit logging to an internal service or triggering CI.
- mcp_tool — call a tool on a connected MCP server directly from the hook.
- prompt — a single-turn Claude evaluation that returns a yes-or-no style decision. For rules that need judgment, not a fixed pattern.
- agent — spawn a subagent to decide (newer).
The prompt and agent types are the interesting shift: a hook can now hold an opinion. If "is this bash command destructive?" is too fuzzy for a regex, a prompt handler can ask a model to judge it at the gate. If you want that gate to be cheap and fast, point the model handler at something like DeepSeek V4 Flash rather than your main coding model.
The event JSON delivered on stdin includes common fields such as session_id, transcript_path, cwd, permission_mode and hook_event_name, plus event-specific fields like tool_input for tool events or last_assistant_message for Stop. Command hooks can also use the ${CLAUDE_PROJECT_DIR} and ${CLAUDE_PLUGIN_ROOT} path placeholders.
Build the hook-driven agent, keep the bill flat
Hooks that call a model (prompt handlers, or your own http endpoint) are a great place to route to a cheaper judge. DataLLM Lab gives you 300+ models behind one OpenAI-compatible key at datallmlab.com/v1, so a gate can use a fast model while your session uses Opus.
Hooks vs skills vs slash
Here is where nearly every 2024–2025 comparison is now structurally wrong. Anthropic merged custom slash commands into skills. A file at .claude/commands/deploy.md and a skill at .claude/skills/deploy/SKILL.md both create /deploy and work the same way; existing commands/ files keep functioning. So "slash command" is no longer a separate primitive — it is one way to invoke a skill.
A skill is a SKILL.md file: YAML frontmatter with a description that tells Claude when to use it, plus markdown instructions. Claude loads it automatically when it judges the task relevant (model-invoked), or you run it directly with /skill-name. Frontmatter can flip that: disable-model-invocation: true makes it manual-only, and user-invocable: false hides it from the slash menu. The body loads only when used, so it costs almost nothing until it fires. Our skills deep-dive covers authoring them.
The clean distinction is who initiates and what it guarantees. A slash command is a human explicitly starting a skill. A skill is Claude loading a reusable method when it judges it relevant. A hook is the harness running something at a named event every time, independent of what the model decides — and because the harness runs it outside the model, it costs zero model tokens.
| Dimension | Hook | Skill | Slash invocation |
|---|---|---|---|
| Who initiates | The harness, automatically | Claude, when relevant | A human, explicitly |
| When it runs | At a named lifecycle event, every time | When the task matches | The moment you type it |
| Model token cost | Zero | ~0 until fired | ~0 until run |
| Can guarantee / block | Yes (exit 2 or deny) | No | No |
| Config location | settings.json hooks object | SKILL.md frontmatter + body | Same file as the skill |
| Best for | Guardrails, formatting, logging | Reusable methods and checklists | On-demand workflows |
Footnote worth internalizing: the "three-way" table is a teaching device. In current docs the real model is two primitives — skills (triggerable by slash or auto-invocation) and hooks (fired deterministically by the harness). Slash is a trigger mode, not a third system.
Config recipes
Three real examples, straight from the getting-started guide. First, a desktop notification when Claude needs your attention:
{
"hooks": {
"Notification": [
{ "matcher": "", "hooks": [
{ "type": "command",
"command": "osascript -e 'display notification \"Claude Code needs your attention\" with title \"Claude Code\"'" }
] }
]
}
}
Second, the canonical format-on-edit hook. Matcher values are tool names; you can pipe them, so Edit|Write catches both. An empty or omitted matcher, or *, matches all. Strings with special characters are treated as unanchored JavaScript regex, and MCP tools match as mcp__<server>__<tool>.
{
"hooks": {
"PostToolUse": [
{ "matcher": "Edit|Write", "hooks": [
{ "type": "command",
"command": "jq -r '.tool_input.file_path' | xargs npx prettier --write" }
] }
]
}
}
Third, blocking a dangerous command. A PreToolUse hook matched on Bash inspects .tool_input.command with jq and, when it sees something destructive, exits 0 and prints JSON on stdout with hookSpecificOutput.permissionDecision set to deny and a permissionDecisionReason. PreToolUse can even return updatedInput to rewrite the tool's arguments before it runs, and the if field (permission-rule syntax such as Bash(rm *)) lets you narrow the match without any script at all.
Documented use cases the docs call out directly: format files after edits, block commands before they execute, notify you when Claude needs input, inject context at session start, run linting or tests after changes, audit logging, and integrating with existing tooling. To see what is configured, /hooks opens a read-only browser; to change anything you edit the JSON or ask Claude to. If your hooks start orchestrating multiple models or routing, tools like a Claude Code router and structured sequential-thinking setups pair well with them.
Which one to reach for
The synthesis that no single source spells out is a cost-and-certainty rule. Put it this way, in order of how much it costs the model to keep the behavior alive:
So the rule of thumb: if a behavior must be guaranteed and can be expressed as a rule, use a hook — it is free and deterministic. If it is a reusable method that needs the model's judgment about when to apply it, write a skill. If you want to keep it dormant until you decide, give the skill a slash name. Reserve CLAUDE.md for genuinely always-relevant context, because you pay for it on every turn. For heavy agentic sessions running Claude Opus 4.8 or Qwen3 Coder Next, pushing guardrails into hooks keeps the window lean for the actual work.
FAQ
What are Claude Code hooks?
User-defined handlers that execute at specific points in Claude Code's lifecycle, giving deterministic control so certain actions always happen instead of relying on the model to choose to run them. They live in a settings JSON file under a single top-level hooks object keyed by event name.
What events can a hook fire on?
The stable core is SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, Notification, Stop, SubagentStop, PreCompact and SessionEnd. The official reference now documents around thirty events in total, some version-gated, so treat the full count as current-docs rather than fixed.
Are hooks just shell commands?
No. There are five handler types: command, http, mcp_tool, prompt and agent. The prompt and agent types let a hook make a judgment call with a model, not just match a fixed pattern. The agent type is newer.
How do I block a tool call?
On a PreToolUse hook, exit code 2 blocks the tool and sends stderr back to Claude as the reason. For finer control, exit 0 and print JSON with hookSpecificOutput.permissionDecision set to deny or allow. Exit 2 does not block PostToolUse, since the tool has already run.
Hook or skill — which should I use?
A hook is run by the harness every time at a lifecycle event, costs zero model tokens, and can guarantee or block. A skill is a reusable method Claude loads when relevant, or that you invoke by name. Since slash commands merged into skills, slash invocation is just one way to trigger a skill.
Where do hook configs live?
In settings JSON at three scopes: the user file for all projects, a shareable project file, and a private local project file. Plugins, managed policy, and skill or agent frontmatter can also add hooks. Browse them read-only with /hooks and edit the JSON directly.
DataLLM Lab