mods API 사용하기 (Use the mods API)
기준일: 2026-11-03
공식 기준: Use the mods API
개요
mods API는 mod가 실제로 무언가를 하기 위해 호출하는 메서드 모음입니다. 명령·도구 추가, 모델 호출, 이벤트 사이의 작업 실행, 파일 시스템·프로세스·네트워크 접근이 여기에 속합니다. 모든 hook이 첫 인자 $로 받으며, 메서드는 $.ui, $.fs 같은 namespace로 묶여 있습니다. 이벤트는 hook이 언제 실행될지 정하고, mods API는 실행된 hook이 호출하는 것입니다.
이 페이지 전에 첫 mod 만들기를 먼저 해 보세요. 모든 메서드는 reference의 mods API methods나 빌드용 type declarations에 있습니다.
핵심 개념
| 하려는 것 | 주요 API |
|---|---|
| 사용자용 명령 추가 | $.command.register + command.run hook |
| Claude용 도구 추가 | $.tool.register + tool.call hook |
| 모델 호출 | $.model.complete, $.model.fork |
| 백그라운드 작업 | $.clock.every, $.clock.after, $.clock.now, $.clock.sleep |
| 턴 없이 보여 주기 | $.ui.status, $.ui.toast, $.ui.log, $.ui.notify |
| 턴 시작 | $.prompt.submit |
| 세션 간 메시지 | $.session.send, session.receive/session.send 이벤트 |
| 파일·프로세스·네트워크 | $.fs, $.process, $.http, $.store, $.env, $.settings, $.session, $.mcp |
상세
명령 또는 도구 추가
mod는 사용자가 실행할 명령과 Claude가 호출할 도구를 추가할 수 있습니다. 둘 다 session.start hook에서 등록합니다. Claude Code는 첫 프롬프트 전에 이 hook을 기다리므로 첫 턴부터 쓸 수 있습니다.
명령 추가
명령은 사용자용입니다. 등록한 뒤 그 이름으로 command.run을 처리합니다. 선택적 일수를 받는 /standup 예:
on('session.start', async ($, e, next) => {
// Add /standup to the command list, with the description the user sees there
await $.command.register({ name: 'standup', description: 'Summarize what changed today', argumentHint: '[days]' })
return next(e)
})
// The matcher limits the hook to /standup, so other commands don't reach it
on('command.run', { command: 'standup' }, async ($, e) => {
// e.args is the text typed after the command name, or an empty string
return { text: 'Summary for the last ' + (e.args || '1') + ' day(s): ...' }
})
세션이 시작되면 /를 입력했을 때 목록에 /standup과 설명이 보입니다. 명령 뒤에 공백을 입력하면 argumentHint가 /standup [days]처럼 보입니다. /standup 3을 실행하면 transcript에 플러그인 이름 뒤로 Summary for the last 3 day(s): ...가 나옵니다. 명령에 내 동작 외의 동작이 없으므로 hook은 next를 호출하지 않습니다.
- 반환한
text는 transcript에 출력되고 Claude도 읽습니다. 아무것도 출력하지 않으려면(예: pane만 여는 명령){}를 반환합니다. - Claude가 작업 중일 때도 실행되게 하려면 등록에
immediate: true를 추가합니다. - 내장 명령이 쓰지 않는 이름을 고르세요(세션에서
/로 확인). 이미 쓰인 이름이면$.command.register가"/focus" refused: it is the built-in /focus같은 메시지로 throw합니다. throw한 hook은 건너뛰어지므로session.starthook의 나머지도 실행되지 않습니다. 명령 등록은 hook 마지막에 하거나try/catch로 감쌉니다.
도구 추가
도구는 Claude용입니다. 이름, Claude가 읽는 설명, 입력 JSON Schema로 등록합니다. Claude에게는 mcp__ + 플러그인 이름 + 밑줄 두 개 + 등록한 이름으로 된 긴 이름으로 보입니다. 호출은 그 전체 이름으로 필터한 tool.call hook에서 처리합니다. 플러그인 my-mod가 ticket을 등록하면 전체 이름은 mcp__my-mod__ticket입니다.
on('session.start', async ($, e, next) => {
await $.tool.register({
name: 'ticket',
// Claude decides when to call the tool from this description
description: 'Look up a ticket by its id and return its title and status',
// The arguments Claude has to send: one required string named id
inputSchema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] },
})
return next(e)
})
// The full tool name is mcp__, the plugin's name, and the registered name
on('tool.call', { tool: 'mcp__my-mod__ticket' }, async ($, e) => {
// The tool's arguments are fields of e, so the id is e.id
const response = await $.http.fetch('https://tickets.example.com/api/' + encodeURIComponent(e.id))
// Return a result either way, so Claude learns when the lookup failed
return { result: response.ok ? response.text : 'Lookup failed with status ' + response.status }
})
티켓에 대해 물으면 Claude가 id로 mcp__my-mod__ticket을 호출할 수 있습니다. 두 번째 hook이 티켓을 가져와 응답 본문을 반환하고, Claude는 이를 도구 결과로 읽습니다. 서버가 오류 상태로 응답하면 Claude는 Lookup failed with status와 숫자를 읽습니다.
팁: MCP tool search가 등록된 도구를 defer하면 Claude는 검색하기 전까지 이름만 보고 설명은 보지 못합니다. 매 턴 고려해야 하는 도구라면 등록에
isDeferred: false를 추가해 전체 도구를 미리 로드합니다. 이 필드는 Claude Code v2.1.293 이상이 필요하며, 이전 버전은 무시합니다.
모델 호출
mod는 분류·요약 같은 작은 작업을 위해 자기 요청을 모델에 보낼 수 있습니다. $.model.complete는 프롬프트만 단독으로 보내고, $.model.fork({ prompt })는 현재 대화 끝에 프롬프트를 붙여 보냅니다.
| 요청 내용 | $.model.complete |
$.model.fork |
|---|---|---|
| 모델 | 넘긴 model |
세션 모델 |
| 시스템 프롬프트 | 짧은 attribution block 뒤에, 넘겼다면 system |
세션 시스템 프롬프트 |
| 메시지 | user 메시지 하나(prompt) |
지금까지의 대화, 그 뒤 user 메시지로 prompt |
| CLAUDE.md 등 프로젝트 컨텍스트 | 포함 안 됨 | 대화의 마지막 요청과 같이 포함 |
| 도구 | 없음 | Claude의 도구(모델이 호출할 수는 없음) |
fork는 대화의 마지막 요청을 반복하므로, 대화가 아직 캐시에 있으면 Claude API가 대부분을 prompt cache에서 처리합니다. 두 호출 모두 세션 자격 증명을 쓰므로 사용자의 플랜·API key·클라우드 제공자로 과금됩니다. 모든 $.model 메서드는 빌드용 types에 있습니다.
프롬프트 하나 보내기
$.model.complete에 model과 prompt를 넘깁니다. prompt는 user 메시지가 됩니다. 역할이나 출력 형식 같은 지시는 system으로 넘기면 시스템 프롬프트가 됩니다.
명령으로 등록한 /triage에 답하며, 작은 모델에게 명령 뒤 텍스트에 라벨을 붙이게 하는 hook:
on('command.run', { command: 'triage' }, async ($, e) => {
const r = await $.model.complete({
model: 'haiku',
// The system prompt sets the job, and the prompt carries the text to label
system: 'Reply with one word: bug, feature, or question.',
prompt: e.args,
// One word needs few tokens, and the call gives up after 15 seconds
maxTokens: 20,
timeoutMs: 15000,
})
// r.text exists only when the model answered, so check r.isAnswered first
const label = r.isAnswered ? r.text.trim() : 'unknown'
return { text: 'Label: ' + label }
})
/triage the export button does nothing를 실행하면 Label: bug 같은 답이 출력되고, 모델이 답하지 않으면 라벨은 unknown입니다.
- Claude API 실패는 호출을 reject하지 않습니다.
r.isAnswered를 확인하고,false면r.reason을 읽습니다. - 조직이 차단한 모델처럼 Claude Code가 보내지 않는 요청은 reject됩니다.
effort등 다른 옵션은 빌드용 types에,maxTokens기본값은 reference의 limits에 있습니다.
prompt caching 사용
$.model.complete는 Claude API의 prompt caching을 지원합니다. API는 요청의 앞부분(prefix)을 내가 설정한 cache breakpoint까지 캐시합니다. 모든 호출이 같은 긴 정적 내용(지시문, 참고 자료 등)으로 시작한다면 그 끝에 breakpoint를 두면 이후 호출은 전체 입력 가격 대신 캐시에서 읽습니다.
breakpoint를 두려면 prompt를 문자열 대신 { text } 블록 배열로 넘기고, 정적 내용의 마지막 블록에 cache: true를 추가합니다. Claude Code가 그 블록을 API의 cache_control 필드와 함께 보냅니다. system도 같은 배열 형태를 받습니다.
참고: 블록 배열은 Claude Code v2.1.292 이상이 필요합니다. 이전 버전은
prompt의 배열을takes { model, prompt } (host check)로 끝나는 오류로 거부하고,system의 배열은 요청에서 뺍니다.
긴 라벨링 규칙을 먼저 보내고 그 뒤에 breakpoint를 둔 /triage hook(RULES는 직접 정의한 문자열):
on('command.run', { command: 'triage' }, async ($, e) => {
const r = await $.model.complete({
model: 'haiku',
prompt: [
// Identical on every call, so it forms the cached prefix
{ text: RULES, cache: true },
// Changes on every call, so it goes after the breakpoint
{ text: e.args },
],
})
return { text: 'Label: ' + (r.isAnswered ? r.text.trim() : 'unknown') }
})
TTL과 breakpoint 수 제한:
- TTL: 캐시 항목은 마지막 사용 후 5분 유지됩니다. TTL은 호출이 아니라 사용자의 Claude Code 설정에서 옵니다. 1시간으로 하려면
subagentPromptCacheTtl을1h로 설정합니다. - 요청당 breakpoint: API는 최대 4개를 받으며, 하나 더 넣으면
r.reason에api-error로 돌아옵니다.
prompt와 system 중 고르기: 요청이 Claude API로 직접 간다고 확신하지 않는 한, 호출들이 공유하는 정적 내용은 prompt 앞부분에 둡니다.
- API key나 Claude 구독으로 Claude API에 직접: 어느 쪽이든 됩니다.
- Amazon Bedrock, Claude Platform on AWS, Google Cloud's Agent Platform, Microsoft Foundry, LLM gateway 경유:
prompt를 씁니다. Claude Code는 시스템 프롬프트를 attribution block으로 시작하는데, 그 fingerprint는 user 메시지 앞부분에서 옵니다.api.anthropic.comendpoint는 캐싱 전에 이 블록을 제거하지만, 다른 endpoint는 프롬프트의 일부로 받으므로prompt의 시작이 다르면system의 breakpoint가 miss될 수 있습니다. - 다른 사람이 실행하는 mod: 제공자를 고를 수 없으므로
prompt를 씁니다.
prefix에서 system이 prompt보다 앞이므로, prompt의 breakpoint는 system도 포함하며 system이 다른 호출은 캐시 miss입니다.
캐시 적중 확인: $.model.complete 결과의 usage 객체에 API의 cache 필드가 있습니다. usage.cache_creation_input_tokens는 캐시에 쓴 토큰, usage.cache_read_input_tokens는 캐시에서 읽은 토큰입니다. 첫 호출은 쓰기, TTL 안의 이후 호출은 읽기가 정상입니다.
- 매번 쓰기만 하고 읽지 않으면 호출마다 prefix가 다르거나 호출 간격이 TTL보다 깁니다.
- 모델이 답한 호출에서 두 필드가 모두 0이면 아무것도 캐시되지 않은 것입니다. 원인을 확인하세요.
- prefix가 너무 짧음: 모델의 최소 길이 미만이면 API가 캐시하지 않고 오류도 반환하지 않습니다.
- prompt caching 비활성화: 모델에
DISABLE_PROMPT_CACHING변수가 적용되면 Claude Code가 breakpoint를 제거하고 캐시 없이 보냅니다. - gateway가
cache_control제거: gateway가 필드를 제거하고도 성공을 반환할 수 있습니다. - 다른 mod가 텍스트 앞부분을 재작성: 그러면 Claude Code가 breakpoint 없이 보냅니다.
model.complete hook이 받는 것
다른 mod의 요청을 검사하거나 바꾸려고 model.complete 이벤트에 hook을 걸면 다음 필드에서 텍스트를 읽습니다.
e.prompt: 항상 문자열. 호출자가 배열을 넘겼다면 블록 텍스트를 순서대로 이어 붙인 것e.system: 같은 방식으로 만든 문자열. 호출자가system을 넘기지 않았으면 없음e.promptBlocks,e.systemBlocks: 호출자의 배열. 해당 필드에 배열을 넘긴 경우에만 존재
Claude Code는 hook이 next에 넘긴 문자열을 보내고, 함께 넘긴 배열로 cache breakpoint 위치를 정합니다. 문자열 앞부분과 여전히 일치하는 앞쪽 블록은 breakpoint와 함께 유지하고, 나머지는 breakpoint 없이 보냅니다. 예를 들어 next({ ...e, prompt: e.prompt + NOTE })는 호출자의 breakpoint를 유지하지만, prompt 앞부분을 바꾸는 hook은 breakpoint를 없앱니다.
백그라운드에서 작업 실행
1분마다 무언가를 확인하는 것처럼 이벤트 하나보다 오래 가는 작업은 session.start에서 시작한 타이머로 돌립니다. hook 자체는 이벤트 하나 동안 실행되며 자기 실행 시간에 시간 제한이 있습니다. next나 mods API 호출을 기다리는 시간은 포함되지 않지만 $.clock.sleep은 예외로 포함됩니다.
$.clock.every,$.clock.after가setInterval,setTimeout을 대신하며 지연(ms)이 첫 인자입니다.$.clock.after(5000, fn)은 5초 뒤fn을 한 번 호출합니다.- 둘 다
cancel()메서드가 있는 타이머를 반환합니다. await $.clock.now()는 현재 시각을 ms로 줍니다.
1분마다 pull request check를 조회해 프롬프트 아래에 보여 주는 hook(summarize는 명령의 JSON 출력을 몇 단어로 바꾸는 직접 만든 함수):
on('session.start', async ($, e, next) => {
// Call the function every 60,000 milliseconds, starting one minute from now
$.clock.every(60_000, async () => {
const status = await $.process.run(['gh', 'pr', 'checks', '--json', 'state'])
// Replace the line under the prompt with the latest summary
$.ui.status('checks: ' + summarize(status.stdout))
})
// Return without waiting for the timer, so the session starts right away
return next(e)
})
세션은 평소처럼 시작되고, 1분 뒤 프롬프트 아래에 ⚠, mod 이름, checks:와 요약이 담긴 줄이 나타나 이후 1분마다 바뀝니다. 타이머 콜백은 이벤트 밖에서 실행되므로 턴 사이에도 계속 돌고 턴을 시작하지 않습니다. 콜백이 throw하면 오류는 debug log로 가고 다음 간격에 다시 실행됩니다.
턴 없이 무언가 보여 주기
| 호출 | 사용자에게 보이는 것 |
|---|---|
$.ui.status(text) |
프롬프트 아래 한 줄. 바꿀 때까지 유지. ⚠ my-mod: checks: 3 passing처럼 ⚠와 mod 이름으로 시작 |
$.ui.toast(text) |
mod 이름이 붙은 toast. 몇 초 뒤 사라짐. fullscreen 렌더링에서는 오른쪽 위 상자, classic renderer에서는 프롬프트 아래 오른쪽 한 줄 |
$.ui.log(text) |
Claude가 읽지 않는 transcript의 흐린 줄. ● my-mod: build finished처럼 ●와 mod 이름으로 시작 |
$.ui.notify(text) |
Claude Code 밖, 터미널의 알림. 사용자의 preferredNotifChannel 설정대로 전송. 비어 있지 않은 { title }을 넘기지 않으면 mod 이름이 제목 |
응답을 주는 것은 $.ui.notify뿐입니다. 사용자 채널이 터미널에 알림을 썼다면 { isSent: true, channel }로 resolve되며, channel은 보낸 설정 값(예: kitty, terminal_bell)입니다. terminal_bell 채널은 벨만 울리고 텍스트와 제목은 보여 주지 않습니다. 그 외에는 { isSent: false, reason }로 resolve됩니다.
reason |
의미 |
|---|---|
disabled |
사용자가 preferredNotifChannel을 notifications_disabled로 설정 |
no-channel |
설정이 auto이고 터미널에 Claude Code가 보낼 수 있는 알림이 없음 |
no-surface |
세션에 터미널이 없음(-p 실행, Agent SDK, desktop 앱, VS Code 확장, 클라우드 세션). 세션 시작 전(mod 로드 중) 호출, Claude Code 종료 중 호출에도 이 값 |
refused |
다른 mod의 ui.notify hook이 보내지 않고 Claude Code 대신 응답 |
text가 문자열이 아니거나, 다른 mod의ui.notifyhook이{ deny }를 반환하거나, 터미널 쓰기에 실패하면 resolve 대신 reject됩니다.- mod는 채널을 고를 수 없고, Claude Code는 mod의 알림 빈도를 제한하지 않습니다. 사용자 설정이 유일한 스위치입니다.
- 호출이 실행 중인 세션에 닿으면 사용자의
Notificationhooks가 타입plugin_notification으로 실행됩니다. 결과가disabled일 때와, 터미널이 없어no-surface인 세션에서도 실행됩니다. 세션 시작 전이나 종료 중 호출에는 실행되지 않습니다.$.ui.notify를 호출한 hook은 그 실행에서 빠지므로, 알림을 보내는classic.Notificationhook이 자기 알림으로 다시 호출되지 않습니다. - 터미널은 알림 표시 여부를 보고하지 않으므로
isSent: true는 보냈다는 뜻이지 사용자가 봤다는 뜻이 아닙니다.
메인 대화의 턴이 중단 없이 끝나면 알리고, 텍스트 알림이 나가지 않았으면 toast로 대체하는 예. 사용자의 Notification hook이 먼저 실행되어 오래 걸릴 수 있으므로 알림을 기다리지 않고 이벤트를 넘깁니다.
on('turn.complete', ($, e, next) => {
const isMainTurn = e.agentId === undefined && !e.isAborted
const fallBack = () => $.ui.toast('Turn finished')
if (isMainTurn) {
$.ui.notify('Turn finished').then(sent => {
const hasText = sent.isSent && sent.channel !== 'terminal_bell'
if (!hasText) fallBack()
}, fallBack)
}
return next(e)
})
VS Code 확장에서는 status 줄과 toast가 프롬프트 상자 위에, log 줄은 대화 안에 표시됩니다.
백그라운드 작업에서 턴 시작
백그라운드 작업이 Claude가 봐야 할 것을 찾으면 $.prompt.submit({ text })로 프롬프트를 제출해 턴을 시작할 수 있습니다. Claude는 mod를 발신자로 밝히는 문장 뒤에 텍스트를 읽습니다. 그 문장 없이 사용자 자신의 말로 보내려면 asUser: true를 추가합니다. 호출은 세션이 idle이 될 때까지 기다렸다가 새 턴을 시작하고, 턴이 시작될 때 resolve되므로 Claude가 작업 중에 실행되는 핸들러에서 await하지 마세요.
백그라운드 작업 멈추기
타이머는 모듈이 재로드되면 멈춥니다. hook 안의 오래 걸리는 작업에는 next.signal을 넘기세요. 처리 중인 이벤트가 버려지면(예: 사용자 중단) abort되는 AbortSignal입니다.
사용자가 턴을 중단한 뒤의 호출과 백그라운드 작업
결과나 오류의 reason이 the session was stopped and nobody has sent input since이면 사용자가 Esc나 Stop 등으로 실행 중인 턴을 멈춘 것입니다. 그 뒤로는 사용자의 다음 입력(프롬프트 메시지 등)이 있을 때까지 세션에서 새 작업을 시작하는 호출이 거부됩니다. $.prompt.submit과 $.command.run을 제외하면, 중단 후 호출·백그라운드 작업에 대한 이 처리는 Claude Code v2.1.304 이상이 필요합니다.
거부되거나 버려진 호출 재시도
turn.complete hook에서 다시 호출합니다. 사용자의 다음 입력까지는 계속 거부되며, 아무것도 대신 큐에 넣거나 반복해 주지 않습니다. 결과나 오류에 다음 문자열이 있으면 인자를 보관해 두었다가 재시도합니다.
the session was stopped and nobody has sent input since: 호출이 거부됨was removed from the queue before it ran: 프롬프트나 명령이 실행 전에 큐에서 빠져 버려짐(예: 사용자가 턴을 멈춤)
아래 예에서는 백그라운드 작업에서 $.prompt.submit 대신 submitPrompt를 호출합니다. validation이 register 안에서 선언한 함수에 $를 넘기는 것을 거부하므로, pendingText 변수와 submitPrompt 함수는 register 위에, turn.complete hook은 안에 둡니다.
// The text of the last refused or dropped prompt, or null
let pendingText = null
async function submitPrompt($, text) {
let reason
try {
const result = await $.prompt.submit({ text })
reason = result?.drop ?? ''
} catch (error) {
reason = String(error?.message ?? error)
}
if (reason.includes('the session was stopped and nobody has sent input since') ||
reason.includes('was removed from the queue before it ran')) {
pendingText = text
}
// Any other result or error isn't retried
}
on('turn.complete', async ($, e, next) => {
if (pendingText !== null) {
const text = pendingText
// Empty pendingText before retrying, so that a second run of this hook
// doesn't submit the same text.
pendingText = null
// Not awaited: the hook would stay open until the new turn starts
submitPrompt($, text)
}
return next(e)
})
사용자의 다음 입력 전에도 중단된 턴 자체와 subagent 턴에 대해 turn.complete가 발생할 수 있고, 그때 한 호출은 다시 거부됩니다. 그러면 submitPrompt가 텍스트를 다음 turn.complete까지 보관합니다.
$.command.run처럼 reject되는 호출은 오류를 catch해 submitPrompt가 reason을 검사하듯 메시지를 검사합니다.
거부된 호출의 반환값
아래 결과에서 <reason>은 호출이 거부된 이유입니다. 중단 후라면 the session was stopped and nobody has sent input since이고, 다른 이유는 아래 "claude -p 실행과 Agent SDK 세션에서 거부된 호출"을 보세요.
| 거부된 호출(일부) | 결과 |
|---|---|
$.prompt.submit |
{ drop: '<reason>' }로 resolve, 프롬프트를 큐에 넣지 않음 |
$.command.run |
메시지가 <reason>으로 끝나는 오류로 reject |
$.agent.spawn |
{ deny: '<reason>' }로 resolve, subagent를 시작하지 않음 |
끝난 subagent에 $.session.send |
{ isDelivered: false, reason: 'Agent "<id>" was not resumed: <reason>.' }로 resolve(<id>는 subagent id). 아직 실행 중인 subagent는 메시지를 받음 |
agent team teammate에 $.session.send |
{ isDelivered: false, reason: 'Teammate "<name>" was not messaged: <reason>.' }로 resolve(<name>은 teammate 이름) |
run_in_background: true인 Bash, Monitor, CronCreate의 $.tool.call |
{ deny: '<tool> was not run: it starts background work, a schedule or a goal, and <reason>' }로 resolve(<tool>은 도구 이름) |
대화형 세션에서 중단 후 이 $.session.send, $.tool.call 호출이 거부되면 사용자는 Stopped: toast를 볼 수 있습니다.
중단 전에 mod가 시작한 작업
사용자가 Esc 등으로 턴을 멈추면 일부는 버려지고 일부는 다음 입력까지 기다립니다.
| 작업 | 사용자의 다음 입력까지 | 대응 |
|---|---|---|
큐에 남은 $.prompt.submit 프롬프트 |
버려짐. { drop: 'the prompt was removed from the queue before it ran' }로 resolve |
재시도 방식으로 다시 제출 |
큐에 남은 $.command.run 명령 |
버려짐. 메시지에 the command was removed from the queue before it ran이 포함된 오류로 reject |
재시도 방식으로 다시 실행 |
| CronCreate 등으로 예약한 1회성 작업이 다음 입력 전에 도래 | 그때는 턴을 시작하지 않고, 입력 후 실행 | 필요 없음 |
| CronCreate 등으로 예약한 반복 작업이 다음 입력 전에 도래 | 턴을 시작하지 않음. 그 회차는 잃고 일정은 유지 | 필요 없음 |
| mod가 시작한 monitor의 이벤트 | monitor는 계속 실행. 다음 입력 전에 낸 이벤트는 버려짐. 단, monitor 종료 알림은 입력 후 도착 | 할 수 있는 것 없음 |
mod가 spawn하고 $.session.send로 재개한 subagent가 다음 입력 전에 끝남 |
완료 알림이 대기하다가 입력 후 대화에 도착 | 필요 없음 |
사용자가 턴을 멈출 때 이미 실행 중이던 $.tool.call의 foreground Bash·PowerShell 명령이 다음 입력 전에 timeout에 도달 |
background로 옮겨지지 않고 timeout에서 멈춤. 호출은 isError가 설정된 채 resolve되고, 출력에 Ended instead of moving to the background: plugins' input is held, by a Stop or by the host's limits. 줄이 포함됨 |
사용자의 다음 입력 후 재시도 방식으로 다시 실행 |
예약 작업이 도래하거나 monitor가 다음 입력 전에 이벤트를 내면 사용자는 Stopped: toast를 볼 수 있습니다.
claude -p 실행과 Agent SDK 세션에서 거부된 호출
claude -p 실행이나 Agent SDK 세션에서 { drop: 'the run reached its --max-turns limit' } 같은 결과는 실행이 mod의 호출을 거부했다는 뜻입니다.
거부된 $.prompt.submit은 { drop: '<reason>' }으로 resolve됩니다. $.command.run, $.agent.spawn 같은 다른 호출은 위 "거부된 호출의 반환값" 표의 결과를 <reason> 자리에 이유를 넣어 돌려줍니다. 이유별 대응:
the run reached its --max-turns limit: mod의 프롬프트와 mod가 spawn한 subagent의 턴도--max-turns나 Agent SDK의 turn limit에 포함됩니다. 턴이 더 필요하면 한도를 높여 실행을 시작하고, 그럴 수 없으면 mod가 거부된 호출을 그만하게 합니다. 실행이 사용자나 SDK 애플리케이션에서 프롬프트를 하나 더 받으면 턴 수가 초기화되어 다시 호출을 받습니다.the run is past its --max-budget-usd limit: 더 높은--max-budget-usd로 시작하거나 Agent SDK의 spend limit을 올립니다. 그럴 수 없으면 mod가 거부된 호출을 그만하게 합니다. 한도는 지금까지의 실행 총비용에 적용되고, 프롬프트를 더 받아도 총액은 계속 쌓이므로 재시도해도 같은 이유가 돌아옵니다.the host has ended its input: 예를 들어 SDK 애플리케이션이 streaming input을 끝냈거나,--input-format stream-json으로 시작한 실행의 stdin이 닫힌 경우입니다. 실행이 끝나는 중이라 입력을 더 받지 않으므로 재시도해도 같은 이유가 돌아옵니다. mod가 거부된 호출을 그만하게 하고, 실행을 직접 시작한다면 다음에는 mod의 호출이 끝날 때까지 입력을 열어 두세요.the session was stopped and nobody has sent input since: Agent SDK의interrupt()등으로 실행이 중단된 것입니다. 사용자나 SDK 애플리케이션의 다음 메시지 후 다시 호출하려면 위 "거부되거나 버려진 호출 재시도"를 따릅니다.
mod를 쓰는 claude -p 실행이 끝나지 않을 때
mod가 매 턴 끝에 프롬프트를 제출하면 프롬프트마다 턴이 하나 더 시작됩니다. --max-turns나 --max-budget-usd가 없으면 그 mod를 로드한 claude -p 실행은 직접 멈출 때까지 계속됩니다.
실행이 스스로 끝나게 하려면 셸 명령에 둘 중 하나를 추가합니다. 다른 사람이 실행을 시작한다면 작업이 끝난 뒤 mod가 프롬프트 제출을 멈추게 하세요. --max-turns를 추가한 명령:
claude -p "find the TODOs" --max-turns 5
실행이 한도에 닿으면 mod의 다음 프롬프트는 턴을 시작하지 않고 거부되며 실행이 끝납니다.
그래도 한도를 넘겨 계속되면 셸에서 claude --version을 실행하고, v2.1.304보다 낮으면 Claude Code를 업데이트합니다. 한도에서 mod 프롬프트를 거부하는 것은 v2.1.299부터이고, mod가 spawn한 subagent의 턴이 --max-turns에 포함되는 것은 v2.1.304부터입니다.
세션 간 메시지 보내고 받기
mod는 내 다른 세션, 이 세션의 subagent, agent team의 teammate에게 일반 텍스트 메시지를 보내고, 오가는 메시지를 관찰할 수 있습니다. 보내려면 SendMessage 도구와 같은 전달을 하는 $.session.send({ to, text })를 호출합니다. to는 받는 대상에 따라:
- 내 다른 세션:
{ sessionId } - subagent 또는 teammate:
$.agent.list()에서 얻은 id로{ agentId } - 받은 메시지의 발신자: 그 메시지가 온 문자열 주소
메시지가 큐에 들어가면 { isDelivered: true }로 resolve됩니다. 전달되지 않으면 { isDelivered: false, reason }으로 resolve되며 reason에 이유가 있습니다.
명령으로 등록한 /ping에 답하며, 뒤에 입력한 id의 세션에 상태를 묻는 hook:
on('command.run', { command: 'ping' }, async ($, e) => {
// e.args is the session id typed after /ping
const sent = await $.session.send({ to: { sessionId: e.args }, text: 'Status? One line.' })
// The call resolves either way, so check isDelivered to learn what happened
if (!sent.isDelivered) $.ui.toast('Not delivered: ' + sent.reason)
// An empty result prints nothing in this session's transcript
return {}
})
메시지가 큐에 들어가면 내 세션에는 아무것도 나타나지 않고, 상대 세션의 Claude가 Status? One line.을 읽습니다. 전달되지 않으면 toast로 이유를 보여 줍니다.
session.receive와 session.send 이벤트로 메시지를 관찰합니다. 둘 다 next(e)를 반환하면 메시지를 그대로 통과시킵니다.
| 이벤트 | 발생 시점 | 유용한 필드 |
|---|---|---|
session.receive |
이 세션에 메시지가 도착, Claude가 읽기 전 | e.text, e.origin.kind(다른 세션·agent면 peer 또는 peer-send-message, task-notification, scheduled-trigger 등). { consumed: reason }을 반환하면 Claude에게 보이지 않음 |
session.send |
SendMessage 도구나 mod에서 메시지가 나가기 직전 | e.to, e.text, e.origin.kind(model 또는 plugin) |
- inbound 메시지를 거부하도록 설정된 세션은
session.receive발생 전에 거부하므로 hook이 보지 못합니다. - 승인 대기 중인 메시지는 hook에 먼저 도착하므로, mod는 아직 승인하지 않은 메시지도 읽을 수 있습니다.
- 메시지가 전달되지 않으면 hook의
next(e)가 reject됩니다. - 받은 메시지의 발신자 이름은 발신자가 쓴 그대로이므로 판단 근거로 삼지 마세요.
파일, 프로세스, 네트워크 접근
mod는 mods API를 통해 Claude Code를 실행하는 사용자와 같은 권한으로 파일 시스템·프로세스·네트워크에 접근합니다. hooks module 자체에는 Node.js API, setTimeout 같은 timer 전역, 자체 네트워크·파일 접근이 없습니다. URL, TextEncoder, AbortController, crypto.subtle 같은 표준 JavaScript·web API는 쓸 수 있습니다.
| Namespace | 하는 일 |
|---|---|
$.fs |
read(path), write(path, text), exists(path), stat(path), list(path)로 파일·디렉터리 작업 |
$.process |
run(['git', 'status'])은 명령을 시작하고 종료 시 resolve. spawn은 오래 실행되는 명령의 출력을 스트리밍 |
$.http |
http/https로 fetch(url, init). 본문을 읽은 뒤 { status, ok, headers, text }로 resolve |
$.store |
세션 간 유지되는 플러그인 전용 JSON key-value 저장소 |
$.env |
환경 변수 get/set. 이름은 문자열 리터럴로 작성 |
$.settings |
read()는 모든 설정을 객체 하나로 병합해 resolve. { source }나 { key }를 주면 대신 source 하나의 설정이나 키 하나의 값으로 resolve(아래 "설정 전체, source 하나, 키 하나 읽기" 참고) |
$.session |
messages()는 transcript를 { role, text, toolUses } 목록으로 반환. 작업 디렉터리, 모델 등도 제공. usage()는 컨텍스트 윈도우 사용량과 플랜 한도 반환 |
$.mcp |
연결된 MCP 서버의 도구 call |
규칙:
- 경로: 상대 경로는 세션의 작업 디렉터리, 또는 hook이 처리하는 이벤트가 subagent의 것이면 그 subagent의 작업 디렉터리 기준
$.fs.list: 디렉터리 하나의 항목을{ name, kind, size, isLink }로 반환하며 재귀하지 않음$.process.run: 인자 목록을 받고 셸을 쓰지 않음. 종료 코드와 무관하게{ exitCode, stdout, stderr }로 resolve. 프로그램을 시작할 수 없거나 timeout(기본 30초)까지 실행 중이면 reject되므로try/catch로 감쌈$.http.fetch: redirect를 최대 5번 따라감. 다른 origin으로 redirect되면 직접 설정한 요청 헤더 중accept,accept-language,content-type,user-agent만 유지하고 나머지는 버리므로,Authorization같은 다른 헤더에 의존하는 요청은 redirect 후 실패할 수 있음. timeout과 본문 크기는 reference의 limits에 있음
이 호출들도 각각 이벤트이며, 이름은 $.를 뺀 namespace와 메서드입니다(예: $.fs.read → fs.read). 체인에서 앞선 mod가 내 호출을 관찰·재작성·거부할 수 있고, 조직은 이 방식으로 mod의 접근 범위를 제한합니다.
mod는 명령이 출력을 냈거나 종료된 뒤에도 내 $.process.spawn 호출을 거부할 수 있으며, 명령이 한 일은 되돌려지지 않습니다. 이 경우 호출은 다음 문자열 중 하나와 거부한 mod의 이유로 끝나는 메시지로 reject됩니다.
$.process.spawn started, and a plugin withheld its result:: 거부한 mod가 명령 출력을 끝까지 읽지 않았음. 아직 실행 중이면 Claude Code가 명령을 멈춤$.process.spawn ran, and a plugin withheld its result:: 거부한 mod가 출력을 끝까지 읽었으므로 명령은 이미 종료됨
설정 전체, source 하나, 키 하나 읽기
$.settings.read()는 사용 중인 모든 source의 설정을 우선순위에 따라 병합한 객체 하나로 resolve됩니다. source 하나만 읽으려면 { source }, 키 하나는 { key }, 또는 둘 다 넘깁니다.
| 호출 | resolve 값 |
|---|---|
$.settings.read() |
병합된 설정 객체. 최상위 키는 permissions, hooks처럼 settings.json 파일의 키 |
$.settings.read({ source: 'policy' }) |
그 source 하나의 설정 객체. 설정이 없으면 {} |
$.settings.read({ key: 'spinnerTipsEnabled' }) |
병합된 설정에서 그 키의 값. 설정되지 않았으면 undefined |
$.settings.read({ key: 'spinnerTipsEnabled', source: 'user' }) |
그 source에서 그 키의 값. 그 source가 설정하지 않았으면 undefined |
source에는 읽을 설정 source 이름 하나를 넘깁니다.
user:~/.claude/settings.jsonproject:.claude/settings.jsonlocal:.claude/settings.local.jsonflag:--settings로 넘긴 것과 SDK의 inline settingspolicy: managed settings
key에는 중첩 객체로 들어가는 점 경로가 아니라 최상위 키 하나의 정확한 이름을 넘깁니다. key 인자는 Claude Code v2.1.304 이상이 필요합니다.
체크리스트
- 명령·도구는
session.starthook에서 등록했고, 명령 등록은 hook 마지막에 두거나try/catch로 감쌌다. - 내장 명령과 겹치지 않는 이름을 골랐다.
- 도구 호출 hook을
mcp__<plugin>__<name>전체 이름으로 필터했다. -
$.model.complete결과에서r.isAnswered를 먼저 확인했다. - 다른 사람이 쓰는 mod라면 캐시할 정적 내용을
system이 아니라prompt앞부분에 두었다. -
setInterval/setTimeout대신$.clock.every/$.clock.after를 썼다. - Claude가 작업 중일 때 실행되는 핸들러에서
$.prompt.submit을await하지 않았다. - 중단 후 거부·버려진 호출은
turn.complete에서 재시도하도록 했다. -
$.process.run을try/catch로 감쌌고,$.http.fetch의 cross-origin redirect 헤더 제거를 고려했다.
다음 단계
- React to events: 도구 호출·프롬프트·턴에 hook을 겁니다.
- Draw in the interface with a mod: mod가 모은 정보를 pane이나 프롬프트 위에 보여 줍니다.
- Test a mod: 테스트에서 이 호출들을 stub합니다.
- Mods reference: 이벤트, mods API 메서드, limits를 확인합니다.
기준일
이 문서는 2026-11-03에 공식 Use the mods API 페이지를 기준으로 작성했습니다. isDeferred(Claude Code v2.1.293 이상), 블록 배열(v2.1.292 이상), 중단 후 호출 처리·$.settings.read의 key 인자(v2.1.304 이상), claude -p 한도에서의 프롬프트 거부(v2.1.299 이상)의 버전 요건은 공식 원문에 명시된 값입니다.