Roman Kryvolapov Engineering Blog

Хуки Claude Code

Привіт!

Claude Code можна довго налаштовувати текстом. Писати інструкції у файлі проєкту, заводити правила, пояснювати агентові, чого робити не треба. Працює це рівно доти, доки модель вважає за потрібне вас слухати.

Інструкція — прохання. Хук — код, який виконається завжди, що б модель цього разу не вирішила.

Різниця практична. Написали в інструкціях «форматуй код після правок» — інколи відформатує. Повісили форматер на подію після правки файлу — форматування відбувається щоразу, навіть коли агент про нього не думав.

У цій статті розбираю хуки повністю: будову, усі події, що можна повернути і як це змінює поведінку агента. А потім показую три хуки, які в мене працюють щодня: звук, контроль лімітів підписки та автоматичний журнал роботи.

Чим хук відрізняється від правила, скіла та підагента

Хук — команда, яку Claude Code запускає сам у певні моменти свого життя. Перед викликом інструмента, після правки файлу, на старті сесії, коли ви надіслали запит, коли хід завершився.

Команді на вхід подається JSON з описом події. Вона відповідає кодом виходу і, якщо треба, власним JSON. Відповідь може зупинити дію, підмінити аргументи виклику, додати текст у контекст моделі або не змінювати нічого — тоді хук просто спостерігач.

Щоб не плутатися у способах розширення:

  • Інструкції та правила — текст у контексті. Модель його читає і зазвичай виконує, але це текст, що конкурує за увагу з рештою завдання.
  • Скіли — знання на вимогу. Підвантажуються, коли завдання їм відповідає. Теж текст, тільки ощадливіший.
  • Підагенти — окремі контексти під галасливі завдання, щоб розвідка не забивала основний діалог.
  • MCP-сервери — нові інструменти, якими модель може скористатися. Якщо захоче.
  • Хуки — детермінований код в обхід моделі. Не «може скористатися», а «виконається».

Правильна зв'язка майже завжди така: хук здобуває факти і тримає гарантії, правило пояснює моделі, що з цими фактами робити. На прикладі контролю бюджету нижче видно, що окремо обидві половини марні.

Перший хук за п'ять хвилин

Почнімо з простого: сповіщення, коли агент чекає на вашу увагу.

Відкриваємо файл налаштувань (глобальні лежать у домашньому каталозі, налаштування проєкту — у самому проєкті) і додаємо блок:

~/.claude/settings.json
{
"hooks": {
"Notification": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "notify-send 'Claude Code' 'Агент чекає на вас'"
}
]
}
]
}
}

Структура вкладена і вперше має вигляд надлишкової. Але вона логічна: hooks — об'єкт, де ключ це ім'я події; усередині перелік груп; у групи є фільтр matcher і власний перелік обробників. Кілька обробників в одній групі виконуються паралельно.

На macOS замість notify-send підійде osascript, на Windows — виклик PowerShell.

Перевірити, що хук зареєстровано, можна командою /hooks просто в сесії. Вона показує всі налаштовані хуки з розбивкою за подіями і каже, з якого файлу налаштувань кожен прийшов. Меню лише для читання, редагувати хуки треба в JSON.

Найчастіша помилка першого налаштування: файл уже містить ключ hooks, і нову подію пишуть замість наявних, а не поруч із ними.

Де живуть налаштування

Місце, куди ви поклали хук, визначає область його дії:

ДеОбластьПотрапляє в репозиторій
Налаштування користувача в домашньому каталозіУсі ваші проєктиНі
Налаштування проєктуОдин проєктТак, якщо закомітити
Локальні налаштування проєктуОдин проєктНі, ігноруються гітом
Корпоративні керовані політикиУся організаціяТак, задає адміністратор
ПлагінПоки плагін увімкненоТак, усередині плагіна
Заголовок скіла або агентаПоки компонент активнийТак, усередині компонента

Рівні складаються, а не перекривають один одного. Хук з користувацьких налаштувань і хук із проєктних на одну подію виконаються обидва. Однаковий обробник, описаний у кількох файлах налаштувань, виконається один раз: дедуплікація є. А от копії з плагінів і скілів вважаються окремими, їх не дедуплікують.

Вимкнути все разом можна налаштуванням disableAllHooks. Діє воно в межах свого рівня: хуки з корпоративних політик так не вимкнути. Є і зворотне налаштування, allowManagedHooksOnly, яким організація забороняє користувацькі, проєктні та плагінні хуки цілком.

Правки файлів налаштувань підхоплюються на льоту, перезапускати сесію не потрібно. Якщо хук не з'явився в меню за кілька секунд, шукайте помилку в JSON: висячі коми та коментарі там заборонені.

Як хук спілкується з Claude Code

Обмін гранично простий. JSON на вхід, код виходу і, за бажанням, JSON на вихід.

Що надходить на вхід

На кожній події в стандартне введення хука прилітає об'єкт. Спільні поля є в усіх подій:

{
"session_id": "abc123",
"transcript_path": "/шлях/до/транскрипта.jsonl",
"cwd": "/поточний/каталог",
"permission_mode": "default",
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": { "command": "npm test" }
}

Далі кожна подія додає своє. Перед викликом інструмента надходять його ім'я та аргументи, під час надсилання запиту — текст запиту, на старті сесії — джерело запуску, наприкінці ходу — остання відповідь асистента.

Окремо запам'ятайте поле зі шляхом до транскрипта. Це файл, куди пишеться вся сесія по рядку. Через нього хук дізнається те, чого в самій події немає: якою моделлю йшла робота, скільки токенів пішло на останній запит, які інструменти викликалися за хід. Обидва складні хуки нижче цим користуються.

Ще хук отримує змінні середовища, зокрема корінь проєкту. Це позбавляє мороки з відносними шляхами.

Що можна повернути

Перший спосіб — код виходу:

  • 0 — заперечень немає, дія триває. Для надсилання запиту, розгортання команди та старту сесії все, що хук надрукував у стандартне виведення, додається в контекст моделі.
  • 2 — блокувати. Те, що хук написав у потік помилок, отримає модель як пояснення і зможе виправитися. Блокувати вміють не всі події: старт сесії та сповіщення, наприклад, не блокуються, там повідомлення просто покажуть користувачеві.
  • будь-який інший — помилка, але не блокувальна. Дія триватиме, а в транскрипті з'явиться рядок про збій хука.

Мінімальний блокувальний хук на shell:

.claude/hooks/protect-files.sh
#!/bin/bash
INPUT=$(cat)
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
case "$FILE" in
*.env|*package-lock.json|*.git/*)
echo "Правка $FILE заборонена політикою проєкту" >&2
exit 2
;;
esac
exit 0

Другий спосіб — структурна відповідь: вийти з нулем і надрукувати JSON. Так можна не просто заборонити, а пояснити, перепитати користувача або підмінити дані:

{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "У цьому проєкті замість grep використовується rg"
}
}

Значення рішення: allow пропускає діалог підтвердження, deny скасовує виклик і передає причину моделі, ask показує користувачеві звичайний запит дозволу. У неінтерактивному режимі є ще defer, що залишає виклик підвішеним для зовнішньої обгортки.

Змішувати способи не можна. За коду виходу 2 ваш JSON ігнорується цілком.

Важлива асиметрія: хук може посилити правила, але не може їх послабити. Заборона з налаштувань сильніша за allow з хука. Зате deny з хука працює навіть у режимі, де підтвердження вимкнено зовсім. Це й робить хуки придатними для політик, які користувач не повинен обходити зміною режиму.

Для додавання контексту є окреме поле:

{
"hookSpecificOutput": {
"hookEventName": "UserPromptSubmit",
"additionalContext": "Поточна гілка: release-42. Заморозка деплою до п'ятниці."
}
}

Тонкість, на якій легко втратити пів дня. Поле зобов'язане лежати всередині вкладеного об'єкта. Написане на верхньому рівні, воно мовчки ігнорується: помилки не буде, просто нічого не станеться.

Решта подій використовують власні схеми рішень. Після виклику інструмента і наприкінці ходу це поле decision зі значенням block, під час запиту дозволу — вкладений об'єкт із полем поведінки. Спільні для всіх поля теж є: continue зі значенням «хиба» зупиняє агента зовсім, systemMessage показує попередження користувачеві, suppressOutput ховає виведення хука з транскрипта.

Розмір відповіді обмежено десятьма тисячами символів. Усе, що довше, зберігається у файл, а в контекст потрапляє початок і шлях. Тож хук, який вивалює гігантський лог, втратить більшу його частину.

Усі події

Подій на момент написання більше тридцяти. Переліком вони не запам'ятовуються, тому ось за групами.

Життя сесії:

ПодіяКоли
SessionStartСесія почалася або відновилася
SetupРазова підготовка під час запуску з прапорцями ініціалізації, для CI та скриптів
SessionEndСесія завершилася

Хід діалогу:

ПодіяКоли
UserPromptSubmitЗапит надіслано, але модель його ще не бачила
UserPromptExpansionВведена команда розгорнулася в запит; можна заблокувати
StopМодель завершила відповідати
StopFailureХід обірвався через помилку API; виведення і код повернення ігноруються
MessageDisplayТекст відповіді виводиться на екран; можна підмінити показ, не чіпаючи транскрипт

Цикл інструментів:

ПодіяКоли
PreToolUseПеред викликом інструмента; можна заблокувати або підмінити аргументи
PermissionRequestПотрібне рішення про дозвіл, можна відповісти за користувача
PermissionDeniedВиклик відхилено автоматичним класифікатором
PostToolUseІнструмент відпрацював успішно
PostToolUseFailureІнструмент упав
PostToolBatchРозв'язалася ціла пачка паралельних викликів

Підагенти та завдання:

ПодіяКоли
SubagentStartПідагент запущено
SubagentStopПідагент завершив
TeammateIdleУчасник команди агентів іде в простій
TaskCreatedСтворюється завдання
TaskCompletedЗавдання позначається виконаним

Файли, каталоги та конфігурація:

ПодіяКоли
InstructionsLoadedЗавантажено файл інструкцій або правил
ConfigChangeФайл налаштувань змінився просто під час сесії
CwdChangedЗмінився робочий каталог
DirectoryAddedДо сесії додали ще один каталог
FileChangedЗмінився файл, за яким ви просили стежити
WorktreeCreate / WorktreeRemoveСтворюється або видаляється робоче дерево гіта

Контекст і зовнішній світ:

ПодіяКоли
PreCompact / PostCompactДо і після стиснення контексту
NotificationClaude Code показує сповіщення
Elicitation / ElicitationResultMCP-сервер запитує введення в користувача і отримує відповідь

Половина переліку вам ніколи не знадобиться, і це нормально. На практиці майже все корисне будується на восьми подіях: старт сесії, надсилання запиту, до і після виклику інструмента, сповіщення, кінець ходу, запуск і зупинка підагента.

Три штуки з переліку рідко помічають, а дарма.

Подія зміни конфігурації дозволяє журналювати і навіть забороняти правки налаштувань посеред сесії. Корисно там, де важлива простежуваність.

Подія зміни робочого каталогу закриває старий біль зі змінними середовища. Інструменти на кшталт direnv перемикають їх у вашій оболонці, але не в оболонці агента. Цей хук разом із подією старту сесії лагодить розбіжність:

{
"hooks": {
"SessionStart": [
{ "hooks": [{ "type": "command", "command": "direnv export bash > \"$CLAUDE_ENV_FILE\"" }] }
],
"CwdChanged": [
{ "hooks": [{ "type": "command", "command": "direnv export bash > \"$CLAUDE_ENV_FILE\"" }] }
]
}
}

А подія завантаження інструкцій дає точку, де видно, які саме правила справді доїхали до контексту. Незамінно, коли правило з масками шляхів чомусь не спрацьовує.

Фільтри: matcher і if

Без фільтра хук спрацьовує на кожну появу своєї події. matcher звужує це на рівні групи:

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

Форматер запуститься після правки файлу і не запуститься після читання або виклику команди. Вертикальна риска розділяє альтернативи, у свіжих версіях так само працює кома. Порожній рядок або відсутність поля означають «на все».

Порівняння чутливе до регістру. Це найчастіша причина мовчазного хука, перевіряйте передусім.

У кожної події matcher фільтрує своє поле. У інструментальних подій ім'я інструмента, у старту сесії спосіб запуску (звичайний старт, відновлення, очищення, стиснення, відгалуження), у завершення причину, у сповіщення тип сповіщення, у запуску підагента тип агента, у стиснення ручне воно чи автоматичне, у обриву ходу вид помилки API. Частина подій фільтрів не підтримує зовсім і спрацьовує завжди.

Інструменти MCP іменуються за схемою з подвійними підкресленнями, де посередині стоїть ім'я сервера. Регулярним виразом легко зловити всі інструменти одного сервера або всі операції запису по всіх серверах одразу.

Поле if — тонший фільтр, доступний лише на інструментальних подіях. Воно використовує синтаксис правил дозволів і дивиться не тільки на ім'я інструмента, а й на аргументи:

{
"type": "command",
"if": "Bash(git *)",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/check-git-policy.sh"
}

Процес хука тоді взагалі не породжується, якщо команда не підходить під шаблон. Заощадження помітне, коли хук висить на кожному виклику командного рядка. Розбір при цьому розумний: складені команди через подвійний амперсанд перевіряються частинами, вкладені підстановки теж.

Але він принципово «на удачу». Якщо команду не вдалося розібрати, фільтр пропускає виклик до хука про всяк випадок. Тому жорсткі заборони робляться системою дозволів, а не фільтром хука.

П'ять типів обробників

Більшість хуків — це команда. Але типів п'ять, і решта чотири розв'язують задачі, для яких команда незручна.

Команда. Запускає виконуваний файл або рядок в оболонці. Основний робочий варіант.

HTTP. Надсилає ту саму подію POST-запитом на адресу, а рішення читає з тіла відповіді. Зручно, коли логіку тримає спільний сервіс, наприклад командний аудит викликів інструментів:

{
"type": "http",
"url": "http://localhost:8080/hooks/tool-use",
"headers": { "Authorization": "Bearer $MY_TOKEN" },
"allowedEnvVars": ["MY_TOKEN"]
}

Підстановка змінних середовища в заголовки працює лише для явно перелічених імен, решта залишаться порожніми. Заблокувати дію кодом HTTP не можна: потрібна успішна відповідь із тим самим JSON-рішенням, що й у команди.

Інструмент MCP. Викликає інструмент уже підключеного сервера і розбирає його відповідь як рішення.

Промпт. Рішення ухвалює маленька модель. Ви пишете питання, їй передається подія, назад приходить «так/ні» з причиною. Вихід для випадків, де потрібне судження, а не детермінована ознака:

{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "prompt",
"prompt": "Перевір, чи всі завдання із запиту виконано. Якщо ні — відповідай {\"ok\": false, \"reason\": \"що лишилося\"}."
}
]
}
]
}
}

Негативна відповідь наприкінці ходу повертається моделі як інструкція, і вона продовжує працювати. За замовчуванням використовується швидка дешева модель, але її можна змінити.

Агент. Те саме, але рішення ухвалює повноцінний підагент з інструментами. Він може прочитати файли, запустити тести і лише потім відповісти. Дорожче й повільніше, зате перевіряє реальний стан проєкту:

{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "agent",
"prompt": "Перевір, що юніт-тести проходять. Запусти їх і подивися результат. $ARGUMENTS",
"timeout": 120
}
]
}
]
}
}

Тип експериментальний, для production-процесів надійніша команда.

Правило вибору просте. Даних події вистачає — промпт. Треба подивитися на код і щось запустити — агент. Усе інше — команда.

Як саме запускається команда

Тут розвилка, що пояснює половину дивних помилок на Windows.

Є в обробника поле args — працює exec-форма. command вважається шляхом до виконуваного файлу, аргументи передаються рівно як написані, оболонки немає зовсім. Лапки, долари та зворотні лапки проходять наскрізь без інтерпретації.

Немає поля args — працює shell-форма. Рядок віддається оболонці, яка розкриє змінні, обробить конвеєри та підстановки. На macOS і Linux це sh, на Windows — Git Bash, а якщо його немає, то PowerShell. Оболонку можна призначити явно.

На Windows exec-форма вимагає справжній виконуваний файл. Файли-обгортки, які npm розкладає для своїх утиліт, виконуваними не є і напряму не запускаються. Або кличте через node зі шляхом до скрипта, або переходьте на shell-форму.

Тайм-аути, фон і ліміти

За замовчуванням у команди, HTTP та MCP-обробника є десять хвилин. Але подія надсилання запиту вріже їх до тридцяти секунд, показ повідомлення до десяти, а всім хукам на завершенні сесії виділено півтори секунди на всіх. Промпт-хук чекає тридцять секунд, агентний хвилину.

Довгу роботу вішають у фон прапорцем async. Такий хук не затримує сесію, але й вирішувати нічого не може: його відповідь уже нікому не потрібна.

Є проміжний варіант, asyncRewake. Хук працює у фоні, а якщо завершиться з кодом 2, агента буде розбуджено і він отримає його повідомлення. Єдиний спосіб повідомити про провал довгої фонової перевірки.

Хук перший: звук, коли агент чекає на людину

Далі три хуки з моєї практики. Почну з найменшого.

Проблема побутова. Агент працює кілька хвилин, ви йдете в інше вікно, а він зупиняється на питанні чи запиті дозволу. І чекає, доки ви згадаєте про термінал.

Розв'язання — звук на трьох подіях: кінець ходу, обрив ходу через помилку API, сповіщення.

.claude/settings.json
{
"hooks": {
"Notification": [
{ "matcher": "", "hooks": [{ "type": "command", "command": "\"${CLAUDE_PROJECT_DIR}/.claude/hooks/play-sound\"", "timeout": 15 }] }
],
"Stop": [
{ "matcher": "", "hooks": [{ "type": "command", "command": "\"${CLAUDE_PROJECT_DIR}/.claude/hooks/play-sound\"", "timeout": 15 }] }
],
"StopFailure": [
{ "matcher": "", "hooks": [{ "type": "command", "command": "\"${CLAUDE_PROJECT_DIR}/.claude/hooks/play-sound\"", "timeout": 15 }] }
]
}
}

Сам програвач — маленька програма на Go. Уся логіка у виборі способу відтворення під поточну систему:

main.go
func main() {
defer func() {
_ = recover()
os.Exit(0) // хук ніколи не роняє сесію
}()
sound := os.Args[1]
if _, err := os.Stat(sound); err != nil {
return
}
switch runtime.GOOS {
case "windows":
// вбудований програвач .NET, нічого доставляти не потрібно
play("powershell", "-NoProfile", "-Command",
fmt.Sprintf("(New-Object Media.SoundPlayer '%s').PlaySync()", sound))
case "darwin":
play("afplay", sound)
default:
// Linux: пробуємо відомі програвачі по черзі до першого, що спрацював
for _, c := range [][]string{
{"pw-play", sound}, {"paplay", sound}, {"aplay", "-q", sound},
{"play", "-q", sound}, {"canberra-gtk-play", "-f", sound},
} {
if play(c[0], c[1:]...) {
break
}
}
}
}

Зверніть увагу на перші рядки. Хук зобов'язаний бути нешкідливим: немає звукової карти, немає програвача, немає файлу — мовчки виходимо з нулем. Хук, що падає, перетворює дрібну зручність на постійне джерело помилок у транскрипті.

Якщо сповіщення видаються надто галасливими, у події є фільтри за типом: лише запит дозволу, лише простій в очікуванні відповіді, лише завершення фонової сесії.

Хук другий: живі ліміти підписки

Найкорисніший із трьох і найпоказовіший. Він робить те, чого модель про себе не знає в принципі.

У підписки є п'ятигодинне вікно, тижневе вікно та окремі тижневі кошики за моделями. Агент усередині сесії цих цифр не бачить, тому не може співвідносити витрати. Він однаково охоче запускає віяло з десяти підагентів і коли до скидання ліміту чотири години, і коли бюджет уже на межі. Закінчується це тим, що ліміт вибивається посеред завдання.

Хук закриває чотири ролі одразу.

Дістає живі цифри. Клієнт під час входу зберігає OAuth-токен локально. Хук читає його і запитує в API поточне споживання, жодних ключів API заводити не потрібно:

func fetchUsage() json.RawMessage {
b, _ := os.ReadFile(credPath) // токен, збережений під час входу
var cred struct {
ClaudeAiOauth struct {
AccessToken string `json:"accessToken"`
} `json:"claudeAiOauth"`
}
if json.Unmarshal(b, &cred) != nil || cred.ClaudeAiOauth.AccessToken == "" {
return nil
}
req, _ := http.NewRequestWithContext(ctx, http.MethodGet,
"https://api.anthropic.com/api/oauth/usage", nil)
req.Header.Set("Authorization", "Bearer "+cred.ClaudeAiOauth.AccessToken)
// ... відповідь кешується на хвилину, щоб не ходити в мережу на кожен запит
}

Дані спільні на весь акаунт: вони враховують і інші ваші сесії, що якраз і потрібно.

Показує їх у рядку стану. Рядок стану налаштовується там само, де хуки, і оновлюється постійно. Картинка весь час перед очима:

Верхній рядок — заповненість контекстного вікна: скільки токенів ніс останній запит за якого розміру вікна. Нижче ліміти: п'ятигодинне вікно, тижневе та окремий тижневий кошик моделі, якою зараз іде робота.

Тут захована техніка, яку варто забрати собі незалежно від Claude Code. Кожен ліміт показано двома рядками. Верхній — скільки бюджету витрачено, нижній — скільки часу вікна вже минуло.

Сама по собі шкала «витрачено 35 %» не каже ні про що. Значення з'являється, коли поруч стоїть друга. Бюджетна шкала обганяє часову — до скидання ви не дотягнете. Відстає — можна працювати спокійно. Дві безглузді окремо цифри разом дають готове рішення.

Кладе ті самі цифри в контекст моделі. На старті сесії та на кожному запиті хук друкує ту саму таблицю в стандартне виведення і виходить з нулем. Для цих подій цього досить, щоб текст потрапив у контекст, окремий JSON не потрібен. До таблиці додаються два рядки, яких у рядку стану немає: темп витрат із прогнозом, коли бюджет скінчиться, і поточна зона з вказівкою, який із лімітів зараз зв'язувальний.

Забороняє віяло агентів на межі бюджету. Ось заради чого все це затівалося. Хук висить на події перед викликом інструмента з фільтром за інструментами запуску підагентів і робочих процесів:

{
"PreToolUse": [
{
"matcher": "Agent|Task|Workflow",
"hooks": [
{ "type": "command", "command": "\"${CLAUDE_PROJECT_DIR}/.claude/hooks/usage-limits\" --mode gate", "timeout": 20 }
]
}
]
}

А сам заслін у гарячій зоні відповідає рішенням:

if st.zone == "RED" || st.zone == "ORANGE" {
decision := "ask"
if st.zone == "RED" {
decision = "deny" // віяло підагентів спалить залишок бюджету
}
out := map[string]any{
"hookSpecificOutput": map[string]any{
"hookEventName": "PreToolUse",
"permissionDecision": decision,
"permissionDecisionReason": reason, // сюди йдуть цифри і порада, що робити натомість
},
}
b, _ := json.Marshal(out)
fmt.Print(string(b))
}

У червоній зоні запуск підагента скасовується, в помаранчевій виноситься на підтвердження користувачеві. Причина йде моделі текстом, і вона перебудовує план: працює в одному потоці замість віяла.

Ще одна деталь, якої не отримати з самої події, — заповненість контекстного вікна. Хук читає хвіст файлу транскрипта, знаходить останній запис із витратами токенів і складає свіже введення з читанням і записом кешу. Виходить розмір останнього запиту. Записи підагентів при цьому пропускаються: у них свій контекст, їхні числа до основного діалогу стосунку не мають.

І друга половина зв'язки. Хук приносить факти, але факт сам по собі поведінки не змінює. Поруч лежить правило, що пояснює, як ці цифри читати: порівнювати бюджетну шкалу з часовою, співвідносити глибину опрацювання зі складністю завдання, рахувати віяло з N агентів за N-кратні витрати, а на підході до порога звужувати обсяг роботи, а не витрачати до стелі.

Кожна половина окремо марна. Правило без хука спирається на вигадку: модель не знає своїх лімітів і починає їх винаходити. Хук без правила видає числа, з якими ніхто нічого не робить. Разом виходить економія, що працює, і це, як на мене, головний рецепт з усієї статті.

До речі, текст правила тут — звичайний промпт, і пишеться він за тими самими техніками: явна таблиця порогових значень замість розпливчастого «заощаджуй», описаний сценарій провалу, чек-лист наприкінці. Розбір технік у мене в статті про промпт-інжиніринг, а про те, як влаштовані правила та скіли загалом, — у статті про практики роботи з Claude Code.

Хук третій: автоматичний журнал роботи

Третій хук відповідає на питання «що взагалі відбувалося в цій сесії».

Запитати про це саму модель — погана ідея. Вона перекаже свою роботу з пам'яті, а пам'ять наприкінці довгої сесії вже стиснута: частина деталей втрачена, частина переказана оптимістично. Потрібен механічний запис, зроблений не моделлю.

Хук слухає майже всі події сесії: старт і кінець, кожен надісланий запит, кінець ходу та його обрив, запуск і зупинку підагентів, стиснення контексту. З інструментальних подій — тільки питання до користувача. Пише один файл на сесію, з розбивкою за автором і датою.

Виходить приблизно так:

management/logs/roman/2026-08-06/a1b2c3d4.log
session start: source=startup · model=claude-opus-5
════════════════════════════════════════════════════════════
prompt:
полагодь нескінченний редирект на протухлому токені, BUG-003
turn: model=claude-opus-5 · effort=high · 4m 12s · tools=23
(Read 9, Grep 5, Edit 3, Bash 6) · subagents=1
files:
~ apps/web/src/api/interceptors.ts +14/-6 +[41-48,52-57] -[41-46]
~ apps/web/tests/auth.spec.ts +31/-0 +[88-118]
answer:
Причина була в тому, що перехоплювач повторював запит зі старим токеном...
session end: reason=prompt_input_exit · duration=51m

Три прийоми з цього хука варті окремої згадки: їх не виведеш із документації.

Підагентів не можна рахувати за викликами інструментів. Віяльний запуск — це один виклик інструмента, що породжує десятки агентів. У транскрипті видно одиницю, насправді тридцять. Тому лічильник набивається за подіями запуску підагента, а рядок ходу його читає й обнуляє:

// кожен SubagentStart дописує один байт у лічильник сесії; запис лише
// в кінець, тому агенти, що стартують одночасно, не затирають одне одного
func noteSubagent(session8 string) {
f, err := os.OpenFile(tallyPath(session8), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil {
return
}
defer f.Close()
_, _ = f.WriteString("x")
}

Повідомлення, набране під час ходу, до хука не доходить. Воно не надсилається, а стає в чергу, тому подія надсилання запиту по ньому не спрацьовує. У журналі з'являлася б відповідь на питання, якого там немає. Такі повідомлення витягуються з транскрипта наприкінці ходу і записуються окремою позначкою перед тим ходом, який вони перервали.

Сповіщення про завершене фонове завдання приходить тією самою подією, що й запит користувача. Записати його як «користувач попросив» означає приписати людині слова, яких вона не казала. Такі повідомлення розпізнаються за початком тексту і пишуться окремим рядком.

Зміни файлів рахуються через діф гіта відносно останнього коміту, з точністю до діапазонів рядків. Гіт при цьому необов'язковий: немає його або каталог не репозиторій — статистика деградує до простого переліку шляхів, а решта пишеться як завжди.

Усі хуки цього журналу висять у фоні. Вони нічого не вирішують, тільки записують, затримувати через них сесію ні до чого.

Чому компільований бінарник, а не скрипт

Усі три хуки — програми на Go, які запускає тонкий перехідник на shell. Вибір неочевидний, поясню.

Скрипт на Node або Python має вигляд простішого рівно до моменту, коли хук починає висіти на кожному надісланому запиті. Старт інтерпретатора — це десятки, а то й сотні мілісекунд на кожну подію. Плюс залежність від того, що потрібне середовище виконання встановлено і потрібної версії. Скомпільований бінарник стартує миттєво і не залежить ні від чого.

Другий доказ — переносимість. У репозиторій кладуться зібрані бінарники під три системи, а хук у налаштуваннях указує на маленький перехідник:

.claude/hooks/usage-limits
#!/bin/sh
case "$(uname -s 2>/dev/null)" in
Darwin*) name="usage-limits-darwin-arm64" ;;
Linux*) name="usage-limits-linux-amd64" ;;
MINGW*|MSYS*|CYGWIN*) name="usage-limits-windows-amd64.exe" ;;
*) name="usage-limits-linux-amd64" ;;
esac
# ... знайти бінарник поруч із собою і передати йому аргументи та стандартне введення
exec "$root/scripts/claude-code/$name" "$@"
# бінарника немає — виходимо мовчки: хук не має права ламати сесію
exit 0

Колезі, який склонував репозиторій, не потрібен ні Go, ні Node. Тільки сам Claude Code. Компілятор потрібен лише тому, хто хук змінює.

Тут-таки граблі, що коштували мені вечора. На Windows Claude Code запускає хуки через Git Bash, а Git Bash не перетравлює перший рядок файлу з віндовим переведенням рядка: виходить «поганий інтерпретатор». Файл-перехідник зобов'язаний зберігатися з юніксовими переведеннями рядків, і це треба закріпити в налаштуваннях гіта:

.gitattributes
.claude/hooks/* text eol=lf

Інакше автоматичне перетворення одного дня все зламає. Мовчки і на чужій машині.

Правила, які заощаджують вечори

Практика, накопичена на трьох хуках і десятці налагоджень.

Хук не має права роняти сесію. Будь-яка помилка — мовчазний вихід з нулем. Ненульовий код повернення від допоміжного хука перетворюється на потік повідомлень про помилки на кожному ході.

У стандартне виведення тільки те, що задумано. Якщо хук повертає JSON, туди не повинно потрапити ані рядка зайвого. Класичний випадок: профіль оболонки друкує привітання, воно приклеюється до JSON, розбір падає. Лікується перевіркою на інтерактивність у профілі:

~/.bashrc
if [[ $- == *i* ]]; then
echo "Shell ready"
fi

Налагоджувальні повідомлення в потік помилок. Він не заважає розбору і видно його в налагоджувальному лозі.

Швидкість важливіша, ніж здається. Хук на надсиланні запиту стоїть на критичному шляху: кожні зайві пів секунди ви чекаєте на кожному повідомленні. Мережеві запити кешуйте.

Ідемпотентність. Той самий хук може спрацювати двічі, паралельно, з двох сесій одразу. Дописування в кінець файлу це переживає, перезапис ні.

Правки після ходу варто перевіряти цілком. Модель змінює файли не лише інструментами правки, а й командами оболонки, і фільтр за інструментами правки таких змін не побачить. Потрібна повнота — додайте перевірку робочого дерева наприкінці ходу.

Обережно з хуком на завершенні ходу. Він уміє змусити агента продовжити роботу, і на цьому легко побудувати нескінченний цикл. Захист є, після восьми поспіль блокувань хук перебивається, але краще самому дивитися на поле, яке повідомляє, що продовження вже викликане цим самим хуком:

Terminal window
INPUT=$(cat)
if [ "$(echo "$INPUT" | jq -r '.stop_hook_active')" = "true" ]; then
exit 0 # уже крутимося з нашої ж вимоги — виходимо
fi

Тільки один хук має підміняти аргументи виклику. Хуки йдуть паралельно, за двох правок перемагає та, що завершилася останньою. Порядок недетермінований.

Безпека

Скажу прямо, бо хуки — готовий механізм виконання довільного коду.

Хук виконується автоматично, з вашими правами, без жодного підтвердження. І отримує на вхід вміст запитів і шлях до транскрипта. Чужа конфігурація, знайдена в інтернеті або надіслана з pull request'ом, — це не «налаштування редактора», а код, який почне виконуватися у вас під час наступного запуску.

Звідси кілька практичних наслідків. Хуки проєкту, що лежать у репозиторії, варто переглядати в рев'ю нарівні з кодом. Секрети в командах хуків не пишуться: файл налаштувань потрапляє в репозиторій, для токенів є змінні середовища. Для HTTP-хуків в організації передбачено перелік дозволених адрес і перелік змінних, які взагалі дозволено підставляти в заголовки. Адміністратор може заборонити користувацькі та проєктні хуки цілком, залишивши тільки корпоративні.

З боку самого Claude Code обмеження теж є: на macOS і Linux хуки запускаються без керувального термінала, тому не можуть писати просто в інтерфейс або підсовувати в нього керувальні послідовності.

Налагодження

Спершу три очевидні перевірки, які закривають більшість випадків «не працює».

Команда /hooks показує, чи зареєстровано хук і з якого файлу налаштувань він прийшов. Немає його там — річ у JSON або в розташуванні файлу. Є, але не спрацьовує — майже напевно не збігся фільтр.

Хук зручно тестувати вручну, без агента. Йому потрібен лише JSON у стандартному введенні:

Terminal window
echo '{"tool_name":"Bash","tool_input":{"command":"ls"}}' | ./my-hook.sh
echo $?

Повна картина в налагоджувальному лозі: які хуки зіставилися, з якими кодами вийшли, що надрукували. Вмикається прапорцем під час запуску або командою посеред сесії.

Типові діагнози за симптомами:

  • «command not found» — відносний шлях. Візьміть змінну з коренем проєкту або перейдіть на exec-форму.
  • «jq: command not found» — утиліту не встановлено. Або ставте, або розбирайте JSON тим, що вже є.
  • Хук не запускається взагалі на macOS або Linux — забуто біт виконання.
  • Помилка розбору JSON за завідомо коректного виведення — стороннє виведення з профілю оболонки.

Що вішати на хуки, а що ні

Хороші кандидати — усе, де потрібна гарантія, а не намір:

  • форматування та лінт після правок;
  • заборона на зміну чутливих файлів і небезпечних команд, з поясненням, яке модель прочитає і врахує;
  • сповіщення, коли агент чекає на людину;
  • додавання фактів у контекст: гілка, середовище, ліміти, стан стенда;
  • журналювання та аудит, особливо там, де потрібна простежуваність;
  • відновлення контексту після стиснення;
  • автопідтвердження вузького, заздалегідь обраного класу запитів дозволу.

Погані кандидати:

  • складна логіка в хуці на кожному запиті — ви платите за неї затримкою на кожному повідомленні;
  • жорсткі заборони, які зобов'язані бути непробивними: для цього є система дозволів, а фільтр хука за аргументами працює «на удачу»;
  • усе, що зручніше сказати словами: поведінка не критична і модель зазвичай і так робить правильно — правило дешевше;
  • широке автопідтвердження дозволів: фільтр виду «на все» перетворює підтвердження на фікцію, зокрема запис файлів і запуск команд.

І головне, заради чого все це. Хуки — єдина частина Claude Code, яка не залежить від того, в якому настрої сьогодні модель. Усе, що має відбуватися завжди, має бути хуком. Решту можна залишити словам.

Про те, як організована робота навколо агента загалом (інструкції проєкту, правила, скіли, облік завдань, керування контекстом), у мене є окрема стаття.