플러그인 만들기가 쉬워지면서 앱이 맞춤형이 됩니다
휴대폰에서 Paseo로 코딩 에이전트를 돌리다가, 에이전트가 읽은 스크린샷이 채팅에 안 뜨는 걸 발견했습니다. Paseo는 에이전트를 채팅으로 쓰는 앱인데, 터미널보다 읽기 편해서 특히 휴대폰에서 자주 켭니다. 그런데 에이전트가 이미지를 읽으면 채팅에는 Read /tmp/screenshot.png 한 줄만 남았습니다. 에이전트는 분명히 그 이미지를 봤는데 저는 볼 수 없었습니다.
plugin 하나를 붙여서 해결했습니다. Paseo 본체는 건드리지 않았습니다. 그러고 나서 다른 앱들은 어떤지 궁금해졌습니다. Claude Code와 Pi에도 이미 확장 시스템이 있어서 두 앱의 확장 소스를 읽고 제 plugin을 다시 봤습니다.
게임도, 에디터도, 브라우저도 오래전부터 확장을 받아 왔습니다. 달라진 건 확장을 쓰는 쪽입니다. 예전에는 사람이 직접 썼는데 이제는 에이전트도 씁니다. 호스트의 예제 몇 개만 읽으면 반나절 만에 확장 하나가 나옵니다. 확장을 만드는 일이 쉬워지면 앱은 사용자마다 달라질 수 있습니다. 어려운 쪽은 앱이 그 코드를 어디까지 받아 주느냐입니다.
확장은 앱이 정한 규칙 안에서 돕니다. 언제 내 코드를 부를지, 무엇을 넘길지, 무엇을 반환해도 되는지, 어떻게 행동해도 되는지가 여기에 들어갑니다. 여기서는 이 규칙 묶음을 계약이라 부르고 계약을 정하는 앱을 호스트라고 하겠습니다.
아래 확장 세 개 중 둘은 각 저장소에 들어 있는 예제이고 하나는 제가 만든 것입니다. 하는 일은 다르지만 셋 다 같은 네 가지 질문에 답합니다. 호스트가 어느 답을 실제로 강제하는지, 어느 답을 그냥 믿는지가 다를 뿐입니다.
확장은 호스트가 부를 때 돕니다
Claude Code는 확장을 mod, Paseo는 plugin, Pi는 extension이라 부르지만 이름만 다를 뿐 구조는 같습니다. 앱이 불러오는 코드이고 부르는 시점도 앱이 정합니다.
Claude Code에서 mod는 hooks/hooks.json이 파일 하나를 가리키는 폴더입니다. Claude Code는 그 파일을 읽어 register(on)을 한 번 부르고 on(...)에 넘긴 함수를 이벤트 이름별로 표에 담아 둡니다. 이벤트가 터지면 이벤트 데이터를 만들어 잠그고 표에 있는 첫 함수를 부릅니다.
가장 단순한 mod는 가드입니다.
export function register(on) {
on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
if (/git push .*--force/.test(e.command)) {
return { deny: 'Force pushes are not allowed in this repository.' }
}
return next(e)
})
}
e는 이벤트이고 next는 그 이벤트를 체인의 다음으로 넘깁니다. 체인 끝에는 Claude Code의 원래 동작이 있습니다. $는 호스트의 API이고 확장이 바깥으로 나갈 수 있는 유일한 통로입니다. 프로그램을 실행하려면 $.process.run을 씁니다. 이 함수는 Claude Code 프로세스 안에서 돕니다.
그림 1. 각 훅은 다음 훅을 감쌉니다. next를 부르지 않고 돌아 나오면 거기서 체인이 끊깁니다.
호스트가 정하는 것은 네 가지입니다. 언제 부를지(이벤트 이름), 무엇을 받는지(이벤트 데이터), 무엇을 반환해도 되는지(next(e), { deny }, { block }), 어떻게 행동해도 되는지($, pi 객체, CLI). 호스트마다 다른 것은 이 넷을 얼마나 강제하느냐입니다. 호스트가 읽고 그대로 행동하는 반환값은 단단한 경계이고 모델에게 전달만 하는 메시지는 부탁에 가깝습니다. 그리고 세 호스트 모두 확장 코드 자체는 샌드박스에 넣지 않습니다. 이 얘기는 마지막에 다시 꺼내겠습니다.
Claude Code의 Blast Radius는 지울 것의 숫자를 먼저 셉니다
Claude Code는 Bash 명령을 실행하기 전에 허용할지 묻는데 rm -rf build든 git reset --hard든 화면에 뜨는 건 명령 한 줄입니다. 허용을 누르면 무엇이 사라지는지는 알려 주지 않아서, Blast Radius가 그 빈틈을 메웁니다. Anthropic의 claude-code-playground 저장소에 예제로 들어 있습니다. 위험한 명령이 실행되기 직전에, 그 명령이 무엇을 바꿀지 숫자로 보여 주고 사람이 누른 버튼에 따라 실행하거나 거부합니다.
Blast Radius는 작업을 날리는 Bash 명령을 지켜봅니다. rm -r이나 rm -f, git reset --hard, git checkout -- ., git clean, force push, 데이터베이스 마이그레이션이 여기에 들어갑니다. 단순한 거부 규칙이면 이 명령을 전부 막고 끝이지만 Blast Radius는 막기 전에 먼저 재서 보여 주고 묻습니다.
숫자는 $.process.run으로 dry run을 돌려 뽑는데 git clean -n은 지워질 파일을 나열합니다. git status --porcelain과 git diff --shortstat은 reset하면 사라질, 아직 커밋하지 않은 작업을 보여 주고 force push면 원격에서 사라질 커밋 수를 셉니다. 이 숫자를 채팅 옆 pane에 Proceed, Cancel 버튼과 함께 띄우고 Cancel에 포커스가 있어서 Enter만 누르면 거부됩니다.
그림 2. 훅이 tool.call 이벤트 안에서 사람을 기다립니다.

그림 3. 실제 Claude Code 세션에서 rm -rf build를 붙잡은 Blast Radius입니다. pane이 지워질 파일과 크기를 보여 주고 Proceed, Cancel을 기다립니다. 스크린샷은 Anthropic의 claude-code-playground에서 가져왔습니다.
여기서 호스트가 실제로 읽는 값은 반환값입니다. 훅이 { deny, reason }을 반환하면 명령이 멈추고 reason은 도구 결과로 Claude에게 돌아갑니다.
훅에는 10초의 자기 시간밖에 없는데 사람은 그보다 오래 걸립니다. 이 mod는 $ 호출 안에서 보내는 시간이 그 10초에 들어가지 않는다는 점을 씁니다. 버튼이 눌릴 때까지 $.process.run으로 sleep 0.25를 반복하는 루프를 돌리고 최대 10분까지 기다립니다. 호스트가 정한 시간 예산이 코드가 기다리는 방식을 정한 셈입니다.
한 mod의 훅들은 같은 파일의 변수를 공유합니다. 그래서 모듈 수준 변수 held 하나로 한 번에 하나만 붙잡습니다. 첫 pane이 열려 있는 동안 서브에이전트가 위험한 명령을 실행하면 pane을 두 개 쌓지 않고 순서를 기다립니다.
이 mod는 뭔가 잘못되면 일단 막습니다. 타임아웃, 오류, 중단이 모두 이유가 붙은 거부로 돌아옵니다. Blast Radius가 이 명령을 붙잡았고 실행하지 않았으니 사용자가 시키지 않으면 다시 시도하지 말라는 내용입니다. 반환값은 도구 결과로 Claude에게 돌아가니까 이 메시지는 모델에게 하는 말입니다. 터미널이 좁아 pane이 안 들어가면 프롬프트 위 띠로 대신 띄웁니다.
호스트는 dry run의 숫자를 검사하지 않습니다. pane에 뜨는 숫자는 mod가 사용자 권한으로 직접 계산한 값입니다. Proceed 버튼 뒤에 있는 근거도 mod를 믿는 수밖에 없습니다.
Pi의 plan mode는 이벤트를 모아 만듭니다
Pi는 서브에이전트도 plan mode도 없이 나오고 README에도 그렇게 적혀 있습니다. 저장소의 plan-mode 예제는 약 400줄짜리 확장입니다. Pi에 plan mode라는 자리가 따로 있는 게 아니라, 확장이 Pi가 이미 열어 둔 이벤트를 조합해 모드를 만듭니다.
그림 4. plan mode는 서로 다른 이벤트를 이어 붙여 만듭니다.
그중 두 조각은 Pi가 직접 막습니다. /plan 명령이나 --plan 플래그, Ctrl+Alt+P로 모드를 켜면 setActiveTools로 edit와 write를 도구 목록에서 빼고 그러면 모델은 그 도구를 아예 부를 수 없습니다. 도구를 빼는 것만으로는 부족합니다. bash로도 파일을 쓸 수 있기 때문입니다. tool_call 핸들러가 읽기 전용 허용 목록에 없는 명령을 막고 { block: true, reason }을 반환합니다. 이유는 “Plan mode: command blocked (not allowlisted). Use /plan to disable plan mode first.”이고 이건 모델에게 전달됩니다.
나머지는 모델이 따라 주기를 바라는 텍스트입니다. before_agent_start 핸들러가 “[PLAN MODE ACTIVE]“로 시작하는 숨은 메시지를 넣어 아무것도 바꾸지 말라고 알립니다. 모드가 꺼지면 context 핸들러가 그 메시지를 걸러 내서 지난 지시가 기록에 남지 않게 합니다.
agent_end 핸들러는 모델의 마지막 메시지에서 Plan: 아래 번호 단계를 뽑아 UI에 메뉴를 띄웁니다. “Execute the plan”을 고르면 도구를 되돌리고 각 단계가 끝날 때마다 [DONE:n]을 적으라는 메시지를 보냅니다. turn_end 핸들러가 그 표시를 찾아 진행 위젯을 갱신합니다. 상태는 pi.appendEntry("plan-mode", ...)로 저장하고 session_start에서 복원해서, 세션을 이어서 열어도 모드가 유지됩니다.

그림 5. 실제 Pi 세션의 plan mode입니다. 추출된 세 단계와 실행 메뉴, 하단의 plan 상태가 보입니다.
그래서 모델이 형식을 어기면 벌어지는 일도 다릅니다. Plan: 제목과 [DONE:n] 표시는 요즘 모델이 형식 지시를 잘 따르기 때문에 동작하는데 모델이 제목을 빼먹으면 확장은 단계를 찾지 못하고 아무것도 하지 않습니다. “Execute the plan” 메뉴가 안 나오고 그 이상 망가지는 것도 없습니다. 형식을 어겨서 잃는 건 기능 하나뿐이고 안전은 Pi가 직접 막는 두 곳에 있습니다.
허용 목록도 보안 경계는 아닙니다. 모델이 실수로 파일을 고치는 일을 막아 줄 뿐이고 확장 자체는 Pi와 같은 OS 권한으로 돕니다.
Paseo의 plugin은 행 한 종류를 바꿔 끼웁니다
제 문제는 Paseo의 타임라인에 있었습니다. 에이전트가 이미지 파일을 읽으면 Paseo는 도구 행을 보여 주는데 그 행이 텍스트라서 inline-image-preview plugin이 이미지로 바꿔 끼웁니다.

그림 6. plugin이 있으면 에이전트가 읽은 스크린샷이 다른 단계들 사이, 대화 한가운데에 나옵니다.
Paseo plugin에는 클라이언트 진입점 index.client.tsx와 서버 진입점 index.server.ts가 있습니다. 클라이언트 코드는 앱 안에 들어가서 iOS, Android, 브라우저에서 같은 코드가 돕니다. 서버 코드는 데몬 옆 서브프로세스에서 Node 권한으로 돕니다. 이 plugin은 세 조각으로 되어 있습니다.
그림 7. transformer가 텍스트 행을 이미지 행으로 바꿔 끼웁니다.
addTimelineTransformer로 등록한 transformer가tool_call행을 하나씩 봅니다. 파일 경로나 base64, data URI로 이미지를 읽는 행이면 이미지 최대 8개를 담은inline-image-preview종류의 plugin 행을 반환합니다. 아니면undefined를 반환하고 원래 행이 남습니다.addTimelineRenderer로 등록한 renderer가 이미지를 그리고 탭하면 전체 화면으로 엽니다.- 핸들러
image.read는 데몬 서브프로세스에서 실행됩니다. PNG, JPEG, GIF, WebP, BMP 파일을 20 MiB까지 읽어 base64로 돌려줍니다. 휴대폰은 데몬이 도는 기기의 파일을 열 수 없기 때문입니다.
여기서 호스트가 실제로 검사하는 것은 행입니다. transformer는 Paseo가 렌더 모델을 만드는 동안 스트리밍 업데이트마다 도는데 undefined를 반환하면 행이 그대로 남고 항목을 반환하면 행이 바뀌고 빈 배열을 반환하면 행이 사라집니다. 반환값은 JSON이어야 하고 plugin이 등록한 스키마와 맞아야 해서, Paseo가 renderer에 넘기기 전에 검증합니다. version 필드는 renderer가 기대할 데이터 모양을 알려 줍니다.
클라이언트는 Node 모듈을 가져올 수 없어서 파일 읽기는 핸들러에서만 일어나고 둘 사이의 RPC는 공유 폴더에 zod 스키마로 정의합니다. 행 데이터가 JSON이어야 하니까 행에는 경로만 싣고 renderer가 RPC로 바이트를 가져오며 이미지는 타임라인을 거치지 않습니다.
행을 알아보지 못한 transformer는 undefined를 반환하니까 실수해도 원래 텍스트 행이 화면에 남고 plugin이 실패해도 앱은 원래 하던 대로 행을 그립니다.
plugin이 붙어 있는 호스트가 바뀌기도 합니다. 최근 Paseo는 도구 결과의 이미지를 네이티브로 그려서 transformer가 이미 [image] 표시가 있는 행을 건너뛰는데 그러지 않으면 같은 스크린샷이 두 번 나옵니다. 이번에는 plugin이 호스트에 맞춰 물러나야 했습니다.
확장이 바꿀 수 있는 범위는 호스트마다 다릅니다
| 확장 | 바꾼 것 | 계약이 강제한 것 |
|---|---|---|
| Blast Radius Claude Code, 프로세스 안 | 사람이 결정한 뒤에 Bash 명령을 실행할지 | 훅 10초 제한 때문에 $ 폴링 루프로 기다림. 결과는 텍스트로 모델에게 돌아감 |
| plan mode Pi, 프로세스 안 | 도구 목록, 허용되는 명령, 모델의 지시 | 강제는 두 곳(도구 목록과 tool_call)이고 나머지는 모델이 따라야 하는 메시지 |
| inline-image-preview Paseo, 클라이언트 코드와 데몬 서브프로세스 | 타임라인 행 한 종류의 생김새 | 스키마와 버전이 있는 JSON 행, Node가 필요한 일은 RPC |
표에 차이가 정리돼 있지만 셋 다 같은 점도 있습니다.
우선 호스트는 자기가 읽은 값만큼만 행동합니다. Claude Code와 Pi는 반환값을 읽고 그대로 행동해서, 반환값 하나로 명령을 거부하거나 고쳐 씁니다. Paseo는 행을 읽고 검증해서, 통과한 것만 그립니다. 읽는 값이 많을수록 호스트와 사용자를 지키기 쉽고 그만큼 확장이 호스트가 읽지 않는 값으로 할 수 있는 일은 줄어듭니다.
저는 처음에 반환값만 잘 맞추면 되는 문제라고 생각했습니다. 그런데 세 앱을 읽어 보니 그것만으로는 부족했습니다. 호스트가 읽지 않는 값이 남아 있었는데 그 값들은 확인 없이 넘어갑니다. Claude Code에서 거부 이유는 모델에게 가는 메시지이고 dry run은 mod 자신의 코드입니다. Pi에서 Plan: 제목과 [DONE:n] 표시는 텍스트이고 허용 목록은 실수를 막아 줄 뿐입니다. Paseo에서는 어떤 행이 이미지인지 plugin이 판단합니다. 세 호스트 모두 확장 코드를 샌드박스에 넣지 않습니다. Claude Code mod는 사용자 권한으로, Pi 확장은 Pi와 같은 OS 권한으로, Paseo plugin은 신뢰하는 코드로 돕니다. 계약은 호스트가 확장에 허락하는 일을 제한할 뿐, 코드가 실제로 할 수 있는 일을 제한하지 않습니다.
다시, 그 스크린샷
저는 이미지 하나만 보고 싶었습니다. 에이전트가 읽은 그 스크린샷입니다. Paseo는 행 한 종류를 바꾸게 해 주었고 그걸로 충분했습니다. fork도, 로드맵 자리도, 앱 팀과의 대화도 필요 없었습니다.
확장 시스템이 해 주는 일은 그게 다입니다. 호스트가 고른 시점에, 호스트가 고른 데이터로 내 함수를 부르고 돌아온 값에 따라 움직입니다. 그 이상을 약속하지는 않습니다. 할 수 있는 일은 그 틀 안에서 정해지고 그 틀은 코드를 쓰기 전에 이미 보입니다.
그래서 요즘 새 호스트를 보면 네 가지 답부터 찾습니다. 이벤트 이름은 언제 끼어들 수 있는지 말해 줍니다. 넘어오는 데이터는 내가 무엇을 알 수 있는지, 반환값은 호스트가 그 답으로 어디까지 할지, API는 내가 무엇을 할 수 있는지 알려 줍니다. 마지막으로 권한을 봅니다. 권한은 호스트가 보지 않는 곳에서 내 코드가 무엇을 할 수 있는지를 보여 줍니다.
다음에 확장을 만들 때는 문서의 예제보다 이 답들부터 보려고 합니다. 앱을 만들 때도 마찬가지입니다. 어느 순간을 열지, 무엇을 넘길지, 무엇을 읽어 돌려받을지, 무엇을 확인하지 않은 채 둘지. 그걸 정하는 게 먼저입니다.
참고 자료:
- Blast Radius, Anthropic의 claude-code-playground
- Claude Code: Mods reference, React to events with a mod, Use the mods API
- Pi extensions 문서와 plan-mode 예제
- Paseo plugin 문서