Hooks trong Claude Code: bắt buộc tuân theo quy ước mà không cần nhắc agent — hoatq.dev

cat blog/.md

Hooks trong Claude Code: bắt buộc tuân theo quy ước mà không cần nhắc agent

date: tags: ai, claude-code, developer-tools, automation, workflow

Ai dùng AI coding agent đủ lâu đều gặp một vấn đề: quy ước của team thì có, tài liệu thì có, nhưng agent quên. Session này nhớ cập nhật CLAUDE.md, session sau quên. Nhớ chạy lint, lại quên định dạng commit message. Dặn đi dặn lại trong SKILL.md vẫn miss. Vấn đề không phải AI “dại” — vấn đề là bạn đang giao nhiệm vụ bắt buộc tuân thủ cho đúng cái không đáng tin cậy: bộ nhớ ngắn hạn của agent.

Giải pháp: hooks. Thay vì trông chờ agent nhớ, viết một shell script chạy tự động ở những sự kiện nhất định. Agent không nhớ cũng không sao — hook nhắc giùm, hoặc trực tiếp chặn thao tác sai. Bài này là những gì mình học được khi setup hook cho monorepo của mình.

Hook là gì và chạy ở đâu

Hook là lệnh shell được Claude Code chạy tự động tại các điểm trong vòng đời của một lần gọi tool hoặc một phiên làm việc. Cấu hình nằm trong .claude/settings.json (project) hoặc ~/.claude/settings.json (toàn cục):

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "bash .claude/hooks/check-claude-md.sh"
          }
        ]
      }
    ]
  }
}

Đọc từ ngoài vào:

  • Sự kiện (PostToolUse): khi nào kích hoạt
  • Bộ khớp (Edit|Write): tool nào sẽ khớp (regex)
  • Lệnh: script chạy khi match

Hook chạy đồng bộ — agent phải đợi hook xong mới tiếp tục. Nên hook phải nhanh (<1 giây là lý tưởng) và không chặn trừ khi cố ý muốn chặn.

Các sự kiện chính

Claude Code có vài sự kiện (event) đáng để ý:

PreToolUse — trước khi tool chạy. Hook có thể chặn tool bằng cách thoát với mã khác 0. Dùng để: chặn tool nguy hiểm, bắt buộc điều kiện tiên quyết (file phải tồn tại, branch phải đúng).

PostToolUse — sau khi tool chạy xong. Dùng để: kiểm tra kết quả, kích hoạt hành động tiếp theo, nhắc agent làm gì đó.

UserPromptSubmit — mỗi lần user gửi prompt. Dùng để: đưa thêm ngữ cảnh vào tự động (nạp các commit gần đây, branch hiện tại), ghi log prompt để kiểm tra sau.

SessionStart — khi phiên bắt đầu. Dùng để: khởi động cache, nạp biến môi trường, kiểm tra điều kiện cần (Docker đang chạy chưa).

Stop — khi agent dừng một lượt. Dùng để: dọn dẹp, tóm tắt kết quả, kích hoạt kiểm tra CI.

Notification — khi Claude Code hiển thị thông báo. Dùng để: tích hợp với hệ thống cảnh báo bên ngoài.

Mỗi sự kiện có định dạng JSON đầu vào khác nhau. Hook nhận JSON qua stdin, parse bằng python3 -c "import json, sys..." hoặc jq.

Định dạng dữ liệu đầu vào cho hook

Hook nhận một gói JSON qua stdin. Với sự kiện PostToolUse, JSON thường có dạng:

{
  "session_id": "abc123...",
  "tool_name": "Edit",
  "tool_input": {
    "file_path": "/path/to/file.py",
    "old_string": "...",
    "new_string": "..."
  },
  "tool_response": {
    "success": true
  }
}

Parse nhanh bằng Python (chạy được trên mọi OS, có sẵn trên hầu hết máy dev):

#!/bin/bash
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | python3 -c "
import sys, json
d = json.load(sys.stdin)
print(d.get('tool_input', {}).get('file_path', ''))
")

Hoặc jq nếu team bạn có sẵn:

FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // ""')

Output: nói chuyện lại với agent

Điểm thú vị: hook có thể in ra stdout và output đó được gửi lại cho agent như một thông báo hệ thống. Đây chính là cơ chế để “nhắc” agent.

Quy ước mình dùng: bọc output trong tag kiểu XML để agent đọc ra rõ ràng:

echo "<claude-md-hook>Modified files in backend/identity/. When this task is complete, evaluate and update backend/identity/CLAUDE.md if there are structural changes.</claude-md-hook>"

Agent thấy output này, hiểu đây là lời nhắc, và thường ghi nhớ “cập nhật CLAUDE.md sau khi xong việc”.

Hook thoát với mã khác 0 sẽ chặn lời gọi tool (với PreToolUse) hoặc hiển thị lỗi (với các sự kiện khác). Dùng cẩn thận — chặn nhầm mà agent không biết tại sao fail thì rất khó debug.

Tình huống 1: nhắc cập nhật CLAUDE.md

Đây là hook đầu tiên mình viết, giải quyết đúng vấn đề “agent sửa code xong quên cập nhật tài liệu”. Logic:

  1. Đọc file_path từ stdin
  2. Xác định file thuộc service nào (backend/identity, frontend/seller…)
  3. Bỏ qua nếu là file config hoặc lock
  4. Gộp trùng theo session (một session chỉ nhắc mỗi service đúng một lần)
  5. In lời nhắc kèm đường dẫn CLAUDE.md cần xem

Bước gộp trùng rất quan trọng — không có nó thì agent edit 10 file trong identity/ sẽ nhận 10 lời nhắc giống hệt nhau, rất nhiễu.

#!/bin/bash
set -euo pipefail
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) || exit 0

[ -z "$FILE_PATH" ] && exit 0
[[ "$FILE_PATH" == */CLAUDE.md ]] && exit 0
[[ "$FILE_PATH" == *.lock ]] && exit 0

# Map to service dir
case "$FILE_PATH" in
  */backend/identity/*)  SERVICE="backend/identity" ;;
  */frontend/seller/*)   SERVICE="frontend/seller" ;;
  *) exit 0 ;;
esac

# Dedup per session
TRACK_FILE="/tmp/claude-md-track-${PPID}"
if [ -f "$TRACK_FILE" ] && grep -qxF "$SERVICE" "$TRACK_FILE"; then
  exit 0
fi
echo "$SERVICE" >> "$TRACK_FILE"

echo "<claude-md-hook>Modified files in ${SERVICE}/. Evaluate updating ${SERVICE}/CLAUDE.md after completing this task.</claude-md-hook>"

Hook này tiết kiệm mình vài chục lần “quên cập nhật tài liệu” mỗi tuần.

Tình huống 2: bắt buộc theo quy ước đặt tên branch

Trước mỗi git commit, kiểm tra tên branch có match ^(feature|fix|chore)/[a-z0-9-]+$ không:

#!/bin/bash
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | python3 -c "
import sys, json; d=json.load(sys.stdin)
print(d.get('tool_input',{}).get('command',''))
")

# Only check git commit
[[ "$COMMAND" != *"git commit"* ]] && exit 0

BRANCH=$(git branch --show-current)
if ! [[ "$BRANCH" =~ ^(feature|fix|chore|hotfix)/[a-z0-9-]+$ ]]; then
  echo "Branch '$BRANCH' không đúng quy ước. Định dạng: feature|fix|chore/kebab-case"
  exit 1   # chặn lệnh commit
fi

Dùng với sự kiện PreToolUse, matcher Bash. Commit trên branch sai → hook exit 1 → Claude Code chặn lệnh, agent thấy lỗi và tự biết phải đổi tên branch trước.

Tình huống 3: tự động đưa ngữ cảnh vào mỗi turn

Với UserPromptSubmit, bạn có thể chèn thêm ngữ cảnh vào đầu mỗi lượt chat. Mình dùng để nạp các commit gần đây vào prompt — agent luôn biết bạn vừa làm gì:

#!/bin/bash
# Chạy ở UserPromptSubmit, in ra ngữ cảnh muốn đưa vào prompt
echo "<recent-commits>"
git log --oneline -5
echo "</recent-commits>"

Đừng lạm dụng — mỗi hook chèn ngữ cảnh là một đoạn cố định, tốn token mỗi lượt chat.

Tình huống 4: chặn secret bị lộ

Hook PreToolUse với matcher Write|Edit, quét nội dung, nếu khớp pattern của secret (AWS key, JWT, private key) thì chặn:

#!/bin/bash
INPUT=$(cat)
NEW=$(echo "$INPUT" | python3 -c "
import sys, json; d=json.load(sys.stdin)
ti=d.get('tool_input',{})
print(ti.get('new_string','') or ti.get('content',''))
")

if echo "$NEW" | grep -qE '(AKIA[0-9A-Z]{16}|-----BEGIN (RSA |EC )?PRIVATE KEY-----)'; then
  echo "Phát hiện có thể là secret trong nội dung. Hủy thao tác write."
  exit 1
fi

Không thay thế được công cụ quét secret chuyên dụng, nhưng cứu được những lần agent copy nhầm từ clipboard.

Nguyên tắc viết hook tốt

Nhanh. Hook chạy đồng bộ. Dưới 500ms là tốt, quá 1 giây là user đã cảm nhận được độ trễ. Tránh gọi API bên ngoài từ trong hook — cache sẵn hoặc đẩy sang chạy nền.

Chạy lại không sinh hậu quả. Hook có thể bị gọi nhiều lần với cùng một đầu vào. Đừng tạo tác dụng phụ kiểu “append log mỗi lần gọi” nếu không có cơ chế gộp trùng.

An toàn khi lỗi. set -euo pipefail ở đầu script. Lỗi không liên quan (mất mạng, lỗi file tạm) → exit 0 (không chặn agent). Chỉ exit khác 0 khi thực sự muốn chặn.

Log debug vào stderr, output cho agent vào stdout. Tách rõ hai kênh — log debug không nên lẫn vào ngữ cảnh của agent.

Phạm vi rõ ràng. Dùng matcher đúng. Hook chạy với tool không liên quan = lãng phí.

Test tay trước khi commit. Chạy hook với input mẫu: echo '{"tool_input":{"file_path":"x.py"}}' | bash hook.sh. Kiểm tra output và mã thoát.

Đừng lạm dụng

Hook mạnh, nhưng mỗi hook là một chỗ logic chạy ngầm mà agent không thấy. Viết nhiều → agent hoang mang khi hành vi trở nên “khó giải thích”. Mình giới hạn 3-5 hook mỗi project, mỗi hook giải quyết đúng một vấn đề đã xảy ra nhiều lần.

Ba lớp kết hợp mình thấy hợp lý nhất:

  • Skill — dạy agent cách làm (viết commit message, mô tả PR).
  • Subagent — chia việc chuyên môn (mình đã viết chi tiết ở đây).
  • Hook — bắt buộc tuân thủ những thứ không được phép bỏ sót (cập nhật tài liệu, chặn secret, quy ước tên branch).

Ba lớp này bổ sung nhau. Thiếu hook, skill chỉ là đề nghị. Thiếu skill, hook chỉ là cảnh sát mà không có luật. Có đủ cả ba, agent hành xử nhất quán và dễ đoán hơn nhiều — không cần nhắc, không lệch quy ước, và mỗi session không phải kể lại từ đầu.

// reactions


cat comments.log


hoatq@dev : ~/blog $