Skill và hook đều mở rộng Claude Code, nhưng theo hai cơ chế khác nhau về bản chất. Skill là markdown nhét vào L2 của prompt: model đọc, model có thể làm theo hoặc diễn giải lại. Hook là shell script chạy ngoài vòng lặp model: binary gọi script, script trả exit code, binary quyết định tiếp tục hay block. Model không can thiệp được.
Hệ quả là hook phù hợp cho những hành vi bạn muốn ép buộc, không phải gợi ý. Ví dụ: mỗi khi agent stop, tự động ghi log ra file. Mỗi khi một tool call sắp chạy, kiểm tra điều kiện nào đó và block nếu cần. Những việc này không thể làm bằng skill vì skill chỉ hướng dẫn model, và model có thể quên hoặc diễn giải sai.
Bài này walkthrough 8 bước để viết một hook đúng cách, kèm ví dụ script cụ thể. Đây cũng là bài cuối của series.
Hook vs skill: khi nào chọn cái nào
Câu hỏi thực tế: “tôi muốn CC luôn làm X, dùng skill hay hook?”
| Tiêu chí | Skill | Hook |
|---|---|---|
| Model có thể bỏ qua không? | Có | Không |
| Chạy ở đâu trong luồng? | Trong prompt (L2) | Ngoài model loop |
| Cần shell/script? | Không, markdown đủ | Có, shell script |
| Force behavior? | Không đảm bảo | Có |
| Đọc dữ liệu runtime (stdin JSON)? | Không | Có |
| Phức tạp hóa prompt không? | Có (tốn token) | Không |
Quy tắc thực dụng: nếu “quên làm X” là không chấp nhận được, dùng hook. Nếu chỉ cần nhắc model làm theo pattern nào đó, skill là đủ.
Bước 0: chọn lifecycle event
Claude Code expose các event sau qua hooks:
| Event | Khi nào fire | Use case điển hình |
|---|---|---|
SessionStart | Đầu mỗi session | Setup môi trường, khởi tạo log |
PreToolUse | Ngay trước khi tool chạy | Gate, validation, block tool call |
PostToolUse | Sau khi tool chạy | Audit log, side effect |
Stop | Khi model kết thúc turn | Notify, cleanup, ghi metrics |
WorktreeCreate | Khi agent yêu cầu tạo worktree | Cấu hình worktree path, branch name |
Một số lưu ý về semantic:
PreToolUselà event duy nhất cho phép block tool call bằng non-zero exit code. Các event khác: non-zero exit code chỉ tạo warning, lifecycle vẫn tiếp tục.WorktreeCreatelà trường hợp đặc biệt: khi hook có mặt, binary nhường quyền kiểm soát hoàn toàn cho hook (không còn dùng default worktree logic).SessionStartfire mỗi session, không phải mỗi turn. Dùng cho 1-time setup.
Bước 1: đặt tên file rõ
Convention: <event>-<action>.sh. Ví dụ:
stop-log-duration.shpretooluse-block-s3-delete.shworktree-create.shsession-start-load-env.sh
Tên rõ giúp bạn đọc lại settings.json sau 3 tháng mà không cần mở từng script để nhớ nó làm gì.
Shebang: #!/usr/bin/env bash cho script không cần feature zsh-specific. Dùng #!/usr/bin/env zsh chỉ khi script thực sự cần zsh feature (ví dụ array 1-based index, glob qualifier (N)).
Bước 2: parse input từ stdin
Hook nhận dữ liệu từ binary qua stdin dưới dạng JSON. Đọc stdin vào biến trước, parse bằng jq. Không dùng regex hack.
#!/usr/bin/env bash
set -euo pipefail
INPUT=$(cat) # đọc toàn bộ stdin
# parse fields cần thiết
TOOL_NAME=$(printf '%s' "$INPUT" | jq -r '.tool_name // ""')
CWD=$(printf '%s' "$INPUT" | jq -r '.cwd // ""')
Dùng // "" để fallback về chuỗi rỗng khi field không có, thay vì nhận null rồi script lỗi ở bước sau.
Schema JSON mỗi event khác nhau. Xem docs hoặc log INPUT ra stderr lần đầu để biết binary truyền gì.
Bước 3: validate và exit sớm
Hook của bạn có thể wire cho nhiều event cùng lúc (hoặc cùng event nhưng nhiều tool name). Exit 0 sớm khi event không liên quan, thay vì để script chạy hết rồi không làm gì.
# Chỉ xử lý tool "Bash", bỏ qua tool khác
if [ "$TOOL_NAME" != "Bash" ]; then
exit 0
fi
Nguyên tắc: fail fast, fail cheap. Validate input ngay sau khi parse, trước khi bất kỳ side effect nào xảy ra.
Bước 4: exit code đúng
Đây là phần quan trọng nhất để hook hoạt động đúng ý.
| Exit code | Ý nghĩa | Áp dụng cho event |
|---|---|---|
0 | OK, lifecycle tiếp tục bình thường | Mọi event |
1-255 (PreToolUse) | Block tool call, không chạy tool | Chỉ PreToolUse |
1-255 (event khác) | Warning, nhưng lifecycle vẫn tiếp tục | Stop, PostToolUse, … |
Khi block từ PreToolUse, binary đọc stdout của script làm lý do block. Ghi message rõ ràng:
# Trong PreToolUse hook khi muốn block
echo "Blocked: s3 delete commands are not allowed in production" >&1
exit 1
Với các event không phải PreToolUse, script có thể exit non-zero để signal lỗi nhưng session vẫn tiếp tục. Dùng điều này để log lỗi mà không làm gián đoạn workflow.
Bước 5: idempotent
Hook có thể fire nhiều lần cho cùng một hành động (retry, session resume, agent respawn). Mọi side effect phải an toàn khi lặp lại.
Ví dụ ghi log: đừng overwrite, dùng append (>>). Ví dụ tạo file config: kiểm tra file đã tồn tại chưa trước khi tạo.
# Idempotent: chỉ ghi nếu chưa có
LOG_FILE="/tmp/cc-sessions.log"
SESSION_ID=$(printf '%s' "$INPUT" | jq -r '.session_id // "unknown"')
# Append an toàn, ghi nhiều lần cũng không phá
printf '%s\t%s\n' "$(date -Iseconds)" "$SESSION_ID" >> "$LOG_FILE"
Pattern tạo worktree cũng dùng idempotency: kiểm tra WT_DIR/.git đã tồn tại chưa, nếu có thì exit 0 ngay (reuse) thay vì tạo lại.
Bước 6: log ra stderr, không phải stdout
Binary của CC capture stdout của hook cho mục đích riêng (ví dụ lý do block với PreToolUse, hoặc worktree path với WorktreeCreate). Nếu script ghi debug info ra stdout, bạn sẽ làm lộn pipeline.
Quy tắc cứng: mọi diagnostic, debug, informational message đều ra stderr.
echo "Hook started: processing Stop event for session $SESSION_ID" >&2
echo "Worktree created at: $WT_DIR" >&2 # debug info
printf '%s\n' "$WT_DIR" # stdout: path trả về cho binary
Khi debug, bạn có thể xem stderr trong CC output hoặc log file mà không làm hỏng stdout pipeline.
Bước 7: handle timeout
Hook chậm làm cả session chậm. Binary chờ hook hoàn thành trước khi tiếp tục lifecycle. Nếu hook có network call (Telegram, Slack, webhook), bạn phải cẩn thận.
Hai cách xử lý:
Cách 1: cap timeout bằng timeout / gtimeout
TIMEOUT_BIN=$(command -v gtimeout || command -v timeout || true)
if [ -n "$TIMEOUT_BIN" ]; then
"$TIMEOUT_BIN" 5 curl -s "$WEBHOOK_URL" -d "$PAYLOAD" || true
fi
Cách 2: chạy async, không block
# Chạy background, disown để không block lifecycle
( some_slow_command >/dev/null 2>&1 || true ) &
disown
exit 0
Async phù hợp cho Stop và PostToolUse (audit, notify) vì kết quả của chúng không ảnh hưởng đến quyết định tiếp theo của binary. Không dùng async cho PreToolUse vì bạn cần kết quả trước khi binary quyết định chạy tool hay không.
Bước 8: wire vào settings.json
Đặt script vào ~/.claude/hooks/ (cùng chỗ với các hook khác để dễ quản lý). Dùng đường dẫn tuyệt đối trong settings.json:
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "~/.claude/hooks/stop-log-duration.sh"
}
]
}
],
"WorktreeCreate": [
{
"hooks": [
{
"type": "command",
"command": "~/.claude/hooks/worktree-create.sh"
}
]
}
]
}
}
Mỗi event nhận một array, mỗi phần tử là một matcher object có thể filter theo điều kiện (ví dụ chỉ trigger với tool name cụ thể). Xem bài 9 để biết chi tiết về matcher syntax (bài 9).
Test: trigger event thủ công. Với Stop, chỉ cần kết thúc một turn. Với PreToolUse, thực thi bất kỳ tool nào trong session. Xem stderr output trong CC log để verify hook đã chạy.
Ví dụ cụ thể: Stop hook ghi log session
Đây là hook đầy đủ ghi thời lượng và thông tin session vào một log file. Khoảng 25 dòng, một trách nhiệm duy nhất, không dependency bên ngoài ngoài jq.
#!/usr/bin/env bash
# stop-log-session.sh
# Stop hook: ghi thông tin turn vào log file.
# Verified against Claude Code v2.1.x Stop hook schema.
set -euo pipefail
INPUT=$(cat)
SESSION_ID=$(printf '%s' "$INPUT" | jq -r '.session_id // "unknown"')
CWD=$(printf '%s' "$INPUT" | jq -r '.cwd // ""')
AGENT_ID=$(printf '%s' "$INPUT" | jq -r '.agent_id // ""')
# Bỏ qua subagent stop; chỉ log turn của main session
if [ -n "$AGENT_ID" ]; then
exit 0
fi
PROJECT=$(basename "${CWD:-unknown}")
LOG_DIR="${HOME}/.claude/logs"
LOG_FILE="${LOG_DIR}/sessions.log"
mkdir -p "$LOG_DIR"
# Append: idempotent, không cần lock vì mỗi turn là 1 dòng độc lập
printf '%s\t%s\t%s\n' \
"$(date -Iseconds)" \
"${SESSION_ID:0:12}" \
"$PROJECT" \
>> "$LOG_FILE"
echo "stop-log-session: logged turn for session ${SESSION_ID:0:8} / project $PROJECT" >&2
exit 0
Chú ý:
- Đọc stdin vào
$INPUTmột lần, parse nhiều lần từ biến đó (không gọicatnhiều lần). mkdir -ptrước khi ghi: idempotent, không lỗi nếu dir đã tồn tại.- Log dir là
~/.claude/logs/để không lẫn vào project repo. - Debug message ra
>&2. - Exit 0 unconditional ở cuối: lỗi ghi log không nên làm gián đoạn session.
Best practice tổng hợp
1 hook = 1 trách nhiệm. Đừng nhồi “ghi log + gửi Telegram + update file + check condition” vào một script. Dùng array trong settings.json để compose nhiều hook nhỏ:
"Stop": [
{ "hooks": [{ "type": "command", "command": "~/.claude/hooks/stop-log-session.sh" }] },
{ "hooks": [{ "type": "command", "command": "~/.claude/hooks/stop-notify.sh" }] }
]
Hai script nhỏ, dễ test riêng, dễ enable/disable từng cái.
Luôn set -euo pipefail ở đầu script. Bắt lỗi sớm, không để script chạy tiếp khi một bước đã fail.
Không dùng global variable giữa hai invocation. Mỗi lần hook được gọi là một process mới, không có state persist (trừ file bạn tự ghi).
Check dependency trước khi dùng. Nếu script cần jq hay curl, kiểm tra binary có sẵn không:
if ! command -v jq >/dev/null 2>&1; then
echo "stop-log-session: jq not found, skipping" >&2
exit 0
fi
Anti-pattern thường gặp
Ghi log ra stdout. Stdout bị binary capture cho mục đích riêng. Debug info ra stdout làm hỏng output pipeline, đặc biệt với WorktreeCreate (binary đọc stdout để lấy path) và PreToolUse (binary đọc stdout để lấy lý do block).
Không handle missing dependency. Script cần jq nhưng không check, chạy trên máy mới chưa cài jq thì crash, mang theo exit code non-zero, có thể block lifecycle không đúng lúc.
Network call đồng bộ trong PreToolUse. Mỗi tool call phải chờ hook hoàn thành. Nếu bạn gọi API 3 giây trong PreToolUse, mỗi Bash call sẽ chậm 3 giây. Cân nhắc lại liệu có thực sự cần network ở đây không.
Không re-verify sau CC upgrade. Hook dựa vào lifecycle event schema và exit code semantic, cả hai đều do binary định nghĩa. Version mới của CC có thể thêm field, đổi tên field, hoặc thay đổi khi nào event fire. Sau mỗi CC version bump đáng kể, kiểm tra lại hook bằng cách trigger event và xem output.
Versioning: re-verify sau mỗi CC upgrade
Hook là điểm kết nối giữa code bạn viết và binary của Anthropic. Khi Anthropic release version mới:
- Kiểm tra changelog xem có đề cập đến hooks không.
- Chạy một session thử, trigger từng event, xem hook log trên stderr.
- Đặc biệt cẩn thận với
WorktreeCreate: đây là event có semantic phức tạp nhất (binary nhường quyền hoàn toàn cho hook khi hook có mặt).
Comment # Verified against Claude Code vX.Y.Z ở đầu mỗi script giúp bạn biết lần cuối cùng script được kiểm tra với version nào.
Tổng kết series: 25 bài hành trình
Series “Claude Code từ zero” mở đầu với câu hỏi đơn giản: Claude Code thực ra là gì? Và dần dần đi qua từng lớp của hệ thống.
Từ anatomy của một session (bài 1) và cách prompt được dựng mỗi turn (bài 2), đến tool loop (bài 3) và permission model (bài 4). Từ compaction và prompt cache (bài 5) đến cách tổ chức CLAUDE.md và rules theo module (bài 6). Skills cho phép wrap workflow phức tạp thành một lệnh (bài 7). Subagents và spawning patterns (bài 8, 16) giúp chia công việc song song hoặc tuần tự. Memory cho CC “nhớ” giữa session (bài 12-14). MCP mở rộng tool list ra ngoài binary (bài 15). Plugins thêm tính năng mà không cần code script (bài 11). Plan mode cho phép xem trước trước khi thực thi (bài 20). Worktree isolation giữ cho main session sạch khi chạy multi-agent (bài 17, 21). Team mode cho phép nhiều agent phối hợp với nhau (bài 18). Branch-per-machine đồng bộ config giữa nhiều máy (bài 22). Mobile coding qua bridge (bài 23). Và cuối cùng là hai bài về production-grade skill (bài 24) và production-grade hook (bài này).
Điều liên kết tất cả các bài lại với nhau là một mental model: mọi customize của Claude Code đều là tác động vào một trong các lớp của prompt, hoặc chen vào vòng lặp tool. Khi bạn hiểu lớp nào làm gì, quyết định “dùng skill hay hook”, “dùng memory hay CLAUDE.md”, “spawn agent hay làm thẳng trong main session” trở nên tự nhiên hơn, không cần đoán mò.
Setup của mỗi người sẽ khác nhau. Bài này và 24 bài trước chỉ là một ví dụ về cách suy nghĩ, không phải một cấu hình cần copy nguyên xi. Thực nghiệm với setup của riêng bạn, phá vỡ nó, sửa lại, và chú ý tại sao nó hoạt động hay không hoạt động. Đó là cách hiểu sâu nhất.
Tóm tắt
- Hook khác skill: hook chạy ngoài model loop, không thể bị model bỏ qua. Dùng hook khi cần ép buộc hành vi.
- 5 lifecycle event:
SessionStart,PreToolUse,PostToolUse,Stop,WorktreeCreate. ChỉPreToolUsecó thể block bằng non-zero exit code. - Viết hook đúng: parse stdin bằng
jq, validate early, log ra stderr (không stdout), handle timeout, đảm bảo idempotent, 1 hook = 1 trách nhiệm. - Re-verify sau mỗi CC upgrade. Comment version vào đầu script.
- Series kết thúc ở đây. Hành trình thực sự bắt đầu khi bạn mở terminal và thực nghiệm.
Bài thuộc series Claude Code từ zero. Series plan tại bài giới thiệu.