클로드 코드 Mods: TypeScript로 화면과 동작 바꾸기
클로드 코드를 오래 쓰다 보면 화면에 뭔가 하나 더 있었으면 좋겠다는 생각이 들 때가 있습니다. 지금 브랜치에 바뀐 파일이 몇 개인지 프롬프트 바로 위에서 보고 싶고, Claude가 고치고 있는 README를 옆에 띄워 놓고 싶고, .env 파일만큼은 절대 건드리지 못하게 막고 싶죠.
지금까지는 상태줄에 셸 스크립트를 걸거나 Hooks로 도구 호출을 막는 정도가 전부였습니다. 둘 다 클로드 코드 바깥에서 스크립트를 돌리는 방식이라, 화면에 버튼이나 패널(pane)을 그리는 건 불가능했어요.
클로드 코드 v2.1.287부터 기본으로 켜진 mod가 이 빈자리를 채웁니다. TypeScript 파일 하나로 클로드 코드 안에 패널을 띄우고, 스피너(spinner) 문구를 바꾸고, 도구 호출을 가로챌 수 있어요. 이번 글에서는 mod 네 개를 직접 만들어 돌려 보면서 실제 화면이 어떻게 바뀌는지 보여드리겠습니다.
Mod가 뭔가요?
mod는 클로드 코드의 동작과 화면을 바꾸는 플러그인입니다. 일반 플러그인과 다른 점은 JavaScript나 TypeScript로 작성한 이벤트 핸들러를 담는다는 거예요. Claude가 도구를 호출하거나 사용자가 프롬프트를 보내거나 화면의 한 부분을 그릴 때마다 클로드 코드가 이 핸들러를 부르고, 핸들러는 그 이벤트를 지켜보거나 바꾸거나 아예 대신 처리할 수 있습니다.
공식 문서는 이 핸들러를 훅(hook)이라고 부릅니다. 기존 settings.json에 적던 Hooks와 이름이 겹치는데요, 문서에서는 둘을 구분하려고 기존 것을 설정 훅(settings hook)이라고 부릅니다. 둘의 차이는 어디서 실행되느냐입니다.
| 구분 | Mod | 설정 훅 |
|---|---|---|
| 실행 위치 | 클로드 코드 프로세스 안 | 외부 셸 명령, HTTP 요청 |
| 작성 언어 | JavaScript, TypeScript | 아무 스크립트 |
| 화면 그리기 | 가능 (패널, 밴드, 버튼, 입력창, 토스트) | 불가능 |
| 바꿀 수 있는 대상 | 도구 호출, 프롬프트, 명령어, 화면 요소 | 도구 호출 허용 여부와 인자, 컨텍스트 |
이미 쓰고 계신 스크립트로 도구 호출을 막거나 기록하는 정도라면 설정 훅으로 충분합니다. 화면에 뭔가를 그리거나 턴 없이 즉시 실행되는 슬래시 명령어를 만들거나, 여러 이벤트 사이에 데이터를 주고받아야 한다면 mod가 답이에요.
참고로 클로드 코드의 /diff 패널도 cc-plugin-diff라는 내장 mod로 구현되어 있습니다. 클로드 코드 팀이 자기 기능을 mod로 만들 만큼 표현력이 충분하다는 뜻이죠.
첫 번째 mod: 도구 호출 세기
가장 단순한 mod부터 만들어 보겠습니다. Claude가 도구를 몇 번 호출했는지 세어서 스피너 옆에 보여 주고, /tally 명령어로도 확인할 수 있게 해 볼게요. mod는 파일 세 개면 됩니다.
first-mod/
├── .claude-plugin/
│ └── plugin.json
└── hooks/
├── hooks.json
└── register.ts
plugin.json은 일반 플러그인과 똑같은 매니페스트입니다.
{
"name": "first-mod",
"version": "0.1.0",
"description": "Counts Claude's tool calls, shows the count beside the spinner, and adds a /tally command"
}
hooks.json에 modules 키로 코드 파일 경로를 적으면 이 플러그인은 mod가 됩니다.
{ "modules": ["./register.ts"] }
이제 핵심인 훅 모듈(hooks module)입니다. register 함수를 내보내면 클로드 코드가 mod를 불러올 때 이 함수를 한 번 호출하면서 on 함수를 넘겨줘요. on을 호출할 때마다 이벤트 하나에 훅 하나가 등록됩니다.
import type { Register } from 'claude-code'
let calls = 0
export const register: Register = on => {
// 세션이 시작될 때 /tally 명령어를 등록
on('session.start', async ($, e, next) => {
await $.command.register({
name: 'tally',
description: '지금까지의 도구 호출 횟수를 보여줍니다',
})
return next(e)
})
// Claude가 도구를 호출하기 직전마다 실행
on('tool.call', async ($, e, next) => {
calls += 1
$.ui.invalidate('ui.render') // 화면을 다시 그려 달라고 요청
return next(e)
})
// /tally를 입력했을 때만 실행 (두 번째 인자가 매처)
on('command.run', { command: 'tally' }, async () => {
return { text: `지금까지 도구를 ${calls}번 호출했습니다` }
})
// 스피너를 그릴 때마다 실행
on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
return next({ ...e, props: { ...e.props, suffix: ` · 도구 호출 ${calls}회…` } })
})
}
빌드 단계는 없습니다. Node.js도 번들러도 필요 없이 클로드 코드가 .ts 파일을 그대로 읽어요. --plugin-dir 옵션으로 폴더를 지정해 실행하면 그 세션에만 mod가 로드됩니다.
claude --plugin-dir ./first-mod
파일 몇 개를 읽어 달라고 시켜 보면 스피너 문구 뒤에 호출 횟수가 붙는 걸 볼 수 있습니다.

작업이 끝난 뒤 /tally를 입력하면 Claude의 턴을 거치지 않고 함수가 바로 실행돼 답을 출력합니다. 앞에 붙은 first-mod:는 클로드 코드가 출처를 밝히려고 자동으로 붙이는 플러그인 이름이에요.

훅 하나가 할 수 있는 세 가지
모든 훅은 ($, e, next) 세 인자를 받습니다. $는 mod가 바깥세상에 손을 뻗는 유일한 통로인 mods API이고, e는 이벤트의 입력값, next는 이벤트를 다음 mod와 클로드 코드 본래 동작으로 넘기는 함수입니다. 웹 프레임워크의 미들웨어와 비슷한 구조라 Express나 Hono를 써 보셨다면 금방 익숙해지실 거예요.
next를 어떻게 부르느냐에 따라 훅이 하는 일이 달라집니다. 우선 next(e)를 그대로 돌려주면 관찰입니다. 앞의 tool.call 훅처럼 숫자만 세고 도구는 원래대로 실행되죠. 또 next({ ...e, ... })처럼 값을 바꿔 넘기면 재작성입니다. 스피너 훅이 suffix만 바꿔 넘겨서 클로드 코드의 스피너는 그대로 두고 뒤에 글자만 덧붙인 게 이 경우예요. 마지막으로 next를 아예 부르지 않고 직접 값을 돌려주면 응답입니다. /tally 훅이 그랬고, 뒤에서 볼 .env 보호 mod도 이 방식으로 도구 호출을 거부합니다.
mod 코드는 Node.js도 브라우저도 아닌 별도의 실행 환경에서 돕니다. fs나 fetch 같은 전역이 없어서 파일을 읽든 프로세스를 띄우든 전부 $.fs, $.process처럼 mods API를 거쳐야 해요. 번거로워 보이지만 이 덕분에 클로드 코드가 mod를 실행하지 않고도 “이 mod는 어떤 이벤트에 걸리고 무엇을 호출하는지”를 정적으로 뽑아낼 수 있습니다. 이 부분은 뒤에서 claude plugin validate로 확인해 보겠습니다.
프롬프트 위에 Git 상태 띄우기
이번에는 화면에 직접 그려 보겠습니다. 프롬프트 입력창 바로 위에는 밴드(band)라는 한 줄짜리 영역이 있는데요, 여기에 현재 브랜치와 바뀐 파일 수를 띄우는 git-band mod를 만들어 볼게요.
import { atom, read, update } from 'claude-code'
import type { EngineInterface, Register } from 'claude-code'
import type { GitInfo } from '../types'
const info = atom({ plugin: 'git-band', key: 'info' } as const, null)
async function refresh($: EngineInterface) {
const branch = await $.process.run(['git', 'branch', '--show-current'])
if (branch.exitCode !== 0) return
const status = await $.process.run(['git', 'status', '--porcelain'])
const changed = status.stdout.split('\n').filter(Boolean).length
const next: GitInfo = { branch: branch.stdout.trim(), changed }
await update($, info, () => next)
}
export const register: Register = on => {
on('session.start', async ($, e, next) => {
await refresh($)
return next(e)
})
// 파일을 바꿀 수 있는 도구가 끝난 뒤 Git 상태를 다시 읽음
on('tool.call', async ($, e, next) => {
const ran = await next(e)
if (e.tool === 'Bash' || e.tool === 'Edit' || e.tool === 'Write') await refresh($)
return ran
})
on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
const current = await read($, info)
if (e.props.hasSurvey || current === null) return next(e)
const { Box, Text } = $.ui.resolve(e)
return (
<Box>
<Text color="magenta">⎇ {current.branch}</Text>
<Text dimColor> · </Text>
<Text color={current.changed > 0 ? 'yellow' : 'green'}>
{current.changed > 0 ? `변경된 파일 ${current.changed}개` : '깨끗함'}
</Text>
</Box>
)
})
}
React를 써 보셨다면 JSX가 반가우실 텐데요, 다른 점이 하나 있습니다. Box와 Text 같은 요소를 import하지 않고 $.ui.resolve(e)에서 꺼내 씁니다. 같은 mod가 터미널과 데스크톱 앱처럼 서로 다른 화면(surface)에 그려질 수 있어서, 그 화면이 지원하는 요소 목록을 그때그때 받아 오는 구조예요.
tool.call 훅도 앞의 예제와 조금 다릅니다. await next(e)로 도구가 실제로 실행되기를 기다린 다음에 Git 상태를 다시 읽어요. 도구 실행 전후 어느 쪽에 끼어들지를 next 호출 위치로 정하는 셈이죠.
세션을 시작하면 프롬프트 위에 브랜치 이름과 함께 “깨끗함”이 표시됩니다.

Claude에게 삭제 API의 버그를 고쳐 달라고 하면, 파일이 수정된 직후 밴드가 노란색 “변경된 파일 1개”로 바뀝니다. 커밋하기 전까지 무엇이 바뀌었는지 프롬프트를 쓰면서 계속 볼 수 있어요.

상태는 $.state에 두기
git-band에서 Git 정보를 모듈 변수가 아니라 atom에 담은 데는 이유가 있습니다. mod는 파일이 바뀔 때마다 다시 로드되는 핫 리로드(hot reload)를 지원하는데, 다시 로드되면 register가 새로 실행되면서 모듈 변수가 전부 초기화돼요. 첫 번째 예제의 calls도 코드를 고칠 때마다 0으로 돌아갑니다.
반면 atom으로 만든 값은 클로드 코드가 세션 동안 대신 들고 있어서 리로드 후에도 살아남습니다. 게다가 그리는 중에 read로 읽은 값은 구독 관계가 생겨서, 다른 훅이 update로 값을 바꾸면 그 값을 읽은 화면만 자동으로 다시 그려져요. $.ui.invalidate를 따로 부를 필요가 없습니다.
$.state에 담는 값은 타입 계약(contract)으로 선언해야 합니다. types/index.d.ts를 만들고 plugin.json의 types 필드에 경로를 적어 주세요.
export type GitInfo = { branch: string; changed: number }
declare module 'claude-code' {
interface PluginState {
'git-band': { info: GitInfo | null }
}
}
세션을 넘어서 값을 유지해야 한다면 $.store를 쓰면 됩니다. 그리는 도중에는 값을 쓸 수 없다는 점도 기억해 두세요. 쓰기는 버튼 핸들러나 다른 이벤트 훅에서만 할 수 있습니다.
.env 수정을 막는 가드
이번에는 도구 호출을 거부하는 mod입니다. Claude가 .env 파일을 편집하거나 셸 명령으로 건드리려 하면 막고 토스트(toast) 알림을 띄워 볼게요.
import type { Register } from 'claude-code'
const ENV_FILE = /(^|[\s/'"])\.env(\.\w+)?\b/
export const register: Register = on => {
on('tool.call', async ($, e, next) => {
const target =
e.tool === 'Edit' || e.tool === 'Write' ? e.file_path : e.tool === 'Bash' ? e.command : ''
if (ENV_FILE.test(target)) {
$.ui.toast(`🔒 env-guard: ${e.tool} 호출을 막았습니다`)
return { deny: '.env 파일은 env-guard가 보호합니다. 사용자에게 직접 수정해 달라고 요청하세요.' }
}
return next(e)
}).catch(($, e, next) => (next.called ? next(e) : { deny: 'env-guard 검사에 실패했습니다.' }))
}
e.tool === 'Edit'으로 비교하면 TypeScript가 e의 타입을 Edit 도구의 입력으로 좁혀 주기 때문에 e.file_path를 바로 쓸 수 있습니다. 클로드 코드가 mod 폴더에 현재 버전의 타입 선언을 자동으로 깔아 주는 덕분이에요.
끝에 붙은 .catch는 꼭 챙겨야 합니다. mod의 훅이 예외를 던지면 클로드 코드는 그 훅을 건너뛰고 다음으로 넘어가요. 일반 훅이라면 고마운 동작이지만, 가드 훅이 건너뛰어지면 막아야 할 호출이 그대로 통과합니다. .catch에서 next가 아직 불리지 않았다면 거부하도록 해 두면, 검사 코드가 터져도 안전한 쪽으로 실패합니다.
처음에는 Edit과 Write만 막았는데, 실제로 돌려 보니 Claude가 sed -i로 .env를 고치려 하더군요. 그래서 Bash 명령도 검사하도록 바꿨습니다. 아래 화면처럼 Bash 호출이 거부되고 오른쪽 위에 토스트가 뜹니다. Claude는 거부 사유를 읽고 우회하지 않은 채 사용자에게 직접 수정하라고 안내했어요.

정규식으로 명령어를 검사하는 방식에는 한계가 있습니다. 변수에 파일명을 담아 우회하는 식의 시도까지 막을 수는 없으니, 정말 중요한 비밀은 샌드박스나 권한 설정의 deny 규칙과 함께 지키는 게 좋습니다.
옆에 마크다운 패널 띄우기
mod의 백미는 패널입니다. 전체 화면 렌더링(fullscreen rendering) 모드의 넓은 터미널에서는 대화 옆에 사이드바로 붙고, 그 외에는 프롬프트 위에 틀로 둘러싸인 영역으로 열려요. /md <경로>를 입력하면 마크다운 파일을 옆 패널에 렌더링해 주는 md-pane mod를 만들어 보겠습니다.
import { atom, read, update } from 'claude-code'
import type { Register } from 'claude-code'
const PANE = 'md-pane'
const path = atom({ plugin: 'md-pane', key: 'path' } as const, '')
export const register: Register = on => {
on('session.start', async ($, e, next) => {
await $.command.register({
name: 'md',
description: 'Open a markdown file in a side pane (/md <path>)',
})
return next(e)
})
on('command.run', { command: 'md' }, async ($, e) => {
const given = e.args.trim()
if (given) await update($, path, () => given)
const current = await read($, path)
if (!current) return { text: 'Usage: /md <path>' }
await $.ui.open({ id: PANE, title: current })
$.ui.invalidate('ui.render')
return { text: `Opened ${current} in a pane.` }
})
// 도구가 파일을 바꿨을 수 있으니 실행 후 패널을 다시 그림
on('tool.call', async ($, e, next) => {
const ran = await next(e)
$.ui.invalidate('ui.render')
return ran
})
on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
const { Text, Markdown } = $.ui.resolve(e)
const current = await read($, path)
try {
return <Markdown text={await $.fs.read(current)} />
} catch {
return <Text color="red">Cannot read {current}</Text>
}
})
}
$.ui.open으로 패널을 열고, ui.render 훅에서 requestId가 일치하는 패널의 내용을 그립니다. Markdown 요소가 제목, 표, 목록을 Claude의 답변과 같은 스타일로 그려 줘요. 전체 화면 모드는 /tui fullscreen으로 켜거나, 환경 변수로 한 번만 켜 볼 수 있습니다.
CLAUDE_CODE_NO_FLICKER=1 claude --plugin-dir ./md-pane
그런데 첫 버전에서는 재미있는 일이 있었습니다. 처음에는 Write나 Edit 도구가 실행됐을 때만 패널을 다시 그리게 했는데요, Claude에게 README의 할 일 하나를 체크해 달라고 하자 Edit이 아니라 sed로 고쳐 버린 겁니다. 왼쪽 diff에는 [x]로 바뀌었는데 오른쪽 패널은 여전히 [ ]예요. 😅

Claude가 어떤 도구로 파일을 바꿀지는 미리 알 수 없습니다. 그래서 위 코드처럼 도구 종류와 상관없이 실행이 끝나면 다시 그리도록 고쳤어요. 이제는 체크하자마자 패널에도 바로 반영됩니다.

Claude에게 mod 만들어 달라고 하기
지금까지는 직접 작성했지만, 사실 가장 쉬운 방법은 Claude에게 부탁하는 겁니다. 클로드 코드에는 mod 작성법을 담은 plugin-authoring이라는 내장 스킬이 들어 있어서, 원하는 걸 말로 설명하기만 하면 돼요.
프롬프트 위에 현재 시각을 HH:MM 형식으로 보여주는 mod를 만들어줘
Claude는 이 스킬을 읽고 ~/.claude/dev-mods/<세션 ID>/ 아래에 mod를 작성합니다. 첫 파일이 저장되는 순간 클로드 코드가 이 세션에서 핫 리로드를 켤지 물어봐요.

이 질문은 권한 모드나 규칙으로 자동 승인할 수 없고 사람만 답할 수 있어요. AI가 쓴 코드가 내 세션 안에서 실행되기 전에 한 번은 사람의 확인을 거치게 하려는 장치입니다. 질문이 떠 있는 동안에도 Claude는 하던 일을 계속하는데요, 제 경우에는 claude plugin validate에서 상태 값을 타입 계약에 선언하지 않았다는 오류가 나자 스스로 고치고, 테스트까지 통과시킨 뒤 1분 만에 턴을 마쳤습니다.
“Enable for this session”을 고르면 mod가 바로 로드되고 프롬프트 위에 시계가 나타납니다. 이후로는 Claude가 코드를 고칠 때마다 그 턴이 끝나는 시점에 다시 로드돼요.

같은 요청을 몇 번 해 보니 mod 이름(clock-band, prompt-clock)과 세부 구현은 매번 달랐지만, 공통으로 눈에 띄는 부분이 있었습니다. 한 번은 시각을 계산하는 코드에 이런 주석이 달려 있었어요.
// Minutes east of UTC; the hooks runtime has no reliable local time zone,
// so it is read from the host.
let offsetMinutes: number | null = null
async function refreshOffset($: EngineInterface) {
const { exitCode, stdout } = await $.process.run(['date', '+%z'])
if (exitCode === 0) offsetMinutes = parseOffset(stdout)
}
앞에서 mod가 Node.js가 아닌 별도 환경에서 돈다고 했는데, 그 환경에는 믿을 만한 로컬 시간대 정보가 없습니다. Claude는 매번 스킬에 딸린 타입 선언을 읽고 이 제약을 알아챈 뒤 $.process.run으로 호스트의 date +%z를 읽어 오는 방식을 택했어요. 시각 계산 함수를 따로 빼서 claude plugin test로 돌릴 테스트를 함께 작성한 것도 같았고요.
Claude가 이렇게 만든 mod는 그 세션에서만 로드되고, 세션 폴더는 일정 기간(cleanupPeriodDays)이 지나면 지워집니다. 계속 쓰고 싶다면 ~/mods/prompt-clock처럼 원하는 곳에 폴더를 복사해 두고 --plugin-dir로 불러오면 됩니다.
검증하고 확인하기
mod를 만들었으면 claude plugin validate로 클로드 코드가 이 mod를 어떻게 읽는지 확인해 보세요. 코드를 실행하지 않고 소스를 정적 분석해서 어떤 이벤트에 훅을 걸었고 어떤 API를 부르는지 보여 줍니다.
claude plugin validate ./first-mod
❯ ./register.ts hooks: session.start, tool.call, command.run{command=tally}, ui.render{component=Spinner}
❯ ./register.ts answers its own command: command.run{command=tally}
❯ ./register.ts gating hook without .catch: tool.call
❯ ./register.ts calls: $.command.register, $.ui.invalidate
✔ Validation passed
gating hook without .catch는 경고가 아니라 사실을 알려 주는 줄입니다. tool.call처럼 무언가를 거부할 수 있는 지점에 걸린 훅이 .catch 없이 등록됐다는 뜻이에요. first-mod는 숫자만 세니 상관없지만, 가드 역할을 하는 훅이라면 앞의 env-guard처럼 .catch를 달아 두어야 합니다.
세션에 어떤 mod가 로드됐는지는 /plugin을 열어 Installed 탭에서 확인할 수 있습니다. 탭 아래 흐린 줄에 활성화된 mod 개수와 이름이 나와요.

설치하기 전에 알아둘 보안
mod는 샌드박스 없이 내 권한으로 실행되는 코드입니다. 공식 문서가 밝히는 mod의 능력을 보면 꽤 강력해요. 내 계정이 접근할 수 있는 모든 파일을 읽고 쓰고, 프로세스를 띄우고, 네트워크 요청을 보낼 수 있습니다. 환경 변수에 든 API 키를 읽을 수 있고, 내가 보내는 프롬프트와 Claude의 모든 도구 호출을 볼 수 있어요. 심지어 도구 호출을 사용자에게 묻기 전에 승인해 버릴 수도 있습니다.
그래서 mod는 마켓플레이스를 포함해 믿을 수 있는 출처에서만 설치해야 합니다. 설치 전에는 저장소를 받아 claude plugin validate로 어떤 이벤트에 걸리고 어떤 API를 부르는지 훑어보세요. $.process.run이나 네트워크 호출이 보이는데 mod의 설명과 맞지 않는다면 의심해 볼 만합니다. 다행히 권한 확인 프롬프트만큼은 mod가 다시 그릴 수 없어서 확인 창의 내용을 속이지는 못합니다.
mod를 끄는 방법도 알아 두면 좋습니다. 특정 mod 하나는 /plugin에서 비활성화하면 되고, 한 세션만 모든 사용자 설정 없이 띄우려면 --safe-mode로 실행합니다. 모든 세션에서 설치한 mod를 끄려면 settings.json에 "disableAllHooks": true를 넣으면 되는데, 이 경우 설정 훅과 상태줄도 함께 멈춘다는 점에 주의하세요.
마치며
이번 글에서는 클로드 코드 mod로 스피너에 숫자를 붙이고, 프롬프트 위에 Git 상태를 띄우고, .env를 지키는 가드를 세우고, 옆에 마크다운 패널을 열어 봤습니다. 모두 TypeScript 파일 하나로 빌드 없이 바로 동작했고요. 훅의 세 인자 ($, e, next)와 “관찰, 재작성, 응답”이라는 세 가지 패턴만 기억하면 나머지는 응용입니다.
처음이라면 직접 짜기보다 Claude에게 “이런 mod 만들어줘”라고 부탁해 보시길 추천합니다. 핫 리로드 덕분에 고쳐 달라고 할 때마다 바로 결과를 확인할 수 있어서, 원하는 모양이 나올 때까지 대화하듯 다듬을 수 있어요. 마음에 드는 mod가 생기면 마켓플레이스로 묶어 팀원과 나눠 보세요.
더 자세한 내용은 클로드 코드 공식 문서의 Mods overview를 참고하세요. Anthropic이 공개한 샘플 mod 모음도 둘러볼 만합니다.
This work is licensed under CC BY 4.0