훅을 쓰면 Claude Code 생명주기의 정확히 정해진 지점에 부수 효과를 끼워 넣을 수 있습니다. 도구가 실행되기 전, 샘플링이 끝난 뒤, 세션이 시작될 때, 파일이 바뀔 때 같은 순간들입니다. 각 훅은 설정 파일에 저장된 matcher + command 쌍이며, 세션 훅은 메모리에만 저장됩니다. 이벤트가 발생하면 Claude Code는 일치하는 훅을 모두 찾고 전부 실행한 뒤, exit code와 stdout을 해석해 계속 진행할지, 막을지, 아니면 출력을 모델에 다시 넣을지 결정합니다.
훅은 가장 중요한 확장 지점입니다. 저장 시 lint 실행, 정책 강제, 관측성 파이프라인, 구조화된 검증 에이전트 같은 기능을 Claude Code 자체를 수정하지 않고도 구현할 수 있습니다.
src/entrypoints/sdk/coreTypes.ts의 HOOK_EVENTS const 배열에 있습니다. 훅 실행 로직은 src/utils/hooks.ts와 src/utils/hooks/ 아래의 개별 exec*Hook.ts 파일들에 있습니다. 설정은 src/utils/hooks/hooksConfigSnapshot.ts에서 읽습니다.
이벤트는 카테고리별로 묶여 있습니다. 각 배지는 settings.json과 TypeScript 소스에 실제로 나타나는 이벤트 이름을 보여줍니다.
아래 다이어그램은 단일 PreToolUse 이벤트의 실행 경로를 보여줍니다. 다른 이벤트도 모양은 같습니다. 이벤트용 훅 수집 → 순서대로 matcher 실행 → 결과 집계 → 결정.
exit code 2와 그 밖의 non-zero를 가르는 구분이 핵심 설계입니다. exit 2는 모델에게 보이는 blocking이고, 다른 non-zero는 사용자에게만 보이는 잡음입니다.
각 이벤트는 자기만의 exit code 의미를 정의합니다. 아래는 이벤트 전체를 가로지르는 요약입니다. 이벤트별 전체 설명은 hooksConfigManager.ts → getHookEventMetadata()에서 읽어보세요.
| Exit code | 효과 |
|---|---|
| 0 | stdout/stderr를 보여주지 않음, 도구 계속 실행 |
| 2 | stderr를 모델에 표시, 도구 호출 차단 |
| other | stderr를 사용자에게만 표시, 도구 계속 실행 |
| Exit code | 효과 |
|---|---|
| 0 | stdout를 transcript 모드(Ctrl+O)에서 표시 |
| 2 | stderr를 즉시 모델에 표시 |
| other | stderr를 사용자에게만 표시 |
| Exit code | 효과 |
|---|---|
| 0 | stdout/stderr를 보여주지 않음, 세션 종료 |
| 2 | stderr를 모델에 표시, 대화 계속됨, stop 방지 |
| other | stderr를 사용자에게만 표시, 세션 종료 |
StopFailure는 턴이 API 오류, rate limit, auth failure, 로 끝났을 때 Stop 대신 실행됩니다. 이 이벤트는 fire-and-forget이며, exit code와 출력은 무시됩니다.
| Exit code | 효과 |
|---|---|
| 0 | stdout를 모델 컨텍스트에 주입, Claude에게 표시됨 |
| 2 | 처리를 차단하고, 원래 프롬프트를 지우고, stderr를 사용자에게 표시 |
| other | stderr를 사용자에게만 표시 |
| Exit code | 효과 |
|---|---|
| 0 | stdout를 Claude에게 표시, 세션용 시드 컨텍스트 |
| 2 | 두 이벤트 모두 blocking error를 무시 |
| other | stderr를 사용자에게만 표시 |
SessionStart는 source matcher를 지원하며 값은 startup, resume, clear, compact입니다. Setup은 trigger matcher를 지원하며 값은 init, maintenance입니다.
| Exit code | 효과, PreCompact |
|---|---|
| 0 | stdout를 사용자 정의 compact 지침으로 덧붙임 |
| 2 | 압축 차단 |
| other | stderr를 사용자에게 표시, 압축은 계속 진행 |
PostCompact에서는 exit 0일 때 stdout를 사용자에게 보여주고, 다른 exit code에서는 stderr만 사용자에게 보여줍니다.
두 이벤트 모두 CLAUDE_ENV_FILE을 설정합니다. 그 파일 경로에 bash export 문을 쓰면 이후의 BashTool 명령에 적용됩니다. 두 이벤트 모두 exit code 2 기반 차단은 지원하지 않으며, non-zero 종료는 stderr를 사용자에게만 보여줍니다.
FileChanged는 stdout JSON 안의 hookSpecificOutput.watchPaths도 지원하며, 이것으로 파일 watcher에 추가 경로를 동적으로 등록할 수 있습니다. matcher 필드는 파이프 구분 파일명 glob, 예: .envrc|.env, 으로 사용됩니다.
type 판별 필드가 실행 엔진을 선택합니다. function을 제외한 모든 타입은 settings.json에 저장할 수 있고, function은 세션 전용이며 TypeScript 코드에서 정의됩니다.
설정된 셸을 통해 서브프로세스를 띄웁니다. 기본값은 bash 또는 사용자의 $SHELL이며, powershell도 지원합니다. 훅 입력은 stdin으로 JSON 형태로 전달됩니다. stdout/stderr 해석은 위의 exit code 규칙을 따릅니다.
주요 옵션: if, timeout, once, async, asyncRewake, statusMessage, shell
LLM에 프롬프트를 보냅니다. 기본값은 small fast model입니다. 프롬프트 안의 $ARGUMENTS는 JSON 훅 입력 자리표시자로 쓰입니다. 모델은 {"ok": true} 또는 {"ok": false, "reason": "..."}로 응답해야 합니다. 강제 JSON schema 출력 모드 덕분에 항상 파싱 가능한 응답만 나옵니다.
주요 옵션: if, timeout, 기본 30초, model, once, statusMessage
모든 도구에 접근할 수 있는 완전한 멀티턴 서브에이전트를 띄웁니다. 최대 50턴입니다. 에이전트는 system prompt로 주입된 경로에서 대화 transcript를 읽고, StructuredOutput 도구를 호출해 {"ok": true/false, "reason": "..."}를 반환합니다. 재귀를 막기 위해 허용되지 않은 도구, AgentTool과 plan mode, 는 걸러집니다.
주요 옵션: if, timeout, 기본 60초, model, once, statusMessage
훅 입력 JSON을 설정된 URL로 POST합니다. 응답 해석은 호출자가 맡습니다. 헤더 값 안에서는 env var 치환을 지원하지만, allowedEnvVars에 있는 변수만 해석됩니다. private/link-local IP 대역을 막는 SSRF guard로 보호되며, loopback, 127.x, 은 의도적으로 허용됩니다.
주요 옵션: if, timeout, 기본 10분, headers, allowedEnvVars, once, statusMessage
addFunctionHook()으로 코드에서 등록하는 in-process TypeScript 함수입니다. boolean 또는 Promise<boolean>을 반환합니다. 세션 범위 전용이라 settings.json에 저장할 수 없습니다. 내부적으로는 skill improvement system과 structured output enforcement에 쓰입니다.
주요 옵션: id, 나중에 제거할 때 사용, timeout, 기본 5초, errorMessage
prompt 훅은 queryModelWithoutStreaming으로 모델에 질의하고, JSON schema 출력 모드로 파싱 가능한 응답을 강제합니다. 이 과정은 UserPromptSubmit 훅을 트리거하지 않습니다. 그러면 무한 재귀가 되기 때문입니다. 같은 패턴이 agent 훅에도 적용됩니다.
훅은 여섯 가지 소스에서 올 수 있습니다. 같은 이벤트 + matcher 조합에 여러 소스의 훅이 있으면 병합되고 모두 실행됩니다. settings/constants.ts의 SOURCES에 정의된 우선순위는 /hooks에서의 표시 순서를 결정할 뿐이며, 실행 자체는 전부 일어납니다.
| 우선순위 | 소스 이름 | 파일 경로 | 범위 |
|---|---|---|---|
| 1 | userSettings |
~/.claude/settings.json |
이 사용자의 모든 프로젝트 |
| 2 | projectSettings |
.claude/settings.json (프로젝트 루트) |
이 저장소에서 일하는 모든 사람 |
| 3 | localSettings |
.claude/settings.local.json |
내 컴퓨터 + 이 프로젝트만 |
| 4 | policySettings |
MDM / managed config, 읽기 전용 | 엔터프라이즈 관리자가 강제 |
| 5 | pluginHook |
~/.claude/plugins/*/hooks/hooks.json |
플러그인이 설치한 훅 |
| 6 | sessionHook |
메모리 전용 | 현재 세션, 종료 시 제거됨 |
allowManagedHooksOnly: true가 설정되면 managed 훅만 실행되고, user, project, local, plugin 훅은 모두 막힙니다. managed 설정에 disableAllHooks: true가 있으면 managed를 포함해 훅이 하나도 실행되지 않습니다. 반대로 disableAllHooks가 non-managed 소스에 있으면 managed 훅은 여전히 실행됩니다. non-managed 설정으로는 managed 훅을 막을 수 없습니다.
훅 이벤트 배열의 각 항목은 HookMatcher입니다. 선택적인 matcher 문자열과 훅 명령 배열을 가진 객체입니다. matcher를 지원하는 이벤트, 예를 들어 PreToolUse는 tool_name으로, Notification은 notification_type으로, SessionStart는 source로 매칭합니다. 문자열이 맞는 훅만 실행되고, matcher가 없는 훅은 조건 없이 실행됩니다.
// ~/.claude/settings.json — 최소 예시 { "hooks": { "PreToolUse": [ { "matcher": "Write", // tool_name === "Write"일 때만 실행 "hooks": [ { "type": "command", "command": "./scripts/pre-write.sh" } ] }, { // matcher가 없으면 모든 tool call에서 실행 "hooks": [ { "type": "command", "command": "./scripts/audit-log.sh" } ] } ] } } settings.json
세션 훅은 메모리에만 존재하며, Map<string, SessionStore>에 저장된 특정 sessionId에 붙습니다. sessionHooks.ts의 세 함수로 코드에서 생성합니다.
// 이 세션에 command/prompt/agent/http 훅 추가 addSessionHook(setAppState, sessionId, 'Stop', '', { type: 'command', command: './verify-output.sh' }) // TypeScript 콜백 훅 추가, function 타입 const hookId = addFunctionHook( setAppState, sessionId, 'Stop', '', // matcher, 비어 있으면 전체 (messages, signal) => checkCondition(messages), // 콜백 '조건을 만족하지 못했습니다', // false일 때의 errorMessage { timeout: 5000, id: 'my-hook' } // 선택 사항 ) // ID로 function 훅 제거 removeFunctionHook(setAppState, sessionId, 'Stop', hookId) sessionHooks.ts API
세션 훅은 reactive하지 않습니다. 평범한 객체가 아니라 Map 안에 있기 때문에 상태 업데이트 중에도 Object.is(next, prev)가 true를 반환하고, 불필요한 리렌더가 일어나지 않습니다. Map은 제자리에서 mutate되고, 세션 범위 데이터만 바뀝니다.
skill이나 agent가 로드되면 해당 frontmatter의 hooks 섹션이 registerSkillHooks() 또는 registerFrontmatterHooks()를 통해 세션 훅으로 등록됩니다. 이 훅들은 세션, 또는 agent, 가 살아 있는 동안에만 존재합니다.
한 가지 세부 사항이 있습니다. agent는 Stop이 아니라 SubagentStop을 트리거합니다. registerFrontmatterHooks()는 agent frontmatter 안의 Stop 항목을 자동으로 SubagentStop으로 바꿔서 올바른 시점에 실행되게 합니다.
"once": true가 들어 있으면 registerSkillHooks()가 onHookSuccess 콜백을 붙이고, 첫 성공 실행 뒤 removeSessionHook()을 호출합니다. 이것이 one-shot 초기화 패턴의 핵심 메커니즘입니다.
command 훅은 두 가지 async 플래그를 지원합니다. async: true이면 훅 프로세스를 시작하지만 Claude는 기다리지 않고 즉시 대화를 이어갑니다. 프로세스는 AsyncHookRegistry에 추적되고 메인 루프에서 polling됩니다.
asyncRewake: true이면 훅은 백그라운드에서 돌지만, 프로세스가 code 2로 끝났을 때 메인 루프가 모델을 다시 깨웁니다. 즉, 훅 자체는 비동기로 실행됐더라도 백그라운드 watcher가 Claude의 다음 응답을 blocking error로 끊어낼 수 있습니다.
{ "PostToolUse": [ { "matcher": "Bash", "hooks": [ { "type": "command", "command": "./run-tests-background.sh", "asyncRewake": true // 테스트 실패 시, exit 2, 모델을 다시 깨움 } ] } ] } asyncRewake 예시
레지스트리는 대기 중인 각 훅을 processId로 추적합니다. 메인 루프가 checkForAsyncHookResponses()로 polling하면 완료된 훅을 마무리하고, stdout에서 JSON SyncHookJSONOutput 응답을 파싱합니다. 없으면 응답 객체 기본값은 {}입니다.
HTTP 훅은 JSON을 URL로 POST합니다. 보안 모델은 세 겹으로 되어 있습니다.
allowedHttpHookUrls가 있으면, 목록의 glob 패턴, 와일드카드는 *, 과 일치하는 URL만 허용됩니다. 빈 배열이면 모든 HTTP 훅이 막힙니다.$VAR_NAME 치환을 넣을 수 있지만, 훅의 allowedEnvVars 배열에 있는 변수만 실제로 해석됩니다. 목록에 없는 변수는 빈 문자열로 바뀌어, 프로젝트 훅을 통한 secret 유출을 막습니다.ssrfGuardedLookup()가 가로채며, private IP 대역, 10.x, 172.16-31.x, 192.168.x, 169.254.x, 100.64-127.x, 과 unspecified/link-local IPv6를 막습니다. loopback, 127.0.0.1, ::1, 은 로컬 개발 서버용으로 의도적으로 허용됩니다. sandbox proxy나 env-var proxy가 활성화되면 guard는 우회됩니다. 프록시가 자기 DNS를 수행하므로, 프록시 자신의 IP를 막아버리면 private network 위의 사내 프록시가 깨지기 때문입니다.{ "Stop": [ { "hooks": [ { "type": "http", "url": "https://api.example.com/claude-stop-event", "headers": { "Authorization": "Bearer $MY_TOKEN" }, "allowedEnvVars": ["MY_TOKEN"], "timeout": 10 } ] } ] } settings.json, env var 인증이 있는 http 훅
헤더 값의 $MY_TOKEN 참조는 MY_TOKEN이 allowedEnvVars에 들어 있기 때문에만 해석됩니다. 같은 헤더 템플릿 안의 다른 $VAR는 조용히 빈 문자열로 바뀝니다. 헤더 값은 HTTP 헤더 주입을 막기 위해 CR/LF/NUL도 제거됩니다.
prompt 훅은 queryModelWithoutStreaming으로 LLM에 메시지를 보냅니다. system prompt는 모델에게 {"ok": boolean, "reason"?: string} 형태의 JSON으로 응답하라고 지시합니다. 출력 형식은 JSON schema 출력 모드로 강제되기 때문에 파싱 불가능한 값을 만들 수 없습니다.
훅은 전송 전에 인자 자리표시자, $ARGUMENTS, $0, $ARGUMENTS[0] 등, 를 해석합니다. 사용 모델의 기본값은 small fast model이며, 훅별로 model 필드에서 바꿀 수 있습니다.
모델이 {"ok": false}를 반환하면 훅 결과는 blocking이며 preventContinuation: true 플래그가 붙습니다. {"ok": true}를 반환하면 성공입니다. 잘못된 JSON이나 schema 검증 실패는 non-blocking error를 만듭니다.
agent 훅은 query()를 통해 완전한 멀티턴 에이전트 루프를 실행합니다. 최대 50턴입니다. 에이전트는 대화 transcript 경로가 포함된 맞춤 system prompt, 권한에 따라 걸러진 전체 도구 접근권, 그리고 결과 반환용 StructuredOutput 도구를 받습니다. structured output 강제 장치는 에이전트 루프 시작 전에 세션 레벨의 Stop function 훅으로 등록되고, 끝난 뒤 정리됩니다.
에이전트가 50턴 안에 StructuredOutput을 호출하지 못하면 결과는 cancelled입니다. 사용자에게 오류는 보이지 않습니다. 도구를 전혀 호출하지 않고 끝나도 마찬가지입니다.
// hookHelpers.ts — registerStructuredOutputEnforcement addFunctionHook( setAppState, sessionId, 'Stop', '', // matcher 없음 = 모든 stop messages => hasSuccessfulToolCall(messages, SYNTHETIC_OUTPUT_TOOL_NAME), `이 요청을 완료하려면 반드시 ${SYNTHETIC_OUTPUT_TOOL_NAME} 도구를 호출해야 합니다. 지금 이 도구를 호출하세요.`, { timeout: 5000 } ) hookHelpers.ts
이 function 훅은 에이전트의 Stop 전에 실행됩니다. 메시지 기록에 성공한 StructuredOutput 도구 호출이 있는지 확인하고, 없으면 오류 메시지를 주입해 종료 전에 꼭 그 도구를 호출하도록 강제합니다.
Claude Code는 훅을 실행하는 것 외에도, hookEvents.ts의 별도 in-process event bus를 통해 SDK 소비자에게 훅 실행 이벤트를 내보냅니다. 이것은 훅 실행 시스템과는 별개이며, 순수하게 observability/telemetry용입니다.
includeHookEvents SDK 옵션과 무관하게 항상 발생하는 이벤트가 두 개 있습니다. SessionStart와 Setup입니다. 소스에서는 이것들을 "원래 allowlist에 들어 있었고 하위 호환되는 저잡음 라이프사이클 이벤트"라고 설명합니다. 다른 이벤트는 모두 SDK 옵션의 includeHookEvents: true 또는 CLAUDE_CODE_REMOTE 모드가 필요합니다.
pendingEvents에 버퍼링됩니다. 핸들러가 등록되면 버퍼된 이벤트는 즉시 비워서 전달됩니다.
if 필터 필드저장 가능한 모든 훅 명령 타입은 선택적인 if 필드를 지원합니다. 문법은 permission rule syntax, 즉 allowedTools 패턴과 같은 문법을 씁니다. 예를 들면 "Bash(git *)", "Read(*.ts)"입니다. 도구 호출이 이 패턴과 맞을 때만 훅이 실제로 실행됩니다.
{ "PreToolUse": [ { "matcher": "Bash", "hooks": [ { "type": "command", "command": "./git-safety.sh", "if": "Bash(git push*)" // git push 명령에서만 실행 } ] } ] } if 필드, git push에서만 실행
if 필드는 훅의 identity에 포함됩니다. 같은 명령이라도 if 값이 다르면 서로 다른 훅으로 간주되며, 세션에 둘 다 등록되어 있으면 둘 다 실행됩니다. shell 필드도 identity의 일부입니다. "command": "foo"와 "shell": "bash" 조합, 그리고 같은 "command": "foo"라도 "shell": "powershell" 조합은 서로 다른 훅입니다.
{ "PreToolUse": [ { "matcher": "Write", "hooks": [ { "type": "command", "command": "jq -e '.tool_input.file_path | test(\"test.*\\.ts$\")' <<< \"$CLAUDE_HOOK_INPUT\" && echo '구현과 함께 테스트도 작성해야 합니다' >&2 && exit 2 || exit 0", "statusMessage": "테스트 커버리지 정책 확인 중..." } ] } ] }
도구 입력 경로가 테스트 파일 패턴과 맞지 않으면 exit 2가 쓰기를 막고, 오류는 Claude에게 보여집니다. 그러면 Claude가 다시 판단하게 됩니다. exit 0이면 아무 표시 없이 쓰기가 계속됩니다.
{ "SessionStart": [ { "matcher": "startup", "hooks": [ { "type": "command", "command": "echo \"오늘은 $(date)입니다. 열린 PR 수: $(gh pr list --json number | jq length)\"" } ] } ] }
exit 0의 stdout는 세션 시작 컨텍스트로 Claude에게 보여집니다. 모델은 첫 사용자 프롬프트 전에 이 내용을 보게 됩니다. 날짜, PR 상태, 브랜치, 배포 상태 같은 동적 환경 정보를 사용자에게 직접 받지 않고 주입할 때 유용합니다.
{ "Stop": [ { "hooks": [ { "type": "agent", "prompt": "구현에 unit test가 포함되어 있고 모두 통과하는지 검증하세요. 무엇이 만들어졌는지 이해하려면 $ARGUMENTS[transcript_path]의 transcript를 읽고, 그다음 테스트를 실행하세요.", "timeout": 120 } ] } ] }
agent 훅은 모든 도구에 접근 가능한 완전한 서브에이전트를 띄웁니다. 파일을 읽고, 명령을 실행하고, transcript를 검사할 수 있습니다. 만약 {"ok": false, "reason": "Tests failed: 3 assertions"}를 반환하면 세션은 계속되고, Claude는 그 실패를 해결해야 합니다.
{ "CwdChanged": [ { "hooks": [ { "type": "command", "command": "[ -f .envrc ] && direnv export bash >> \"$CLAUDE_ENV_FILE\" || true" } ] } ] }
Claude Code는 CLAUDE_ENV_FILE에 임시 파일 경로를 넣어 줍니다. 거기에 export VAR=value 줄을 쓰면 이후 BashTool 명령에 그 env var가 적용됩니다. 이 패턴은 direnv 데몬 전체를 돌리지 않고도 direnv 연동과 비슷한 효과를 냅니다.
{ "PreToolUse": [ { "matcher": "Bash", "hooks": [ { "type": "prompt", "prompt": "다음 bash 명령이 곧 실행됩니다: $ARGUMENTS\nok: true는 이 명령이 운영 인프라를 파괴적으로 수정하지 않을 때만 반환하세요. 흔한 파괴적 패턴: terraform destroy, 운영 네임스페이스에서의 kubectl delete, DROP TABLE, 중요한 경로에서의 rm -rf.", "model": "claude-sonnet-4-6" } ] } ] }
prompt 훅은 regex로 쓰기 어려운 애매한 정책 판단에 잘 맞습니다. 모델은 단순한 패턴 일치가 아니라 의도를 평가합니다. 이런 미묘한 경우에는 더 강한 모델, haiku보다 sonnet, 을 쓰는 편이 정확도가 높습니다.
src/entrypoints/sdk/coreTypes.ts의 HOOK_EVENTS에 정의되어 있습니다. 새 이벤트가 추가되면 가장 먼저 그곳에 나타납니다. 메타데이터, 설명, matcher 필드, 유효한 matcher 값, 는 hooksConfigManager.ts → getHookEventMetadata()에 있습니다.command, 셸 서브프로세스, → prompt, 단일 LLM 호출, → agent, 완전한 멀티턴 에이전트, → http, URL로 POST, → function, TypeScript 콜백, 세션 전용. 단순한 타입일수록 지연이 적고 예측 가능성이 높습니다.allowManagedHooksOnly는 나머지를 모두 억제하고, policy 안의 disableAllHooks는 managed 훅까지 멈춥니다. non-managed 설정으로는 managed 훅을 막을 수 없습니다.Object.is(next, prev)가 true를 유지해서 store listener가 실행되지 않습니다. 병렬 에이전트 워크플로에서 한 tick 안에 수십 개 function 훅이 등록될 수 있기 때문에 이 점이 중요합니다.hookEvents.ts, 는 훅 실행과 별개입니다. 순수한 observability 기능입니다. 항상 발생하는 것은 SessionStart와 Setup뿐이고, 나머지는 includeHookEvents: true 또는 remote mode가 필요합니다. 아직 SDK 소비자가 붙지 않았더라도 최대 100개 이벤트가 버퍼링됩니다.git push bash 명령을 실행할 때만 훅이 실행되게 하고 싶습니다. 어느 필드가 이를 제어할까요?policySettings에서 disableAllHooks: true를 설정했습니다. 플러그인도 훅을 등록합니다. 무엇이 실행될까요?"asyncRewake": true를 추가했습니다. 모델은 언제 끼어들어 중단될까요?