Each hook is defined in their respective subdirectory!
ui/.claude
├── scripts
│ └── prettier-hook.sh
└── settings.json
backend/.claude
├── scripts
│ └── ruff-hook.sh
└── settings.json
If you're using Claude Code in a monorepo, you've probably hit this wall: hooks only execute from the directory where you invoked Claude Code. Want to trigger ui/ formatting hooks when working from the parent directory? Too bad. Claude Code only looks for hooks in $CWD/.claude, not in subdirectories.
This has been discussed in several GitHub issues:
- anthropics/claude-code#456 - "Monorepo support for hooks"
- anthropics/claude-code#789 - "Feature request: recursive hook discovery"
The root cause is simple: Claude Code's hook discovery is intentionally scoped to the invocation directory for security and predictability. It won't recursively search subdirectories.
In a typical monorepo structure like this:
monorepo/
├── ui/ # React app with prettier
│ └── .claude/
│ └── hooks/
├── backend/ # Python service with ruff
│ └── .claude/
│ └── hooks/
└── .claude/ # Parent directory
When you run Claude Code from the monorepo root, edits to ui/src/App.jsx won't trigger the prettier hook in ui/.claude/hooks/. Same for backend formatting. You end up with inconsistent formatting or having to remember to run Claude Code from each subdirectory separately.
This limitation makes it effectively untenable to run Claude Code with hooks from the parent monorepo directory at all—you lose the primary benefit of monorepo tooling (cross-cutting changes across multiple projects) because your development workflow forces you to work in isolated subdirectories.
Just cd ui/ before running Claude Code. Simple, but defeats the point of a monorepo. You lose the ability to make cross-cutting changes.
Copy all your hooks to the parent .claude/hooks/ and add conditionals. Works until you forget to sync them. Now you have two sources of truth.
{
"hooks": {
"PostToolUse": [{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "if [[ \"$CHANGED_FILE\" == backend/* ]]; then backend/.claude/hooks/format.sh; fi"
},
{
"type": "command",
"command": "if [[ \"$CHANGED_FILE\" == ui/* ]]; then ui/.claude/hooks/format.sh; fi"
}
]
}]
}
}This is closer, but you still have to manually update the parent config every time a subdirectory adds/removes/renames a hook. Fragile and doesn't scale.
Instead of hard coding hook paths, create a router that dynamically discovers and executes subdirectory hooks based on file paths.
This is also backwards compatible with running from the subproject directories.
1. Create the router script (.claude/hooks/monorepo-router.sh):
#!/bin/bash
# Monorepo Hook Router
# Automatically routes PostToolUse hooks to the appropriate subdirectory based on file path
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-.}"
# Read the hook input JSON from stdin
INPUT=$(cat)
# Extract the file path from the JSON input
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
# If no file path, exit silently
if [[ -z "$FILE_PATH" || "$FILE_PATH" == "null" ]]; then
exit 0
fi
# Determine which subdirectory the file belongs to
if [[ "$FILE_PATH" == */backend/* ]]; then
SUBDIR="backend"
elif [[ "$FILE_PATH" == */ui/* ]]; then
SUBDIR="ui"
else
# File is not in a managed subdirectory, exit silently
exit 0
fi
# Check if subdirectory has a post-tool-use hook and execute it
HOOK_SCRIPT="$PROJECT_DIR/$SUBDIR/.claude/hooks/post-tool-use.sh"
if [[ -x "$HOOK_SCRIPT" ]]; then
# Pass the JSON input to the subdirectory hook via stdin
echo "$INPUT" | "$HOOK_SCRIPT"
fi2. Configure parent settings (.claude/settings.json):
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "bash ./.claude/hooks/monorepo-router.sh"
}
]
}
]
}
}3. Create subdirectory hooks following the convention <subdir>/.claude/hooks/post-tool-use.sh:
# ui/.claude/hooks/post-tool-use.sh
#!/bin/bash
cd "$CLAUDE_PROJECT_DIR/ui" && cat | "$CLAUDE_PROJECT_DIR"/ui/.claude/scripts/prettier-hook.sh# backend/.claude/hooks/post-tool-use.sh
#!/bin/bash
cd "$CLAUDE_PROJECT_DIR/backend" && cat | "$CLAUDE_PROJECT_DIR"/backend/.claude/scripts/post-edit-format.shImportant gotcha: The cd is necessary because tools like make and prettier need to run from the correct working directory to find their config files (Makefile, .prettierrc, etc.).
- Claude Code triggers the parent
PostToolUsehook when you edit a file - The router reads the hook JSON from stdin (standard Claude Code hook interface)
- It extracts the file path and pattern-matches to determine the subdirectory
- If a matching
post-tool-use.shexists in that subdirectory, it pipes the JSON to it - The subdirectory hook can then call whatever formatters/linters it needs
Adding a new subdirectory? Easy:
- Add an
elifclause to the router (two lines) - Create
<subdir>/.claude/hooks/post-tool-use.sh
Changing a subdirectory hook? Just edit that subdirectory's post-tool-use.sh. The parent config never needs updating.
Want multiple hooks per subdirectory? Your post-tool-use.sh can call multiple scripts:
#!/bin/bash
cd "$CLAUDE_PROJECT_DIR/backend"
cat | "$CLAUDE_PROJECT_DIR"/backend/.claude/scripts/format.sh
cat | "$CLAUDE_PROJECT_DIR"/backend/.claude/scripts/lint.sh
cat | "$CLAUDE_PROJECT_DIR"/backend/.claude/scripts/test.shOr better yet, have post-tool-use.sh dispatch to other scripts based on the hook data:
#!/bin/bash
cd "$CLAUDE_PROJECT_DIR/backend"
INPUT=$(cat)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name')
case "$TOOL_NAME" in
Edit|Write)
echo "$INPUT" | "$CLAUDE_PROJECT_DIR"/backend/.claude/scripts/format.sh
;;
Bash)
echo "$INPUT" | "$CLAUDE_PROJECT_DIR"/backend/.claude/scripts/security-check.sh
;;
esacThe main tradeoff is the static naming convention: subdirectory hooks must be named post-tool-use.sh or whatever hook name you're referring to. If you have a subdirectory that wants to use a different name, you'll need to either:
- Stick with the convention and have that script dispatch internally
- Modify the router to support that subdirectory's custom naming
This is a deliberate tradeoff. Conventions reduce flexibility but eliminate configuration drift. In practice, having a standard entry point (post-tool-use.sh) that dispatches to project-specific scripts gives you the best of both worlds.
Ideally, Claude Code would support something like:
{
"hooks": {
"hookDiscoveryPaths": ["./ui/.claude", "./backend/.claude"],
"PostToolUse": "..."
}
}But until that ships, the router pattern works great and is entirely user-land code.
Hook routing solves the monorepo hook problem with minimal configuration and zero synchronization overhead. Each subdirectory owns its hooks, the parent provides routing, and you never have to remember to update both places.
The pattern is also applicable beyond Claude Code - any tool that needs directory-scoped configuration in a monorepo can benefit from this approach.
Full implementation: [see this repo's .claude/ directory]

