Omniscio

OmniscioGuidesClaude Code hooks

Claude Code hooks

A hook is your own code running at a chosen moment inside Claude Code: before a tool runs, after a file is edited, when a turn ends. Anthropic calls them “user-defined shell commands, HTTP endpoints, MCP tool calls, LLM prompts, or subagents that execute automatically at specific points in Claude Code’s lifecycle.” This page covers every event the documentation lists, exactly where the configuration lives and which files are shareable, three examples you can copy, the rule that decides whether a hook can actually stop something, and what Anthropic warns about before you write one.

Checked against Anthropic’s Claude Code 2.1.310 on October 24, 2026. By the Omniscio team. Updated .

What a hook is, and when it runs

A hook is not the model’s decision. Claude Code fires it at the same moment, whether or not the model remembers to do anything, and the same events fire in the terminal, the IDE extensions, the desktop app and cloud sessions. So if you are running several Claude Code sessions at once, every one of them fires its own hooks.

Events fall into three cadences, and knowing which one you are in tells you how often your command will run:

  • Once per session: SessionStart and SessionEnd.
  • Once per turn: UserPromptSubmit, Stop and StopFailure.
  • On every tool call: PreToolUse and PostToolUse, except end-conversation calls, which skip both.

Every hook event the documentation lists

This is the full set from Anthropic’s hooks reference, with the moment each one fires.

EventWhen it fires
SessionStartWhen a session begins or resumes
SetupWhen you start Claude Code with --init-only, or with --init or --maintenance in -p mode. For one-time preparation in CI or scripts
UserPromptSubmitWhen a prompt is submitted, before Claude processes it
UserPromptExpansionWhen a user-typed command expands into a prompt, before it reaches Claude. Can block the expansion
PreToolUseBefore a tool call executes. Can block it
PermissionRequestWhen a tool call needs a permission decision
PermissionDeniedWhen auto mode denies a tool call, including denials without a classifier verdict
PostToolUseAfter a tool call succeeds
PostToolUseFailureAfter a tool call fails
PostToolBatchAfter a full batch of parallel tool calls resolves, before the next model call
NotificationWhen Claude Code sends a notification
MessageDisplayWhile assistant message text is displayed
SubagentStartWhen a subagent is spawned
SubagentStopWhen a subagent finishes
TaskCreatedWhen a task is being created
TaskCompletedWhen a task is being marked as completed
StopWhen Claude finishes responding
StopFailureWhen the turn ends due to an API error
TeammateIdleWhen an agent-team teammate is about to go idle
InstructionsLoadedWhen a CLAUDE.md or .claude/rules/*.md file is loaded into context, at session start and when files are lazily loaded during a session
ConfigChangeWhen a configuration file changes during a session
CwdChangedWhen the working directory changes, for example when Claude runs a cd
DirectoryAddedWhen a working directory is added mid-session
FileChangedWhen a watched file changes on disk; the matcher names which filenames to watch
WorktreeCreateWhen a worktree is being created. Replaces the default git behaviour
WorktreeRemoveWhen a worktree a WorktreeCreate hook created is being removed
PreCompactBefore context compaction
PostCompactAfter context compaction completes
PreModelSwitchBefore Claude Code applies a model switch that you or a client requested. Can block the switch
PostModelSwitchAfter the session’s model changes, including changes Claude Code makes on its own
ElicitationWhen an MCP server requests user input during a tool call
ElicitationResultAfter a user responds to an MCP elicitation, before the response goes back to the server
SessionEndWhen a session terminates

Notification is worth a second look because its timing surprises people: it fires on a delay by design rather than instantly. permission_prompt fires once the prompt has waited about six seconds, and idle_prompt about 60 seconds after Claude finished responding. If you want a hook the moment a decision is needed, PermissionRequest is the event for that.

Where hooks are configured

A hook lives in a settings file, and which settings file decides two things: who the hook applies to, and whether it travels with your project.

LocationScopeShareable
~/.claude/settings.jsonAll your projectsNo, local to your machine
.claude/settings.jsonSingle projectYes, can be committed to the repo
.claude/settings.local.jsonSingle projectNo; gitignored when Claude Code saves a setting to it
Managed policy settingsOrganization-wideYes, admin-controlled
A plugin’s hooks/hooks.jsonWhen the plugin is enabledYes, bundled with the plugin
A skill’s frontmatterThe rest of the session once the skill is invokedYes, defined in the skill file
A subagent’s frontmatterWhile that subagent is runningYes, defined in the subagent file

Four details change what these files do in practice. They merge across levels rather than replacing each other, so user, project and local settings each add their own without removing the managed ones. Hooks from settings files, managed policy and plugins also fire inside subagents. A cloud session does not read your local ~/.claude/settings.json, so a hook you rely on locally is not automatically there. And an administrator can restrict the whole thing: allowManagedHooksOnly blocks your user, project, local and plugin hooks, with one exemption for plugins force-enabled in managed settings’ enabledPlugins.

To see what is actually configured, type /hooks at the Claude Code prompt. It opens a read-only browser that labels each hook with where it comes from (user settings, project settings, local settings, a plugin, or the current session), and selecting one shows the full text of what it runs and where it is defined. There is an All events entry at the end that browses every event including ones with no hooks configured, which is the fastest way to confirm an event name is real.

To remove a hook, delete its entry from the file. To switch all of them off without deleting anything, set "disableAllHooks": true. Because settings precedence applies, a false in a project’s .claude/settings.json overrides a true in your user settings, and to turn hooks off for a single run whatever the project says, pass --settings '{"disableAllHooks": true}'.

How a hook is put together

Every hook has three levels: a hook event to respond to, a matcher group that filters when it fires, and one or more hook handlers that run. The handler’s type is "command" for a shell command, which is what nearly everyone uses; the rest are "http", "mcp_tool", "prompt" and the experimental "agent".

The matcher is where most mistakes happen, because how it is read depends on what characters are in it:

  • "*", an empty string, or no matcher at all matches everything.
  • A matcher made only of letters, digits, _, -, spaces, commas and | is read as an exact string, or as a list of exact strings separated by | or ,. So Edit|Write matches either tool exactly.
  • Anything containing another character is treated as a JavaScript regular expression, and it is unanchored. Edit.* matches both Edit and NotebookEdit, so wrap it in ^ and $ when you want a whole-string match.

What the matcher filters depends on the event. The tool events filter on tool name; SessionStart on how the session started; SessionEnd on why it ended; Notification on notification type; PreCompact and PostCompact on manual or auto. Matchers are case sensitive.

Two more fields are worth knowing. if narrows a tool event further, using permission-rule syntax such as Bash(git *) or Edit(*.ts), and is evaluated only on the tool events; on any other event, a hook with if set never runs. timeout is in seconds: ten minutes for command, http and mcp_tool, thirty for prompt, sixty for agent, and ten minutes becomes thirty seconds on UserPromptSubmit, PreModelSwitch and PostModelSwitch and ten seconds on MessageDisplay. SessionEnd hooks of any type are the exception: they share a 1.5-second budget, which a longer per-hook timeout raises to match, up to 60 seconds.

On the if filter specifically, Anthropic is blunt about its limits: when Claude Code cannot work out which commands the Bash input runs, your hook runs regardless of the pattern. Use the permission system, not a hook, to enforce a hard allow or deny.

Safety: a hook runs with your permissions

This is the part to read before the examples, not after. Anthropic’s own warning is unambiguous:

“Command hooks execute shell commands with your full user permissions. They can modify, delete, or access any files your user account can access. Review and test all hook commands before adding them to your configuration.”

There is a second warning that matters more than it looks, and it is about trust rather than permissions. Claude Code checks workspace trust before it runs any hook from a settings file. In an interactive session it holds back hooks from every settings file, including your own user settings, until you accept the workspace-trust dialog for the folder. But in a -p or SDK session it never shows that dialog and treats the folder as trusted, which means hooks committed in a repository’s .claude/settings.json run in a folder you have never trusted. If you script claude -p over a repository you did not write, review its .claude/ settings files, or start with --bare, or turn hooks off for that run with --settings '{"disableAllHooks": true}'.

Anthropic’s practice list is short and worth applying literally: validate and sanitize inputs; quote shell variables as "$VAR"; check for .. in file paths; use absolute paths for scripts, via ${CLAUDE_PROJECT_DIR} in exec form or double-quoted in shell form; and skip sensitive files such as .env, .git/ and keys.

One reassurance is worth having alongside the warnings. For a hook in a settings file or a plugin’s hooks/hooks.json, a "deny" holds in every permission mode, including bypass permissions, because PreToolUse fires before any permission-mode check. A hook returning "allow", by contrast, cannot bypass deny rules from settings and cannot suppress every prompt. There is one documented exception to the deny: if your machine has no managed settings and your session does not use /login credentials for a Team or Enterprise plan, an installed mod can approve a call your PreToolUse hook blocked. That seam where hooks meet permission rules is a subject of its own, and the permission prompts you will see are covered separately.

Three hooks you can copy

These are Anthropic’s own examples from the hooks quickstart, kept as they are given there. The first two parse JSON with jq, so install it first; the notification example is the macOS one and lives in ~/.claude/settings.json.

1. Format a file after Claude edits it

A PostToolUse hook with an Edit|Write matcher, so it runs only after file-editing tools. Add it to .claude/settings.json in your project root.

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "jq -r '.tool_input.file_path' | xargs npx prettier --write"
          }
        ]
      }
    ]
  }
}

When the hook succeeds, Claude Code shows nothing in the conversation, so the way to confirm it ran is to look at the file: ask Claude to add a line with single-quoted strings to a JavaScript file and Prettier’s defaults will have rewritten them to double quotes.

2. Block edits to protected files

This one stops Claude touching a .env, a lockfile, or anything in .git/. It is a separate script that exits with code 2, which is the code that blocks. Save it as .claude/hooks/protect-files.sh.

#!/bin/bash
# protect-files.sh

INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')

# Normalize Windows backslash separators so the patterns below match
FILE_PATH="${FILE_PATH//\\//}"

PROTECTED_PATTERNS=(".env" "package-lock.json" ".git/")

for pattern in "${PROTECTED_PATTERNS[@]}"; do
  if [[ "$FILE_PATH" == *"$pattern"* ]]; then
    echo "Blocked: $FILE_PATH matches protected pattern '$pattern'" >&2
    exit 2
  fi
done

exit 0

On macOS and Linux the script has to be executable, so run chmod +x .claude/hooks/protect-files.sh. Then register it as a PreToolUse hook in .claude/settings.json:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-files.sh"
          }
        ]
      }
    ]
  }
}

Ask Claude to add a comment to your .env and the edit is stopped before it runs. Claude receives the script’s Blocked: message as the tool’s error, so it knows why and can try something else rather than retrying blindly.

3. Get a desktop notification when Claude needs you

A Notification hook, so you can switch to another task instead of watching the terminal. Add this to ~/.claude/settings.json:

{
  "hooks": {
    "Notification": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "osascript -e 'display notification \"Claude Code needs your attention\" with title \"Claude Code\"'"
          }
        ]
      }
    ]
  }
}

If nothing appears on macOS, the reason is usually permission rather than your hook: osascript routes notifications through Script Editor, and if Script Editor has not been granted notification permission the command fails silently with no prompt. Run osascript -e 'display notification "test"' once in Terminal so Script Editor appears in your notification settings, then turn on Allow Notifications for it. The quickstart gives the Linux and Windows equivalents in the same section.

If your settings file already has a hooks key, add the new event as a sibling of the existing event keys rather than replacing the whole object. Every event name is a key inside the one hooks object. You can also describe what you want to the CLI and ask Claude to write the hook for you, then check it with /hooks.

What the exit code decides

When your hook finishes, Claude Code reads its exit code to decide whether to continue with the action that triggered it.

  • Exit 0 is success. Claude Code applies any JSON your hook printed, and the action goes ahead unless that JSON blocks or denies it.
  • Exit 2 is a blocking error. On events that can block, Claude Code stops the action: a PreToolUse hook blocks the tool call, a UserPromptSubmit hook rejects the prompt. Your hook’s stderr is the message that comes with the block, unless the hook also printed JSON with a blocking decision, in which case that decision’s reason is used instead. On a tool event Claude receives the message as the tool’s error, while a rejected prompt never reaches Claude at all.
  • Any other exit code is a non-blocking error. The action goes ahead, and you see a <hook name> hook error notice in the transcript carrying the first line of your stderr. Claude does not see it.

Anthropic warns about that third point, and it is the one to remember: exit code 1 without valid JSON output is a non-blocking error, even though 1 is the conventional Unix failure code. A hook written to refuse something, that exits 1 on the refusal, does not refuse anything. If your hook is meant to enforce a policy, use exit 2.

What your hook prints changes the outcome too, so the two are read together. Claude Code matches your stdout against your exit code:

What the hook printedExit 0Exit 2Any other exit code
A JSON object that passes schema validationSuccess; the fields applyBlocking error. Claude Code still reads the fields, but they cannot override the blockSuccess. Claude Code ignores the exit code and the fields alone decide
JSON that cannot be parsed, or fails validationNon-blocking error; the notice carries the parse or validation messageBlocking error; your stderr is the reasonNon-blocking error; the notice carries the parse or validation message
Plain text, or nothingSuccessBlocking error; your stderr is the reasonNon-blocking error; the notice carries the first line of your stderr

For your stdout to count as JSON output it has to be one JSON object, on one line or several, with nothing around it but whitespace. A JSON array is plain text by that rule, and so is a quoted string — so build the payload with an encoder such as jq rather than string concatenation, which is how quoting inside values goes wrong.

A timeout is not a block. Anthropic cancels a hook that reaches its timeout and discards its output, so on PreToolUse a stalled hook does not stop the tool call — the call continues through the normal permission flow. To block when a command or http hook cannot start or times out, set onFailure: "block" — the field exists on those two handler types only.

What JSON output can also do

Exit codes let you block or stay quiet. Printing JSON on exit 0 gives you finer control, and Anthropic’s advice is to pick one approach per hook rather than mixing them — exit 2 keeps its blocking effect even when you also print JSON.

Every event accepts four fields, though some discard them: continue, which set to false stops Claude entirely after the hook runs; stopReason, the message shown when it does; systemMessage, a warning shown to you; and terminalSequence, a terminal escape sequence Claude Code emits on your behalf. That last one is how you raise a desktop notification or set a window title, because hooks run without a controlling terminal and cannot write to /dev/tty — and it is ignored in a -p or SDK session, which have no interface to write to.

Richer control lives in a nested object called hookSpecificOutput, which requires a hookEventName field naming the event. Its most useful field is additionalContext: the string is wrapped in a system reminder and inserted where the hook fired, reaching the model on its next request without appearing as a chat message.

When a hook does not work

Anthropic’s quickstart lists the failures worth recognising, and the project’s issue tracker carries the ones that persist. The dated reports below are from anthropics/claude-code, each linked so you can read its current state.

  • The hook is configured but never runs. Confirm it appears under the correct event in /hooks, and check the matcher matches the tool name exactly. A hook in a skill’s frontmatter does not appear there until that skill has been invoked in the session.
  • A hook error appears in the transcript. Your script exited non-zero when you did not mean it to. Test it by hand with sample JSON on stdin and check the exit code. If the notice carries a JSON parse or validation message, the stdout looked like JSON but was not valid — build it with an encoder. If the script never runs at all, make it executable.
  • The hook works, but Claude Code labels it an error. Reported as issue 17088, open since 2026-01-09, where a PreToolUse hook that exits 0 with no output still shows the text PreToolUse:Bash hook error. The label is misleading rather than a failure, but it makes a healthy hook look broken.
  • A plugin’s hook output never reaches Claude. Reported as issue 12151, open since 2025-11-22: UserPromptSubmit and SessionStart hooks supplied by a plugin run, but their output is not captured or passed into the agent’s context. The identical configuration works from a settings file instead — a useful diagnostic if a hook seems to fire with no effect.
  • A hung hook looks like a frozen session. Reported as issue 104363, filed 2026-10-22 against the desktop app, where a PostToolUse command hook that never returns blocks the turn for up to 600 seconds while the interface keeps spinning and says nothing about a hook running. It is the clearest argument for setting an explicit timeout on anything you hang off a tool event.
  • A console window flashes on Windows. Reported as issue 104312, filed 2026-10-22, where hook commands began flashing a visible console window on UserPromptSubmit and Stop after the VS Code extension updated to 2.1.306.

All four reports were open on the day this page was written, so treat them as live rather than solved. They share a shape worth carrying into anything you write: a hook that cannot start, cannot parse its own output, or never returns is reported to you and then stepped over. None of those failures stops anything for you on a normal event — stopping an action takes a deliberate exit 2, a denial in your JSON output, or onFailure set to block. The two worktree events are the exception: any non-zero exit code from a WorktreeCreate hook fails the creation outright.

Where Omniscio fits

Omniscio has a Hooks panel under Agent Tools, beside Skills, MCP Servers and Browser Logins, and it takes a different angle from Anthropic’s settings files. It manages the Claude Code hooks Omniscio composes into every session it spawns — the built-in ones it ships and any you define — and keeps them in Omniscio’s own database rather than writing into your global ~/.claude config. Your settings file is neither read nor written by it.

That is why custom hooks behave differently here: they are composed into the one session-private settings file Omniscio already injects at spawn, so a hook you add applies to Omniscio’s sessions and leaves a terminal session alone. Each has a global on or off plus per-project overrides that win over it, and the form offers the common events rather than all thirty-three.

Two limits are worth knowing. It is an in-development feature, so it ships hidden until you turn it on under Settings → Lab, and it is desktop-only with hook creation kept human-only, because a hook runs an arbitrary command. The library page covers the rest.

How we tested this

Everything here about Anthropic’s Claude Code was read from Anthropic’s own documentation on October 24, 2026, against claude --version answering 2.1.310 (Claude Code) on this machine. No hook was configured and no example was run to write this page, so the behaviour described is Anthropic’s account of it rather than a run of our own, and the issue reports are quoted from their reporters.

Common questions

What can a hook not do?

Command hooks communicate through standard output, standard error and exit codes only. They cannot trigger slash commands or tool calls, and text you return as additional context reaches Claude as plain text rather than as an instruction it has to follow. A PostToolUse hook also cannot undo anything, because the tool has already run by the time it fires.

Can I switch off one hook without deleting it?

No. There is no way to disable an individual hook while keeping it in the configuration, so removing one means deleting its entry from the settings file. You can switch all of them off at once with the disableAllHooks setting, or for a single run by passing it to the settings flag.

Do hooks work on Windows?

Yes. You can run an individual hook in PowerShell by setting its shell field to powershell, and Claude Code auto-detects pwsh.exe before falling back to Windows PowerShell 5.1. A blocked tool call can report “blocking because Git Bash wasn’t found”, and that message means a PreToolUse or PermissionRequest command hook failed or timed out and blocked the tool call. Without Git Bash, any exit code other than 0 or 2 from that hook counts as a failure. The fix depends on what you want: add onFailure continue and the call goes ahead when the hook fails; add onFailure block and have the script exit 0 where it only warns, and a real crash still blocks. If the hook is written for Bash, install Git for Windows or set CLAUDE_CODE_GIT_BASH_PATH, or set its shell field to powershell so it stops depending on Git Bash.

Run the hooks you want, in every session

Omniscio can compose the hooks it ships, and any you define, into every session it spawns — kept in its own database rather than written into your global Claude config. The custom-hook panel is still in development and ships hidden until you turn it on under Settings → Lab. The features page covers what else it does, and you can download it for Windows, macOS or Linux.