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

Hook có ba cách “nói” lại với Claude Code:

Stdout đơn giản — output gửi lại cho agent như thông báo hệ thống. Quy ước mình dùng: bọc trong tag kiểu XML cho rõ:

echo "<claude-md-hook>Modified files in backend/identity/. Evaluate updating CLAUDE.md after this task.</claude-md-hook>"

JSON output có cấu trúc — kiểm soát được hành vi cụ thể. Đây là cách PreToolUse deny đúng chuẩn (exit code khác 0 cũng chặn nhưng JSON cho lý do rõ hơn):

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "block-secrets.sh: '/path/to/.env' matches sensitive pattern. Use placeholder like {{API_KEY}}."
  }
}

Với UserPromptSubmit, JSON dạng additionalContext chèn ngữ cảnh vào trước prompt:

{
  "hookSpecificOutput": {
    "hookEventName": "UserPromptSubmit",
    "additionalContext": "📍 branch: feature/xyz | 3 uncommitted files | issue #1234"
  }
}

Exit code khác 0 — chặn lời gọi tool (PreToolUse) hoặc hiển thị lỗi (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. Mình ưu tiên JSON permissionDecision: "deny" với permissionDecisionReason chi tiết hơn là exit code thuần.

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 đọc file secret

Hook PreToolUse với matcher Read|Bash, kiểm tra path không phải .env, secrets/, credentials, SSH keys. Khác bài trước, mình dùng JSON deny format thay vì exit code:

#!/usr/bin/env bash
set -euo pipefail
INPUT=$(cat)
TOOL=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('tool_name',''))")
TOOL_INPUT=$(echo "$INPUT" | python3 -c "import sys,json; print(json.dumps(json.load(sys.stdin).get('tool_input',{})))")

REGEX='(^|/)\.env(\.|$)|(^|/)secrets/|credentials\.(json|ya?ml)|\.aws/credentials|id_rsa|\.ssh/'

deny() {
  cat <<EOF
{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "$1"
  }
}
EOF
  exit 0
}

if [[ "$TOOL" == "Read" ]]; then
  P=$(echo "$TOOL_INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('file_path',''))")
  if echo "$P" | grep -qE "$REGEX"; then
    deny "block-secrets: '$P' matches sensitive pattern. Use placeholder {{API_KEY}}."
  fi
fi
exit 0

Khác với chỉ exit 1, JSON output cho agent thấy lý do cụ thể — agent biết phải dùng placeholder thay vì lặp lại Read.

Tình huống 5: format file sau Edit/Write

PostToolUse với matcher Edit|Write|MultiEdit, lấy file_path, chạy formatter tương ứng:

#!/usr/bin/env bash
INPUT=$(cat)
TOOL=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('tool_name',''))")
case "$TOOL" in Edit|Write|MultiEdit) ;; *) exit 0;; esac

FP=$(echo "$INPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('tool_input',{}).get('file_path',''))")
[[ -f "$FP" ]] || exit 0

case "$FP" in
  *.ts|*.tsx|*.js|*.jsx|*.json|*.md)
    [[ -x ./node_modules/.bin/prettier ]] && \
      ./node_modules/.bin/prettier --write --log-level=warn "$FP" >/dev/null 2>&1 || true
    ;;
  *.py)
    command -v ruff >/dev/null && ruff format "$FP" >/dev/null 2>&1 || true
    ;;
esac
exit 0

Mỗi lần agent edit, file tự được format. Agent không cần “nhớ chạy prettier” — hook nhắc tay giùm.

Tình huống 6: inject git context vào mỗi prompt

UserPromptSubmit với JSON additionalContext để chèn branch + dirty state. Đặc biệt hữu ích cho multi-repo project:

#!/usr/bin/env bash
cd "$PROJECT_ROOT" || exit 0
[[ -d .git ]] || exit 0

BRANCH=$(git branch --show-current)
CHANGED=$(git status --porcelain | wc -l | tr -d ' ')
ISSUE=$(echo "$BRANCH" | sed -nE 's|^[^/]+/([0-9]+)/.*|\1|p')

PARTS="📍 branch: $BRANCH"
[[ "$CHANGED" != "0" ]] && PARTS+=" | 📝 $CHANGED uncommitted"
[[ -n "$ISSUE" ]] && PARTS+=" | issue #$ISSUE"

ESCAPED=$(printf '%s' "$PARTS" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read())[1:-1])')
cat <<EOF
{
  "hookSpecificOutput": {
    "hookEventName": "UserPromptSubmit",
    "additionalContext": "$ESCAPED"
  }
}
EOF

Agent biết bạn đang ở đâu mà không phải gọi git status — đặc biệt giá trị khi user nhờ “review my changes” hoặc “commit this”.

Hooks vs statusline — đừng nhầm

Một thứ dễ nhầm với hooks: statusline (statusline.sh). Statusline chạy liên tục ở đáy terminal, hiển thị cho user xem. Còn hooks chạy theo sự kiện và output cho agent đọc (qua context injection).

Nếu bạn muốn user thấy git state liên tục → statusline.sh + wire qua "statusLine" trong settings.json. Nếu bạn muốn agent biết git state khi user gõ prompt → inject-context.sh UserPromptSubmit hook. Hai cái khác nhau, có thể dùng song song (mình dùng cả hai cho multi-repo project).

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”. Project hiện tại mình có 4 hook (block-secrets, format-on-edit, inject-context, stop-summary) — đủ dùng. Không phải tất cả 6 tình huống ở trên đều cần enable cùng lúc; chọn 3-5 cái phù hợp với project bạn nhất.

Trong toàn cảnh, hook chỉ là một lớp trong stack 5 lớp mình tổ chức .claude/:

  • Rule (rules/) — quy ước, single source of truth (branch strategy, security baseline, coding guide).
  • Skill (skills/) — dạy agent cách làm một procedure (SKILL.md anatomy).
  • Command (commands/) — slash command user gõ thẳng (/commit, /pr).
  • Subagent (agents/) — role/persona có context riêng (subagent design).
  • Hook (hooks/) — bắt buộc tuân thủ những thứ không được phép bỏ sót (chặn secret, format code, inject context).

Năm 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. Thiếu rules, skill và agent copy-paste nội dung lẫn nhau. Có đủ cả năm, 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 $