Orca named about 120 branches after my GitHub username. Claude Code hooks are the check I use when a CLAUDE.md line doesn't hold. I run agents through Orca, and the name was supposed to look like a conventional commit: feat/, fix/, chore/, or docs/, then a kebab-case slug.
The line in the file didn't stop that. A PreToolUse hook on Bash blocks the git command and tells Claude to use feat/ plus a slug.
A hook blocks the command, and a CLAUDE.md line asks Claude to follow the rule.
What I checked: event names, matchers, exit codes, and environment variables in Anthropic's hooks reference, plus the best practices page and the subagents page, on 28 October 2026. On 29 October 2026 I read the hooks guide, including its limitations list, and the reference notes on a Windows Bash matcher and on hook commands running unsandboxed. The branch rule is from that Orca setup. The script below is that rule written out so you can copy it. Examples are illustrative. Example: the hook turns github-user/add-hooks into feat/add-hooks.
Claude Code hooks vs CLAUDE.md: one blocks, one asks
A name that has to match a pattern belongs in a hook.
Anthropic's best practices split instructions into two kinds. A prompt, a CLAUDE.md line, or a skill is text Claude reads and then decides about. A hook, a deny rule, or a managed setting is something Claude Code runs, whatever the model decided. If you keep adding emphasis to a line and Claude still misses it, that page says the line belongs in a hook.
The branch rule was that kind of line. It sat in the file, and Orca still created a branch prefixed with the GitHub username. What the file is for, including the study of length and layout, is in CLAUDE.md best practices.
A hook fires inside the session you already have. A scheduled task starts a separate run: a cloud Routine, a Desktop task, or a cron that dies with the session.
Claude Code hooks examples: the branch check in settings.json
permissionDecisionReason on the deny is the text Claude sees. This settings entry runs the script.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/check-branch.sh",
"args": []
}
]
}
]
}
}PreToolUse runs before the tool call. The matcher is Bash, so this script sees the Bash tool. "args": [] selects exec form. Claude Code spawns the script with no shell, so the file has to be executable (chmod +x). ${CLAUDE_PROJECT_DIR} is the project root where the session started, also exported as CLAUDE_PROJECT_DIR. In a worktree that variable stays on the original root.
#!/bin/bash
input=$(cat) # one JSON object on stdin
command=$(jq -r '.tool_input.command // empty' <<<"$input") # tool_input.command
name=$(
printf '%s\n' "$command" | grep -Eo \
'git[[:space:]]+(checkout|switch)[[:space:]]+-[bBcC][[:space:]]+[^[:space:];|&<>]+|git[[:space:]]+branch[[:space:]]+[^[:space:]-][^[:space:];|&<>]*|git[[:space:]]+worktree[[:space:]]+add[[:space:]]+(-b|-B)[[:space:]]+[^[:space:];|&<>]+' \
| head -n 1 | awk '{print $NF}' | tr -d "'\""
) # branch name from checkout -b, switch -c, branch, or worktree add -b
if [ -z "$name" ]; then
exit 0 # not a create, so allow
fi
pattern='^(feat|fix|chore|docs)/[a-z0-9]+(-[a-z0-9]+)*$' # feat/, fix/, chore/, or docs/, then a kebab-case slug
if printf '%s\n' "$name" | grep -Eq "$pattern"; then
exit 0 # name matches, so allow
fi
slug=$(printf '%s' "$name" | sed 's|.*/||' | tr '[:upper:]' '[:lower:]' | tr '_' '-' | tr -cd 'a-z0-9-')
reason="Branch '${name}' is blocked. Use feat/${slug}." # the text Claude sees
jq -n --arg reason "$reason" '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: $reason
}
}' # exit 0, so Claude Code applies this JSONExit 0 is what makes Claude Code apply the JSON. Exit 2 also blocks a PreToolUse call, and a JSON allow can't override that block. git checkout main misses the create forms, so it passes.
If jq is missing, the script exits non-zero and the command still runs unless onFailure is "block", which needs Claude Code v2.1.295 or later. Exit 1 with no valid JSON lets the command through. The symptom tree for that silence is why a hook isn't firing.
Print nothing around the JSON. Other stdout, including a line from a shell profile, keeps Claude Code from reading the object.
Project hooks go in .claude/settings.json, which you can commit. ~/.claude/settings.json applies to every project on that machine, and a cloud session doesn't read that home file. .claude/settings.local.json stays off the remote. Hooks from these files merge, and /hooks lists which file each one came from.
The matcher stays exact until a regex character
The matcher filters on the tool name. Letters, digits, _, -, spaces, commas, and pipes stay on the exact-match path, so Bash matches the Bash tool and Edit|Write matches either edit tool. Any other character makes a JavaScript regular expression, and RegExp.prototype.test succeeds on a match anywhere in the value. A whole-name match needs ^ and $.
The if field holds one permission rule, such as Bash(git checkout *). && and || aren't legal inside it. This hook leaves if unset. The matcher is Bash, and the script looks for checkout, switch, branch, and worktree.
A hook that matches only Bash never fires on Windows when Claude Code hasn't registered the Bash tool, which is the case without Git Bash. Shell commands go through PowerShell there. The hooks reference is the source for that limit, not the Orca setup.
The first test prompt never reached the hook
The first prompt was "Make a branch for TICKET-1234". It failed as a test. The agent looked up the real ticket, found a closed one, and stopped to ask. No git checkout ran, so PreToolUse had nothing to judge.
The rewrite was "Do not run any command, just tell me the branch name." That returned a name and no tool call. A prompt that says "make the branch" lets the agent look the ticket up. A prompt that forbids every command shows you the name.
The command you already know is wrong is what fires the hook. For this rule, that's a checkout whose branch starts with the GitHub username. The script above answers it with a feat/ slug.
A hook command runs unsandboxed
The hooks guide calls an exit-2 script a guardrail. In its example both PreToolUse hooks run, the guardrail exits 2, the deny wins, and Claude sees that stderr. The branch script above is the other block: exit 0, with the deny in the JSON.
Hook commands themselves run unsandboxed. The hooks reference says so in the DirectoryAdded section. This check is a command hook, so the script runs unsandboxed while it decides on the Bash call.
PreToolUse runs only when Claude calls a tool. Files you mention with @ are inserted while Claude Code builds the prompt, by default, so no PreToolUse hook fires for them, including one that matches Read. The test above is one case of that rule.
A permissionDecision of deny blocks the tool even in bypassPermissions mode and with --dangerously-skip-permissions, because PreToolUse fires before the permission-mode check, including dontAsk. A plugin mod is a plugin that runs inside the Claude Code process. It can still approve that blocked call when the machine has no managed settings and the session isn't using /login credentials for a Team or Enterprise plan. In plan mode that approval is limited to a read-only call.
Command hooks use stdout, stderr, and exit codes. They can't trigger a slash command or a tool call. additionalContext is a system reminder Claude reads as plain text.
An output style does not reach a subagent
A subagent runs its own system prompt, so an output style doesn't shape its responses, except in a fork.
SubagentStart fires when a subagent is spawned or resumed. It can't block creation. It can return additionalContext, inserted before the subagent's first prompt. Exit 2 doesn't stop the subagent, and Claude doesn't see that stderr.
Hooks from settings also run inside the subagent, so the branch check fires on a subagent's Bash call, with agent_id and agent_type on the input.
{
"hooks": {
"SubagentStart": [
{
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/subagent-note.sh",
"args": []
}
]
}
]
}
}#!/bin/bash
jq -n '{
hookSpecificOutput: {
hookEventName: "SubagentStart",
additionalContext: "Write in short sentences. One idea per paragraph."
}
}'With no matcher, the hook runs for every agent type. additionalContext is capped at 10,000 characters.
SessionStart can carry a warning the same way. Matchers are startup, resume, clear, compact, and fork. Plain stdout on this event is added to context, and so is additionalContext. Exit 2 shows a hook error to you, and the session continues.
CLAUDE_ENV_FILE is set for SessionStart, Setup, CwdChanged, and FileChanged. $CLAUDE_CODE_REMOTE is "true" in a remote web environment and unset in the local CLI.
{
"hookSpecificOutput": {
"hookEventName": "SessionStart",
"additionalContext": "The login for this session has expired. Sign in again before calling anything that needs it."
}
}PostToolUse runs after the edit has landed
PostToolUse uses the same tool-name matchers as PreToolUse. It fires after a successful call, with tool_input and tool_response. These hooks can't undo the action, because the tool has already executed, and exit 2 shows stderr to Claude. PostToolUseFailure is the event after a failed call.
Anthropic's hooks guide uses PostToolUse for a formatter:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs npx prettier --write"
}
]
}
]
}
}A matcher of Edit|Write doesn't run when Bash rewrites the file. The guide points at FileChanged for that, where the matcher is a list of filenames. updatedToolOutput can replace what Claude sees. The file is already on disk.
The next checkout still has to match
Copy the script to .claude/hooks/check-branch.sh, run chmod +x on it, and add the PreToolUse entry to .claude/settings.json. Orca picks a name and then runs git, which is the command this hook sees.
Frequently asked questions
- What are Claude Code hooks?
- Hooks are commands, HTTP calls, MCP tools, prompts, or subagents that Claude Code runs at a fixed point, such as PreToolUse or PostToolUse. Declare them under hooks in settings.json: https://code.claude.com/docs/en/hooks.
- Where do Claude Code hooks go?
- Commit them in .claude/settings.json, keep a machine-wide copy in ~/.claude/settings.json, or use .claude/settings.local.json for a copy that stays off the remote. A cloud session doesn't read the home file, and hooks from these files merge.
- What is the difference between PreToolUse and PostToolUse?
- PreToolUse runs before the tool call and can deny it with permissionDecision or exit code 2. PostToolUse runs after a successful call, and exit code 2 there shows stderr to Claude without undoing the edit.
- How do Claude Code hooks compare with CLAUDE.md?
- A CLAUDE.md line is text Claude reads and can skip. A command hook runs in Claude Code whether or not the model follows the line, and Anthropic's best practices page puts a rule that has to hold every time in a hook, a deny rule, or a managed setting.
- What exit code blocks a Claude Code tool call?
- Exit code 2 blocks on PreToolUse, and exit 0 applies JSON, including a permissionDecision of deny. Any other code is a non-blocking error unless onFailure is block, from Claude Code v2.1.295, and exit 2 on SessionStart doesn't stop the session.
- Do Claude Code hooks run inside subagents?
- Hooks from settings files run when a subagent calls a tool, and the input includes agent_id and agent_type. An output style doesn't shape a subagent, except in a fork, and SubagentStart can pass additionalContext into the subagent before its first prompt.
- What branch names does the PreToolUse example allow?
- A conventional-commit type prefix, feat/, fix/, chore/, or docs/, then a kebab-case slug. The example denies github-user/add-hooks and suggests feat/add-hooks.