mod로 이벤트에 반응하기 (React to events with a mod)
기준일: 2026-11-03
공식 기준: React to events with a mod
개요
hook은 이름 붙은 이벤트가 일어날 때 Claude Code가 실행하는 함수(이벤트 핸들러)입니다. Claude Code는 도구 실행, 프롬프트 제출, 모델 요청 전송, 세션 시작·종료처럼 무언가를 하려는 시점마다 이벤트를 발생시킵니다. hook은 Claude Code가 행동하기 전에 실행되므로 이벤트를 관찰하거나, 재작성하거나, Claude Code 대신 응답할 수 있습니다. hook은 on(eventName, handler)로 등록합니다.
이 페이지 전에 첫 mod 만들기를 먼저 해 보세요. 모든 이벤트와 정확한 필드는 reference나 빌드용 type declarations에 있습니다.
핵심 개념
| 개념 | 설명 |
|---|---|
$ |
hook의 첫 인자. mods API |
e |
이벤트. 깊게 freeze되어 있어 필드에 대입하면 throw |
next |
다음 핸들러. 다른 mod의 hook이거나 체인 끝에서는 Claude Code 자체 동작. 결과로 resolve |
| middleware chain | 한 이벤트의 핸들러들이 이루는 체인. next를 어떻게 쓰느냐로 관찰·재작성·응답이 갈림 |
| matcher | on의 두 번째 인자. 이벤트 필드와 비교하는 필터 객체 |
| 하려는 것 | 방법 |
|---|---|
| 관찰 | 작업 후 return next(e) (사후 작업은 await next(e) 뒤에) |
| 재작성 | 수정한 사본으로 next({ ...e, ... }) 호출 |
| 응답 | next를 호출하지 않고 결과 반환. 이후 mod와 Claude Code 동작이 실행되지 않음 |
상세
hook이 이벤트를 다루는 방식
이벤트 관찰
이벤트를 바꾸지 않고 보려면 작업을 하고 next(e)를 반환합니다.
on('tool.call', async ($, e, next) => {
// Runs before the tool does
$.ui.log('Claude is about to use ' + e.tool)
// Pass the event on unchanged
return next(e)
})
각 도구가 실행되기 전에 transcript에 ● my-mod: Claude is about to use Bash 같은 흐린 줄이 나옵니다(my-mod는 플러그인 이름). 도구는 mod가 없을 때처럼 실행됩니다.
이벤트 뒤에 작업하려면 await next(e) 후 작업하고 결과를 반환합니다.
on('tool.call', async ($, e, next) => {
// Let the tool run, and wait for its result
const result = await next(e)
// Runs after the tool does
$.ui.log(e.tool + ' finished')
// Give the result back unchanged
return result
})
이번에는 도구가 끝난 뒤 줄이 나옵니다. hook이 next(e) 결과를 그대로 반환하므로 Claude가 읽는 결과는 같습니다.
이벤트 재작성
프롬프트 텍스트처럼 Claude Code가 처리할 내용을 바꾸려면 수정한 사본으로 next를 호출합니다. 이벤트 자체는 immutable입니다(깊게 freeze, 필드 대입 시 throw).
on('prompt.submit', async ($, e, next) => {
// Pass on a copy of the event with its text changed
return next({ ...e, text: e.text.trim() })
})
이후 핸들러와 Claude Code는 trim된 프롬프트만 받고 원본은 보지 못합니다. 결과도 바꿀 수 있습니다: await next(e) 후 필드 하나를 바꾼 사본을 반환합니다.
이벤트에 응답
직접 처리하려면 next를 호출하지 않고 결과를 반환합니다. 체인이 short-circuit되어 이후 mod와 Claude Code 동작이 실행되지 않습니다.
on('tool.call', { tool: 'Bash' }, async () => {
// No call to next, so the command never runs
return { deny: 'Bash is turned off in this project. Use the file tools.' }
})
Claude가 Bash 명령을 시도하면 실행되지 않고, Claude는 deny 텍스트를 도구 결과로 읽습니다. 이벤트별 결과 형태는 reference의 events 절에 있습니다.
hook이 처리할 이벤트 거르기
일부 이벤트에만 hook을 실행하려면 on의 두 번째 인자로 필터(matcher)를 넘깁니다. 객체의 필드를 이벤트 필드와 비교해 모든 필드가 일치할 때만 실행합니다. 필드 값은 단일 값, 허용 값 배열, 정규식이 될 수 있습니다.
// A string matches one value: Bash calls only
on('tool.call', { tool: 'Bash' }, hook)
// An array matches any value in it: Edit calls and Write calls
on('tool.call', { tool: ['Edit', 'Write'] }, hook)
// A regular expression matches by pattern: every tool of one MCP server
on('tool.call', { tool: /^mcp__github__/ }, hook)
hook은 Bash·Edit·Write 호출과 mcp__github__로 시작하는 도구 호출에 각각 한 번 실행됩니다. Read 같은 다른 도구에는 실행되지 않습니다.
- 이벤트 이름에 wildcard를 쓸 수 있습니다.
'classic.*'는 모든 settings hook 이벤트,'*'는 telemetry 이벤트를 제외한 모든 이벤트에 일치합니다. telemetry 이벤트는 자기 이름과{ to: 'collector' }필터로 등록합니다. - 이벤트는 matcher당 한 번만 등록합니다. matcher 없이
session.start에on을 두 번 호출하면 모듈이on("session.start") is registered twice without a matcher로 로드에 실패합니다. 세션 시작 시 할 일은 hook 하나에 모으세요.
Claude가 하는 일에 hook 걸기
도구 호출 막기 또는 바꾸기
tool.call hook은 Claude가 쓰려는 모든 도구를 보고 거부·인자 변경·통과를 결정할 수 있습니다. subagent의 호출과 MCP 도구 호출을 포함해 도구 실행 직전에 발생합니다. e.tool은 도구 이름이고 도구 인자는 e의 필드입니다(예: Bash의 e.command). next(e)를 호출하면 Claude Code가 권한 검사 후 도구를 실행합니다.
force push를 거부하고 이유를 알려 주는 hook:
// The matcher limits the hook to Bash calls, so e.command is the shell command
on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
if (/git push .*--force/.test(e.command)) {
// Returning without calling next answers the event, so the command never runs
return { deny: 'Force pushes are not allowed in this repository. Push to a new branch instead.' }
}
// Every other command goes on to the permission check and then to Bash
return next(e)
})
git push --force를 시도하면 명령이 실행되지 않고 권한 프롬프트도 나오지 않습니다(next를 호출하지 않았으므로). Claude는 deny 텍스트를 도구 결과로 읽으므로 Claude가 따를 수 있는 지시문으로 쓰세요. 다른 Bash 명령은 평소대로 실행됩니다.
도구 실행 뒤 작업하는 예. $.ui.log는 Claude가 읽지 않는 흐린 줄을 transcript에 추가합니다.
on('tool.call', { tool: ['Edit', 'Write'] }, async ($, e, next) => {
// Wait for the permission check and the tool, and keep what they produced
const result = await next(e)
// A refused call comes back as { deny }, and a failed one has isError set
const changed = !result.deny && !result.isError
if (changed && e.file_path.endsWith('.mdx')) $.ui.log('Claude changed ' + e.file_path)
// Return the result as it came, so Claude reads what the tool returned
return result
})
Claude가 .mdx 파일을 편집하거나 쓰면 파일 이름이 흐린 줄로 나옵니다. 다른 종류의 파일, 거부되거나 실패한 호출은 기록하지 않습니다. 받은 결과를 그대로 반환하므로 Claude가 보는 것은 바뀌지 않습니다.
그 밖에 할 수 있는 일:
- 호출 바꾸기: 바꾼 인자를
next에 넘깁니다. - 재시도:
next(e)를 다시 호출합니다. 첫 결과에isError가 있으면 도구를 한 번 더 실행해 그 결과를 반환할 수 있습니다. - 직접 응답:
next를 호출하지 않고result필드가 있는 객체를 반환합니다. 내장 도구라면 빌드용 types에 있는 그 도구 결과의 형태를 맞춥니다. 권한 프롬프트도 도구 실행도 없으므로 반환한 결과가 Claude가 아는 전부입니다. - 결과를 Claude에게서 숨기기:
await next(e)뒤에{ deny: reason }을 반환합니다. Claude는next결과 대신 이유를 읽습니다. 도구가 이미 실행됐다면 결과를 숨길 뿐 도구가 한 일은 되돌리지 않습니다. 도구가 실행되고 성공했다면 이유 앞에Bash ran, and a plugin withheld its result:같은 안내가 붙습니다.
조직의 managed settings hook은 모든 mod의 tool.call hook보다 먼저 실행됩니다. managed hook이 호출을 막으면 그 호출에 대해 어떤 mod의 tool.call hook도 실행되지 않습니다.
사용자가 결정할 때까지 도구 호출 보류
tool.call hook은 next를 호출하거나 반환하기 전에 await할 수 있고, 그동안 도구 호출은 대기합니다. 사용자에게 물으려면 $.ui.ask를 호출합니다. Claude가 질문할 때 쓰는 대화상자에 질문과 번호 매긴 옵션 목록을 보여 주고, 사용자가 고른 라벨로 resolve됩니다. 옵션 뒤에는 다른 답을 입력하는 행과 Chat about this 행이 추가됩니다.
아래 RISKY 패턴은 rm -r, rm -rf, git reset --hard, --force가 붙은 git push에 일치하고, git push -f 같은 다른 표기는 놓칩니다.
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) => {
// Let every other command through without a question
if (!RISKY.test(e.command)) return next(e)
// Start from the safe answer, so a question nobody answers refuses the command
let answer = 'Refuse'
try {
// The tool call waits here until the user picks one of the two labels
answer = await $.ui.ask('Run this command? ' + e.command, ['Run it', 'Refuse'])
} catch {
// The user dismissed the question, or this is a claude -p run with nobody to ask
}
if (answer !== 'Run it') {
// Answer without calling next, so the command doesn't run
return { deny: 'The user declined this command. Ask before trying a different approach.' }
}
return next(e)
})
}
Claude가 rm -rf build 같은 명령을 시도하면 명령이 담긴 질문이 뜨고 명령은 답을 기다립니다.
- Run it 선택: hook이
next(e)를 호출하고, 그 뒤 일반 권한 검사도 그대로 실행됩니다. - Refuse 선택: 명령이 실행되지 않고 Claude는
deny텍스트를 읽습니다. - 직접 입력:
$.ui.ask가 입력 텍스트로 resolve됩니다. hook은Run it과 비교하므로 다른 텍스트는 모두 거부입니다. - 아무도 답하지 않음: 사용자가 질문을 닫거나 Chat about this를 고르거나
claude -p실행이면$.ui.ask가 reject되고,catch가 답을Refuse로 남깁니다.
대기는 $.ui.ask 같은 mods API 호출 안에서 하세요. 그 시간은 hook 시간 제한에 포함되지 않지만, 직접 만든 promise를 기다리는 시간은 포함됩니다. Claude Code는 시간 초과된 hook을 건너뛰므로 보류했던 명령이 실행되어 버립니다.
사용자에게 묻기 전에 도구 호출 허용·거부
도구 호출을 실행해도 되는지는 Claude Code가 그 결정을 내리는 이벤트 tool.check에서 정합니다. 고정된 명령이나 경로라면 코드가 필요 없는 Bash(npm test) 같은 권한 규칙을 쓰세요. tool.check는 현재 Git 브랜치나 다른 hook이 기록한 값처럼 그 순간의 상태에 결정이 달려 있을 때 씁니다.
- 반환값: 직접 결정하려면
{ decision: 'allow' }나{ decision: 'deny', reason }같은 객체를 반환합니다. 결정을 그대로 두려면await next(e)결과를 반환합니다. await next(e)결과: Claude Code 자체 결정.decision이allow,ask,deny인 객체이며,next(e)실행 중에는 권한 프롬프트가 뜨지 않습니다.- 인자:
e.input에 도구 인자가 들어 있습니다(예: Bash의command). - 발생 시점:
PreToolUsehooks 실행 뒤, 그리고 권한 프롬프트, auto mode의 classifier,PermissionRequesthook보다 앞
현재 브랜치가 main일 때 git push를 거부하는 hook:
on('tool.check', { tool: 'Bash' }, async ($, e, next) => {
// Claude Code's own decision
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' }
})
main에서: 규칙이git push를 허용해도deny를 반환합니다.- 다른 브랜치, 다른 명령: mod가 없을 때와 같은 결정을 받습니다.
이 hook은 명령 텍스트를 비교할 뿐이므로 Claude에 대한 리마인더로 여기세요. 모두에게 main push를 막으려면 Git 호스트에서 브랜치를 보호합니다.
참고:
tool.checkhook은allow나ask도 반환할 수 있습니다. plan mode에서 파일 편집처럼 read-only가 아닌 호출에 대해서는next(e)가 반환한 결정을 더 엄격하게 할 수만 있고 완화할 수는 없습니다. hook의allow가 대체할 수 없는 권한 규칙과PreToolUsehook 결정은 Extend permissions with hooks를 봅니다.
도구 호출이 read-only인지 확인
tool.check hook에서 e.isReadOnly는 도구가 그 호출을 read-only로 보고하면 true이고, 그렇지 않으면 false가 아니라 필드가 없습니다. e.isReadOnly는 Claude Code v2.1.304 이상이 필요합니다.
Read, Grep, Glob, WebSearch 같은 도구는 모든 호출을 read-only로 보고합니다. Bash와 MCP 도구는 다음 경우에 read-only로 보고합니다.
- Bash: 명령이 Claude Code 내장 read-only 명령 검사를 통과할 때
- MCP 도구: 서버가 도구의
readOnlyHintannotation을true로 설정했을 때
도구 자체의 보고이므로 이 필드만 보고 호출을 승인하지 마세요. 예를 들어 WebFetch와 Agent 호출은 read-only로 보고되지만, WebFetch는 URL을 요청하고 Agent는 자기 도구를 실행하는 subagent를 시작합니다.
read-only가 아니고 아직 거부되지 않은 모든 mcp__github__ 호출에 ask를 반환하는 hook:
on('tool.check', { tool: /^mcp__github__/ }, async ($, e, next) => {
const decided = await next(e)
// Read-only calls and denied calls pass through unchanged
if (e.isReadOnly || decided.decision === 'deny') return decided
return { decision: 'ask', reason: 'This tool can change the GitHub repository' }
})
read-only 여부로 tool.check hook 거르기
- read-only 호출만: matcher에
isReadOnly: true를 추가합니다. 예:on('tool.check', { tool: 'Bash', isReadOnly: true }, hook) - read-only가 아닌 호출: 위
mcp__github__예처럼 hook 본문에서e.isReadOnly를 검사합니다. matcher에isReadOnly: false를 넣으면 hook이 절대 실행되지 않습니다.
프롬프트 재작성 또는 덧붙이기
prompt.submit hook은 턴이 시작되기 전에 각 프롬프트를 보고 텍스트를 바꾸거나 덧붙일 수 있습니다. e.text가 입력된 내용입니다.
| 하려는 것 | 반환값 |
|---|---|
| 프롬프트 재작성. transcript와 프롬프트 기록에 새 텍스트가 보임 | next({ ...e, text: newText }) |
| 프롬프트 뒤에 Claude만 읽는 텍스트 추가 | next({ ...e, context: [...(e.context ?? []), extraText] }) |
| 프롬프트 전송 중단 | { drop: 'the reason' } |
프롬프트가 pull request를 언급하면 현재 브랜치 이름을 Claude에게 추가하는 hook:
on('prompt.submit', async ($, e, next) => {
// Pass on a prompt that doesn't mention a pull request as it is
if (!/\bPR\b|pull request/i.test(e.text)) return next(e)
const git = await $.process.run(['git', 'branch', '--show-current'])
// Outside a git repository the command fails, so there's no branch to add
if (git.exitCode !== 0) return next(e)
// Keep any context an earlier hook added, and add one more line for Claude
return next({ ...e, context: [...(e.context ?? []), 'Current branch: ' + git.stdout.trim()] })
})
open a PR for this change를 보내면 transcript의 메시지는 그대로이고, Claude는 그 뒤에 Current branch: feature/auth 같은 줄도 읽습니다. pull request를 언급하지 않는 프롬프트는 그대로 통과하고 git도 실행되지 않습니다.
- 프롬프트를 멈추려면
next를 호출하지 않고{ drop: 'the reason' }을 반환합니다. 텍스트는 사용자 프롬프트 입력으로 돌아가고Prompt dropped by a hook:뒤에 이유가 보이므로, 이유는 사용자에게 하는 말로 쓰세요. next(e)로 프롬프트를 통과시킨 뒤drop을 반환하면 턴은 그대로 실행되고, hook은a drop after its next() was answered가 포함된 메시지로 실패합니다.- Claude가 읽는 나머지는 다른 이벤트가 다룹니다: 시스템 프롬프트 섹션마다
prompt.section, 첫 메시지와 함께 보내는 컨텍스트prompt.context, skill 텍스트skill.prompt. 이 hook들의 텍스트가 요청마다 바뀌면 prompt cache가 무효화됩니다.skill.prompthook에서는 skill이 어느 플러그인·파일에서 왔는지도 읽을 수 있습니다(reference 참고).
턴 따라가기
턴은 프롬프트 하나에 답하며 Claude가 하는 모든 일입니다.
| 이벤트 | 발생 시점 | hook이 할 수 있는 일 |
|---|---|---|
turn.start |
턴 시작 | 관찰. e.turnId로 나머지 두 이벤트에서 턴을 식별 |
turn.step |
Claude Code가 모델에 요청 하나를 보내기 직전. 도구 호출이 있는 턴에는 여러 번. subagent 요청이면 e.agentId 설정 |
요청별 토큰 사용량 읽기, next({ ...e, model })로 다른 모델에 보내기, 모델 호출 없이 응답 |
turn.complete |
턴 종료. 사용자가 중단한 턴도 포함(e.isAborted가 true). e.answer는 Claude 최종 텍스트, e.durationMs는 소요 시간, e.usage는 턴 토큰 합계. subagent 턴이면 e.agentId 설정 |
관찰, 또는 { text: 'Done in 12 seconds' }처럼 text 필드가 있는 객체를 반환해 답변 아래 줄 표시 |
turn.step은 스트리밍되므로 async generator로 씁니다. yield* next(e)는 응답을 스트리밍되는 대로 전달하고 완료된 결과로 평가됩니다. 각 요청에서 Claude API가 prompt cache로 처리한 양을 기록하는 hook:
// function* makes the hook a generator, which can pass the response on piece by piece
on('turn.step', async function* ($, e, next) {
// Send the request, forward each piece as it arrives, and keep the finished result
const result = yield* next(e)
// Skip a result that reports no token counts
if (result.usage) {
$.ui.log('cache read ' + result.usage.cache_read_input_tokens + ' · wrote ' + result.usage.cache_creation_input_tokens)
}
// Return the result unchanged, so the turn continues as usual
return result
})
응답은 평소처럼 스트리밍되고, 요청이 끝날 때마다 캐시에서 읽은 토큰 수와 쓴 토큰 수가 흐린 줄로 나옵니다. 도구 호출이 있는 턴은 요청이 여러 번이라 줄도 여러 개입니다.
result.usage는 Claude API가 보고한 요청의 토큰 수(input_tokens,output_tokens,cache_read_input_tokens,cache_creation_input_tokens)와 응답한model을 담습니다.- subagent 요청에도 실행되므로 메인 대화만 원하면
e.agentId를 확인합니다. - advisor tool 호출처럼 요청 중 API가 직접 실행한 도구 호출은
result.serverToolUses에서 봅니다. Claude Code가 실행하는 호출이 아니므로tool.call·tool.checkhook이 발생하지 않습니다. 해당 호출이 없으면 필드가 없으며, Claude Code v2.1.290 이상이 필요합니다.
요청을 다른 모델로 보내기
요청을 다른 모델로 보내려면 yield* next({ ...e, model: 'haiku' })처럼 model을 바꾼 이벤트를 next에 넘깁니다.
model이 haiku 같은 alias일 때 turn.step 요청이 모델 오류로 실패하면 Claude Code v2.1.309 이상으로 업데이트하거나 전체 모델 id를 씁니다. v2.1.309 전에는 alias가 쓴 그대로 전송됐습니다.
설정 읽기 바꾸기 또는 거부하기
settings.read hook으로 설정 읽기를 바꾸거나 거부할 수 있습니다. 대상은 mod의 읽기와 Claude Code 자체 읽기 둘 다입니다.
- mod의 읽기: mod가
$.settings.read를 호출할 때 - Claude Code 자체 읽기: 세션의 mod가 로드된 뒤 Claude Code가 세션 설정을 병합할 때 hook이 처음 실행되고, 설정이나 로드된 플러그인이 바뀔 때 다시 실행됨
어떤 mod가 이 이벤트를 받는지와 Claude Code 버전을 확인하세요.
- 내장 guard: managed settings가 있거나 Team·Enterprise
/login이 세션 자격 증명이면, 내장 guard가 설치했거나--plugin-dir로 로드한 mod의 이 hook을 건너뛸 수 있습니다. debug log에서settings.read bypassed by cc-plugin-sec-default를 검색해 내 mod 이름이 찍힌 줄이 있으면, 조직 관리자에게 그 mod를 조직 mod로 설치해 달라고 요청합니다. - 버전: Claude Code 자체 읽기에서 이벤트가 발생하는 것과
e.layers필드는 Claude Code v2.1.304 이상이 필요합니다.
settings.read hook이 받는 것
| 필드 | 타입 | 내용 |
|---|---|---|
e.layers |
array | 설정을 담고 있는 사용 중인 source마다 { source, settings } 객체 하나. 우선순위가 낮은 것부터 plugin, user, project, local, flag, policy. claude plugin test에서는 비어 있음 |
e.source |
string | $.settings.read({ source: 'policy' })처럼 mod가 요청한 단일 source. 이때 e.layers는 그 layer 하나만 담음. Claude Code 자체 읽기와 그 밖의 병합 설정 읽기에서는 없음 |
e.layers에서 plugin은 활성화된 플러그인이 제공하는 설정이며 layer 이름일 뿐, $.settings.read에 넘길 수 있는 source는 아닙니다. flag는 --settings로 넘긴 설정, policy는 managed settings입니다.
Claude Code 자체 읽기와 mod 읽기 구분
next.origin을 확인합니다.
- Claude Code 자체 읽기:
{ plugin: 'engine', tier: 'core' } - mod의 읽기:
plugin이 호출한 mod의 이름
settings.read hook이 반환할 수 있는 것
읽기를 그대로 두려면 next(e)의 결과를 바꾸지 않고 반환하고, 아니면 다음 중 하나를 반환합니다.
| 반환 | Claude Code 자체 읽기에서 | mod의 읽기에서 |
|---|---|---|
{ value } |
바꾼 키마다 세션 병합 설정의 값 전체를 교체. 단 값이 키의 schema에 맞아야 함 | 그 mod의 호출이 내 value로, 또는 요청한 key를 내 value에서 꺼낸 값으로 resolve. 배열이 아닌 객체 전체를 반환할 것 |
{ deny: reason } |
세션은 어떤 hook도 응답하기 전에 병합된 설정을 유지하고, debug log가 reason을 기록 |
그 mod의 호출이 <calling mod>: $.settings.read: <reason>으로 reject |
Claude Code 자체 읽기에서는 { value }를 next(e)의 value로부터 만드세요. 빠뜨린 키는 unset이 됩니다. env처럼 Claude Code가 source별로 따로 읽는 키는 value에서 바꿔도 효과가 없습니다.
settings.read hook에서 next(e)가 주는 것
next(e)는 { value } 또는 { deny }로 resolve됩니다.
{ value }:value는 병합된 설정,e.source가 설정돼 있으면 그 source 하나의 설정. 내 hook 뒤에 실행되는(아래 "mod 실행 순서" 참고) hook이 자기{ value }를 반환했다면 그 hook의value를 받음{ deny }: 내 hook 뒤에 실행되는 hook이 읽기를 거부한 것. 그{ deny }를 바꾸지 않고 반환
settings.read hook으로 설정 하나 바꾸기
먼저 위에서 어떤 mod가 이 이벤트를 받는지, 어느 버전부터인지 확인하세요.
키 하나를 바꾸려면 next(e)의 value를 펼친 뒤 그 키를 설정합니다. 아래 hook은 spinnerTipsEnabled를 바꿉니다. next.origin을 확인하지 않으므로 Claude Code 자체 읽기와 mod의 병합 설정 읽기 모두에서 키를 바꿉니다.
on('settings.read', async ($, e, next) => {
const read = await next(e)
// Pass through a read of one source, so that only the merged settings change.
// Pass through a { deny } from a later hook too: it has no value.
if (e.source || !read.value) return read
// Keep every other key and change this one
return { value: { ...read.value, spinnerTipsEnabled: false } }
})
Claude Code 자체 읽기에서 hook이 문자열처럼 키의 schema를 벗어난 값을 반환하면 그 키만 hook 응답 전에 병합된 값을 유지합니다. 이때 debug log에는 the answer for <key> is no value the key can hold가 남습니다. 값이 맞는데도 키가 바뀌지 않으면 키가 적용되지 않는 이유를 보세요.
settings hook 이벤트 처리
settings hook은 설정 파일에 구성하는 command·HTTP·prompt·agent hook입니다. Stop, SessionEnd, PostToolUse 같은 각 settings hook 이벤트는 classic. 뒤에 이벤트 이름을 붙인 이벤트(예: classic.Stop)로도 발생합니다. e는 settings hook이 stdin으로 받는 JSON이며 transcript_path를 포함합니다. 내장 guard가 로드되는 곳에서는 설치한 mod의 이 이벤트 hook이 실행되지 않습니다.
Claude가 응답을 마칠 때 발생하는 Stop으로 transcript 저장 위치를 기록하는 hook:
on('classic.Stop', async ($, e, next) => {
// e has the same fields a Stop hook in a settings file reads from stdin
$.ui.log('Transcript saved at ' + e.transcript_path)
// Pass the event on, so Stop hooks in your settings files still run
return next(e)
})
Claude가 응답을 마칠 때마다 transcript 파일 경로가 흐린 줄로 나옵니다. next(e)를 반환하므로 턴 종료 방식은 바뀌지 않고, 설정 파일의 Stop hook도 그대로 실행됩니다.
다른 mod와 함께 실행하기
여러 mod가 같은 이벤트를 처리할 수 있고 어느 것이든 실패할 수 있습니다. 도구 호출을 막는 mod라면 체인에서의 위치와 hook 실패 시 동작을 확인하세요.
mod 실행 순서
같은 이벤트의 hook은 하나의 middleware chain을 이룹니다. 각 mod의 next가 다음 mod의 hook을 호출하고, 마지막 next가 Claude Code 자체 동작에 닿습니다. 첫 mod가 가장 바깥쪽입니다. 다른 mod보다 먼저 이벤트를 보고, 그들보다 나중에 결과를 보며, 그들의 실행 여부를 결정합니다. 뒤의 mod는 앞의 mod가 이벤트를 보는 것을 막을 수 없습니다.
Claude Code는 mod 출처에 따라 체인 순서를 정합니다.
- 내장 guard
sec-default@builtin(/plugin에cc-plugin-sec-default로 표시, 로드되는 경우), 조직이prependPlugins에 나열한 mod, 그 밖에 조직 소속으로 간주되며appendPlugins에 없는 mod - 사용자가 설치한 mod
- 조직이
appendPlugins에 나열한 mod - Claude Code에 내장된 나머지 mod
사용자가 설치한 mod 사이에서는 manifest의 dependencies에 나열한 mod보다 그 mod가 먼저 실행됩니다. 한 모듈 안에서는 register가 on을 호출한 순서대로 실행됩니다.
settings hook이 순서에서 실행되는 위치
설정 파일의 PreToolUse hook도 도구 호출 중 mod 체인의 고정된 지점에서 실행됩니다.
- managed settings의
PreToolUsehook: 첫 mod의tool.callhook보다 먼저 실행. managed hook이 호출을 막으면 그 호출에 대해 어떤 mod의tool.callhook도 실행되지 않음 - 그 외 모든 설정 파일과 플러그인
hooks/hooks.json의PreToolUsehook: 마지막 mod가next를 호출한 뒤, Claude Code 자체 동작의 일부로 실행.next없이tool.call에 응답한 mod는 이들의 실행을 막고,next를 호출한 mod는 반환되는 결과에서 그 결정을 봄
tool.check는 PreToolUse hook 실행 뒤에 발생합니다. hook의 allow가 대체할 수 없는 권한 규칙과 PreToolUse 결정은 Extend permissions with hooks를 봅니다.
실패하는 hook 처리
hook이 실패해도 세션은 깨지지 않으며, 대신 무엇을 할지 정할 수 있습니다. .catch 핸들러가 없는 hook이 throw하거나, 시간 초과되거나, 잘못된 형태의 결과를 반환하면 next 호출 여부에 따라 달라집니다.
next호출 전에 실패: Claude Code가 그 hook을 건너뛰고 다음 핸들러가 대신 실행nextresolve 후에 실패: 그 결과가 유지되고 아무것도 다시 실행되지 않음
mod, 이벤트, 이유를 담은 한 줄이 남습니다(예: my-mod: tool.call hook skipped: threw Error: boom). 어디서 보는지는 세션에 따라 다르며 Find out why a mod does nothing에 정리돼 있습니다. 그림이 검증에 실패한 ui.render hook은 요소로 tree 만들기에 설명된 대로 다르게 보고됩니다.
호출을 막는 hook을 fail closed로 만들려면 대신 응답하는 .catch 오류 핸들러를 붙입니다. 아래에서 guard는 내 hook 함수이고, 핸들러는 next.called로 guard가 실패 시점에 이미 next를 호출했는지 판단합니다.
// on returns a registration, and .catch attaches a handler to that one hook
on('tool.call', { tool: 'Bash' }, guard).catch(async ($, e, next) => {
// guard had already called next, so return what came back
if (next.called) return next(e)
// next.error.kind says why the handler was asked, such as 'throw' or 'timeout'
return { deny: 'The command guard failed, so this command was not run: ' + next.error.kind }
})
guard가 Bash 호출에서 throw하거나 시간 초과되면 Claude Code가 같은 이벤트로 핸들러를 호출합니다.
guard가next호출 전에 실패: 명령이 실행되지 않고, Claude는 끝에 kind가 붙은deny텍스트를 읽음guard가next호출 후에 실패: 핸들러의next(e)는 명령을 다시 실행하지 않고guard의 호출이 낸 결과로 resolve되며, Claude는 그 결과를 읽음
핸들러에는 별도의 더 짧은 시간 제한이 있습니다. 핸들러 자체가 throw하거나 시간 초과되면 Claude Code는 핸들러가 없는 것처럼 hook을 건너뜁니다. guard가 next를 호출하지 않았다면 명령은 mod가 없을 때처럼 진행됩니다.
같은 핸들러 형태를 다른 guard에도 씁니다. next.called가 false일 때 이벤트별 거부 값을 반환합니다.
| 이벤트 | 거부 반환값 | next.called 검사 |
|---|---|---|
prompt.submit |
{ drop: 'the reason' } |
필요 |
config.set |
{ deny: 'the reason' } |
필요 |
tool.check |
{ decision: 'deny', reason: 'the reason' } |
불필요. next resolve 후 반환한 거부도 유효 |
plugin.register |
{ refuse: 'the reason' } (Refuse mods when your check fails 참고) |
불필요. next resolve 후 반환한 거부도 유효 |
tool.call |
{ deny: ... } |
next resolve 후 반환한 deny는 결과를 Claude에게서 숨김 |
체크리스트
- 관찰만 하는 hook은 항상
next(e)또는await next(e)의 결과를 반환한다. - 이벤트를 바꿀 때 필드에 대입하지 않고
{ ...e, ... }사본을next에 넘겼다. - matcher 없이 같은 이벤트를 두 번 등록하지 않았다.
-
deny텍스트를 Claude가 따를 수 있는 지시문으로 썼다. - 사용자 확인 대기는
$.ui.ask같은 mods API 호출 안에서 하고, reject 시 안전한 기본값으로 처리했다. -
e.isReadOnly만으로 승인하지 않았고, matcher에isReadOnly: false를 넣지 않았다. -
turn.stephook을 async generator로 쓰고yield* next(e)로 전달했다. - 호출을 막는 hook에는
.catch핸들러를 붙여 fail closed로 만들었다.
다음 단계
- Use the mods API: 명령·도구 추가, 모델 호출, 타이머 작업을 다룹니다.
- Draw in the interface with a mod: hook이 모은 정보를 pane이나 프롬프트 위에 보여 줍니다.
- Test a mod: 테스트에서 이 이벤트들을 발생시킵니다.
- Mods reference: 모든 이벤트, mods API 메서드, limits를 확인합니다.
기준일
이 문서는 2026-11-03에 공식 React to events with a mod 페이지를 기준으로 작성했습니다. e.isReadOnly(Claude Code v2.1.304 이상)와 result.serverToolUses(v2.1.290 이상), Claude Code 자체 읽기에서의 settings.read·e.layers(v2.1.304 이상), turn.step 모델 alias 해석(v2.1.309 이상)의 버전 요건은 공식 원문에 명시된 값입니다.