Nightshift Dev
Agent Ops

Claude Code hooks: the guardrails that make agents trustworthy

By Albert Nahas, 9 min read, updated

Claude Code hooks are shell commands (or HTTP calls, prompts or small agents) that Claude Code runs at fixed points in a session, such as before a tool call, after it, or when Claude tries to stop. A hook reads a JSON event on stdin and can block the action by exiting with code 2, or steer it by printing JSON, which makes hooks the only part of your setup the model can't talk its way around.

That last point is why I care about them. CLAUDE.md is advice. A PreToolUse hook that exits 2 is a wall. I run scheduled Claude Code agents on OpenWhen, my production product, and the instructions they get matter less than the checks they can't skip.

Where hooks live

Hooks go in the hooks key of a settings file. Which file decides who they apply to:

FileScope
~/.claude/settings.jsonEvery project on your machine
.claude/settings.jsonOne project, committed and shared
.claude/settings.local.jsonOne project, not committed
Managed policy settingsOrganization-wide
Plugin hooks/hooks.jsonWherever the plugin is enabled
Skill or subagent frontmatterOnly while that skill or subagent runs

The shape is event, then a list of matcher groups, then a list of handlers:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/guard-bash.sh"
          }
        ]
      }
    ]
  }
}

CLAUDE_PROJECT_DIR is the project root where the session started. Use it instead of relative paths, because Claude changes directory during a session and a relative path breaks quietly.

Handler type can be command, http, mcp_tool, prompt or agent. Command, HTTP and MCP tool hooks time out after 600 seconds by default, prompt hooks after 30 and agent hooks after 60. On UserPromptSubmit the command default drops to 30 seconds, so keep per-prompt hooks fast.

Every hook event

There are 33 events. Most people need five of them. Here's the full list grouped by what they're for, with what the matcher filters on:

EventMatcher filters onExit 2 does
PreToolUsetool nameBlocks the tool call
PostToolUse, PostToolUseFailuretool nameShows stderr to Claude (tool already ran)
PostToolBatchnoneStops the loop before the next model call
PermissionRequesttool nameNot honored; deny through JSON instead
PermissionDeniedtool nameNothing; the denial already happened
UserPromptSubmitnoneBlocks the prompt before Claude sees it
UserPromptExpansioncommand name
StopnoneClaude keeps working instead of stopping
SubagentStopagent typeThe subagent keeps working
SubagentStartagent type
SessionStartstartup, resume, clear, compact, forkShows stderr to you only
SessionEndclear, resume, logout, prompt_input_exit, other
Setupinit, maintenanceIgnored
PreCompact, PostCompactmanual, autoPreCompact blocks
Notificationnotification type, e.g. permission_promptIgnored
StopFailureerror type, e.g. rate_limitIgnored
TaskCreated, TaskCompleted, TeammateIdlenoneBlocking
InstructionsLoadedload reason, e.g. session_startIgnored
ConfigChangesettings source, e.g. project_settingsBlocking
CwdChanged, DirectoryAdded, FileChangednone, add reason, filename
WorktreeCreate, WorktreeRemovenoneCreate blocks; remove fails if the dir remains
PreModelSwitch, PostModelSwitchmodel namePreModelSwitch blocks
Elicitation, ElicitationResultMCP server nameBlocking
MessageDisplaynone

Matcher syntax has three modes. "*", "" or no matcher matches everything. A plain string of letters, digits, _, -, spaces, commas and pipes is an exact match or list, so Edit|Write means exactly those two tools. Anything else is an unanchored JavaScript regex, so Edit.* also catches NotebookEdit and you need ^Edit$ to be strict. MCP tools match with mcp__.*.

One thing I got wrong: my global settings put "matcher": "*" on UserPromptSubmit. That event has no matcher support, so the field is silently ignored. Harmless, but it told me I'd never read the table.

What a hook receives and returns

Every hook gets JSON on stdin with common fields (session_id, transcript_path, cwd, permission_mode, hook_event_name) plus event-specific ones. A PreToolUse event looks like this:

{
  "session_id": "abc123",
  "cwd": "/home/user/my-project",
  "permission_mode": "default",
  "hook_event_name": "PreToolUse",
  "tool_name": "Bash",
  "tool_input": { "command": "npm test" },
  "tool_use_id": "toolu_01ABC123"
}

The exit code is the simplest control:

  • 0: success. If stdout is a single JSON object, Claude Code applies its fields. Plain-text stdout is added to Claude's context only for UserPromptSubmit, UserPromptExpansion, SessionStart and PostModelSwitch.
  • 2: blocking error. Stderr becomes the reason Claude sees. Exit 2 wins even if your stdout JSON said allow.
  • Anything else: non-blocking error. The action continues and the transcript shows a hook error notice.

For finer control, exit 0 and print JSON. On PreToolUse the decision lives in hookSpecificOutput:

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "Pushes to main go through a PR"
  }
}

permissionDecision takes allow, deny, ask or defer. allow skips the prompt, ask forces one, deny blocks. defer ends the turn so the call can be resumed later and only works in claude -p runs. When several hooks disagree, deny beats defer beats ask beats allow, and your settings' deny and ask rules are still evaluated whatever the hook returns. You can also return updatedInput to rewrite the tool's arguments before it runs, and additionalContext (capped at 10,000 characters) to hand Claude a note.

A PreToolUse hook example

Here's a guard I'd put on any agent that runs without me. It blocks force pushes and direct pushes to main, and lets everything else fall through to normal permissions:

#!/bin/bash
# .claude/hooks/guard-bash.sh
CMD=$(jq -r '.tool_input.command // empty')

if echo "$CMD" | grep -qE 'git push.*(--force| -f( |$)| main( |$))'; then
  echo "Blocked: no force pushes and no direct pushes to main. Open a PR." >&2
  exit 2
fi

exit 0

Exiting 0 with no output means "no opinion", so the regular permission flow decides. That matters. A guard should only ever say no.

A rewrite hook does the opposite job: it returns updatedInput to change the command, for example to route Bash through a token-saving CLI proxy. Watch what you pair it with. Returning permissionDecision: "allow" alongside the rewrite means the new command skips the permission prompt. If you write a rewrite hook, return the new input without allow unless you've thought hard about what you're approving.

The newer if field narrows a handler with permission-rule syntax, so your script only runs for the calls you care about:

{
  "type": "command",
  "if": "Bash(git push *)",
  "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/guard-bash.sh"
}

The four hooks I run on unattended agents

1. SessionStart: re-inject the rules after compaction. CLAUDE.md loads once. In a long run, compaction summarizes it along with everything else and the rules get fuzzy. A SessionStart hook fires on compact too, and its plain stdout lands in context, so printing your standards there puts them back in full after every compaction. I packaged this as an open-source plugin, claude-core-values.

2. UserPromptSubmit: a one-line reminder per prompt. The same plugin prints only the motto on each prompt, about 15 tokens. Over a 50-turn session that's roughly 750 tokens. I also inject the current date and time this way, because agents that guess today's date write wrong report filenames.

3. PreToolUse on Bash: the guard above. The only hook in this list that actually stops damage.

4. Stop: don't let it quit early. A Stop hook that exits 2, or returns "decision": "block" with a reason, keeps Claude working. My eval-loop plugin uses it to force a self-evaluation against a rubric and loop until the score clears a threshold. My autonomous-claude-queue plugin uses it to hand Claude the next task from a queue.

A Stop hook that always blocks is an infinite loop with a credit card attached. Claude Code gives you two protections. The input carries stop_hook_active: true when Claude is already continuing because of a stop hook, and there's a built-in cap: after eight consecutive continuations it overrides the block and ends the turn (CLAUDE_CODE_STOP_HOOK_BLOCK_CAP changes it). I still add my own. The queue plugin has a max iteration count per session and stall detection, so a task that makes no progress is marked failed instead of retried forever.

If the hook is giving guidance rather than reporting an error, return additionalContext instead of a block. Claude continues the same way, but the transcript labels it as Stop hook feedback instead of an error.

Debugging hooks

Most broken hooks I've seen, mine included, read the wrong field. I once wrote a "stuck pattern" detector that pulled .toolUse.name from stdin. That isn't a documented field; the real one is tool_name. So the tool name came back empty on every call, and every check built on it was checking nothing. Pipe a real event through your script before you trust it:

echo '{"tool_name":"Bash","tool_input":{"command":"git push --force"}}' \
  | .claude/hooks/guard-bash.sh; echo "exit: $?"

Then check three things: the script is executable, jq is on the PATH the hook runs with, and the matcher actually matches (exact strings are case-sensitive).

What I'd do next

Hooks enforce rules; they don't set direction. For agents that run on a timer, pair them with a written charter and a kill switch, covered in the agent charter guide, and with the headless setup in how to run Claude Code on a schedule.

Questions people ask

What are Claude Code hooks?

Hooks are commands, HTTP calls, prompts or agents that Claude Code runs automatically at points in a session, such as before a tool call or when Claude stops. They receive the event as JSON and can block or modify the action.

How do I block a command with a PreToolUse hook?

Register a command hook under PreToolUse with a matcher like Bash, read tool_input.command from stdin, and exit with code 2 when it matches. Whatever you print to stderr becomes the reason Claude sees.

What does exit code 2 mean in a Claude Code hook?

Exit 2 is a blocking error. On PreToolUse it blocks the tool call, on UserPromptSubmit it blocks the prompt, and on Stop it keeps Claude working. Other non-zero codes are logged as errors and the action continues.

Where do I put Claude Code hooks?

In the hooks key of ~/.claude/settings.json for all projects, .claude/settings.json for a shared project config, or .claude/settings.local.json for a personal one. Plugins, skills and subagents can also define hooks.

How do I stop a Stop hook from looping forever?

Check the stop_hook_active field and let Claude stop when it is true or when your condition can't be met. Claude Code also ends the turn after eight consecutive stop-hook continuations, but add your own iteration cap too.

Do hooks run in claude -p headless mode?

Yes. A claude -p run loads hooks from your settings like an interactive session does, unless you pass --bare, which skips hooks, skills, plugins, MCP servers and CLAUDE.md.