คืนก่อน เราสั่ง agent ตัวหนึ่งให้ไล่แก้เทสต์ทั้งชุด รู้อยู่แล้วว่านาน เลยลุกไปชงกาแฟ พอกลับมานั่งหน้าจอ มีเทอร์มินัลเปิดอยู่หกแท็บ หน้าตาเหมือนกันหมด แล้วก็งงว่าตัวไหนรันจบ ตัวไหนยังทำอยู่ ต้องไล่คลิกดูทีละแท็บ
ปัญหาไม่ใช่ว่า agent ทำงานไม่ดี มันทำได้ดี ปัญหาคือ เราไม่รู้ว่ามันเสร็จเมื่อไหร่ และเสร็จอันไหน จอเงียบเหมือนเดิมทั้งตอนกำลังคิดและตอนจบแล้ว
สิ่งที่เราอยากได้ง่ายมาก เสียงสั้นๆ ตอนงานยาวจบ ที่บอกว่า "แท็บ api สรุปว่าแก้เทสต์เสร็จแล้ว" แค่นั้น จะได้เดินออกไปทำอย่างอื่นได้โดยไม่ต้องเฝ้าจอ แล้วมันก็พาไปเจอของที่ใหญ่กว่าเสียงแจ้งเตือน นั่นคือ hook ซึ่งเป็นจุดที่เราแทรกตัวเข้าไปสั่งพฤติกรรมของ agent ได้จริงๆ
ช่วงที่ 1hook คืออะไร ทำไมมันคือช่องที่คุณสั่ง agent ได้
เวลาเราคุยกับ agent เรามักคิดว่าจะปรับพฤติกรรมมันได้ทางเดียว คือ เขียน prompt ให้ดีขึ้น แต่ prompt เป็นการ "ขอ" ไม่ใช่การ "บังคับ" โมเดลจะทำตามหรือไม่ก็ได้ ส่วน hook คนละเรื่องกัน มันคือคำสั่งที่ตัว Claude Code เองรันให้แน่นอนทุกครั้ง ตามจังหวะที่กำหนด ไม่ผ่านการตัดสินใจของโมเดลเลย
คำที่ควรจำคือ deterministic แปลว่าผลออกมาเหมือนเดิมทุกครั้ง เพราะ host เป็นคนสั่งรัน ไม่ใช่โมเดล จุดนี้แหละที่ทำให้ hook เชื่อถือได้พอจะเอาไปทำเรื่องสำคัญ เช่น บล็อกคำสั่งอันตราย หรือกันไม่ให้ commit ความลับ ของแบบนี้ฝากไว้กับ "ขอให้โมเดลอย่าลืม" ไม่ได้
จังหวะที่ใช้บ่อย
Claude Code เปิดหลายจังหวะให้แทรก แต่ที่ใช้ในบทความนี้มีสองอัน
- UserPromptSubmit ยิงตอนคุณกด Enter ส่งคำสั่ง ก่อนที่ agent จะเริ่มคิด
- Stop ยิงตอน agent ทำเทิร์นนั้นจบ กำลังจะคืนจอให้คุณ
ยังมีจังหวะอื่นอีก เช่น PreToolUse กับ PostToolUse (ก่อนและหลัง agent เรียกใช้เครื่องมือ) และ Notification (ตอน agent ต้องหยุดรอถามคุณ) แต่ละอันเปิดโอกาสต่างกัน ตรงนี้เราหยิบแค่ Stop กับ UserPromptSubmit มาพอทำเรื่องเสียงแจ้งให้ครบ
hook ได้ข้อมูลอะไรมาบ้าง
ทุกครั้งที่ยิง Claude Code จะป้อนข้อมูลของเทิร์นเข้ามาทาง stdin เป็น JSON ก้อนหนึ่ง ที่เราใช้จริงมีสามค่า
session_idรหัส session ใช้ตั้งชื่อไฟล์ชั่วคราวแยกกันต่อ sessioncwdโฟลเดอร์ที่ทำงานอยู่ เอามาเดาชื่อแท็บได้transcript_pathที่อยู่ของไฟล์บทสนทนา เผื่ออยากดึงข้อความล่าสุดมาพูด
นี่คือ seam ช่องที่แน่นอน มีข้อมูลของเทิร์นส่งมาให้ และเราแทรกโค้ดของเราเข้าไปได้ ที่เหลือคือเอา seam นี้ไปทำอะไรก็ได้
ช่วงที่ 2ตัวอย่างจริง เสียงบอกว่าอะไรเสร็จ แท็บไหน
ลองแกะปัญหาเดิมออกมา จริงๆ มันมีสองคำถามซ้อนกัน หนึ่ง งานเสร็จเมื่อไหร่ สอง เสร็จ "อะไร" และ "แท็บไหน" เราจะแก้ทีละอัน
ทำไมต้องมีสอง hook
ปัญหาแรกซ่อนกับดักไว้ Stop ยิงตอนจบเทิร์นก็จริง แต่ถ้าเทิร์นสั้นๆ แค่ถามตอบประโยคเดียว เราไม่อยากได้เสียงทุกครั้ง อยากได้เฉพาะตอนงานยาวจบ ปัญหาคือ Stop เพียงตัวเดียว ไม่รู้ว่าเทิร์นเริ่มตอนไหน เลยวัดไม่ได้ว่านานแค่ไหน
ทางออกคือให้ UserPromptSubmit จดเวลาเริ่มไว้ก่อน แล้ว Stop ค่อยเอาเวลาปัจจุบันมาลบ ได้ระยะเวลาของเทิร์น สอง hook ทำงานเป็นคู่ อันหนึ่งเปิดนาฬิกา อีกอันปิดแล้วอ่านค่า
hook แรก จดเวลาเริ่มของเทิร์น (UserPromptSubmit)
#!/usr/bin/env bash
# notify-turn-start.sh (UserPromptSubmit hook)
set -uo pipefail
sid="$(jq -r '.session_id // "nosession"')"
sid="${sid//[^A-Za-z0-9_-]/_}" # กันตัวอักษรแปลกในชื่อไฟล์
date +%s > "/tmp/cc-turn-start-${sid}" # จดเวลาเริ่ม
rm -f "/tmp/cc-done-note-${sid}" # ล้างสรุปเก่าของเทิร์นก่อนหน้า
exit 0
ขั้นที่ต้องระวังตอนจบเทิร์น
hook ที่สอง (Stop) ทำสามอย่างตามลำดับ กันเทิร์นสั้น หาว่าเสร็จอะไร แล้วพูด แต่มีกับดักที่เจอกับตัวเอง Stop ยังยิงตอน resume, /clear และ compact ด้วย ถ้าไม่กันไว้ มันจะพูดมั่วตอนเปิด session ขึ้นมาใหม่ วิธีกันง่ายมาก กรณีพวกนั้นไม่มีไฟล์เวลาเริ่ม (เพราะไม่ได้ผ่าน UserPromptSubmit) เช็กว่าไม่มีไฟล์ก็ข้ามไปเลย
ส่วน "เสร็จอะไร" เลือกแหล่งข้อมูลสองชั้น ชั้นแรกดีที่สุด ให้ตัว agent เขียนสรุปหนึ่งบรรทัดลงไฟล์ชั่วคราวเองระหว่างทำงาน (สรุปที่คนเขียนย่อมคมกว่า) ถ้าเทิร์นไหนมันไม่ได้เขียน ค่อย fallback ไปดึงข้อความสุดท้ายจาก transcript มาตัดหางให้สั้น
hook ที่สอง กันเทิร์นสั้น หาว่าเสร็จอะไร แล้วพูด (Stop)
#!/usr/bin/env bash
# notify-done.sh (Stop hook)
set -uo pipefail
MIN_SEC="${CC_NOTIFY_MIN_SEC:-600}" # พูดเฉพาะเทิร์นที่ยาว >= 10 นาที
[ "${CC_NOTIFY:-1}" = "0" ] && exit 0 # ปิดเสียงชั่วคราวได้โดยไม่ต้องลบ hook
INPUT="$(cat)"
sid="$(printf '%s' "$INPUT" | jq -r '.session_id // "nosession"')"
sid="${sid//[^A-Za-z0-9_-]/_}"
cwd="$(printf '%s' "$INPUT" | jq -r '.cwd // empty')"
tpath="$(printf '%s' "$INPUT" | jq -r '.transcript_path // empty')"
# --- กันเทิร์นสั้น + กรณี resume/clear ที่ไม่มีไฟล์เวลาเริ่ม ---
start_file="/tmp/cc-turn-start-${sid}"
[ -f "$start_file" ] || exit 0
start="$(cat "$start_file")"; rm -f "$start_file"
case "$start" in (*[!0-9]*|"") exit 0 ;; esac
elapsed=$(( $(date +%s) - start ))
[ "$elapsed" -lt "$MIN_SEC" ] && exit 0
# --- แท็บไหน ตั้ง CC_TAB_LABEL เองต่อแท็บ ไม่งั้นใช้ชื่อโฟลเดอร์ ---
label="${CC_TAB_LABEL:-}"
[ -z "$label" ] && [ -n "$cwd" ] && [ "$cwd" != "$HOME" ] && label="$(basename "$cwd")"
# --- เสร็จอะไร สรุปที่ agent เขียนเองก่อน ไม่มีค่อยดึงจาก transcript ---
note="/tmp/cc-done-note-${sid}"; msg=""
[ -s "$note" ] && msg="$(cat "$note")" && rm -f "$note"
[ -z "$msg" ] && [ -f "$tpath" ] && msg="$(tail -n 200 "$tpath" \
| jq -rs '[.[]|select(.type=="assistant")|.message.content[]?|select(.type=="text")|.text]|last // empty' \
| sed -E 's/\`[^\`]*\`//g; s/[#*_>|~-]//g' | tr '\n' ' ' | cut -c1-160)"
[ -z "$msg" ] && msg="done"
say -r 175 "${label:+$label: }$msg" # TTS ในตัวแมค สลับเป็นตัวอื่นได้
ต่อสายให้ Claude Code รู้จัก hook
เหลือขั้นสุดท้าย บอก Claude Code ว่า event ไหนให้รันสคริปต์ไหน เขียนใน settings.json (เช่น ~/.claude/settings.json) ใต้คีย์ hooks ให้ Stop รันแบบ async ไว้ เสียงจะได้ไม่หน่วงการคืนจอ
~/.claude/settings.json
{
"hooks": {
"UserPromptSubmit": [
{ "hooks": [{ "type": "command",
"command": "bash ~/.claude/hooks/notify-turn-start.sh" }] }
],
"Stop": [
{ "hooks": [{ "type": "command", "async": true,
"command": "bash ~/.claude/hooks/notify-done.sh" }] }
]
}
}
chmod +x ให้สคริปต์ทั้งสอง เปิด session ใหม่ แล้วลองสั่งงานยาวสักอัน (หรือลดค่า CC_NOTIFY_MIN_SEC ชั่วคราวเป็น 5 เพื่อทดสอบ) พองานจบ ควรได้ยินเสียงบอกชื่อแท็บกับสรุปงาน
ยกให้พูด "อะไรเสร็จ" ได้คมขึ้น
ชั้น fallback ที่ดึงจาก transcript ใช้ได้ แต่บางทีได้ประโยคเกริ่นนำมาแทนใจความ ทางที่คมกว่าคือฝึกให้ตัว agent เขียนสรุปเองใกล้จบเทิร์น สั่งครั้งเดียว เช่น บอกมันว่า "ก่อนจบเทิร์นยาว ให้เขียนสรุปหนึ่งบรรทัดลง /tmp/cc-done-note-$session_id" เท่านี้เสียงก็เปลี่ยนจาก "แก้โค้ดในไฟล์ ก. เสร็จ" เป็น "แก้บั๊ก null ตอน login เสร็จ เทสต์ผ่านหมด" ซึ่งบอกอะไรได้มากกว่า
ช่วงที่ 3ต่อยอดเป็นของคุณเอง
สิ่งเดียวที่อยากให้จำ
เสียงแจ้งเป็นแค่ข้ออ้างสนุกๆ ของที่อยากให้ติดตัวจริงคือ hook คือช่องที่แน่นอน ที่คุณสอนนิสัยใหม่ให้ agent ได้ พอมองมันเป็น seam ไม่ใช่แค่ลูกเล่น คุณจะเริ่มเห็นว่าพฤติกรรมหลายอย่างที่เคยต้องคอย "เตือนโมเดล" จริงๆ แล้วยกไปทำเป็น hook ได้ แล้วมันจะเกิดขึ้นทุกครั้งโดยไม่ต้องลุ้น
seam เดียวกันนี้ทำอะไรได้อีก
- เด้งเตือนตอน agent ถามคำถาม ใช้
Notificationevent เล่นเสียงตอนมันหยุดรอคุณ จะได้ไม่ปล่อยให้ค้างคาโดยไม่รู้ตัว - บล็อกคำสั่งอันตราย ใช้
PreToolUseตรวจก่อนมันรันเชลล์ เจอrm -rfหรือ push ไป main ก็ปฏิเสธได้ - จัดรูปแบบโค้ดอัตโนมัติ ใช้
PostToolUseรัน formatter ทุกครั้งหลังมันแก้ไฟล์ - กันไม่ให้หลุดความลับ ตรวจ diff ก่อน commit ว่ามี key หรือ
.envปนไหม
ทุกอันคือ seam เดิม เปลี่ยนแค่ event กับสิ่งที่รันในนั้น
เริ่มยังไงดี
อย่าเพิ่งทำครบทุกชั้นในทีเดียว ก่อไฟให้ติดก่อน
- เขียน Stop hook สั้นที่สุด ให้มัน
say "done"เฉยๆ ต่อใน settings.json แล้วยืนยันว่าได้ยินเสียงตอนจบเทิร์นจริง - เติมด่านกันเทิร์นสั้น คือ เพิ่ม UserPromptSubmit ที่จดเวลา แล้วให้ Stop เช็กระยะเวลา
- เติม "เสร็จอะไร" ดึงจาก transcript ก่อน แล้วค่อยยกให้ agent เขียนสรุปเอง
- เติม "แท็บไหน" ตั้ง
CC_TAB_LABELต่อแท็บ
แต่ละชั้นทดสอบได้ทันที และของทั้งหมดเป็นเชลล์ล้วน ไม่ต้องลงอะไรเพิ่มนอกจาก jq ที่หลายเครื่องมีอยู่แล้ว
พอเสียงแรกดังขึ้นตอนงานยาวจบ ความรู้สึกมันเปลี่ยนไปเลย จอไม่ใช่ของที่ต้องเฝ้าอีกต่อไป และนั่นคือสิ่งที่ hook ให้ ไม่ใช่แค่เสียง แต่คือปุ่มที่คุณกดเพิ่มนิสัยให้เครื่องมือของตัวเองได้
บทความนี้เป็นหนึ่งชั้น (เครื่องมือ/skill) ใน สถาปัตยกรรม AI agent ระดับ production ทั้ง 7 ชั้น
- Claude Code hooks reference (เอกสารทางการ) · docs.claude.com/en/docs/claude-code/hooks
- Claude Code settings (คีย์
hooksใน settings.json) · docs.claude.com/en/docs/claude-code/settings - สคริปต์และพฤติกรรมทั้งหมดในบทความ ทดสอบจริงบนเครื่องผู้เขียน (macOS, คำสั่ง
sayในตัว) · ตัวอย่างเป็นฉบับย่อที่รันได้เอง