Một dev backend ở startup fintech đã viết vào CLAUDE.md:
- Giải thích hook là gì và tại sao nó deterministic (tất định) trong khi CLAUDE.md và skill thì probabilistic (xác suất)
- Nắm rõ 5 lifecycle events: PreToolUse, PostToolUse, UserPromptSubmit, Stop, Notification — và khi nào dùng cái nào
- Cấu hình hooks đúng cách trong settings.json với matcher, command, và script
- Dùng exit codes (0 / 2) để allow hoặc block tool call từ PreToolUse hook
- Share hooks với cả team bằng cách commit settings.json và script vào repo
Hook là gì?
Hook là một script chạy tự động tại các điểm cụ thể trong lifecycle của Claude Code. Bạn cấu hình hook trong settings.json — khai báo: event nào, tool nào (matcher), và command nào cần chạy.
Điểm cốt lõi phân biệt hook với mọi cơ chế khác:
Hook không phụ thuộc vào "ký ức" hay "quyết định" của model. Đó là lý do duy nhất khiến nó deterministic.
Lifecycle của Claude Code — nơi hooks can thiệp
| Cơ chế | Tính chất | Ai quyết định chạy? |
|---|---|---|
| Hook | Deterministic — luôn chạy | Hệ thống Claude Code (không phải model) |
| CLAUDE.md | Probabilistic | Model đọc, model quyết định |
| Skill | Probabilistic | Model nhận request, model quyết định invoke |
| MCP | Available-when-needed | Model quyết định gọi tool |
┌─────────────────────────────────────────────────────────────────────┐ │ CLAUDE CODE LIFECYCLE │ ├─────────────────────────────────────────────────────────────────────┤ │ │ │ User gõ prompt │ │ │ │ │ ▼ │ │ ┌─────────────┐ │ │ │UserPromptSubmit│ ◄── Hook chạy ở đây (trước khi Claude xử lý) │ │ └──────┬──────┘ │ │ │ │ │ ▼ │ │ Claude xử lý, quyết định gọi tool │ │ │ │ │ ▼ │ │ ┌─────────────┐ │ │ │ PreToolUse │ ◄── Hook chạy ở đây (có thể BLOCK tool call) │ │ └──────┬──────┘ │ │ │ exit 0 → allow / exit 2 → block │ │ ▼ │ │ Tool call thực thi (Edit, Bash, Read, v.v.) │ │ │ │ │ ▼ │ │ ┌─────────────┐ │ │ │ PostToolUse │ ◄── Hook chạy ở đây (format, log, test) │ │ └──────┬──────┘ │ │ │ │ │ ▼ │ │ Claude tiếp tục loop hoặc kết thúc │ │ │ │ │ ▼ │ │ ┌─────────────┐ │ │ │ Stop │ ◄── Hook chạy ở đây (notify, cleanup) │ │ └─────────────┘ │ │ │ │ ┌─────────────────┐ │ │ │ Notification │ ◄── Hook chạy khi Claude send notification │ │ └─────────────────┘ │ │ │ └─────────────────────────────────────────────────────────────────────┘
5 Hook Events
Lưu ý: Matcher là regex match trên tên tool (Edit, Bash, Write, v.v.). PreToolUse và PostToolUse thường cần matcher để tránh hook chạy trên mọi tool call.
| Event | Chạy khi nào | Có thể block? | Typical use case | Cần matcher? |
|---|---|---|---|---|
| PreToolUse | Trước khi tool call thực thi | Có (exit 2) | Block write vào prod file, block rm -rf, validate input | Khuyến nghị |
| PostToolUse | Sau khi tool call hoàn thành | Không | Auto-format code, chạy lint, ghi audit log, trigger test | Khuyến nghị |
| UserPromptSubmit | Khi user submit prompt, trước khi Claude xử lý | Không trực tiếp | Log prompt, inject thêm context, validate prompt format | Không bắt buộc |
| Stop | Khi Claude hoàn thành response | Không | Gửi Slack notification, cleanup temp files, trigger deploy | Không bắt buộc |
| Notification | Khi Claude gửi notification | Không | Custom notification routing, macOS say, Slack ping | Không bắt buộc |
Anatomy của hook config
Hook được cấu hình trong .claude/settings.json (project-level, có thể commit vào repo) hoặc ~/.claude/settings.json (user-level, áp dụng mọi project).
Cấu trúc JSON
Giải thích từng thành phần
Script nhận gì từ Claude Code?
Script của bạn nhận JSON qua stdin chứa thông tin về event:
- Event key (PostToolUse, PreToolUse, v.v.) — lifecycle event bạn muốn hook vào
- matcher — regex string match với tên tool. "Edit|MultiEdit|Write" nghĩa là hook chỉ chạy khi Claude dùng một trong 3 tools này. Để trống "" nghĩa là match mọi tool call
- type — hiện tại chỉ có "command" (chạy shell command)
- command — lệnh shell hoặc đường dẫn tới script. Dùng $CLAUDE_PROJECT_DIR thay vì hardcode absolute path
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|MultiEdit|Write",
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/format-on-edit.sh"
}
]
}
],
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/block-dangerous-commands.sh"
}
]
}
],
"Stop": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/notify-done.sh"
}
]
}
]
}
}Script nhận gì từ Claude Code?
Script output ra stdout/stderr, và exit code là tín hiệu quan trọng nhất.
{
"tool_name": "Edit",
"tool_input": {
"file_path": "/path/to/file.ts",
"old_string": "const x = 1",
"new_string": "const x = 2"
}
}Exit Codes và Behavior
Quan trọng: Khi PreToolUse hook exit với code 2, Claude nhận được stderr message như một "giải thích tại sao bị từ chối". Claude sẽ tự điều chỉnh — ví dụ thay đổi approach hoặc hỏi lại bạn. Đây là cơ chế feedback loop thông minh.
| Exit code | Behavior | Use case | Ví dụ |
|---|---|---|---|
| 0 | Proceed normally — tool call được allow hoặc PostToolUse tiếp tục | Mọi trường hợp bình thường | Script chạy Prettier thành công |
| 2 | Block tool call (chỉ valid cho PreToolUse). stderr message được feed back cho Claude như feedback | Enforce hard rules | Block write vào infra/prod/* |
| Khác (1, 3, ...) | Non-blocking error — hiển thị cho user nhưng không dừng Claude | Script lỗi nhưng không muốn block | Prettier không tìm thấy, bỏ qua |
PreToolUse hook exit 2
│
▼
Claude nhận stderr: "BLOCKED: Cannot write to infra/prod/ — use staging only"
│
▼
Claude tự điều chỉnh: "Tôi sẽ thử tạo file ở infra/staging/ thay thế"Ví dụ thực chiến: Auto-format hook
Đây là hook phổ biến nhất — auto-format file sau mỗi lần Claude edit.
Bước 1: Tạo script
Tạo file .claude/hooks/format-on-edit.sh:
Bước 2: Cấp quyền thực thi
#!/usr/bin/env bash
# Hook: Auto-format file after Claude edits it
# Receives JSON on stdin with tool_input.file_path
set -euo pipefail
# Đọc JSON từ stdin, extract file_path
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('tool_input',{}).get('file_path',''))" 2>/dev/null || echo "")
# Nếu không có file path, thoát bình thường
if [ -z "$FILE_PATH" ]; then
exit 0
fi
# Nếu file không tồn tại, thoát bình thường
if [ ! -f "$FILE_PATH" ]; then
exit 0
fi
# Xác định formatter theo extension
EXT="${FILE_PATH##*.}"
case "$EXT" in
ts|tsx|js|jsx|json|css|html|md)
# Prettier cho web stack
if command -v prettier &>/dev/null; then
prettier --write "$FILE_PATH" --log-level warn
echo "Formatted (prettier): $FILE_PATH" >&2
fi
;;
go)
# gofmt cho Go
if command -v gofmt &>/dev/null; then
gofmt -w "$FILE_PATH"
echo "Formatted (gofmt): $FILE_PATH" >&2
fi
;;
py)
# ruff cho Python (nhanh hơn black)
if command -v ruff &>/dev/null; then
ruff format "$FILE_PATH" --quiet
echo "Formatted (ruff): $FILE_PATH" >&2
elif command -v black &>/dev/null; then
black "$FILE_PATH" --quiet
echo "Formatted (black): $FILE_PATH" >&2
fi
;;
*)
# Extension không hỗ trợ — thoát bình thường
exit 0
;;
esac
exit 0Bước 2: Cấp quyền thực thi
Bước 3: Thêm vào settings.json
chmod +x .claude/hooks/format-on-edit.shBước 3: Thêm vào settings.json
Bước 4: Commit vào repo
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|MultiEdit|Write",
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/format-on-edit.sh"
}
]
}
]
}
}Bước 4: Commit vào repo
Toàn bộ team clone repo về sẽ tự động có hook này.
Bước 5: Test
git add .claude/hooks/format-on-edit.sh .claude/settings.json
git commit -m "chore: add auto-format hook for Claude Code"Bước 5: Test
Claude edit file. Ngay sau khi edit xong, hook chạy Prettier. File được format trước khi Claude tiếp tục bất kỳ bước nào. Không cần nhắc, không có 20% fail rate.
> Sửa file src/components/Button.tsx — đổi className "btn" thành "button"Blocking với PreToolUse
PreToolUse hook là cơ chế enforce "hard rules" — những điều phải được đảm bảo, không phải chỉ "đề xuất".
Script nhận gì?
Ví dụ: Block lệnh Bash nguy hiểm
Tạo .claude/hooks/block-dangerous-commands.sh:
{
"tool_name": "Bash",
"tool_input": {
"command": "rm -rf /tmp/old-build"
}
}Ví dụ: Block lệnh Bash nguy hiểm
Config trong settings.json:
#!/usr/bin/env bash
# Hook: Block dangerous bash commands in PreToolUse
# exit 0 = allow, exit 2 = block (stderr → Claude feedback)
set -euo pipefail
INPUT=$(cat)
TOOL=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('tool_name',''))" 2>/dev/null || echo "")
COMMAND=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('tool_input',{}).get('command',''))" 2>/dev/null || echo "")
# Chỉ check tool Bash
if [ "$TOOL" != "Bash" ]; then
exit 0
fi
# RULE 1: Block rm -rf (quá nguy hiểm, không ngoại lệ)
if echo "$COMMAND" | grep -qE 'rm\s+-[a-zA-Z]*r[a-zA-Z]*f|rm\s+-[a-zA-Z]*f[a-zA-Z]*r'; then
echo "BLOCKED: 'rm -rf' không được phép. Dùng 'rm -r' kèm đường dẫn cụ thể, hoặc xác nhận với user trước." >&2
exit 2
fi
# RULE 2: Block write/modify vào infra/prod/
if echo "$COMMAND" | grep -qE '(infra/prod|production\.env|\.env\.prod)'; then
echo "BLOCKED: Không được thao tác file production trực tiếp. Chỉ dùng infra/staging/." >&2
exit 2
fi
# RULE 3: Block git push --force
if echo "$COMMAND" | grep -qE 'git push.*--force|git push.*-f\b'; then
echo "BLOCKED: force push không được phép. Tạo PR thay thế." >&2
exit 2
fi
# Cho phép mọi lệnh khác
exit 0Blocking với PreToolUse (tiếp)
Khi Claude cố chạy rm -rf /tmp/cache:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/block-dangerous-commands.sh"
}
]
},
{
"matcher": "Write|Edit|MultiEdit",
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/block-prod-writes.sh"
}
]
}
]
}
}Blocking với PreToolUse (tiếp)
[Hook blocked: BLOCKED: 'rm -rf' không được phép. Dùng 'rm -r' kèm đường dẫn cụ thể...]
Claude: "Tôi hiểu rồi. Tôi sẽ dùng 'rm -r /tmp/cache' thay thế để xoá thư mục một cách an toàn hơn."Hook vs Skill vs CLAUDE.md vs MCP
Đây là bảng so sánh quan trọng nhất trong bài — quyết định khi nào dùng cơ chế nào:
Quy tắc quyết định đơn giản:
Cross-reference: xem thêm Bài 2.9 (Skills) và Bài 2.7 (CLAUDE.md) để so sánh chi tiết hơn.
- Nếu bạn cần đảm bảo điều gì đó xảy ra → Hook
- Nếu bạn muốn Claude biết cách làm gì đó khi bạn hỏi → Skill
- Nếu bạn muốn Claude luôn nhớ context chung của project → CLAUDE.md
- Nếu bạn cần Claude truy cập hệ thống ngoài → MCP
| Tiêu chí | Hook | Skill | CLAUDE.md | MCP |
|---|---|---|---|---|
| Tính chất | Deterministic — luôn chạy | Probabilistic — model quyết định | Probabilistic — model đọc, có thể quên | Available-when-needed |
| Trigger | Event trong lifecycle | Model match request với skill name | Load lúc session start, model đọc | Model quyết định gọi tool |
| Visibility | Script ngoài context | Load vào context khi match | Luôn trong context | Tool definition trong context |
| Best for | Guarantees — phải xảy ra 100% | Patterns — workflow tái sử dụng | Project-wide rules — conventions, commands | External integrations — databases, APIs |
| Ví dụ | Auto-format mỗi edit | /commit với chuẩn commit message | "Dùng pnpm, không npm" | GitHub, Linear, Slack |
| Overhead | Chạy script ngoài | Load markdown vào context | Luôn có trong context | Tool defs trong context |
Case studies theo role
Backend Engineer: Lint guarantee
Mỗi lần Claude edit bất kỳ file .ts, PostToolUse hook auto-chạy eslint --fix. Không bao giờ có CI lint failure nữa — dù session dài 4 tiếng hay Claude đang cuối context.
DevOps / Security Engineer: Prod file protection
PreToolUse hook block mọi write vào infra/prod/. Claude chỉ được phép thao tác infra/staging/. Block + stderr message rõ ràng giúp Claude tự chọn đường dẫn staging, không cần dev can thiệp.
Engineering Team: Async notification
Stop hook gửi Slack message khi Claude hoàn thành task. Team có thể giao Claude task dài (30+ phút), làm việc khác, nhận Slack ping khi xong. Không cần ngồi nhìn màn hình chờ.
# .claude/hooks/lint-on-edit.sh
FILE_PATH=$(cat | python3 -c "import sys,json; print(json.load(sys.stdin).get('tool_input',{}).get('file_path',''))")
[[ "$FILE_PATH" == *.ts ]] && npx eslint --fix "$FILE_PATH" --quiet
exit 0Engineering Team: Async notification
Compliance team: Audit log bắt buộc
PreToolUse hook log mọi Bash command vào file audit với timestamp, user, command. Không có exception. Đây là regulatory requirement mà hook đảm bảo 100% — không thể "quên" như khi viết trong CLAUDE.md.
# .claude/hooks/notify-slack.sh
curl -s -X POST "$SLACK_WEBHOOK_URL" \
-H 'Content-type: application/json' \
--data '{"text":"Claude finished the task. Ready for review."}' > /dev/null
exit 0Compliance team: Audit log bắt buộc
Open Source Maintainer: Test trước commit
PostToolUse hook chạy test suite sau mỗi lần Claude edit file source (không phải test file). Nếu test fail, script log ra stderr — Claude biết có regression và tự fix trước khi tiếp tục.
Solo founder: macOS notification
Notification hook chạy say "Claude xong rồi" (macOS text-to-speech). Bạn có thể rời bàn phím, về máy sẽ nghe thông báo khi Claude hoàn thành task background.
# .claude/hooks/audit-log.sh
COMMAND=$(cat | python3 -c "import sys,json; print(json.load(sys.stdin).get('tool_input',{}).get('command',''))")
echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) | USER=$USER | CMD=$COMMAND" >> /var/log/claude-audit.log
exit 0Solo founder: macOS notification
# .claude/hooks/say-done.sh
say "Claude xong rồi, về review đi bạn ơi" &
exit 0Anti-patterns
Script chạy quá lâu (>10 giây)
Hook là synchronous trong lifecycle. Nếu script mất 30 giây (ví dụ chạy full test suite mỗi edit), mỗi tool call của Claude sẽ bị block 30 giây. UX cực kỳ tệ. Giải pháp: chạy test nhanh, hoặc dùng background process (&) với Stop hook thay vì PostToolUse.
PreToolUse không xử lý input rỗng
Nếu tool call không có field bạn expect (ví dụ Bash không có command khi là subshell), script crash → false-positive block. Luôn handle gracefully: || echo "" và check empty trước khi process.
Hardcode absolute path trong command
CLAUDE_PROJECT_DIR là env var Claude Code inject vào khi chạy hook — luôn trỏ đến root của project hiện tại.
Không commit settings.json và script vào repo
Nếu chỉ có bạn có hook, team sẽ commit code không format, không lint. Mất đi toàn bộ lợi ích "deterministic for the whole team". Hook chỉ thực sự mạnh khi toàn team có cùng config.
Hook chứa secret / API key
Script được commit vào repo. Nếu script hardcode SLACK_TOKEN=xoxb-..., bạn vừa leak secret. Dùng environment variable: $SLACK_WEBHOOK_URL và set trong môi trường local / CI secrets.
Dùng hook cho việc nên là Skill
Hook là cho guarantees. Nếu bạn viết hook để "nhắc Claude viết commit message đúng chuẩn", đó là việc của Skill hoặc CLAUDE.md — không cần guarantee mỗi tool call. Hook không cần thiết = overhead vô nghĩa.
Matcher quá rộng (no matcher)
// SAI — vỡ khi teammate clone về
"command": "/Users/john/projects/myapp/.claude/hooks/format.sh"
// ĐÚNG — work ở bất kỳ machine nào
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/format.sh"Matcher quá rộng (no matcher)
Matcher rỗng nghĩa là hook chạy mỗi lần Claude gọi bất kỳ tool nào. Với PostToolUse formatter, điều này vô nghĩa và chậm.
// BAD — hook chạy trên MỌI tool call (Read, Glob, Grep, v.v.)
{ "matcher": "" }
// GOOD — chỉ chạy khi Claude edit file
{ "matcher": "Edit|MultiEdit|Write" }Mẹo nâng cao
Dùng CLAUDE_PROJECT_DIR cho mọi path reference
Env var này được inject tự động bởi Claude Code. Script của bạn work bất kể cwd của Claude đang ở đâu trong project.
Combine hooks: PreToolUse → PostToolUse → Stop pipeline
# Trong script
LOG_FILE="$CLAUDE_PROJECT_DIR/logs/claude-audit.log"
CONFIG="$CLAUDE_PROJECT_DIR/.env.local"Combine hooks: PreToolUse → PostToolUse → Stop pipeline
Ba hooks phối hợp tạo một pipeline hoàn chỉnh: validate trước, cleanup sau, notify khi xong.
Idempotent scripts — chạy nhiều lần không có side effect
Prettier, gofmt, ruff đều idempotent (chạy nhiều lần ra cùng kết quả). Tuy nhiên nếu script của bạn append vào file log, đảm bảo format đủ thông tin để avoid duplicate entries gây nhầm lẫn.
Test hook isolation trước khi enable
PreToolUse: validate input (block nếu cần)
↓
PostToolUse: format + lint + log
↓
Stop: notify team qua SlackTest hook isolation trước khi enable
Chạy script trực tiếp với mock stdin trước khi enable trong settings.json. Dễ debug hơn nhiều so với enable rồi chạy Claude.
Conditional logic trong script (không chỉ dựa vào matcher)
Matcher chỉ match tên tool. Script có thể implement logic phức tạp hơn:
# Tạo mock input
echo '{"tool_name":"Edit","tool_input":{"file_path":"/tmp/test.ts"}}' \
| .claude/hooks/format-on-edit.shConditional logic trong script (không chỉ dựa vào matcher)
Debug: comment tạm matcher để isolate hook behavior
Khi debug Claude behavior, bạn nghi hook đang can thiệp. Cách nhanh nhất: đổi matcher thành một string không bao giờ match ("DISABLED_Edit|MultiEdit"). Claude behavior trở về "thuần" — so sánh để xác nhận hook là culprit.
Multiple matchers trong 1 entry
# Chỉ format nếu file trong src/ (không format test fixtures)
if [[ "$FILE_PATH" == */src/* ]]; then
prettier --write "$FILE_PATH"
fiMultiple matchers trong 1 entry
Một hook entry cover 3 tools. Thực tế với file editing, bạn muốn cover cả 3: Edit (single block), MultiEdit (nhiều blocks), Write (tạo file mới).
{
"matcher": "Edit|MultiEdit|Write",
"hooks": [...]
}Áp dụng ngay
Bài tập 1: Auto-format hook (~25 phút)
Tạo PostToolUse hook auto-format cho project của bạn.
Bước 1: Xác định formatter bạn đang dùng (Prettier, eslint, ruff, gofmt, v.v.)
Bước 2: Tạo .claude/hooks/format-on-edit.sh (xem script ở phần trên, adapt cho stack của bạn)
Bước 3: Thêm vào .claude/settings.json:
Bước 4: Test script isolation với mock input trước
Bước 5: Nhờ Claude edit 5 file khác nhau. Verify mỗi lần đều format. Không có ngoại lệ.
Bước 6: Commit cả script + settings vào repo
Bài tập 2: PreToolUse blocking hook (~20 phút)
Tạo hook block 1 dangerous pattern trong project của bạn.
Chọn 1 trong các scenario:
Bước 1: Tạo .claude/hooks/block-dangerous.sh với logic check và exit 2
Bước 2: Test reject case: tạo mock input với command nguy hiểm, verify script exit 2 với đúng stderr message
Bước 3: Test allow case: mock input với command bình thường, verify script exit 0
Bước 4: Enable trong settings.json, test với Claude thực tế
Bonus: Thử nhờ Claude làm action bị block → xem Claude tự adjust như thế nào dựa vào stderr feedback
- Block rm -rf trong Bash commands
- Block write vào .env files
- Block edit files trong infra/prod/
- Block git push --force
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|MultiEdit|Write",
"hooks": [{"type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/format-on-edit.sh"}]
}
]
}
}Tóm tắt
Hook là cơ chế duy nhất trong Claude Code mà hành vi là deterministic — không phụ thuộc vào model "nhớ" hay "quyết định". Đây là công cụ để bạn enforce hard rules, không phải đề xuất.
5 takeaways cốt lõi:
Quote đáng nhớ:
- Hook chạy tại lifecycle events — script thông thường, không có technology mới để học
- PostToolUse cho guarantees sau action: format, lint, log, test — chạy mỗi lần không trừ
- PreToolUse cho blocking: exit 2 + stderr message → Claude tự điều chỉnh
- Stop / Notification cho async notifications — để làm việc khác, nhận ping khi Claude xong
- Commit settings.json + script vào repo → toàn team có cùng hook, không ai bị "thiếu"
- Claude Code Hooks documentation — Anthropic official docs
- Claude Code settings.json reference — cấu trúc đầy đủ
- /hooks command — gõ trong Claude Code session để xem và edit hooks interactively
- CLAUDE_PROJECT_DIR env var — inject tự động bởi Claude Code khi chạy hook script