Mục tiêu học tập

Sau bài này, bạn sẽ có thể:
  • ✅ 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

Mở đầu: “Claude quên” không còn là lý do hợp lý nữa

Một dev backend ở startup fintech đã viết vào CLAUDE.md:
“Sau mỗi lần edit file TypeScript, hãy chạy Prettier để format code.”
Đơn giản, rõ ràng. Trong 2 tuần đầu, Claude làm đúng khoảng 80% thời gian. Đủ tốt để bỏ qua. Cho đến buổi chiều thứ Sáu trước ngày release. CI pipeline báo đỏ: 47 file lỗi format. Dev phải ngồi lại chạy Prettier tay toàn bộ thư mục src/. Mất 40 phút. Sprint bị trễ. Vấn đề không phải Claude “lười”. Vấn đề là CLAUDE.md là probabilistic — model đọc rồi quyết định có thực hiện hay không, dựa trên ngữ cảnh hiện tại, độ dài cuộc hội thoại, và hàng chục yếu tố khác. 80% tốt là một con số tốt trong ML. Nhưng trong production workflow, 80% nghĩa là 1 trong 5 lần bạn sẽ bị bất ngờ. Hook giải quyết chính xác vấn đề này. Khi bạn đặt logic vào hook, Claude không còn là người quyết định nữa. Hệ thống Claude Code chạy script của bạn tại event được chỉ định — mà không cần model “nhớ” hay “quyết định”. 100% mỗi lần, không ngoại lệ.
“If something needs to happen every time without fail, don’t put it in a prompt. Put it in a hook.” — Hooks documentation, Anthropic
Đây là bài học bạn sẽ nhớ mãi sau khi đọc xong bài này.

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:
Cơ chếTính chấtAi quyết định chạy?
HookDeterministic — luôn chạyHệ thống Claude Code (không phải model)
CLAUDE.mdProbabilisticModel đọc, model quyết định
SkillProbabilisticModel nhận request, model quyết định invoke
MCPAvailable-when-neededModel quyết định gọi tool
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

5 Hook Events

EventChạy khi nàoCó thể block?Typical use caseCần matcher?
PreToolUseTrước khi tool call thực thiCó (exit 2)Block write vào prod file, block rm -rf, validate inputKhuyến nghị
PostToolUseSau khi tool call hoàn thànhKhôngAuto-format code, chạy lint, ghi audit log, trigger testKhuyến nghị
UserPromptSubmitKhi user submit prompt, trước khi Claude xử lýKhông trực tiếpLog prompt, inject thêm context, validate prompt formatKhông bắt buộc
StopKhi Claude hoàn thành responseKhôngGửi Slack notification, cleanup temp files, trigger deployKhông bắt buộc
NotificationKhi Claude gửi notificationKhôngCustom notification routing, macOS say, Slack pingKhông bắt buộc
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.

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

{
  "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"
          }
        ]
      }
    ]
  }
}

Giải thích từng thành phần

  • 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

Script nhận gì từ Claude Code?

Script của bạn nhận JSON qua stdin chứa thông tin về event:
{
  "tool_name": "Edit",
  "tool_input": {
    "file_path": "/path/to/file.ts",
    "old_string": "const x = 1",
    "new_string": "const x = 2"
  }
}
Script output ra stdout/stderr, và exit code là tín hiệu quan trọng nhất.

Exit Codes và Behavior

Exit codeBehaviorUse caseVí dụ
0Proceed normally — tool call được allow hoặc PostToolUse tiếp tụcMọi trường hợp bình thườngScript chạy Prettier thành công
2Block tool call (chỉ valid cho PreToolUse). stderr message được feed back cho Claude như feedbackEnforce hard rulesBlock write vào infra/prod/*
Khác (1, 3, …)Non-blocking error — hiển thị cho user nhưng không dừng ClaudeScript lỗi nhưng không muốn blockPrettier không tìm thấy, bỏ qua
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.

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:
#!/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 0

Bước 2: Cấp quyền thực thi

chmod +x .claude/hooks/format-on-edit.sh

Bước 3: Thêm vào settings.json

{
  "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

git add .claude/hooks/format-on-edit.sh .claude/settings.json
git commit -m "chore: add auto-format hook for Claude Code"
Toàn bộ team clone repo về sẽ tự động có hook này.

Bước 5: Test

> Sửa file src/components/Button.tsx — đổi className "btn" thành "button"
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.

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ì?

{
  "tool_name": "Bash",
  "tool_input": {
    "command": "rm -rf /tmp/old-build"
  }
}

Ví dụ: Block lệnh Bash nguy hiểm

Tạo .claude/hooks/block-dangerous-commands.sh:
#!/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 0
Config trong settings.json:
{
  "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"
          }
        ]
      }
    ]
  }
}
Khi Claude cố chạy rm -rf /tmp/cache:
[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:
Tiêu chíHookSkillCLAUDE.mdMCP
Tính chấtDeterministic — luôn chạyProbabilistic — model quyết địnhProbabilistic — model đọc, có thể quênAvailable-when-needed
TriggerEvent trong lifecycleModel match request với skill nameLoad lúc session start, model đọcModel quyết định gọi tool
VisibilityScript ngoài contextLoad vào context khi matchLuôn trong contextTool definition trong context
Best forGuarantees — phải xảy ra 100%Patterns — workflow tái sử dụngProject-wide rules — conventions, commandsExternal 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
OverheadChạy script ngoàiLoad markdown vào contextLuôn có trong contextTool defs trong context
Quy tắc quyết định đơn giả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
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.

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.
# .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 0

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/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 0

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/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 0

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/say-done.sh
say "Claude xong rồi, về review đi bạn ơi" &
exit 0

Anti-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

// 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"
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)

// 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" }
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.

Mẹo nâng cao

Dùng CLAUDE_PROJECT_DIR cho mọi path reference

# Trong script
LOG_FILE="$CLAUDE_PROJECT_DIR/logs/claude-audit.log"
CONFIG="$CLAUDE_PROJECT_DIR/.env.local"
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

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

# Tạo mock input
echo '{"tool_name":"Edit","tool_input":{"file_path":"/tmp/test.ts"}}' \
  | .claude/hooks/format-on-edit.sh
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:
# Chỉ format nếu file trong src/ (không format test fixtures)
if [[ "$FILE_PATH" == */src/* ]]; then
  prettier --write "$FILE_PATH"
fi

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

{
  "matcher": "Edit|MultiEdit|Write",
  "hooks": [...]
}
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).

Á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:
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|MultiEdit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/format-on-edit.sh"
          }
        ]
      }
    ]
  }
}
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:
  • Block rm -rf trong Bash commands
  • Block write vào .env files
  • Block edit files trong infra/prod/
  • Block git push —force
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

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:
  • 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”
Quote đáng nhớ:
“If something needs to happen every time without fail, don’t put it in a prompt. Put it in a hook.”

Bài tiếp theo

Bạn đã nắm toàn bộ hệ sinh thái mở rộng của Claude Code: CLAUDE.md, Skills, MCP, và Hooks. Bài 2.12 sẽ là quiz tổng hợp — kiểm tra lại toàn bộ kiến thức từ Bài 2.1 đến 2.11, với câu hỏi thực tế về cách chọn đúng công cụ trong từng tình huống. Cross-reference: Bài 2.4 (hook trong EPCC workflow) — Bài 2.6 (hook auto-run lint trước commit) — Bài 2.7 (hook vs CLAUDE.md khi nào dùng cái nào) — Bài 2.9 (hook vs skill — deterministic vs probabilistic) ➡️ Bài tiếp theo: Bài 2.12 — Quiz tổng hợp Claude Code 101

Tài liệu tham khảo

Khoá học “Claude Code 101” — bản tiếng Việt v1.0Bản quyền 2026 Anthropic. Mọi quyền được bảo lưu.