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:

EventKhi nào fireGhi chú
SessionStartSession bắt đầuChạy một lần per session
UserPromptSubmitUser gửi messageTrước khi prompt vào model
PreToolUseNgay trước khi binary chạy toolCó thể block tool
PostToolUseNgay sau khi tool chạy xongAudit, log
StopCC kết thúc một turnNotification, cleanup
WorktreeCreateBinary tạo agent worktreeOverride worktree path và base ref

PreToolUseWorktreeCreate 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.

EventExit 0Exit non-zero
PreToolUseCC tiếp tục chạy toolCC block tool, báo lỗi cho user
WorktreeCreateCC dùng stdout làm worktree pathCC abort tạo worktree
StopTurn kết thúc bình thườngLogged nhưng không block gì
PostToolUseTiếp tụcLogged
SessionStart, UserPromptSubmitTiếp tụcCó 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:

  1. Hook đọc stdin, lấy namecwd từ JSON payload.
  2. Worktree được tạo tại .claude/worktrees/<name>/ (gitignored), không phải chỗ mặc định của binary.
  3. Branch tạo với --no-track và 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ú ý:

  1. Filter bằng payload field: agent_id không rỗng nghĩa là stop đến từ subagent. Hook bỏ qua, tránh notification flood khi có 5 subagent song song.
  2. Async với & + disown: network call (gửi Telegram message) được fork sang background, hook exit ngay. Turn không bị block chờ network.
  3. || true trên network call: nếu gửi thất bại, không exit non-zero. Hook này ở Stop event, 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 ở PreToolUsePostToolUse 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 codeCLAUDE.md / rules (L2)
Wrap workflow thành slash commandSkill
Chạy shell script khi event lifecycle xảy ra, không qua modelHook
Block tool call nguy hiểm trước khi chạyHook ở PreToolUse
Override cách binary tạo worktreeHook ở WorktreeCreate
Gửi notification khi CC xong turnHook ở 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. PreToolUseWorktreeCreate có 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, || true cho 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.