Hooks — biến quy ước thành luật
Script chạy tự động ở những điểm xác định trong phiên. Skill là lời khuyên; hook là luật. Kèm 6 hook thật, đã chạy thử.
Skill là lời khuyên — model có thể nói vòng qua nó. Hook là luật — script chạy bất kể model đang nhớ gì, còn bao nhiêu token, hay có “đồng ý” hay không.
Đó là toàn bộ lý do hook tồn tại.
Hook sống ở đâu
Bốn nơi, ưu tiên từ trên xuống:
| File | Phạm vi |
|---|---|
| Managed policy settings | Toàn tổ chức |
~/.claude/settings.json |
Mọi project của bạn |
.claude/settings.json |
Một project — commit vào git |
.claude/settings.local.json |
Một project, chỉ máy bạn — git bỏ qua |
Hook nói chuyện với Claude thế nào
Vào: JSON qua stdin. Lấy đường dẫn file bằng jq -r '.tool_input.file_path'.
Ra: JSON qua stdout, và chỉ khi exit 0.
Đây là chỗ hầu hết ví dụ trên mạng sai:
exit 1không chặn gì cả. Tài liệu ghi rõ: “Claude Code treats exit code 1 as a non-blocking error and proceeds with the action, even though 1 is the conventional Unix failure code.”
exit 2 có chặn — nhưng khi đó “Claude Code ignores stdout and any JSON in it”, chỉ đọc stderr. Nghĩa là bạn mất luôn thông điệp có cấu trúc.
Nên cách đúng để chặn một PreToolUse là: in JSON ra stdout rồi exit 0.
{"hookSpecificOutput":{
"hookEventName":"PreToolUse",
"permissionDecision":"deny",
"permissionDecisionReason":"Lý do, Claude sẽ đọc câu này"
}}
⚠️ Lưu ý: mỗi sự kiện có tên trường riêng.
PreToolUsedùngpermissionDecision.PostToolUsedùngadditionalContext.UserPromptSubmitdùngdecision: "block". Không có trường nào tênblockhayfeedback— nếu bạn thấy chúng trong một bài blog, ví dụ đó không chạy.
Sáu hook thật
Tất cả nằm trong object "hooks" của .claude/settings.json. Mỗi hook dưới đây đã được chạy thử.
1. Chặn sửa file khi đang ở nhánh main
Claude hay refactor thẳng trên main thay vì tách nhánh.
"PreToolUse": [{
"matcher": "Edit|MultiEdit|Write",
"timeout": 5,
"statusMessage": "Kiểm tra nhánh git…",
"hooks": [{
"type": "command",
"command": "b=$(git branch --show-current 2>/dev/null); if [ \"$b\" = \"main\" ] || [ \"$b\" = \"master\" ]; then printf '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"Đang ở nhánh %s. Tạo feature branch trước.\"}}\\n' \"$b\"; fi; exit 0"
}]
}]
💡 Tip:
matcherlà regex trên tên tool, không neo hai đầu.Edit|MultiEdit|Writekhớp cả ba.
2. Chặn đọc/ghi secret
"PreToolUse": [{
"matcher": "Read|Write|Edit",
"timeout": 3,
"hooks": [{
"type": "command",
"command": "p=$(jq -r '.tool_input.file_path // empty'); if printf '%s' \"$p\" | grep -qE '(\\.env|\\.ssh/|aws/credentials)'; then printf '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"Chặn truy cập secret: %s\"}}\\n' \"$p\"; fi; exit 0"
}]
}]
Không khớp thì hook im lặng và exit 0 — Claude làm việc bình thường.
3. Tự format sau khi ghi file
"PostToolUse": [{
"matcher": "Edit|MultiEdit|Write",
"timeout": 15,
"statusMessage": "Đang format…",
"hooks": [{
"type": "command",
"command": "p=$(jq -r '.tool_input.file_path // empty'); case \"$p\" in *.js|*.jsx|*.ts|*.tsx) [ -f \"$p\" ] || exit 0; npx prettier --write \"$p\" >/dev/null 2>&1 && printf '{\"hookSpecificOutput\":{\"hookEventName\":\"PostToolUse\",\"additionalContext\":\"Đã format %s bằng Prettier.\"}}\\n' \"$p\" ;; esac; exit 0"
}]
}]
4. Trả lỗi TypeScript ngược vào vòng lặp của Claude
Claude viết code chạy được nhưng sai type. tsc bắt được, và additionalContext đẩy lỗi thẳng vào lượt sau.
"PostToolUse": [{
"matcher": "Edit|MultiEdit|Write",
"timeout": 30,
"statusMessage": "Kiểm tra type…",
"hooks": [{
"type": "command",
"command": "p=$(jq -r '.tool_input.file_path // empty'); case \"$p\" in *.ts|*.tsx) ;; *) exit 0 ;; esac; out=$(npx tsc --noEmit 2>&1) || jq -cn --arg e \"$(printf '%s' \"$out\" | head -n 10)\" '{hookSpecificOutput:{hookEventName:\"PostToolUse\",additionalContext:(\"tsc báo lỗi type:\\n\"+$e)}}'; exit 0"
}]
}]
💡 Tip:
PostToolUsekhông chặn được. Nó chỉ đưa thông tin về. Muốn chặn thì phải làm ởPreToolUse.
5. Nhắc lại luật repo sau mỗi lần nén context
Phiên dài, Claude nén lịch sử, và bản tóm tắt hay quên mất CLAUDE.md.
"PostCompact": [{
"timeout": 5,
"hooks": [{
"type": "command",
"command": "[ -f CLAUDE.md ] || exit 0; jq -cn --arg r \"$(head -n 50 CLAUDE.md)\" '{hookSpecificOutput:{hookEventName:\"PostCompact\",additionalContext:(\"Context vừa nén. Nhắc lại luật repo:\\n\"+$r)}}'"
}]
}]
6. Ping desktop khi Claude cần bạn
"Notification": [{
"hooks": [{
"type": "command",
"command": "osascript -e 'display notification \"Claude Code cần bạn trả lời.\" with title \"Claude Code\"'"
}]
}]
Windows: đổi sang
powershell.exe -Command "[System.Reflection.Assembly]::LoadWithPartialName('System.Windows.Forms'); [System.Windows.Forms.MessageBox]::Show('Claude Code cần bạn trả lời.','Claude Code')"
Sự kiện nào chặn được
Hai sự kiện chặn được.
Tám cái còn lại chỉ nhìn.
Nhầm chỗ này là hook của bạn chạy, in JSON, rồi để Claude làm điều bạn vừa cấm. Bảng dưới đây phân biệt rõ.
Chặn được
2Chỉ hai sự kiện này ngăn được một hành động. Mọi thứ khác chỉ quan sát.
PreToolUse
matcher: regex tên tool
Chạy ngay trước một tool. Chặn bằng permissionDecision: "deny" trên stdout, exit 0.
Đây là nơi khoá file, chặn secret, cấm lệnh nguy hiểm.
UserPromptSubmit
Chạy khi bạn bấm Enter. Chặn bằng decision: "block".
Cũng dùng để tự chèn luật repo vào mỗi prompt.
Không chặn được — chỉ quan sát hoặc bổ sung
8PostToolUse
Sau khi tool chạy xong. Trả thông tin về bằng additionalContext.
Format code, lint, chạy test, đẩy lỗi type ngược vào vòng lặp.
SessionStart
Lúc mở phiên hoặc --resume. Nạp env, chèn git log gần đây.
SessionEnd
Lúc đóng phiên.
PreCompact
Ngay trước khi nén lịch sử.
PostCompact
Ngay sau khi nén. Chỗ tốt nhất để nhắc lại CLAUDE.md.
Notification
Khi Claude cần bạn duyệt hoặc trả lời. Ping desktop, Slack, Discord.
Stop
Khi Claude kết thúc một lượt.
SubagentStop
Khi một subagent kết thúc.
Trường chung của mọi hook
3timeout
số giây
Quá hạn thì hook bị huỷ.
statusMessage
chuỗi
Dòng chữ hiện cạnh spinner trong lúc hook chạy.
matcher
regex
Lọc theo tên tool. Không neo hai đầu — Bash khớp Bash, mcp__.* khớp mọi MCP tool.
Ba lỗi hay gặp
Dùng exit 1 để chặn. Không chặn. Claude vẫn làm. Đây là lỗi phổ biến nhất, vì 1 là mã lỗi quen thuộc của Unix.
Trả {"block": true} hoặc {"feedback": "…"}. Không có hai trường này. PreToolUse dùng permissionDecision, PostToolUse dùng additionalContext, UserPromptSubmit dùng decision.
Đặt hook chặn ở PostToolUse. Lúc đó tool đã chạy xong rồi. Muốn ngăn thì phải ở PreToolUse.
💡 Tip: hook chỉ nạp lúc mở phiên. Sửa
settings.jsonxong phải khởi động lại Claude Code, nếu không bạn đang chạy phiên bản cũ mà tưởng đã cập nhật.