SpyBara
Go Premium

plugins/mods/events.md 2026-10-31 23:02 UTC to 2026-11-01 23:57 UTC

No changes for this document in this history range.

2026
Mon 9 23:02 Tue 10 21:57

mod로 이벤트에 반응하기

mod에서 Claude Code 이벤트를 처리합니다. 도구 호출, 프롬프트, 턴을 관찰하거나 재작성하거나 직접 응답하고, 훅이 처리할 이벤트를 필터링하며, 다른 mod와의 공존을 계획합니다.

훅은 이벤트 핸들러, 즉 이름이 지정된 이벤트가 발생할 때 Claude Code가 실행하는 함수입니다. Claude Code는 도구를 실행하거나, 프롬프트를 제출하거나, 모델에 요청을 보내거나, 세션을 시작하거나 종료하는 등 작업을 수행하려는 각 시점에 이벤트를 발생시킵니다. 훅은 Claude Code가 작업을 수행하기 전에 실행되므로 이벤트를 관찰하거나, 재작성하거나, Claude Code를 대신하여 응답할 수 있습니다. 훅은 on(eventName, handler)로 등록합니다.

여기서 시작하기 전에 첫 번째 mod를 먼저 만드십시오. 모든 이벤트와 정확한 필드는 레퍼런스를 참조하거나 사용 중인 빌드의 타입을 확인하십시오.

훅이 이벤트를 처리하는 방식

훅은 이벤트와 그 이벤트에 대해 Claude Code가 수행할 동작 사이에 위치하므로, 이벤트를 관찰하거나, 다시 작성하거나, 직접 응답할 수 있습니다. 훅은 세 개의 인수를 받습니다. $로 전달되는 mods API, e로 전달되는 이벤트, next로 전달되는 다음 핸들러입니다. 하나의 이벤트에 대한 핸들러들은 미들웨어 체인을 구성합니다. next(e)는 다음 핸들러를 호출하며, 이 핸들러는 다른 mod의 훅이거나 체인의 끝에서는 Claude Code 자체의 동작입니다. next(e)는 그 결과로 resolve됩니다. 훅이 next로 무엇을 하느냐에 따라 세 가지 중 어느 것을 수행할지가 결정됩니다.

이벤트 관찰하기

이벤트를 변경하지 않고 관찰하려면 원하는 작업을 수행한 뒤 next(e)를 반환합니다. 다음 훅은 Claude가 사용하려는 각 도구를 로그에 기록합니다.

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)
})

각 도구가 실행되기 전에 ● 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
})

이제 각 도구가 완료된 후에 해당 줄이 표시됩니다. 훅이 next(e)가 resolve된 값을 그대로 반환하므로, 어느 경우든 Claude는 동일한 결과를 읽습니다.

이벤트 다시 작성하기

프롬프트의 텍스트처럼 Claude Code가 처리하는 대상을 변경하려면, 이벤트의 수정된 복사본으로 next를 호출합니다. 이벤트 자체는 불변입니다. 깊게 동결(deeply frozen)되어 있어 필드에 값을 할당하면 오류가 발생합니다. 다음 훅은 각 프롬프트가 전송되기 전에 앞뒤 공백을 제거합니다.

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는 공백이 제거된 프롬프트를 받으며 원본은 보지 않습니다. 결과를 변경할 수도 있습니다. await next(e)를 실행한 다음, 필드 하나를 교체한 결과의 복사본을 반환하면 됩니다.

이벤트에 응답하기

이벤트를 직접 처리하려면 next를 호출하지 않고 결과를 반환합니다. 이렇게 하면 체인이 단락(short-circuit)되므로 이후의 mod와 Claude Code 자체의 동작이 실행되지 않습니다. 다음 훅은 모든 Bash 명령을 거부합니다.

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 텍스트를 도구의 결과로 읽습니다. 각 이벤트에는 고유한 결과 형태가 있으며, 이벤트 레퍼런스에 나열되어 있습니다.

훅이 처리할 이벤트 필터링하기

일부 이벤트에 대해서만 훅을 실행하려면 on의 두 번째 인수로 필터를 전달합니다. Claude Code에서는 이 필터를 matcher라고 부릅니다. matcher는 필드를 이벤트의 필드와 비교하는 객체이며, 모든 필드가 일치할 때만 훅이 실행됩니다. 필드 값으로는 단일 값, 허용되는 값의 배열, 또는 정규 표현식을 사용할 수 있습니다.

다음 예시의 각 줄은 동일한 함수 hook을 더 좁은 범위의 도구 호출에 대해 등록합니다.

// 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와 같은 다른 도구의 호출은 세 가지 중 어느 것과도 일치하지 않으므로 hook이 실행되지 않습니다.

이벤트 이름에는 와일드카드를 사용할 수 있습니다. 'classic.*'는 모든 설정 훅 이벤트와 일치합니다. '*'는 텔레메트리 이벤트를 제외한 모든 이벤트와 일치하며, 텔레메트리 이벤트는 고유한 이름과 { to: 'collector' } 필터를 사용합니다.

각 이벤트는 matcher당 한 번씩 등록합니다. matcher 없이 session.start에 대해 on을 두 번 호출하면 모듈이 on("session.start") is registered twice without a matcher 오류와 함께 로드에 실패합니다. 세션 시작 시 mod가 수행하는 모든 작업은 하나의 훅에 넣으십시오.

Claude가 하는 작업에 훅 연결하기

이 이벤트들을 처리하면 도구 호출, 프롬프트, 턴, 설정 읽기가 진행되는 동안 이를 확인하거나 변경할 수 있습니다. 모든 이벤트와 훅이 반환할 수 있는 값은 이벤트 레퍼런스를 참조하세요.

도구 호출 차단 또는 변경하기

tool.call 훅은 Claude가 사용하려는 각 도구를 확인하므로 호출을 거부하거나, 인수를 변경하거나, 그대로 통과시킬 수 있습니다. tool.call은 Claude Code가 도구를 실행하려 할 때 발생하며, 서브에이전트가 수행하는 호출과 MCP 도구 호출도 포함됩니다. e.tool은 도구의 이름이고, 도구의 인수는 Bash의 e.command처럼 e의 필드입니다. next(e)를 호출하면 Claude Code가 권한 검사를 실행한 다음 도구를 실행합니다.

다음 훅은 강제 푸시하는 Bash 명령을 거부하고 Claude에게 그 이유를 알려 줍니다.

// 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)
})

Claude가 git push --force를 시도하면 훅이 next를 호출하지 않으므로 명령이 실행되지 않고 권한 프롬프트도 표시되지 않습니다. Claude는 deny 텍스트를 도구의 결과로 읽으므로, Claude가 따를 수 있는 지시문 형태로 작성해야 합니다. 그 밖의 모든 Bash 명령은 mod가 없을 때와 동일하게 실행됩니다.

이 훅이 예외를 발생시키거나 시간 초과되면 Claude Code는 훅을 건너뛰고, 명령은 mod가 없을 때와 동일하게 진행됩니다. 대신 명령을 거부하려면 훅에 .catch 핸들러를 추가하세요.

도구가 실행된 후에 작업하려면 await next(e)를 수행하고, 필요한 작업을 한 다음, next가 반환한 값을 반환합니다. 다음 훅은 Claude가 변경하는 각 .mdx 파일을 $.ui.log로 로그에 기록합니다. 이 함수는 Claude가 읽지 않는 흐린 줄을 트랜스크립트에 추가합니다.

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 필드가 있는 객체를 반환합니다. 기본 제공 도구의 경우 result에는 빌드의 타입에서 해당 도구 자체의 결과가 갖는 형태를 지정합니다. 권한 프롬프트가 표시되지 않고 도구도 실행되지 않으므로, 반환한 결과가 Claude가 해당 상황에 대해 알게 되는 전부입니다.
  • Claude에게 결과 보류하기: await next(e) 후에 { deny: reason }을 반환합니다. Claude는 next가 반환한 값 대신 지정한 이유를 읽습니다. 도구가 실행된 경우 deny는 Claude가 그 결과를 보지 못하게 할 뿐, 도구가 수행한 작업을 되돌리지는 않습니다. 도구가 실행되어 성공한 경우 이유 앞에 Bash ran, and a plugin withheld its result: 같은 안내 문구가 붙습니다.

조직의 관리형 설정에 있는 훅은 모든 mod의 tool.call 훅보다 먼저 실행됩니다. 관리형 훅이 호출을 차단하면 해당 호출에 대해서는 어떤 mod의 tool.call 훅도 실행되지 않습니다.

사용자가 결정할 때까지 도구 호출 보류하기

훅은 도구 호출을 일시 중지하고 진행하기 전에 사용자에게 어떻게 할지 물을 수 있습니다. tool.call 훅은 next를 호출하거나 반환하기 전에 await할 수 있으며, 그때까지 도구 호출은 대기 상태로 유지됩니다. 사용자에게 질문하려면 $.ui.ask를 호출합니다. 이 함수는 Claude가 사용자에게 질문할 때 사용하는 대화 상자에서 질문을 번호가 매겨진 옵션 목록 위에 표시하고, 사용자가 선택한 레이블로 resolve됩니다. 대화 상자는 옵션 뒤에 다른 답변을 입력하는 행과 Chat about this 행을 추가합니다.

이 예제의 RISKY 패턴은 rm -r, rm -rf, git reset --hard, 그리고 --force가 포함된 git push와 일치하며, git push -f 같은 다른 표기는 놓칩니다. 이 모듈은 패턴과 일치하는 Bash 명령을 실행하기 전에 확인을 요청합니다.

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을 선택하는 경우: 훅이 next(e)를 호출하며, 그 후에도 일반적인 권한 검사가 계속 실행됩니다
  • 사용자가 Refuse를 선택하는 경우: 명령이 실행되지 않으며, Claude는 deny 텍스트를 읽습니다
  • 사용자가 답변을 입력하는 경우: $.ui.ask가 입력된 텍스트로 resolve됩니다. 훅은 이를 Run it과 비교하므로 그 외의 텍스트는 명령을 거부합니다.
  • 아무도 답변하지 않는 경우: 사용자가 질문을 닫거나 Chat about this를 선택하면, 그리고 claude -p 실행에서는 $.ui.ask가 reject되므로 catch 블록이 답변을 Refuse로 유지합니다

대기는 $.ui.ask 같은 mods API 호출 안에서 이루어지도록 해야 합니다. 그 시간은 훅의 시간 제한에 포함되지 않기 때문입니다. 직접 만든 promise를 기다리는 데 소요된 시간은 포함됩니다.

Claude Code는 시간 초과된 훅을 건너뛰므로, 보류된 명령이 실행됩니다. 대신 명령을 거부하려면 on이 반환하는 등록에 .catch 핸들러를 연결하세요. 이 핸들러는 훅 내부의 catch 블록과는 별개입니다.

사용자에게 묻기 전에 도구 호출 승인 또는 거부하기

도구 호출의 실행 가능 여부를 결정하려면 Claude Code가 그 결정을 내리는 이벤트인 tool.check를 처리합니다.

고정된 명령이나 경로에는 코드가 필요 없는 Bash(npm test) 같은 권한 규칙을 사용합니다. 현재 Git 브랜치나 다른 훅이 기록한 값처럼 그 시점의 상태에 따라 결정이 달라지는 경우에 tool.check를 처리합니다.

  • 반환할 값: 호출을 직접 결정하려면 { decision: 'allow' }나 { decision: 'deny', reason } 같은 자체 객체를 반환합니다. 결정을 그대로 두려면 await next(e)가 제공하는 값을 반환합니다.
  • await next(e)가 제공하는 값: Claude Code 자체의 결정으로, decision이 allow, ask, deny 중 하나인 객체입니다. next(e)가 실행되는 동안에는 권한 프롬프트가 표시되지 않습니다.
  • 인수: e.input에는 Bash의 command처럼 도구의 인수가 들어 있습니다
  • tool.check가 발생하는 시점: PreToolUse 훅이 실행된 후, 그리고 권한 프롬프트, 자동 모드의 분류기 또는 PermissionRequest 훅보다 먼저 발생합니다

다음 훅은 현재 브랜치가 main일 때 git push를 거부합니다.

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를 거부하고 그 밖의 모든 호출은 그대로 통과시킵니다.

  • main에서: 규칙이 git push를 허용하더라도 훅이 deny를 반환합니다
  • 다른 브랜치에서, 그리고 다른 명령의 경우: 호출에는 mod가 없을 때와 동일한 결정이 적용됩니다
  • 훅이 예외를 발생시키거나 시간 초과되는 경우: 이때도 호출에는 mod가 없을 때와 동일한 결정이 적용됩니다. 대신 호출을 거부하려면 { decision: 'deny', reason: 'the reason' }을 반환하는 .catch 핸들러를 추가하세요.

이 훅은 명령의 텍스트를 대조하므로 Claude를 위한 알림 정도로 취급해야 합니다. 모든 사람의 main 푸시를 차단하려면 Git 호스트에서 브랜치를 보호하세요.

[!NOTE]

tool.check 훅은 allow나 ask를 반환할 수도 있습니다. 플랜 모드에서 파일 편집처럼 읽기 전용이 아닌 호출의 경우, 훅은 next(e)가 반환하는 결정을 더 엄격하게 만들 수는 있지만 완화할 수는 없습니다. 훅의 allow로 대체할 수 없는 권한 규칙과 PreToolUse 훅 결정은 훅으로 권한 확장하기를 참조하세요.

도구 호출이 읽기 전용인지 확인하기

tool.check 훅에서 도구가 호출을 읽기 전용으로 보고하면 e.isReadOnly는 true입니다. 그렇지 않으면 이 필드는 false가 아니라 아예 존재하지 않습니다. e.isReadOnly에는 Claude Code v2.1.304 이상이 필요합니다.

Read, Grep, Glob, WebSearch 같은 일부 도구는 모든 호출을 읽기 전용으로 보고합니다. Bash와 MCP 도구는 다음 경우에 호출을 읽기 전용으로 보고합니다.

  • Bash: 명령이 Claude Code의 기본 제공 읽기 전용 명령 검사를 통과하는 경우
  • MCP 도구: 서버가 도구의 readOnlyHint 어노테이션을 true로 설정하는 경우

이 값은 도구 자체의 보고이므로, 이 필드만으로 호출을 승인해서는 안 됩니다. 예를 들어 WebFetch와 Agent 호출은 읽기 전용으로 보고되지만, WebFetch는 URL을 요청하고 Agent는 자체 도구를 실행하는 서브에이전트를 시작합니다.

다음 훅은 읽기 전용이 아니고 아직 거부되지 않은 모든 mcp__github__ 호출에 대해 ask를 반환합니다.

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' }
})

호출의 읽기 전용 여부로 tool.check 훅 필터링하기

e.isReadOnly를 기준으로 훅이 처리하는 호출을 제한하려면, 읽기 전용 호출에는 matcher를 사용하고 나머지에는 훅 본문을 사용합니다.

  • 읽기 전용 호출만: on('tool.check', { tool: 'Bash', isReadOnly: true }, hook)처럼 matcher에 isReadOnly: true를 추가합니다
  • 읽기 전용이 아닌 호출: mcp__github__ 예제처럼 훅 본문에서 e.isReadOnly를 검사합니다. matcher에 isReadOnly: false를 넣으면 훅이 실행되지 않습니다.

프롬프트 다시 작성하거나 내용 추가하기

prompt.submit 훅은 턴이 시작되기 전에 각 프롬프트를 확인하므로 텍스트를 다시 작성하거나 내용을 추가할 수 있습니다. e.text는 입력된 내용입니다.

수행할 작업 반환할 값
프롬프트를 다시 작성합니다. 트랜스크립트와 프롬프트 기록에 새 텍스트가 표시됩니다. next({ ...e, text: newText })
프롬프트 뒤에 Claude만 읽는 텍스트를 추가합니다 next({ ...e, context: [...(e.context ?? []), extraText] })
프롬프트가 전송되지 않도록 합니다 { drop: 'the reason' }

다음 훅은 프롬프트에 풀 리퀘스트가 언급될 때마다 Claude를 위해 현재 브랜치 이름을 추가합니다.

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 같은 프롬프트를 보내면 트랜스크립트의 메시지는 그대로 보이며, Claude는 그 뒤에 Current branch: feature/auth 같은 줄도 읽습니다. 풀 리퀘스트를 언급하지 않는 프롬프트는 변경 없이 전달되며 git도 실행되지 않습니다.

프롬프트를 중단하려면 next를 호출하지 않고 { drop: 'the reason' }을 반환합니다. 텍스트는 사용자의 프롬프트 입력란으로 되돌아가고, 사용자에게는 Prompt dropped by a hook: 뒤에 지정한 이유가 표시되므로 이유는 사용자를 대상으로 작성해야 합니다. 다음 경우에는 프롬프트가 그대로 전송됩니다.

  • 훅의 next(e) 호출이 프롬프트를 통과시킨 후에 훅이 drop을 반환하는 경우: 턴은 그대로 실행되며, 훅은 a drop after its next() was answered가 포함된 메시지와 함께 실패합니다
  • 훅이 예외를 발생시키거나 시간 초과되는 경우: Claude Code는 훅을 건너뛰고 프롬프트를 전송합니다. 실패로 인해 훅이 중단했을 프롬프트가 전송되지 않도록 .catch 핸들러를 추가하세요.

다른 이벤트는 Claude가 읽는 나머지 내용을 다룹니다. 시스템 프롬프트의 각 섹션에는 prompt.section, 첫 번째 메시지와 함께 전송되는 컨텍스트에는 prompt.context, 스킬의 텍스트에는 skill.prompt를 사용합니다. 이러한 훅에서 나온 텍스트가 요청마다 달라지면 프롬프트 캐시가 무효화됩니다. skill.prompt 훅에서는 스킬이 어떤 플러그인이나 파일에서 왔는지도 읽을 수 있습니다.

턴 추적하기

턴은 하나의 프롬프트에 응답하여 Claude가 수행하는 모든 작업입니다. 턴을 추적하려면 turn.start, turn.step, turn.complete를 처리합니다.

이벤트 발생 시점 훅이 할 수 있는 작업
turn.start 턴이 시작될 때 관찰합니다. e.turnId는 나머지 두 이벤트에서 해당 턴을 식별합니다.
turn.step Claude Code가 모델에 요청 하나를 보내려 할 때입니다. 도구 호출이 있는 턴에는 요청이 여러 개 있습니다. 서브에이전트의 요청에는 e.agentId가 설정됩니다. 각 요청의 토큰 사용량을 읽거나, next({ ...e, model })로 다른 모델에 보내거나, 모델을 호출하지 않고 응답합니다
turn.complete 턴이 종료되었을 때이며, 사용자가 중단한 턴도 포함됩니다. 이 경우 e.isAborted는 true입니다. e.answer는 Claude의 최종 텍스트, e.durationMs는 소요 시간, e.usage는 턴의 토큰 합계입니다. 서브에이전트의 턴에서는 e.agentId가 설정된 상태로 발생합니다. 관찰하거나, { text: 'Done in 12 seconds' }처럼 text 필드가 있는 객체를 반환하여 답변 아래에 한 줄을 표시합니다

이 이벤트는 스트리밍되므로 turn.step 훅은 async generator로 작성합니다. yield* next(e)는 응답을 스트리밍되는 대로 전달하고 완료된 결과로 평가됩니다. 다음 훅은 각 요청 중 Claude API가 프롬프트 캐시에서 제공한 양을 로그에 기록합니다.

// 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
})

Claude의 응답은 mod가 없을 때와 동일하게 화면에 스트리밍됩니다. 각 요청이 완료되면 트랜스크립트의 흐린 줄에 캐시에서 읽은 토큰 수와 캐시에 기록된 토큰 수가 표시됩니다. 도구 호출이 있는 턴에는 요청이 여러 개 있으므로 여러 줄이 추가됩니다.

result.usage에는 Claude API가 요청에 대해 보고하는 토큰 수인 input_tokens, output_tokens, cache_read_input_tokens, cache_creation_input_tokens와 응답한 model이 들어 있습니다. 이 훅은 서브에이전트의 요청에도 실행되므로, 메인 대화만 원하는 경우 e.agentId를 확인하세요.

advisor 도구 호출처럼 요청 중에 API가 직접 실행한 도구 호출을 확인하려면 result.serverToolUses를 읽습니다. Claude Code는 이러한 호출을 실행하지 않으므로 tool.call이나 tool.check 훅이 발생하지 않습니다. 응답에 이러한 호출이 없으면 이 필드는 존재하지 않으며, Claude Code v2.1.290 이상이 필요합니다.

다른 모델에 요청 보내기

model이 haiku 같은 별칭일 때 turn.step 요청이 모델 오류로 실패하면 Claude Code v2.1.309 이상으로 업데이트하거나 전체 모델 ID를 사용하세요. v2.1.309 이전에는 별칭이 작성된 그대로 전송되었습니다.

요청을 다른 모델에 보내려면 yield* next({ ...e, model: 'haiku' })처럼 model을 변경한 이벤트를 next에 전달합니다.

설정 읽기 변경 또는 거부하기

settings.read 훅을 사용하면 mod의 읽기든 Claude Code 자체의 읽기든 설정 읽기를 변경하거나 거부할 수 있습니다.

  • mod의 읽기: mod가 $.settings.read를 호출합니다.
  • Claude Code 자체의 읽기: 훅은 세션의 mod가 로드된 후 Claude Code가 세션의 설정을 병합할 때 처음 실행됩니다. 설정이나 로드된 플러그인이 변경되면 훅이 다시 실행됩니다.

어떤 mod가 이 이벤트를 받는지와 Claude Code 버전을 확인하세요.

  • 기본 제공 가드: 관리형 설정이 있거나 Team 또는 Enterprise /login이 세션의 자격 증명인 경우, 기본 제공 가드는 설치했거나 --plugin-dir로 로드한 mod에서 이 훅을 건너뛸 수 있습니다. 확인하려면 디버그 로그에서 settings.read bypassed by cc-plugin-sec-default를 검색하세요. 해당 텍스트가 포함된 줄에 해당 mod의 이름이 있으면 조직의 관리자에게 해당 mod를 조직의 mod 중 하나로 설치해 달라고 요청하세요.
  • 버전: Claude Code 자체의 읽기에서 이 이벤트가 발생하는 것과 e.layers 필드에는 Claude Code v2.1.304 이상이 필요합니다.

settings.read 훅이 받는 값

필드 타입 담고 있는 내용
e.layers array 설정을 담고 있는 사용 중인 각 소스마다 하나씩 있는 { source, settings } 객체로, 우선순위가 가장 낮은 것부터 나열됩니다: plugin, user, project, local, flag, policy. claude plugin test에서는 비어 있습니다.
e.source string $.settings.read({ source: 'policy' })처럼 mod가 요청한 하나의 소스입니다. 이 경우 e.layers에는 해당 레이어만 들어 있습니다. Claude Code 자체의 읽기와 병합된 설정에 대한 그 밖의 모든 읽기에서는 존재하지 않습니다.

e.layers에서 plugin은 활성화된 플러그인이 제공하는 설정을 담으며, 레이어 이름일 뿐 $.settings.read에 전달할 수 있는 source가 아닙니다. flag는 --settings로 전달된 설정을, policy는 관리형 설정을 담습니다.

Claude Code 자체의 읽기와 mod의 읽기 구분하기

next.origin을 확인하세요.

  • Claude Code 자체의 읽기: { plugin: 'engine', tier: 'core' }
  • mod의 읽기: plugin은 호출한 mod의 이름입니다

settings.read 훅이 반환할 수 있는 값

읽기를 그대로 두려면 next(e)의 결과를 변경 없이 반환하고, 그렇지 않으면 다음 중 하나를 반환합니다.

반환 값 Claude Code 자체의 읽기에서 mod의 읽기에서
{ value } 변경된 각 키에 대해, 제공한 값이 키의 스키마에 맞는 경우 세션의 병합된 설정에서 해당 키의 값 전체를 교체합니다 해당 mod의 호출은 제공한 value 또는 제공한 value에서 가져온, 요청한 key로 확인되므로 배열이 아닌 전체 객체를 반환해야 합니다
{ deny: reason } 세션은 어떤 훅이 응답하기 전에 병합된 설정을 그대로 유지하며, 디버그 로그에 reason이 기록됩니다 해당 mod의 호출이 <calling mod>: $.settings.read: <reason>으로 reject됩니다

Claude Code 자체의 읽기에서는 생략한 키가 설정 해제되므로 next(e)의 value를 바탕으로 { value }를 구성하세요. Claude Code가 env 같은 키를 각 소스에서 따로 읽는 경우, value에서 그 키를 변경해도 효과가 없습니다.

next(e)가 settings.read 훅에 제공하는 값

next(e)는 { value } 또는 { deny }로 resolve됩니다.

  • { value }: value는 병합된 설정이며, e.source가 설정된 경우에는 하나의 소스의 설정입니다. 현재 훅 다음에 실행되는 훅이 자체 { value }를 반환했다면 대신 그 훅의 value를 받습니다.
  • { deny }: 현재 훅 다음에 실행되는 훅이 읽기를 거부했습니다. 해당 { deny }를 그대로 반환하세요.

settings.read 훅에서 설정 하나 변경하기

먼저 어떤 mod가 이 이벤트를 받는지, 그리고 어떤 버전부터인지 확인하세요.

키 하나를 변경하려면 다음 훅이 spinnerTipsEnabled에 대해 하는 것처럼 next(e)의 value를 spread한 다음 해당 키를 설정합니다. 이 훅은 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 자체의 읽기에서 훅이 문자열처럼 키의 스키마를 벗어나는 값을 반환하면, 해당 키만 어떤 훅이 응답하기 전에 병합된 값을 유지합니다. 이때 디버그 로그에는 the answer for <key> is no value the key can hold가 표시됩니다. 값이 스키마에 맞는데도 키가 변경되지 않으면 키가 효과가 없는 이유를 참조하세요.

설정 훅 이벤트 처리하기

설정 훅은 설정 파일에서 구성하는 command, HTTP, prompt, agent 훅입니다. Stop, SessionEnd, PostToolUse 같은 각 설정 훅 이벤트는 classic. 뒤에 설정 훅 이벤트 이름이 붙은 이벤트(예: classic.Stop)이기도 합니다. e는 설정 훅이 stdin으로 받는 JSON이며, transcript_path를 포함합니다. 기본 제공 가드가 로드되는 환경에서는 설치한 mod에 있는 이 이벤트들의 훅이 실행되지 않습니다.

다음 훅은 Claude가 응답을 마칠 때 발생하는 Stop을 사용하여 세션의 트랜스크립트가 저장된 위치를 로그에 기록합니다.

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가 응답을 마칠 때마다 트랜스크립트의 흐린 줄에 트랜스크립트 파일의 경로가 표시됩니다. 훅이 next(e)를 반환하므로 이벤트를 관찰만 하며 턴이 종료되는 방식은 전혀 변경하지 않습니다.

다른 mod와 함께 실행하기

여러 mod가 같은 이벤트를 처리할 수 있으며, 그중 어느 것이든 실패할 수 있습니다. mod가 도구 호출을 차단한다면 체인에서 해당 mod의 위치와 훅이 실패할 때 어떤 일이 일어나는지 확인하십시오.

mod가 실행되는 순서

같은 이벤트에 대한 훅은 하나의 미들웨어 체인을 형성합니다. 각 mod의 next는 다음 mod의 훅을 호출하며, 마지막 next는 Claude Code 자체의 동작에 도달합니다. 첫 번째 mod가 가장 바깥쪽에 있습니다. 이 mod는 다른 mod보다 먼저 이벤트를 보고 다른 mod보다 나중에 결과를 보며, 다른 mod의 실행 여부를 결정합니다. 뒤에 있는 mod는 앞에 있는 mod가 이벤트를 보는 것을 막을 수 없습니다.

Claude Code는 각 mod의 출처에 따라 체인의 순서를 정합니다.

  1. 기본 제공 가드 sec-default@builtin(로드되는 경우, /plugin에서 cc-plugin-sec-default로 표시되는 Claude Code 내장 mod), 조직이 prependPlugins에 나열한 mod, 그리고 조직의 mod로 간주되면서 appendPlugins에 없는 그 밖의 mod
  2. 사용자가 설치한 mod
  3. 조직이 appendPlugins에 나열한 mod
  4. Claude Code에 내장된 그 밖의 mod

사용자가 설치한 mod 중에서는 mod가 매니페스트의 dependencies 아래에 나열한 mod보다 먼저 실행됩니다. 하나의 모듈 안에서는 register가 on을 호출한 순서대로 훅이 실행됩니다.

설정 훅이 실행되는 순서상의 위치

설정 파일에 구성된 PreToolUse 훅도 도구 호출 중에 mod 체인의 고정된 지점에서 실행됩니다.

  • 관리형 설정의 PreToolUse 훅: 첫 번째 mod의 tool.call 훅보다 먼저 실행됩니다. 관리형 훅이 호출을 차단하면 해당 호출에 대해 어떤 mod의 tool.call 훅도 실행되지 않습니다.
  • 그 밖의 모든 설정 파일과 플러그인의 hooks/hooks.json에 있는 PreToolUse 훅: Claude Code 자체 동작의 일부로서 마지막 mod가 next를 호출한 후에 실행됩니다. next를 호출하지 않고 tool.call에 응답하는 mod는 이 훅의 실행을 막으며, next를 호출하는 mod는 반환되는 결과에서 이 훅의 결정을 확인할 수 있습니다.

tool.check는 PreToolUse 훅이 실행된 후에 발생합니다. tool.check 훅의 allow가 대체할 수 없는 권한 규칙과 PreToolUse 훅 결정을 알아보려면 훅으로 권한 확장하기를 참조하십시오.

실패한 훅 처리하기

훅이 실패해도 세션이 중단되지는 않으며, 대신 어떤 일이 일어날지 직접 결정할 수 있습니다. .catch 핸들러가 없는 훅이 예외를 던지거나, 시간 초과되거나, 잘못된 형태의 결과를 반환하면, 이후 동작은 해당 훅이 next를 호출했는지에 따라 달라집니다.

  • next를 호출하기 전에 실패한 경우: Claude Code는 해당 훅을 건너뛰고 그 자리에서 다음 핸들러를 실행합니다
  • next가 완료된 후에 실패한 경우: 해당 결과가 그대로 유지되며, 어떤 것도 다시 실행되지 않습니다

mod, 이벤트, 이유를 나타내는 한 줄이 기록됩니다(예: my-mod: tool.call hook skipped: threw Error: boom). 이 내용을 확인하는 위치는 mod가 아무 동작도 하지 않는 이유 찾기에 나와 있듯이 세션에 따라 다릅니다. 트리가 유효성 검사를 통과하지 못한 ui.render 훅은 잘못된 형태의 결과를 반환한 훅으로 간주됩니다.

호출을 차단하는 훅이 실패 시 닫힌 상태(fail closed)가 되도록 하려면, 그 자리에서 대신 응답하는 .catch 오류 핸들러를 추가하십시오. 여기서 guard는 사용자의 훅 함수이며, 핸들러는 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 호출에서 예외를 던지거나 시간 초과되면, Claude Code는 같은 이벤트로 핸들러를 호출합니다.

  • guard가 next를 호출하기 전에 실패한 경우: 명령은 실행되지 않으며, Claude는 끝에 kind 값이 붙은 deny 텍스트를 읽습니다
  • guard가 next를 호출한 후에 실패한 경우: 핸들러의 next(e)는 명령을 다시 실행하지 않고 guard의 호출이 생성한 결과로 완료되며, Claude는 그 결과를 읽습니다

핸들러에는 자체적으로 더 짧은 시간 제한이 적용됩니다. 핸들러 자체가 예외를 던지거나 시간 초과되면, Claude Code는 핸들러가 없는 것처럼 해당 훅을 건너뜁니다. guard가 next를 호출하지 않았다면, 명령은 mod가 없을 때와 같이 그대로 진행됩니다.

같은 형태의 핸들러는 prompt.submit 또는 config.set의 가드에도 적용됩니다. next.called가 false이면 이벤트 레퍼런스에 해당 이벤트에 대해 나열된 거부를 반환하십시오. prompt.submit에는 { drop: 'the reason' }, config.set에는 { deny: 'the reason' }을 반환합니다.

tool.check와 plugin.register에서는 next가 완료된 후에 반환된 거부도 유효하므로, next.called를 검사하지 않고 거부를 반환하십시오.

  • tool.check: { decision: 'deny', reason: 'the reason' }을 반환합니다
  • plugin.register: 검사가 실패하면 mod 거부하기에 나와 있듯이 { refuse: 'the reason' }을 반환합니다

tool.call에서는 next가 완료된 후에 반환된 deny가 Claude에게 결과를 전달하지 않습니다.

다음 단계