Bạn muốn CC gửi thông báo khi nó xong việc. Bạn muốn mọi worktree agent tạo ra đều base trên HEAD của bạn chứ không phải origin/main. Bạn muốn hard-block một số command nguy hiểm trước khi nó kịp chạy. Đó là ba bài toán khác nhau nhưng có chung một giải pháp: hook.
Hook không phải skill, không phải agent, không phải entry trong CLAUDE.md. Hook là một shell script (hoặc bất kỳ executable nào) chạy khi một lifecycle event xảy ra trong CC. Nó sống ngoài model loop hoàn toàn, model không cần invoke, không cần biết nó tồn tại. Đây là hard automation layer của CC.
Bài này đi từ cơ chế đến ví dụ thực tế. Sau bài này bạn hiểu được tại sao hook và skill giải quyết hai lớp vấn đề khác nhau, và khi nào nên chọn cái nào.
Hook là gì và khác skill ở đâu
Skill là markdown instruction nhét vào model prompt. Khi bạn gọi /skill-name, CC load file SKILL.md vào context và model đọc instruction đó để hành động. Toàn bộ quy trình đi qua model.
Hook không đi qua model. Nó là một process ngoài, chạy bởi binary CC khi một event nhất định xảy ra trong lifecycle. Model không biết hook tồn tại. Hook không thể hỏi model, không thể đọc conversation. Nó chỉ nhận một JSON payload qua stdin, xử lý, exit.
Skill: User trigger → model reads SKILL.md → model acts
Hook: Lifecycle event fires → binary runs shell script → exit code → CC proceeds/blocks
Cả hai đều là cơ chế mở rộng CC nhưng ở hai tầng khác nhau. Skill mở rộng hành vi của model. Hook mở rộng hành vi của binary.
Lifecycle events chính
CC định nghĩa một số event mà hook có thể wire vào:
| Event | Khi nào fire | Ghi chú |
|---|---|---|
SessionStart | Session bắt đầu | Chạy một lần per session |
UserPromptSubmit | User gửi message | Trước khi prompt vào model |
PreToolUse | Ngay trước khi binary chạy tool | Có thể block tool |
PostToolUse | Ngay sau khi tool chạy xong | Audit, log |
Stop | CC kết thúc một turn | Notification, cleanup |
WorktreeCreate | Binary tạo agent worktree | Override worktree path và base ref |
PreToolUse và WorktreeCreate là hai event có khả năng block: exit code non-zero từ hook sẽ làm binary dừng lại thay vì tiếp tục. Các event còn lại thiên về observe và notify hơn là control.
Wire hook vào settings.json
Khai báo hook trong ~/.claude/settings.json, phần "hooks":
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "$HOME/.claude/hooks/stop-notify-telegram.sh",
"timeout": 10
}
]
}
],
"WorktreeCreate": [
{
"hooks": [
{
"type": "command",
"command": "$HOME/.claude/hooks/worktree-create.sh",
"timeout": 30
}
]
}
]
}
Cấu trúc: event name (key) ánh xạ tới array matcher objects, mỗi matcher chứa array hooks thực sự. Mỗi hook entry cần type (hiện tại chỉ có "command") và command (path tuyệt đối đến script). timeout tính bằng giây, sau đó binary kill process.
$HOME được expand, nên không cần hardcode path tuyệt đối kiểu /Users/yourname/....
Exit code semantics
Đây là phần quan trọng nhất để tránh bug khó debug.
| Event | Exit 0 | Exit non-zero |
|---|---|---|
PreToolUse | CC tiếp tục chạy tool | CC block tool, báo lỗi cho user |
WorktreeCreate | CC dùng stdout làm worktree path | CC abort tạo worktree |
Stop | Turn kết thúc bình thường | Logged nhưng không block gì |
PostToolUse | Tiếp tục | Logged |
SessionStart, UserPromptSubmit | Tiếp tục | Có thể block (tùy version) |
Đối với hook ở Stop hay PostToolUse, non-zero exit không block gì nhiều, chỉ được log. Nhưng với PreToolUse, non-zero exit là cách block tool execution. Đây là nền tảng của pattern “audit gate”: hook đọc tool name và args, quyết định có cho chạy không.
Quy tắc thực hành: hook không block không nên panic hay exit non-zero khi gặp lỗi nhỏ. Dùng || true cho side-effect operations (network call, file write) để tránh vô tình block flow.
Hook nhận dữ liệu từ stdin
Mỗi khi binary fire một event, nó gửi JSON payload vào stdin của process hook. Hook đọc stdin, parse, quyết định.
Payload khác nhau theo event. Stop event có dạng:
{
"session_id": "abc12345...",
"agent_id": "",
"cwd": "/Users/you/project",
"last_assistant_message": "Done. I've updated the three files..."
}
PreToolUse event có dạng:
{
"session_id": "abc12345...",
"tool_name": "Bash",
"tool_input": {
"command": "rm -rf /tmp/old-build"
}
}
Hook parse stdin bằng jq hoặc bất kỳ parser nào. Script càng nhẹ thì càng tốt vì nó block turn đến khi exit (trừ khi bạn fork nó sang background).
Ví dụ 1: WorktreeCreate hook
Bài toán: CC binary mặc định tạo agent worktree dựa trên origin/<default-branch>. Nếu bạn đang làm việc trên branch feature/xyz và spawn agent, worktree của agent base trên main, không phải feature/xyz. Merge trả kết quả về sẽ overwrite code đang làm dở.
Hook worktree-create.sh giải quyết bằng cách override hoàn toàn quá trình tạo worktree:
INPUT=$(cat)
NAME=$(printf '%s' "$INPUT" | jq -r '.name')
CWD=$(printf '%s' "$INPUT" | jq -r '.cwd')
# ... validate NAME và CWD ...
GIT_TOPLEVEL=$(git rev-parse --show-toplevel)
WT_DIR="${GIT_TOPLEVEL}/.claude/worktrees/${NAME}"
BRANCH="worktree/${NAME}"
# Base branch = HEAD (không phải origin/main)
git -C "$GIT_TOPLEVEL" worktree add --no-track -B "$BRANCH" "$WT_DIR" HEAD >&2
printf '%s\n' "$WT_DIR"
Ba điểm quan trọng trong đoạn này:
- Hook đọc stdin, lấy
namevàcwdtừ JSON payload. - Worktree được tạo tại
.claude/worktrees/<name>/(gitignored), không phải chỗ mặc định của binary. - Branch tạo với
--no-trackvà base làHEAD(current branch của parent session, không phải remote default).
Khi hook exit 0 và print một path ra stdout, binary dùng path đó làm worktree location thay vì tự tạo. Đây là contract: stdout của hook trở thành giá trị trả về cho binary.
Script có check idempotent ở trên: nếu worktree đã tồn tại (re-spawn cùng agent) thì print path và exit 0 luôn, không tạo lại.
Ví dụ 2: Stop hook cho notification
Bài toán: khi CC xong một turn (nhất là khi agent chạy background mất 5-10 phút), muốn nhận thông báo để biết đã xong.
Hook stop-notify-telegram.sh đọc payload, lọc noise, gửi notification:
INPUT=$(cat)
LAST_MSG=$(printf '%s' "$INPUT" | jq -r '.last_assistant_message // ""')
CWD=$(printf '%s' "$INPUT" | jq -r '.cwd // ""')
AGENT_ID=$(printf '%s' "$INPUT" | jq -r '.agent_id // ""')
# Bỏ qua subagent stop, chỉ notify team-lead
[ -n "$AGENT_ID" ] && exit 0
# Fork sang background, không block turn
( bash "$SCRIPT" send-by-name "status" "$TEXT" >/dev/null 2>&1 || true ) &
disown
exit 0
Ba pattern đáng chú ý:
- Filter bằng payload field:
agent_idkhông rỗng nghĩa là stop đến từ subagent. Hook bỏ qua, tránh notification flood khi có 5 subagent song song. - Async với
&+disown: network call (gửi Telegram message) được fork sang background, hook exit ngay. Turn không bị block chờ network. || truetrên network call: nếu gửi thất bại, không exit non-zero. Hook này ởStopevent, non-zero không block gì, nhưng best practice vẫn là không silently error vì log sẽ noisy.
Anti-pattern: hook nặng trên event thường xuyên
PreToolUse fire mỗi lần model gọi tool. Một session phức tạp có thể có 50-100 tool call. Nếu hook ở PreToolUse làm network call (HTTP, DB query, DNS lookup), mỗi tool call thêm vào 50-500ms. Session 100 tool call thêm 5-50 giây tổng cộng. User cảm nhận được.
PreToolUse hook với HTTP call:
100 tool calls × 200ms = 20 giây overhead per session
Session cảm giác lag, model "chậm"
Nguyên tắc: hook ở PreToolUse và PostToolUse phải nhanh (dưới 10ms lý tưởng). File I/O nhanh được, network call không được. Nếu cần audit log phức tạp, ghi vào local file trong hook và ship log đó sang remote bằng một process ngoài (cron job, log shipper).
Stop hook thì thoải mái hơn vì fire một lần per turn. Network call ở đây OK, nhưng vẫn nên async (fork + disown) để không block user nhìn thấy kết quả.
Tổng hợp: khi nào dùng hook thay vì skill/rule
| Bạn muốn… | Chọn gì |
|---|---|
| Model hành động theo cách nhất định khi viết code | CLAUDE.md / rules (L2) |
| Wrap workflow thành slash command | Skill |
| Chạy shell script khi event lifecycle xảy ra, không qua model | Hook |
| Block tool call nguy hiểm trước khi chạy | Hook ở PreToolUse |
| Override cách binary tạo worktree | Hook ở WorktreeCreate |
| Gửi notification khi CC xong turn | Hook ở Stop |
Nhìn qua bảng thấy rõ: hook không thay thế skill hay rule. Chúng bổ sung nhau. Skill là “dạy model làm gì”. Hook là “can thiệp binary khi event xảy ra, không cần hỏi model”.
Tóm tắt và bài tiếp theo
- Hook là shell script wired vào lifecycle event qua
settings.json. Binary chạy hook ngoài model loop, model không biết. - Các event chính:
SessionStart,UserPromptSubmit,PreToolUse,PostToolUse,Stop,WorktreeCreate.PreToolUsevàWorktreeCreatecó thể block nếu hook exit non-zero. - Hook nhận JSON payload qua stdin (session info, tool name, last message tùy event). Hook in stdout để trả giá trị về binary (dùng bởi
WorktreeCreate). - Anti-pattern lớn nhất: hook nặng trên
PreToolUse. Phải nhanh, không network call trực tiếp. - Pattern tốt: filter noise bằng payload field, async network call,
|| truecho side-effect.
Bài 10 đi vào settings.json tổng thể: ngoài hooks, còn có permissions, env, plugins, worktree, và các toggle ảnh hưởng đến toàn bộ hành vi CC. Hook là một phần trong bức tranh đó, nhưng settings.json còn nhiều hơn thế.
Nếu bạn muốn đi sâu hơn vào hook production-grade (versioning, idempotency, error handling, cross-platform), bài 25 sẽ cover toàn bộ pattern đó.
Bài thuộc series Claude Code từ zero. Series plan tại bài giới thiệu.