---
title: "Hooks trong Claude Code hoạt động thế nào?"
description: "Hooks biến những chỉ dẫn 'nhớ làm giúp tôi' thành đảm bảo chắc chắn. Bài này mổ xẻ vòng đời hook, cách cấu hình, luồng vào/ra bằng exit code & JSON, kèm ví dụ thật."
date: 2026-07-15T02:00:00.000Z
author: "Claude Opus 4.8"
tags: ["Claude Code", "Hooks", "Automation", "DevTools"]
lang: vi
canonical: https://namnn.com/blog/hooks-trong-claude-code/
---

Khi làm việc với Claude Code, có những thứ bạn muốn **luôn luôn** xảy ra: format code sau mỗi lần sửa, chặn tuyệt đối việc đụng vào `.env`, ghi log mọi câu lệnh shell. Nếu chỉ dặn model "nhớ format nhé", bạn đang phó thác cho *phán đoán* của một hệ thống xác suất — nó có thể quên. **Hooks** giải quyết đúng chỗ đó: chúng là các lệnh chạy **tự động, tất định** tại những mốc cố định trong vòng đời của Claude Code.

## Vòng đời: hook cắm vào đâu?

Claude Code chạy theo một **agentic loop** — nhận prompt, suy nghĩ, gọi tool, lặp lại tới khi xong. Hook cắm vào giữa các mốc đó. Đây là những điểm bạn dùng nhiều nhất:

```mermaid
flowchart TD
    A["Bạn gõ prompt"] --> B["Claude chọn công cụ"]
    B --> C["Gọi tool · Bash / Edit"]
    C --> D["Lặp agentic loop tới khi xong"]
    D --> E["Claude dừng"]
    U(["UserPromptSubmit"]) -.-> A
    P(["PreToolUse — chặn / duyệt / sửa input"]) -.-> C
    C -.-> Q(["PostToolUse — format / inject"])
    S(["Stop — ép làm tiếp nếu chưa đạt"]) -.-> E
```

*Bốn hook hay dùng nhất quanh agentic loop — `PreToolUse` chạy TRƯỚC khi tool thực thi (chặn được); `PostToolUse` chạy SAU (không undo được).*

Bốn cái đó mới là phần bạn chạm vào hằng ngày. Tài liệu chính thức hiện liệt kê khoảng ba chục event<sup class="cite"><a href="#ref-1">1</a></sup>, phủ gần như mọi mốc trong một phiên làm việc: `SessionStart` / `SessionEnd` (đầu và cuối phiên), `Notification` (khi Claude chờ bạn phản hồi), `PreCompact` / `PostCompact` (quanh lúc nén context), `SubagentStop`, `PermissionRequest`, cho tới cả `FileChanged` hay `WorktreeCreate`. Không cần thuộc hết; chỉ cần biết rằng chỗ nào bạn muốn chen vào thì nhiều khả năng đã có sẵn một mốc ở đó.

## Cấu hình ở đâu?

Hooks khai báo trong `settings.json`<sup class="cite"><a href="#ref-2">2</a></sup>, theo thứ tự ưu tiên từ rộng đến hẹp:

| Vị trí | Phạm vi | Commit vào repo? |
|---|---|---|
| `~/.claude/settings.json` | Mọi project (máy bạn) | Không |
| `.claude/settings.json` | Riêng project này | Có — chia sẻ cho team |
| `.claude/settings.local.json` | Riêng project, riêng bạn | Không (bị gitignore) |

Cấu trúc JSON gồm: tên **event** → danh sách nhóm, mỗi nhóm có **`matcher`** (lọc theo tên tool) và mảng **`hooks`**:

```json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/guard.sh",
            "timeout": 30
          }
        ]
      }
    ]
  }
}
```

**Matcher** lọc tool nào kích hoạt hook: `"Bash"` (khớp đúng tên), `"Edit|Write"` (hoặc), `"mcp__.*"` (regex cho MCP tools), hoặc bỏ trống / `"*"` để khớp tất cả. Với các event không phải tool (như `SessionStart`), matcher lọc theo *lý do* — ví dụ `"compact"`, `"startup"`, `"resume"`.

## Luồng vào/ra: hook "nói chuyện" với Claude ra sao?

Đây là phần cốt lõi. Mỗi khi kích hoạt, hook nhận **một JSON qua stdin**, rồi trả quyền điều khiển lại bằng **exit code** và (tùy chọn) **JSON qua stdout**.

**Input mẫu** cho một `PreToolUse` trên Bash:

```json
{
  "session_id": "session-xyz",
  "cwd": "/Users/namnn/project",
  "hook_event_name": "PreToolUse",
  "tool_name": "Bash",
  "tool_input": { "command": "npm test" }
}
```

**Exit code** quyết định hành vi:

| Exit code | Ý nghĩa |
|---|---|
| **0** | OK. Với `UserPromptSubmit`/`SessionStart`, stdout được nối vào context của Claude. |
| **2** | **Chặn.** Hành động bị chặn (nếu event chặn được); nội dung stderr được đưa lại cho Claude làm phản hồi. |
| Khác | Lỗi không chặn — hành động vẫn tiếp tục, lỗi hiện trong transcript. |

Khi cần điều khiển tinh vi hơn exit code, in **JSON có cấu trúc** ra stdout (với exit 0). Ví dụ, một `PreToolUse` từ chối kèm lý do:

```json
{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "Dùng 'git push --force-with-lease' thay cho '--force'"
  }
}
```

`permissionDecision` nhận `allow`, `deny` hoặc `ask`<sup class="cite"><a href="#ref-1">1</a></sup>. Còn một giá trị thứ tư là `defer`, nhưng nó chỉ dùng được ở chế độ non-interactive với cờ `-p`: hook thoát process mà vẫn giữ nguyên tool call, để một lớp Agent SDK bọc ngoài thu thập thêm dữ liệu rồi chạy tiếp. Trong phiên tương tác hằng ngày, bạn hầu như chỉ đụng tới ba giá trị đầu.

Thậm chí bạn có thể **viết lại input** của tool bằng `updatedInput` trước khi nó chạy, ví dụ tự đổi `--force` thành `--force-with-lease`.

Khi nhiều hook cùng khớp một tool, chúng không tranh nhau: Claude Code gộp kết quả và lấy câu trả lời **ngặt nhất**, theo thứ tự `deny` → `defer` → `ask` → `allow`. Vì vậy thêm một hook chỉ có thể làm mọi thứ chặt hơn, không bao giờ lỏng đi.

Trọn vẹn một lượt `PreToolUse` chặn rồi cho Claude thử lại trông như sau:

```mermaid
sequenceDiagram
    participant C as Claude
    participant H as PreToolUse hook
    participant T as Tool · Bash
    C->>H: muốn chạy "git push --force"
    H-->>C: deny + lý do
    Note over C: Claude tự điều chỉnh
    C->>H: thử "git push --force-with-lease"
    H->>T: allow
    T-->>C: kết quả trả về
```

> `PreToolUse` chạy **trước** nên chặn hoặc sửa được input; `PostToolUse` chạy **sau** nên chỉ transform kết quả hoặc chặn *lượt model kế tiếp*, chứ không undo được việc đã làm.

## Ba ví dụ thật

### 1. Tự format sau mỗi lần sửa file

```json
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "jq -r '.tool_input.file_path' | xargs npx prettier --write"
          }
        ]
      }
    ]
  }
}
```

Sau khi Claude sửa file, hook lấy `file_path` từ JSON input rồi chạy Prettier. Im lặng, tất định, không bao giờ quên.

### 2. Chặn cứng việc đụng vào file nhạy cảm

`.claude/hooks/protect.sh`:

```bash
#!/bin/bash
INPUT=$(cat)
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')

for p in ".env" "package-lock.json" ".git/"; do
  if [[ "$FILE" == *"$p"* ]]; then
    echo "Chặn: $FILE trùng mẫu bảo vệ '$p'" >&2
    exit 2
  fi
done
exit 0
```

Trả `exit 2` → tool bị chặn, và Claude nhận đúng lý do để tự điều chỉnh. Quan trọng: hook `deny` chặn được **kể cả** ở chế độ `bypassPermissions` — đó là hàng rào cứng, không lách được.

### 3. Báo desktop khi Claude cần bạn

```json
{
  "hooks": {
    "Notification": [
      {
        "matcher": "permission_prompt|idle_prompt",
        "hooks": [
          {
            "type": "command",
            "command": "osascript -e 'display notification \"Claude cần bạn\" with title \"Claude Code\"'"
          }
        ]
      }
    ]
  }
}
```

Rời máy đi pha cà phê mà không sợ bỏ lỡ prompt xin quyền.

## Vài cạm bẫy nên nhớ

- **`exit 1` không chặn được gì.** Chỉ `exit 2` mới chặn. Docs nói thẳng rằng Claude Code coi exit code 1 là lỗi không chặn và vẫn tiến hành hành động, dù 1 mới là mã lỗi quy ước của Unix<sup class="cite"><a href="#ref-1">1</a></sup>. Quen tay viết `exit 1` để từ chối thì hook của bạn chỉ ghi một dòng lỗi vào transcript rồi để mọi thứ chạy tiếp.
- **Hook chạy bằng quyền của bạn** — với SSH key, token, quyền ghi file của bạn. Chỉ dùng hook từ nguồn tin cậy, đọc script trước khi bật.
- **Đừng in secret ra stdout/stderr** — Claude đọc được nội dung đó như một system message.
- **`PostToolUse` không undo được** — cần chặn thì phải dùng `PreToolUse`.
- **Cẩn thận shell injection** — dùng `jq` để trích & quote giá trị, đừng nội suy thẳng `tool_input` vào chuỗi lệnh.
- **Output từ `.bashrc`/`.zshrc`** in vô điều kiện sẽ làm hỏng JSON của hook — bọc trong kiểm tra shell tương tác (`[[ $- == *i* ]]`).

## Hook siết được, nhưng không nới được

Quay lại cái hook chặn `.env` ở trên. Điều khiến nó đáng tin không nằm ở mấy dòng script, mà ở chỗ `PreToolUse` chạy trước mọi bước kiểm tra permission mode, nên một hook trả `deny` vẫn chặn được tool kể cả khi phiên làm việc mở bằng `--dangerously-skip-permissions`<sup class="cite"><a href="#ref-1">1</a></sup>. Không ai lách nó bằng cách đổi permission mode của mình được, và đó là lý do hook đủ sức mang những luật mà bạn muốn cả team phải theo.

Chiều ngược lại thì không đúng, và đây là chỗ dễ hiểu nhầm. Một hook trả `allow` không xoá được luật deny đã khai trong settings; nó chỉ bỏ qua bước hỏi cho những gì settings vốn đã cho phép. Nói theo tài liệu, hook siết chặt được hạn chế chứ không nới lỏng quá giới hạn mà permission rules đặt ra<sup class="cite"><a href="#ref-1">1</a></sup>.

Sự bất đối xứng ấy không phải một thiếu sót còn dang dở, nó chính là thứ làm hook đáng dùng. Một cơ chế vừa siết vừa nới được thì chẳng bảo đảm nổi điều gì, vì luật nào cũng có thể bị chính một hook khác gỡ ra. Còn khi công cụ chỉ đi được một chiều, bạn đọc một file `settings.json` là biết giới hạn trên của những gì Claude Code làm được trong repo này, không cần suy đoán.

Nếu trong repo của bạn đang có một quy tắc mà mọi người vẫn "nhớ thì làm", chẳng hạn chạy migration trước khi test hay không bao giờ commit thẳng lên `main`, thì đó là ứng viên đầu tiên nên chuyển thành hook. Câu hỏi đáng hỏi không phải là Claude có nhớ làm việc đó không, mà là nếu nó quên thì cái gì chặn lại?

## Tài liệu tham khảo

<ol class="references">
<li id="ref-1">Anthropic (2025). <em>Hooks reference — Claude Code.</em> <a href="https://code.claude.com/docs/en/hooks" target="_blank" rel="noopener">code.claude.com/docs/en/hooks</a></li>
<li id="ref-2">Anthropic (2025). <em>Automate actions with hooks — Claude Code.</em> <a href="https://code.claude.com/docs/en/hooks-guide" target="_blank" rel="noopener">code.claude.com/docs/en/hooks-guide</a></li>
</ol>