Phản ứng với event
Hook là một event handler: một hàm Claude Code chạy khi một event có tên xảy ra. Claude Code kích hoạt event ở mỗi thời điểm nó sắp hành động, như khi chạy một tool, gửi một prompt, gửi request tới model, hay bắt đầu hoặc kết thúc session. Hook của bạn chạy trước khi Claude Code hành động, nên nó có thể quan sát event, viết lại event, hoặc trả lời thay cho Claude Code. Bạn đăng ký hook bằng on(eventName, handler).
Hãy xây dựng mod đầu tiên trước khi đọc trang này. Để xem mọi event và các field chính xác của chúng, xem phần tham chiếu hoặc đọc type cho bản build của bạn.
Hook xử lý event như thế nào
Phần tiêu đề “Hook xử lý event như thế nào”Hook nằm giữa một event và việc Claude Code sẽ làm với event đó, nên nó có thể quan sát, viết lại, hoặc tự trả lời event. Hook nhận ba tham số: mods API là $, event là e, và handler kế tiếp là next. Các handler của một event tạo thành một chuỗi middleware. next(e) gọi handler kế tiếp, có thể là hook của một mod khác hoặc, ở cuối chuỗi, hành vi của chính Claude Code, và trả về kết quả. Cách hook của bạn dùng next quyết định nó làm việc nào trong ba việc trên.
Quan sát event
Phần tiêu đề “Quan sát event”Để quan sát một event mà không thay đổi nó, hãy làm việc của bạn rồi trả về next(e). Hook này ghi log mỗi tool mà Claude sắp dùng:
on('tool.call', async ($, e, next) => { // Chạy trước khi tool chạy $.ui.log('Claude is about to use ' + e.tool) // Chuyển event đi tiếp nguyên vẹn return next(e)})Trước mỗi lần tool chạy, một dòng chữ mờ như ● my-mod: Claude is about to use Bash xuất hiện trong transcript, với my-mod là tên plugin của bạn. Tool chạy như khi không có mod.
Để hành động sau event, hãy await next(e), làm việc của bạn, rồi trả về kết quả. Hook này ghi log mỗi tool sau khi nó đã chạy xong:
on('tool.call', async ($, e, next) => { // Để tool chạy, và chờ kết quả của nó const result = await next(e) // Chạy sau khi tool đã chạy $.ui.log(e.tool + ' finished') // Trả lại kết quả nguyên vẹn return result})Giờ dòng log xuất hiện sau khi mỗi tool chạy xong. Claude đọc được cùng một kết quả trong cả hai trường hợp, vì hook trả về đúng giá trị mà next(e) đã trả.
Viết lại event
Phần tiêu đề “Viết lại event”Để thay đổi thứ Claude Code sẽ xử lý, như nội dung một prompt, hãy gọi next với một bản copy đã sửa của event. Bản thân event là bất biến (immutable): nó bị đóng băng sâu (deeply frozen), và gán giá trị cho một field sẽ ném lỗi. Hook này cắt khoảng trắng thừa ở mỗi prompt trước khi gửi:
on('prompt.submit', async ($, e, next) => { // Chuyển đi một bản copy của event với text đã đổi return next({ ...e, text: e.text.trim() })})Các handler sau đó và Claude Code nhận prompt đã được cắt khoảng trắng và không bao giờ thấy bản gốc. Bạn cũng có thể thay đổi kết quả: await next(e), rồi trả về một bản copy của kết quả với một field đã được thay.
Trả lời event
Phần tiêu đề “Trả lời event”Để tự xử lý một event, hãy trả về kết quả mà không gọi next. Việc này cắt ngắn (short-circuit) chuỗi, nên các mod phía sau và hành vi của chính Claude Code không chạy. Hook này từ chối mọi lệnh Bash:
on('tool.call', { tool: 'Bash' }, async () => { // Không gọi next, nên lệnh không bao giờ chạy return { deny: 'Bash is turned off in this project. Use the file tools.' }})Khi Claude thử chạy một lệnh Bash, lệnh không chạy, và Claude đọc text trong deny như kết quả của tool. Mỗi event có dạng kết quả riêng, được liệt kê trong phần tham chiếu event.
Lọc event mà hook xử lý
Phần tiêu đề “Lọc event mà hook xử lý”Để hook chỉ chạy cho một số event, truyền một bộ lọc làm tham số thứ hai của on. Claude Code gọi bộ lọc này là matcher. Đó là một object có các field được so sánh với field của event, và hook chỉ chạy khi mọi field đều khớp. Mỗi field có thể là một giá trị, một mảng các giá trị được chấp nhận, hoặc một regular expression.
Mỗi dòng trong ví dụ này đăng ký cùng một hàm hook cho một tập tool call hẹp hơn:
// Một chuỗi khớp đúng một giá trị: chỉ các call Bashon('tool.call', { tool: 'Bash' }, hook)// Một mảng khớp bất kỳ giá trị nào trong đó: call Edit và call Writeon('tool.call', { tool: ['Edit', 'Write'] }, hook)// Một regular expression khớp theo mẫu: mọi tool của một MCP serveron('tool.call', { tool: /^mcp__github__/ }, hook)hook chạy một lần cho mỗi call Bash, Edit hoặc Write, và một lần cho mỗi call tới tool có tên bắt đầu bằng mcp__github__. Call tới bất kỳ tool nào khác, như Read, không khớp với cả ba, nên hook không chạy.
Tên event có thể là wildcard. 'classic.*' khớp mọi event của settings hook. '*' khớp mọi event, trừ các event telemetry, loại event cần tên riêng và bộ lọc { to: 'collector' }.
Mỗi event chỉ đăng ký một lần cho mỗi matcher. Nếu bạn gọi on hai lần cho session.start mà không có matcher, module sẽ không nạp được với lỗi on("session.start") is registered twice without a matcher. Hãy gom mọi việc mod cần làm lúc session bắt đầu vào một hook duy nhất.
Hook vào những gì Claude đang làm
Phần tiêu đề “Hook vào những gì Claude đang làm”Xử lý các event sau để xem hoặc thay đổi một tool call, một prompt hay một turn ngay khi nó diễn ra. Để xem mọi event và những gì hook có thể trả về, xem phần tham chiếu event.
Chặn hoặc thay đổi tool call
Phần tiêu đề “Chặn hoặc thay đổi tool call”Một hook tool.call thấy mọi tool mà Claude sắp dùng, nên nó có thể từ chối call, thay đổi tham số, hoặc cho đi tiếp. tool.call được kích hoạt khi Claude Code sắp chạy một tool, kể cả call do subagent thực hiện và call tới MCP tool. e.tool là tên tool, còn tham số của tool là các field của e, như e.command với Bash. Khi bạn gọi next(e), Claude Code chạy bước kiểm tra permission rồi mới chạy tool.
Hook này từ chối một lệnh Bash force-push, và cho Claude biết lý do:
// Matcher giới hạn hook ở các call Bash, nên e.command là lệnh shellon('tool.call', { tool: 'Bash' }, async ($, e, next) => { if (/git push .*--force/.test(e.command)) { // Trả về mà không gọi next là trả lời event, nên lệnh không bao giờ chạy return { deny: 'Force pushes are not allowed in this repository. Push to a new branch instead.' } } // Mọi lệnh khác đi tiếp tới bước kiểm tra permission rồi tới Bash return next(e)})Khi Claude thử git push --force, lệnh không chạy và cũng không có permission prompt nào hiện ra, vì hook không bao giờ gọi next. Claude đọc text trong deny như kết quả của tool, nên hãy viết nó thành một chỉ dẫn mà Claude có thể làm theo. Mọi lệnh Bash khác chạy như khi không có mod.
Để hành động sau khi tool đã chạy, hãy await next(e), làm việc của bạn, rồi trả về thứ next đã đưa cho bạn. Hook này ghi log mỗi file .mdx mà Claude thay đổi, bằng $.ui.log, method thêm một dòng chữ mờ vào transcript mà Claude không đọc:
on('tool.call', { tool: ['Edit', 'Write'] }, async ($, e, next) => { // Chờ bước kiểm tra permission và tool, giữ lại thứ chúng tạo ra const result = await next(e) // Call bị từ chối trả về dạng { deny }, call lỗi thì có isError const changed = !result.deny && !result.isError if (changed && e.file_path.endsWith('.mdx')) $.ui.log('Claude changed ' + e.file_path) // Trả về kết quả nguyên trạng, để Claude đọc đúng thứ tool trả về return result})Sau khi Claude sửa hoặc ghi một file .mdx, một dòng chữ mờ trong transcript ghi tên file. Không có gì được ghi log với loại file khác, hoặc với call bị từ chối hay bị lỗi. Cách Claude nhìn thấy call không thay đổi, vì hook trả về đúng kết quả nó nhận được.
Để thay đổi một call, truyền tham số đã sửa cho next. Để thử lại một call, gọi next(e) lần nữa: hook thấy isError ở kết quả đầu có thể chạy tool lần thứ hai và trả về kết quả đó. Để tự trả lời một call, trả về một object có field result, như { result: 'Skipped by my-mod' }, mà không gọi next. Khi làm vậy, không có permission prompt nào hiện ra và tool không chạy, nên kết quả bạn trả về là tất cả những gì Claude biết về chuyện đã xảy ra.
Các hook trong managed settings của tổ chức chạy trước hook tool.call của mọi mod, và một quyết định chặn từ chúng là cuối cùng.
Giữ tool call cho đến khi user quyết định
Phần tiêu đề “Giữ tool call cho đến khi user quyết định”Hook có thể tạm dừng một tool call và hỏi user nên làm gì trước khi cho nó đi tiếp. Một hook tool.call có thể await trước khi gọi next hoặc trả về, và tool call sẽ ở trạng thái chờ cho tới lúc đó. Để đặt câu hỏi cho user, gọi $.ui.ask. Nó hiển thị câu hỏi của bạn phía trên một danh sách đánh số các lựa chọn, trong chính hộp thoại Claude dùng để hỏi bạn, và trả về nhãn mà user chọn. Sau các lựa chọn của bạn, hộp thoại thêm một dòng để gõ câu trả lời khác và một dòng Chat about this.
Mẫu RISKY trong ví dụ này khớp với rm -r, rm -rf, git reset --hard, và git push kèm --force, nhưng bỏ sót các cách viết khác như git push -f. Module này hỏi trước khi chạy một lệnh Bash khớp với mẫu:
const RISKY = /\brm\s+-rf?\b|\bgit\s+reset\s+--hard\b|\bgit\s+push\b.*--force/
export function register(on) { on('tool.call', { tool: 'Bash' }, async ($, e, next) => { // Cho mọi lệnh khác đi qua mà không hỏi if (!RISKY.test(e.command)) return next(e) // Bắt đầu từ câu trả lời an toàn, để câu hỏi không ai trả lời sẽ từ chối lệnh let answer = 'Refuse' try { // Tool call chờ ở đây cho đến khi user chọn một trong hai nhãn answer = await $.ui.ask('Run this command? ' + e.command, ['Run it', 'Refuse']) } catch { // User đã bỏ qua câu hỏi, hoặc đây là lần chạy claude -p không có ai để hỏi } if (answer !== 'Run it') { // Trả lời mà không gọi next, nên lệnh không chạy return { deny: 'The user declined this command. Ask before trying a different approach.' } } return next(e) })}Khi Claude thử một lệnh như rm -rf build, câu hỏi xuất hiện kèm lệnh đó, và lệnh chờ câu trả lời:
- User chọn Run it: hook gọi
next(e), và bước kiểm tra permission thông thường vẫn chạy sau đó - User chọn Refuse: lệnh không chạy, và Claude đọc text trong
deny - User gõ một câu trả lời:
$.ui.asktrả về đoạn text đã gõ. Hook so sánh nó vớiRun it, nên mọi text khác đều từ chối lệnh. - Không ai trả lời:
$.ui.askreject khi user bỏ qua câu hỏi hoặc chọn Chat about this, và trong lần chạyclaude -p, nên khốicatchgiữ câu trả lời ởRefuse
Hãy để việc chờ nằm bên trong một lời gọi mods API như $.ui.ask, vì thời gian đó không bị tính vào giới hạn thời gian của hook. Thời gian chờ một promise của riêng bạn thì có bị tính. Claude Code bỏ qua một hook bị quá thời gian, và lệnh đang bị giữ sẽ chạy.
Duyệt hoặc từ chối tool call trước khi hỏi user
Phần tiêu đề “Duyệt hoặc từ chối tool call trước khi hỏi user”Để quyết định một tool call có được chạy không, hãy xử lý tool.check, event mà tại đó Claude Code đưa ra quyết định này. Nó được kích hoạt sau khi các permission rule và settings hooks đã quyết định, và next(e) trả về quyết định của chúng: allow, ask hoặc deny. Hook của bạn trả về đúng quyết định đó hoặc một quyết định khác. e.input chứa tham số của tool, như command với Bash.
Với một lệnh hay đường dẫn cố định, hãy dùng permission rule như Bash(npm test), không cần viết code. Hãy xử lý tool.check khi quyết định phụ thuộc vào điều đang đúng ở thời điểm đó, như Git branch hiện tại hoặc một giá trị mà hook khác đã ghi lại.
Hook này từ chối git push khi branch hiện tại là main:
on('tool.check', { tool: 'Bash' }, async ($, e, next) => { // Quyết định của permission rule và settings hooks: 'allow', 'ask' hoặc 'deny' const decided = await next(e) if (!e.input.command.includes('git push')) return decided const branch = await $.process.run(['git', 'branch', '--show-current']) if (branch.stdout.trim() !== 'main') return decided return { decision: 'deny', reason: 'Push from a branch other than main' }})Trên main, hook trả về deny, kể cả khi có rule cho phép git push. Trên branch khác, và với các lệnh khác, call nhận đúng quyết định mà nó sẽ nhận khi không có mod.
Hook này so khớp theo nội dung text của lệnh, nên hãy coi nó như một lời nhắc cho Claude. Để chặn push lên main với mọi người, hãy bảo vệ branch trên Git host của bạn.
Hook có thể trả về allow, ask hoặc deny, nên nó cũng có thể duyệt một call đã bị một PreToolUse hook ngoài managed settings chặn. Trang Extend permissions with hooks liệt kê những quyết định nào được giữ nguyên trước một mod.
Viết lại hoặc bổ sung prompt
Phần tiêu đề “Viết lại hoặc bổ sung prompt”Một hook prompt.submit thấy mỗi prompt trước khi turn bắt đầu, nên nó có thể viết lại text hoặc thêm nội dung vào. e.text là những gì đã được gõ.
| Để làm việc này | Trả về |
|---|---|
| Viết lại prompt. Message trong transcript hiển thị text mới. | next({ ...e, text: newText }) |
| Thêm text chỉ Claude đọc, nằm sau prompt | next({ ...e, context: [...(e.context ?? []), extraText] }) |
| Ngăn không cho prompt được gửi đi | { drop: 'the reason' } |
Hook này thêm tên branch hiện tại cho Claude mỗi khi prompt có nhắc tới pull request:
on('prompt.submit', async ($, e, next) => { // Prompt không nhắc tới pull request thì cho đi nguyên trạng if (!/\bPR\b|pull request/i.test(e.text)) return next(e) const git = await $.process.run(['git', 'branch', '--show-current']) // Ngoài một git repository lệnh sẽ lỗi, nên không có branch nào để thêm if (git.exitCode !== 0) return next(e) // Giữ context mà hook trước đã thêm, và thêm một dòng nữa cho Claude return next({ ...e, context: [...(e.context ?? []), 'Current branch: ' + git.stdout.trim()] })})Khi bạn gửi một prompt như open a PR for this change, message của bạn trong transcript trông vẫn như cũ, và Claude đọc thêm một dòng như Current branch: feature/auth phía sau. Prompt không nhắc tới pull request thì đi qua nguyên trạng, và git không chạy.
Các event khác bao quát phần còn lại của những gì Claude đọc: prompt.section cho từng phần của system prompt, prompt.context cho context gửi kèm message đầu tiên, và skill.prompt cho nội dung của một skill. Text từ các hook này nếu thay đổi giữa các request sẽ làm mất hiệu lực prompt cache.
Theo dõi một turn
Phần tiêu đề “Theo dõi một turn”Một turn là mọi thứ Claude làm để trả lời một prompt. Xử lý turn.start, turn.step và turn.complete để theo dõi một turn:
| Event | Kích hoạt khi | Hook có thể làm gì |
|---|---|---|
turn.start | Một turn bắt đầu | Quan sát. e.turnId định danh turn trong hai event còn lại. |
turn.step | Claude Code sắp gửi một request tới model. Một turn có tool call sẽ có nhiều request. e.agentId có giá trị với request của subagent. | Đọc token usage của từng request, gửi nó sang model khác bằng next({ ...e, model }), hoặc trả lời mà không gọi model |
turn.complete | Turn đã kết thúc, kể cả turn bị user ngắt, khi đó e.isAborted là true. e.answer là text cuối cùng của Claude, e.durationMs là thời gian chạy, và e.usage là tổng token của turn. Turn của subagent cũng kích hoạt event này với e.agentId có giá trị. | Quan sát, hoặc trả về object có field text, như { text: 'Done in 12 seconds' }, để hiện một dòng dưới câu trả lời |
Hãy viết hook turn.step dưới dạng async generator, vì event này là dạng stream. yield* next(e) chuyển tiếp response theo từng phần khi nó được stream về, và trả về kết quả hoàn chỉnh. Hook này ghi log lượng mỗi request được Claude API phục vụ từ prompt cache:
// function* biến hook thành generator, có thể chuyển response đi từng phầnon('turn.step', async function* ($, e, next) { // Gửi request, chuyển tiếp từng phần khi nó đến, và giữ lại kết quả hoàn chỉnh const result = yield* next(e) // Bỏ qua kết quả không có số liệu token if (result.usage) { $.ui.log('cache read ' + result.usage.cache_read_input_tokens + ' · wrote ' + result.usage.cache_creation_input_tokens) } // Trả về kết quả nguyên vẹn, để turn tiếp tục như bình thường return result})Response của Claude vẫn được stream ra màn hình như khi không có mod. Sau khi mỗi request hoàn tất, một dòng chữ mờ trong transcript cho biết số token đọc từ cache và số token ghi vào cache. Một turn có tool call gồm nhiều request, nên sẽ có nhiều dòng.
result.usage chứa số liệu token mà Claude API báo cho một request, cộng với model đã trả lời: input_tokens, output_tokens, cache_read_input_tokens và cache_creation_input_tokens. Hook cũng chạy cho request của subagent, nên hãy kiểm tra e.agentId khi bạn chỉ muốn xử lý cuộc hội thoại chính.
Xử lý các event của settings hook
Phần tiêu đề “Xử lý các event của settings hook”Settings hooks là các hook dạng command, HTTP, prompt và agent mà bạn cấu hình trong settings file. Mỗi event của settings hook, như Stop, SessionEnd hay PostToolUse, cũng là một event có tên bắt đầu bằng classic. theo sau là tên event của settings hook, như classic.Stop. e là đoạn JSON mà một settings hook nhận qua stdin, bao gồm cả transcript_path.
Hook này dùng Stop, event kích hoạt khi Claude trả lời xong, để ghi log nơi transcript của session được lưu:
on('classic.Stop', async ($, e, next) => { // e có cùng các field mà một Stop hook trong settings file đọc từ stdin $.ui.log('Transcript saved at ' + e.transcript_path) // Chuyển event đi tiếp, để các Stop hook trong settings file vẫn chạy return next(e)})Mỗi khi Claude trả lời xong, một dòng chữ mờ trong transcript cho biết đường dẫn file transcript. Hook trả về next(e), nên nó chỉ quan sát event và không thay đổi gì trong cách turn kết thúc.
Chạy cùng các mod khác
Phần tiêu đề “Chạy cùng các mod khác”Nhiều mod có thể xử lý cùng một event, và bất kỳ mod nào cũng có thể gặp lỗi. Nếu mod của bạn chặn tool call, hãy kiểm tra vị trí của nó trong chuỗi và điều gì xảy ra khi hook của nó lỗi.
Thứ tự chạy của các mod
Phần tiêu đề “Thứ tự chạy của các mod”Các hook trên cùng một event tạo thành một chuỗi middleware. next của mỗi mod gọi hook của mod tiếp theo, và next cuối cùng tới hành vi của chính Claude Code. Mod đầu tiên là lớp ngoài cùng: nó thấy event trước các mod khác và thấy kết quả sau chúng, và nó quyết định các mod khác có được chạy không. Một mod phía sau không thể ngăn mod phía trước thấy event.
Claude Code sắp xếp chuỗi theo nguồn gốc của mỗi mod:
- Guard tích hợp sẵn
sec-default@builtin, một mod có sẵn trong Claude Code mà/pluginliệt kê làcc-plugin-sec-default, ở những nơi nó được nạp; các mod mà tổ chức của bạn liệt kê trongprependPlugins; rồi tới mọi mod khác được tính là của tổ chức và không nằm trongappendPlugins - Các mod bạn cài
- Các mod mà tổ chức liệt kê trong
appendPlugins - Các mod tích hợp sẵn khác của Claude Code
Trong số các mod bạn cài, một mod chạy trước các mod mà nó liệt kê trong dependencies của manifest. Trong cùng một module, các hook chạy theo thứ tự register gọi on.
Settings hooks nằm ở đâu trong thứ tự
Phần tiêu đề “Settings hooks nằm ở đâu trong thứ tự”Các PreToolUse hook cấu hình trong settings file cũng chạy trong một tool call, tại những điểm cố định trong chuỗi các mod:
PreToolUsehook từ managed settings: chạy trước hooktool.callcủa mod đầu tiên, và một quyết định chặn từ chúng là cuối cùng, nên không mod nào thấy call đó.PreToolUsehook từ mọi settings file khác và từhooks/hooks.jsoncủa các plugin: chạy sau khi mod cuối cùng gọinext, như một phần hành vi của chính Claude Code. Một mod trả lờitool.callmà không gọinextsẽ khiến các hook này không chạy, còn một mod có gọinextsẽ thấy quyết định của chúng trong kết quả nó trả về.
tool.check được kích hoạt sau khi các hook đó và các permission rule đã quyết định, nên một hook trên nó có thể duyệt một call đã bị một hook trong nhóm thứ hai chặn.
Xử lý hook bị lỗi
Phần tiêu đề “Xử lý hook bị lỗi”Một hook bị lỗi không làm hỏng session, và bạn có thể quyết định điều gì xảy ra thay thế. Khi một hook không có .catch handler ném lỗi, quá thời gian, hoặc trả về kết quả sai dạng, điều xảy ra tiếp theo phụ thuộc vào việc nó đã gọi next hay chưa:
- Lỗi trước khi gọi
next: Claude Code bỏ qua nó, và handler kế tiếp chạy thay - Lỗi sau khi
nextđã trả về: kết quả đó được giữ nguyên, và không có gì chạy lần thứ hai
Một dòng ghi tên mod, event và lý do, ví dụ my-mod: tool.call hook skipped: threw Error: boom. Bạn đọc dòng này ở đâu tùy vào loại session, như phần Tìm hiểu vì sao mod không làm gì liệt kê. Một hook ui.render có phần vẽ không hợp lệ được báo theo cách khác, như phần Dựng cây từ các element mô tả.
Để một hook chặn call “đóng khi lỗi” (fail closed), hãy thêm một .catch error handler trả lời thay nó. Ở đây, guard là hàm hook của bạn:
// on trả về một registration, và .catch gắn handler vào đúng hook đóon('tool.call', { tool: 'Bash' }, guard).catch(async ($, e, next) => { // next.error.kind là 'throw' hoặc 'timeout', cho biết guard lỗi theo kiểu nào return { deny: 'The command guard failed, so this command was not run: ' + next.error.kind }})Khi guard hoạt động bình thường, handler không bao giờ chạy. Khi guard ném lỗi hoặc quá thời gian trên một call Bash, Claude Code gọi handler với cùng event đó. Handler trả về { deny }, nên lệnh không chạy, và Claude đọc text có throw hoặc timeout ở cuối. Nếu không có handler, Claude Code sẽ bỏ qua guard và chạy lệnh. Handler có giới hạn thời gian riêng, ngắn hơn.
Đọc tiếp
Phần tiêu đề “Đọc tiếp”- Dùng mods API: thêm command và tool, gọi model, chạy tác vụ theo timer
- Vẽ giao diện bằng mod: hiển thị những gì hook của bạn thu thập trong pane hoặc phía trên prompt
- Kiểm thử mod: kích hoạt bất kỳ event nào ở trên từ test
- Tham chiếu Mods: mọi event, mọi method của mods API, và các giới hạn
Bài tiếp theo: Dùng mods API - Thêm command và tool, gọi model, chạy timer, nhắn tin giữa các session và truy cập file, mạng.