이번에 Claude에 새로운 기능 Mod라는 게 나오고, 사람들의 관심이 꽤나 뜨겁다.
그래서 Mod가 뭔지, 알기 위한 사전 지식과 예제 코드를 통해
어떤 Mod를 어떻게 만들지 정리해본다.
Mod는 Claude Code의 동작과 UI를 바꾸는 JavaScript/TypeScript 코드이다.
Skills, CLAUDE.md와는 엄연히 다르고 Hooks와 비슷하다.
Mod 설명에 앞서, 더 잘 이해하기 위한 정보들을 정리한다.
Claude는 모델이다. Anthropic API를 통해 요청과 응답이 오간다.
Claude Code를 다른 모델과도 연결할 수 있는 이유는 API Endpoint를 임의로 설정할 수 있기 때문이다.
파일을 읽거나 명령을 실행하는 능력은 없다. "실행해 줘"라고 요청만 한다.
Claude Code는 컴퓨터에서 도는 프로그램이다. 모델 - 컴퓨터를 잇는다.
로 서로 협력하는 관계이다.

그림을 보면, 1번에서 API를 통해, Claude 모델이 gh pr list 요청을 반환하고
-> 2~5번에서 Claude Code가 그 요청을 검사하고, 실행하고
-> 6번에서 Claude에 전달하고, Claude가 그걸 읽고 사용자에게 응답한다.
위 루프에서 사용자가 제어할 수 있는 곳은 두 곳이다.
Skills · CLAUDE.md
Hooks · Mod
Hooks는 셸 스크립트, Mod는 엔진 안에서 도는 TypeScript다.
두 가지는 강제성의 차이가 된다.
EX) "gh 쓰기는 사용자에게 무조건 묻고 해라"
tool.check가 ask를 답한다Mod가 검증이 아니라, 단순 프롬프트에 덧붙인다면 지침과 동일하게 동작한다.
Claude Code가 하는 일 하나하나에 붙은 이름.
prompt.submittool.callturn.complete각 이름마다 코드를 등록할 수 있다.
(일종의 웹의 onClick - 버튼 누르면, 등록한 함수 실행되는 형태)

next(e)다음 차례로 넘기는 함수
// e = 지금 하려는 일의 입력. 예: e.command === 'gh pr list'
on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
if (e.command.includes('rm -rf')) {
// next 를 안 부름 → 명령은 실행되지 않는다
return { deny: '위험한 명령' }
}
// 다음 차례로 넘긴다. 아래 단계(다른 훅, PreToolUse, 권한 판정 등)가 끝나면 결과가 온다.
const result = await next(e)
return result
})next(e)를 기준으로, 위쪽은 실행 전 / 아래쪽은 실행 후이다.deny와 같이 아래 단계에서 처리한 값이 올 수 있다.tool.call을 통해 도구를 실행하기 전에 막거나, 입력을 고칠 수 있다.
{deny: '민감한 요청'} Claude에 에러 전파 및 이유 전달 가능EX) 커밋 메시지 형식 체크, 배포 workflow 트리거 전 체크
tool.call 의 await next(e) 이후
EX) gradle 테스트의 결과는, BUILD SUCCESSFUL 만 응답
-> 테스트 결과 XML을 읽어 실행된 테스트 개수를 추가
tool.check를 통해 권한 판단이 가능하다. allow / ask / deny
allow : 묻지 않고 바로 실행ask : 권한 프롬프트로 사용자에게 확인deny : 막고, 이유는 Claude에게 에러로 전달EX) gh api 조회는 allow, -X POST·-f 가 붙은 쓰기는 ask 와 같은 명시적 제어 가능
prompt.compose, prompt.submit를 통해 매 요청에 규칙이나 현재 상태를 보낼 수 있다.EX) 한글이 있을 때만 한글 문체 관련 컨텍스트를 주입하고, 현재 PR의 미답변 코멘트를 자동으로 추가한다.
turn.start, turn.completeEX) 이번 턴에 Java 파일이 바뀌면, 턴이 끝날 때 컴파일 검사를 돌리고 결과를 알린다.
tool.register, agent.registerEX) MCP 서버 없이 "내 PR 목록" 같은 도구를 Claude에게 준다. (코드에서는 $.tool.register, $.agent.register로 부른다)
패널 형태, 프롬프트 위 한 줄, 상태줄, 타이머, 소리 등등
UI를 더 잘 쓰기 위한 방법들을 제공한다.
ui.render(패널 그리기) · $.ui.status(상태줄) · $.ui.toast(알림) · $.clock.every(타이머)EX) 내 PR 현황을 패널로 띄우고, 새 리뷰가 오면 알림을 띄운다.
애매한 영역이지만, 단순 패널이나 기능을 붙이는 게 mod를 잘 쓰는 건 아닌 것 같다.
Claude가 하는 일과 엮고, 이를 시각화하거나 구체적으로 명시할 때 의미가 있다고 생각한다.
아래
command-log는 이 중 세 가지(실행 앞뒤 기록 · 권한 판정 · 화면)를 써 본다.
간단한 mod를 작성해보자.
Claude가 실행한 Bash 명령을 기록하는 command-log를 만들어본다.
/command-log로 패널을 열면, 실행한 명령 · 성공 여부 · 걸린 시간이 보인다.12 · 실패 1 로 보여준다.
GitHub에서 command-log 전체 예제 코드 보기
Mod 구성은 아래와 같이 되어있다.

이 폴더가 플러그인이라고 나타낸다. 없으면 플러그인으로 읽히지 않는다.
{
"name": "command-log",
"version": "0.1.0",
"description": "Claude가 실행한 Bash 명령을 패널(/command-log)과 상태줄에 보여주고, gh로 GitHub에 쓰는 명령은 실행 전에 묻는다",
"types": "./types/index.d.ts",
"userConfig": {
"askGhWrite": {
"type": "boolean",
"title": "gh 쓰기 전에 묻기",
"description": "gh로 PR 코멘트 · 이슈 생성 같은 쓰기를 하기 전에 권한 프롬프트를 띄운다",
"default": true
}
}
}name, version, description, types를 명시한다.
어느 코드 파일을 읽을지 알려준다.
Mod는 modules로 TypeScript 파일을 가리킨다.
{
"modules": ["./register.tsx"]
}Mod의 본체다.
register를 내보내면 Claude Code가 mod를 읽을 때 한 번 호출한다. (핫 리로드 때도 다시 부름)
export const register: Register = (on, options) => {
const isAskGhWrite = options.askGhWrite !== false
// 아래 1~6의 on(...)이 여기 들어간다
}on : 이벤트에 함수를 등록하는 도구options : plugin.json-userConfig에 사용자가 넣은 값세션이 시작될 때 불릴 함수를 등록한다. 등록할 함수는 ($, e, next)를 받는다.
on('session.start', async ($, e, next) => {
await $.command.register({
name: 'command-log',
description: 'Claude가 실행한 Bash 명령을 패널로 본다. off 를 붙이면 닫는다',
argumentHint: '[off]',
})
return next(e)
})$ : Claude Code에 무언가 시킬 때 사용e : 이벤트 정보next : 다음 차례로 넘기는 함수Mod 코드는 격리된 곳에서 돌아 Claude Code를 직접 제어할 수 없고, API를 통해 요청한다.
($.ui.status('PR 리뷰 요청'),$.process.run(['gh', 'api', ...]))
슬래시 커맨드를 등록한다.
name : 커맨드 이름description : 커맨드 목록에 보여주는 설명argumentHint : 커맨드 뒤 붙일 인자 힌트, 입력하려고 할 때 보여준다.여기선 커맨드 등록만 한다. 할 일은
command.run에 따로 등록한다.
on('command.run', { command: 'command-log' }, async ($, e) => {
if (e.args.trim() === 'off') {
await $.ui.close({ id: PANE })
return { text: '명령 기록 패널을 닫았다.' }
}
await $.ui.open({ id: PANE, title: '명령 기록', focus: true })
return { text: '명령 기록 패널을 열었다. 닫으려면 /command-log off' }
})/command-log 를 입력하면 불릴 함수를 등록한다.
e.args : 커맨드 뒤에 추가한 인자{ text } : 커맨드의 결과를 보여줄 글$.ui.open은 패널을 열기만 한다. 패널에 그리는 것은 $.ui.render에 등록된 함수가 담당한다.
records는 실행한 명령 목록 데이터다.
import { atom, read, update } from 'claude-code'
const records = atom({ plugin: 'command-log', key: 'records' } as const, [])export type CommandRecord = { command: string; isOk: boolean; ms: number }
declare module 'claude-code' {
interface PluginState {
'command-log': { records: CommandRecord[] }
}
}값의 타입은 types/index.d.ts에 적는다.
실행 앞뒤에 끼어든다.
on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
const startedAt = await $.clock.now()
const result = await next(e)
const ms = (await $.clock.now()) - startedAt
// 막혔거나, 실패한 경우
const isOk = result.deny === undefined && result.isError !== true
await update($, records,
list => [...list, { command: e.command, isOk, ms }].slice(-MAX_RECORDS))
$.ui.status(statusOf(await read($, records)))
return result
}).catch(($, e, next) => next(e))Claude가 Bash를 실행할 때마다 걸린 시간과 성공 여부를 records에 기록한다.
{ tool: 'Bash' } : Bash 도구 실행할 때만 호출
update는 Claude가 제공하는 함수다.
패널을 그린다.
on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
const { Box, Text } = $.ui.resolve(e)
const list = await read($, records)
return (
<Box flexDirection="column">
{list.length === 0 && <Text dimColor>아직 실행한 명령이 없다.</Text>}
{list.map((one, index) => (
<Text key={`record-${index}`} color={one.isOk ? undefined : 'error'} wrap="truncate-end">
{one.isOk ? '✓' : '✕'} {one.command} · {one.ms}ms
</Text>
))}
</Box>
)
}){ component: 'Pane', requestId: PANE } : 이 패널을 그릴 때만 호출$.ui.resolve(e) : 그릴 부품을 Claude Code에서 받는다.
돌려준 값을 그대로 패널에 그린다.
=> Bash 실행 -> 4. update(records) -> records 변화 -> 5. 렌더링 다시 호출 -> read(records)
검사를 통해 특정 기능을 명시적으로 제어할 수 있다.
on('tool.check', { tool: 'Bash' }, async ($, e, next) => {
const command = (e.input as { command?: string }).command ?? ''
if (isAskGhWrite && isGhWrite(command)) {
return { decision: 'ask', reason: 'gh 로 GitHub 에 쓰는 명령이다' }
}
return next(e)
// 판정하다 실패하면 묻기
}).catch(() => ({ decision: 'ask' }))export const isGhWrite = (command: string): boolean => {
if (/\bgh\s+api\b/.test(command)) {
return /(-X|--method)[\s=]*(POST|PUT|PATCH|DELETE)\b/i.test(command) || /\s(-f|-F|--field|--raw-field|--input)\s/.test(command)
}
return /\bgh\s+(pr|issue)\s+(create|comment|edit|close|merge|review)\b/.test(command)
}e.input : 실행하려는 도구의 인자. Bash라면 { command }ask는 권한을 물어보고, reason은 이유를 보여준다.
Hook tool.check requires confirmation for this command:
gh 로 GitHub 에 쓰는 명령이다와 같이 하단에 명시가 된다.
Claude Code 세션의 프롬프트에 입력한다.
/plugin install command-log --marketplace youngsu5582/playground당연히, 개인이 따라서 구현했으면
youngsu5582/playground이 주소를 바꾸면 된다.
Add marketplace? 에 y, 설치 범위를 고르면 그 세션부터 바로 켜진다..claude-plugin/marketplace.json 이 이 저장소를 설치 목록으로 만든다.{
"name": "youngsu5582-playground",
"description": "playground 에서 직접 돌려 보며 만든 Claude Code 플러그인",
"owner": { "name": "youngsu5582" },
"plugins": [{ "name": "command-log", "source": "./claude-mod-playground/command-log" }]
}/command-log 로 패널을 연다.askGhWrite)은 /plugin configure command-log 로 바꾼다.Mod는 완전히 새로운 기능이 나온 게 아니다.
기존 Claude Code의 작업들을 사용자가 의도적으로 제어할 수 있게 인터페이스를 열어준 것과 비슷하다.
'원하는 이벤트 때(tool.call, tool.check ...) 특정 행위를 등록해 실행되게 한다.'
이 설명이 Mod의 전부이다.
"패널로 보여줄수도, 프롬프트에 추가 내용을 주입할 수도, 내용을 조작할 수도"
원하는 대부분의 것들이 가능하다.
앞에서 적었듯, Mod는 Claude가 하는 일과 엮을 때 의미가 있다.
예시로는
git push --force -> --force-with-lease)가 있을 것 같다.
나는,
프론트엔드 개발자들도 배포하기 편하게 & 현황 확인하기 편하게, '개발서버 배포 패널'과 배포를 마우스 클릭만으로 할 수 있는 Mod를 만들었다.
자기 PR의 현황(누가 리뷰해 줬는지, 내가 리뷰를 반영한 뒤 추가 코멘트가 달렸는지)과 내가 리뷰해야 하는 PR 목록을 볼 수 있는 Mod를 만들었다.
기존 Harness에서 셸 스크립트로 작성된 Hooks와 지침으로만 강제한 규칙들을 Mod로 이관해서 강제화 및 가시성을 높였다.
개인 공부할 때 도움이 되는 타이머와 명세를 순서대로 보여주고, AI가 직접 구현하거나 답변하는 것을 제한하는 Mod를 만들었다.
정도를 POC로 개선했다.
어느 정도 써보거나, Mod를 고도화해서 쓸 수 있는 포인트가 있다면 써보고 다음 글로 남겨 보려고 한다.