Hooks intercept Copilot's tool calls to run custom scripts — either before a tool executes (to block or validate) or after (to format or post-process). They act as automated quality gates without manual intervention.
Location: templates/hooks/*.json (configs) + templates/hooks/scripts/ (scripts)
Copilot decides to edit a file
→ PreToolUse hook fires (guard-protected-files)
→ Script checks if the file is protected
→ If protected: asks for confirmation before proceeding
→ If allowed: tool executes normally
→ PostToolUse hook fires (format-on-edit)
→ Script detects file type
→ Runs the appropriate formatter
→ File is saved with correct formatting
Config file (.json):
{
"hooks": [
{
"event": "PostToolUse",
"tools": ["insert_edit_into_file", "replace_string_in_file"],
"script": ".github/hooks/scripts/my-script.sh",
"permissionDecision": "allow"
}
]
}Event types:
PreToolUse— Runs before the tool. Can block execution.PostToolUse— Runs after the tool. Can post-process results.
Permission decisions (for PreToolUse):
"allow"— Silently allow the tool to proceed"deny"— Silently block the tool"ask"— Prompt the user for confirmation
Event: PostToolUse — runs after every file edit
What it does: Automatically formats files after Copilot edits them, using the appropriate formatter for each language.
Supported formatters:
| File Type | Formatter | Fallback |
|---|---|---|
.ex, .exs |
mix format |
— |
.ts, .tsx, .js, .jsx, .css, .scss, .json, .md |
npx prettier |
— |
.py |
ruff format |
black |
.rs |
rustfmt |
— |
.go |
gofmt |
— |
Config (.github/hooks/format-on-edit.json):
{
"hooks": [
{
"event": "PostToolUse",
"tools": ["insert_edit_into_file", "replace_string_in_file", "create_file"],
"script": ".github/hooks/scripts/format-on-edit.sh",
"permissionDecision": "allow"
}
]
}Behavior:
- Detects file extension from the tool's output
- Runs the matching formatter silently
- If the formatter isn't installed, skips gracefully (no error)
- Adds no latency for unrecognized file types
Event: PreToolUse — runs before any file edit
What it does: Prevents accidental edits to sensitive or auto-generated files by asking for confirmation.
Protected patterns:
| Pattern | Examples |
|---|---|
| Lock files | package-lock.json, yarn.lock, mix.lock, Gemfile.lock, poetry.lock, pnpm-lock.yaml, Cargo.lock |
| Environment files | .env, .env.local, .env.production |
| Secrets/certificates | *.pem, *.key, *.cert, *.p12 |
Config (.github/hooks/guard-protected-files.json):
{
"hooks": [
{
"event": "PreToolUse",
"tools": ["insert_edit_into_file", "replace_string_in_file", "create_file"],
"script": ".github/hooks/scripts/guard-protected-files.sh",
"permissionDecision": "ask"
}
]
}Behavior:
- Checks the target file path against protected patterns
- If the file matches: returns
"ask"to prompt for confirmation - If the file is safe: returns
"allow"silently - Never blocks — always gives you the choice
Event: PostToolUse — runs after every file edit
What it does: Warns about debug statements left in files — console.log, debugger, IO.inspect, binding.pry, fmt.Println, println!, and more.
Supported languages:
| Language | Detected Patterns |
|---|---|
| JS/TS | console.log, console.debug, debugger |
| Python | print(), breakpoint(), pdb.set_trace() |
| Elixir | IO.inspect, IEx.pry, dbg() |
| Ruby | puts, pp, binding.pry, byebug |
| Go | fmt.Println, fmt.Printf |
| Rust | println!, dbg! |
Config (.github/hooks/console-log-detector.json):
{
"hooks": {
"PostToolUse": [
{
"type": "command",
"command": ".github/hooks/scripts/console-log-detector.sh",
"timeout": 5,
"description": "Warn about debug statements left in edited files"
}
]
}
}Event: PostToolUse — runs after every file edit
What it does: Flags TODO, FIXME, HACK, and XXX comments that don't reference an issue number. Encourages linking TODOs to issues so they're trackable.
Config (.github/hooks/todo-tracker.json):
{
"hooks": {
"PostToolUse": [
{
"type": "command",
"command": ".github/hooks/scripts/todo-tracker.sh",
"timeout": 5,
"description": "Flag TODOs without issue links"
}
]
}
}Behavior:
- Scans source files for TODO/FIXME/HACK/XXX comments
- Ignores TODOs that reference issues:
TODO(#123): description - Skips markdown, JSON, YAML, and other non-source files
- Shows up to 5 unlinked TODOs per edit
Event: PostToolUse — runs after every file edit
What it does: Warns when a file exceeds 500 lines, encouraging modular code. The threshold is configurable via the DX_MAX_FILE_LINES environment variable.
Config (.github/hooks/file-size-limiter.json):
{
"hooks": {
"PostToolUse": [
{
"type": "command",
"command": ".github/hooks/scripts/file-size-limiter.sh",
"timeout": 5,
"description": "Warn when files exceed line threshold"
}
]
}
}Behavior:
- Default threshold: 500 lines
- Override:
export DX_MAX_FILE_LINES=800 - Skips binary, minified, and lock files
Event: PostToolUse — runs after file creation
What it does: Checks if a corresponding test file exists when a new source file is created. If not, reminds you to add tests.
Config (.github/hooks/test-companion-reminder.json):
{
"hooks": {
"PostToolUse": [
{
"type": "command",
"command": ".github/hooks/scripts/test-companion-reminder.sh",
"timeout": 5,
"description": "Remind to create tests for new source files"
}
]
}
}Behavior:
- Checks for
*.test.*,*.spec.*,*_test.*in the same directory,__tests__/, andtest/ - Skips when the file being created IS a test file
- Skips non-source files (markdown, JSON, YAML, CSS, etc.)
Event: PreToolUse — runs before file edits
What it does: Asks for confirmation before modifying linter, formatter, or build config files. Agents sometimes "fix" lint errors by weakening the rules — this hook catches that.
Protected files: .eslintrc*, .prettierrc*, tsconfig*.json, .rubocop.yml, .credo.exs, pyproject.toml, .golangci.yml, rustfmt.toml, biome.json, and more.
Config (.github/hooks/config-protector.json):
{
"hooks": {
"PreToolUse": [
{
"type": "command",
"command": ".github/hooks/scripts/config-protector.sh",
"timeout": 5,
"description": "Require confirmation before modifying linter/formatter configs"
}
]
}
}Event: PreToolUse — runs before file edits
What it does: Asks for confirmation before modifying package manifest files (package.json, Gemfile, mix.exs, Cargo.toml, go.mod, etc.). Every dependency adds to bundle size, attack surface, and maintenance burden.
Config (.github/hooks/large-dependency-guard.json):
{
"hooks": {
"PreToolUse": [
{
"type": "command",
"command": ".github/hooks/scripts/large-dependency-guard.sh",
"timeout": 5,
"description": "Require confirmation before adding dependencies"
}
]
}
}#!/bin/bash
# .github/hooks/scripts/my-hook.sh
# Hook scripts receive context via environment variables:
# - Tool name and arguments are available in the hook context
# Your logic here
echo "allow" # or "deny" or "ask"Make it executable:
chmod +x .github/hooks/scripts/my-hook.sh{
"hooks": [
{
"event": "PreToolUse",
"tools": ["insert_edit_into_file"],
"script": ".github/hooks/scripts/my-hook.sh",
"permissionDecision": "ask"
}
]
}Edit a file that should trigger the hook and verify the behavior.
Ready-to-copy recipes for common use cases. Each recipe includes the config file and script.
Run ESLint (or your linter) after every file edit to catch issues immediately:
{
"hooks": {
"PostToolUse": [
{
"type": "command",
"command": ".github/hooks/scripts/auto-lint.sh",
"timeout": 15,
"description": "Run linter after file edits"
}
]
}
}#!/usr/bin/env bash
set -euo pipefail
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | grep -o '"filePath"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/"filePath"[[:space:]]*:[[:space:]]*"//;s/"$//' || true)
[[ -z "$FILE_PATH" || ! -f "$FILE_PATH" ]] && exit 0
case "${FILE_PATH##*.}" in
ts|tsx|js|jsx) npx eslint --fix "$FILE_PATH" 2>/dev/null || true ;;
ex|exs) mix credo "$FILE_PATH" 2>/dev/null || true ;;
py) ruff check --fix "$FILE_PATH" 2>/dev/null || true ;;
rb) rubocop -A "$FILE_PATH" 2>/dev/null || true ;;
esac
exit 0Run tests for the file you just edited:
#!/usr/bin/env bash
set -euo pipefail
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | grep -o '"filePath"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/"filePath"[[:space:]]*:[[:space:]]*"//;s/"$//' || true)
[[ -z "$FILE_PATH" || ! -f "$FILE_PATH" ]] && exit 0
NAME_NO_EXT="${FILE_PATH%.*}"
EXT="${FILE_PATH##*.}"
# Check for co-located test file
for TEST in "${NAME_NO_EXT}.test.${EXT}" "${NAME_NO_EXT}.spec.${EXT}" "${NAME_NO_EXT}_test.${EXT}"; do
if [[ -f "$TEST" ]]; then
case "$EXT" in
ts|tsx|js|jsx) npx vitest run "$TEST" --reporter=verbose 2>/dev/null || true ;;
py) python -m pytest "$TEST" -v 2>/dev/null || true ;;
ex|exs) mix test "$TEST" 2>/dev/null || true ;;
esac
break
fi
done
exit 0Block edits when on main or production branches:
#!/usr/bin/env bash
set -euo pipefail
BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown")
case "$BRANCH" in
main|master|production|staging)
cat <<EOF
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "ask",
"permissionDecisionReason": "You're on the $BRANCH branch. Create a feature branch first."
}
}
EOF
;;
esac
exit 0Sort imports after editing TypeScript files:
#!/usr/bin/env bash
set -euo pipefail
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | grep -o '"filePath"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/"filePath"[[:space:]]*:[[:space:]]*"//;s/"$//' || true)
[[ -z "$FILE_PATH" || ! -f "$FILE_PATH" ]] && exit 0
case "${FILE_PATH##*.}" in
ts|tsx|js|jsx)
if command -v npx &>/dev/null; then
npx organize-imports-cli "$FILE_PATH" 2>/dev/null || true
fi
;;
esac
exit 0Hook scripts receive JSON on stdin from the tool event. The shape depends on the event type:
{
"tool_name": "insert_edit_into_file",
"tool_input": {
"filePath": "/path/to/file.ts",
"content": "..."
}
}Common fields to extract:
| Field | Where to Find | Use For |
|---|---|---|
filePath |
tool_input.filePath |
File being edited/created |
content |
tool_input.content |
New file content |
newString |
tool_input.newString |
Replacement text in edits |
oldString |
tool_input.oldString |
Text being replaced |
command |
tool_input.command |
Terminal command being run |
message |
tool_input.message |
Commit message |
PostToolUse hooks: Print warnings/info to stdout. Exit 0. Output is shown to the agent.
PreToolUse hooks: To request confirmation, print JSON:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "ask",
"permissionDecisionReason": "Explain why confirmation is needed"
}
}Permission decisions: "allow" (proceed silently), "deny" (block silently), "ask" (prompt user).
#!/usr/bin/env bash
# my-hook.sh — One-line description
set -euo pipefail
INPUT=$(cat)
# Extract file path (works for most edit/create tools)
FILE_PATH=$(echo "$INPUT" | grep -o '"filePath"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/"filePath"[[:space:]]*:[[:space:]]*"//;s/"$//' || true)
if [[ -z "$FILE_PATH" ]]; then
exit 0 # No file path — nothing to check
fi
# Your logic here
exit 0Test hooks locally by piping mock input:
echo '{"tool_name":"insert_edit_into_file","tool_input":{"filePath":"src/app.ts"}}' | bash .github/hooks/scripts/my-hook.sh| Hook | Event | Purpose |
|---|---|---|
| Auto-lint | PostToolUse | Run ESLint/Credo after edits |
| Test runner | PostToolUse | Auto-run related tests after code changes |
| Import sorter | PostToolUse | Sort imports after editing source files |
| Schema guard | PreToolUse | Prevent direct edits to generated files (GraphQL schema, Prisma client) |
| Branch protector | PreToolUse | Block edits on specific branches (main, production) |
- Keep hooks fast: They run on every matching tool call — keep scripts under 1 second
- Fail open: If your script errors, make sure the default is to allow (not block)
- Test locally: Run
bash .github/hooks/scripts/my-hook.shto verify before relying on it - Use
permissionDecision: "ask"for safety-critical hooks — never silently deny without good reason - Scope narrowly: Use the
toolsarray to limit which tool calls trigger the hook