Claude Code hooks let you run your own shell commands automatically at specific points in Claude Code’s lifecycle — for example, after it edits a file or finishes a command. Imagine every Kotlin file Claude touches getting formatted and linted on the spot, or your test suite kicking off the moment a test file changes. Hooks make these workflows deterministic: they always run, instead of relying on Claude to remember. This guide shows how to set up Claude Code hooks for a real Android project, with examples you can paste straight into your settings.
How Claude Code hooks actually work
This is the part most write-ups get wrong, so it’s worth being precise. Hooks are not loose files dropped into a folder — they’re configured as a hooks block inside a JSON settings file. You have three places to put them:
- ~/.claude/settings.json — applies to every project on your machine.
- .claude/settings.json — project-scoped; commit this so your whole team shares the same hooks.
- .claude/settings.local.json — project-scoped but personal (git-ignored).
Each hook is attached to an event. There are many events, not just three: SessionStart, UserPromptSubmit, PreToolUse (before a tool runs, and able to block it), PostToolUse (after a tool succeeds), PostToolUseFailure (after a tool fails), Notification (when Claude needs your input or permission), Stop, SubagentStop, PreCompact, SessionEnd, and more. For file-automation on Android, the workhorses are PreToolUse and PostToolUse.
Two more things to know before the examples. First, you filter which tool a hook responds to with a matcher — the tool’s name, like Bash or Edit|Write (a regex). You can narrow further with an if condition using permission-rule syntax such as Edit(*.kt). Second, hooks do not receive command-line arguments like $1. Claude Code passes a JSON object describing the event on stdin, and you pull fields out of it with a tool like jq — for instance, the edited file path lives at .tool_input.file_path.
Here is the minimal shape of a hook entry, so the examples below make sense:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "your-command-here" }
]
}
]
}
}
You can verify what’s registered any time by running /hooks in Claude Code, which lists every event and the hooks attached to it. The menu is read-only — to change a hook you edit the JSON (or just ask Claude to do it for you).
Hook 1: auto-format and lint after Kotlin edits
The classic first hook: every time Claude edits a Kotlin file, run ktfmt to format it and detekt to lint it. We use the PostToolUse event, match the file-editing tools with Edit|Write, and scope it to Kotlin files with an if condition. Add this to .claude/settings.json:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"if": "Edit(*.kt)",
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/format-kotlin.sh",
"timeout": 30
}
]
}
]
}
}
The command can be any shell one-liner, but for anything non-trivial it’s cleaner to point at a script. A common convention is to keep those scripts in .claude/hooks/ and reference them with the $CLAUDE_PROJECT_DIR environment variable, which Claude Code sets to your project root. Here is format-kotlin.sh — note how it reads the JSON event from stdin and extracts the file path with jq, rather than reading a positional argument:
#!/usr/bin/env bash
set -euo pipefail
# Claude Code sends the event as JSON on stdin.
INPUT=$(cat)
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
# Nothing to do if there is no file path (or it is not Kotlin).
[ -z "$FILE" ] && exit 0
case "$FILE" in
*.kt|*.kts) ;;
*) exit 0 ;;
esac
if command -v ktfmt >/dev/null 2>&1; then
ktfmt "$FILE"
fi
if command -v detekt >/dev/null 2>&1; then
# Print findings to stderr; exit 2 feeds them back to Claude.
if ! detekt --input "$FILE"; then
echo "detekt reported issues in $FILE" >&2
exit 2
fi
fi
One detail worth understanding: a hook’s exit code is how it talks back to Claude. Exit 0 means success. For a PostToolUse hook, exit 2 is special — whatever you wrote to stderr is fed back to Claude as feedback it can act on, which is exactly what you want when the linter finds something to fix. Any other non-zero code surfaces as an error without that feedback loop.
Hook 2: run tests after editing a test file
When Claude generates or edits a test, it’s handy to run just that test immediately. Same event and matcher as before, but scoped to your test source set:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"if": "Edit(*/src/test/**/*.kt)",
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/run-test.sh",
"timeout": 120
}
]
}
]
}
}
#!/usr/bin/env bash set -uo pipefail INPUT=$(cat) FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty') [ -z "$FILE" ] && exit 0 # src/test/java/com/example/MyTest.kt -> com.example.MyTest TEST_CLASS=$(echo "$FILE" \ | sed -E 's#.*/src/test/(java|kotlin)/##' \ | sed -E 's#\.kt$##' \ | tr '/' '.') echo "Running $TEST_CLASS..." >&2 if ./gradlew test --tests "$TEST_CLASS" >&2 2>&1; then exit 0 else echo "Tests failed for $TEST_CLASS" >&2 exit 2 # hand the failure back to Claude fi
Because the script exits 2 on failure and prints to stderr, Claude sees the failing output and can take another pass at the code. This is especially useful right after Claude scaffolds a new ViewModel or UseCase and its test in the same session.
Hook 3: get notified when a Gradle build finishes
Claude Code doesn’t have a special “build finished” event — builds run as ordinary Bash commands. So the correct way to be notified is to hook the Bash tool and narrow to Gradle invocations with an if condition. Pair PostToolUse (fires when the command succeeds) with PostToolUseFailure (fires when it fails) to cover both outcomes. This example uses macOS osascript for the desktop notification:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Bash",
"if": "Bash(*gradlew*)",
"hooks": [
{
"type": "command",
"command": "osascript -e 'display notification \"Gradle build succeeded\" with title \"Android Build\"'"
}
]
}
],
"PostToolUseFailure": [
{
"matcher": "Bash",
"if": "Bash(*gradlew*)",
"hooks": [
{
"type": "command",
"command": "osascript -e 'display notification \"Gradle build FAILED\" with title \"Android Build\"'"
}
]
}
]
}
}
On Linux, swap the osascript call for notify-send; on Windows, use a PowerShell message box. The point is that there’s no magic event name to invent — you match the real tool call and react to its success or failure.
Advanced: run only the affected module’s tests
On a multi-module Android project, running the whole suite after every edit is wasteful. Instead, when a file under src/main changes, run tests for just the Gradle module that owns it. Match main-source edits and let the script figure out the module from the path:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"if": "Edit(*/src/main/**/*.kt)",
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/affected-tests.sh",
"timeout": 300
}
]
}
]
}
}
#!/usr/bin/env bash
set -uo pipefail
INPUT=$(cat)
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
[ -z "$FILE" ] && exit 0
# Path relative to the project root, e.g. feature-login/src/main/...
REL=${FILE#"$CLAUDE_PROJECT_DIR/"}
MODULE=${REL%%/src/*}
echo "Testing module :$MODULE ..." >&2
if ./gradlew ":$MODULE:test" >&2 2>&1; then
exit 0
else
echo "Module tests failed for :$MODULE" >&2
exit 2
fi
This keeps the feedback loop short: change a file, and seconds later you know whether you broke that module. If you want to also cover modules that depend on the changed one, you’d extend the script to walk Gradle’s dependency graph. For more on structuring tests around Claude, see our guide on testing and debugging with Claude Code.
A real-world hook workflow
Here’s how these pieces combine in a normal session:
- You ask Claude to generate a LoginViewModel. It writes the file, and the Kotlin PostToolUse hook formats and lints it automatically.
- You ask for the matching test. Claude writes it, the test hook runs it, and if it fails, the stderr output (thanks to exit 2) goes straight back to Claude so it can fix the code.
- You ask Claude to run a full ./gradlew build. The Bash hooks fire a desktop notification telling you whether it passed or failed.
None of this depends on Claude choosing to run these steps — the hooks guarantee they happen. For more ways to extend Claude Code in your workflow, see our post on Claude Code skills for automating boilerplate.
Best practices for hooks
- Keep them fast. Hooks run inline; a slow hook stalls the session. Set a sensible timeout and lean on per-module or per-file scoping.
- Scope tightly. Use the matcher plus an if condition (like Edit(*.kt)) so expensive work only runs when it’s actually relevant.
- Use exit codes deliberately. Exit 0 for success, exit 2 to hand stderr back to Claude as actionable feedback, other non-zero codes for hard errors.
- Read stdin, not arguments. Parse the JSON event with jq; don’t assume positional parameters.
- Test scripts standalone. Pipe a sample JSON payload into your script (echo ‘{…}’ | ./format-kotlin.sh) before wiring it into settings.
- Mind the security note. Hooks run with your full user permissions and execute automatically — review any hook before committing it, especially shared ones.
Integrating hooks into your team workflow
Put project hooks in .claude/settings.json and commit it (along with the scripts in .claude/hooks/). Every developer then gets the same formatting, linting, and test automation with no setup. Keep machine-specific tweaks in .claude/settings.local.json, which stays out of version control. Hooks pair well with review: see our guide on code review with Claude Code for how to automate pre-review checks.
Conclusion
Claude Code hooks turn repetitive checks into guaranteed background steps. Start with one — auto-formatting Kotlin after edits — confirm it with /hooks, then layer on test runs and build notifications as you find the friction points in your own workflow. Because it’s all just JSON in a settings file plus a few shell scripts, you can read, version, and share the whole setup with your team.
This post was written by a human with the help of Claude, an AI assistant by Anthropic.
