2026-10-13 Claude Code Mods and Making a Korean Version of pinboard
Claude Code now has a way to extend it called mods. At first I only looked at the concept and set it aside because nothing grabbed me. Later, while browsing community mods, I installed one called pinboard to try it out. Everything in it was in English, which bothered me, so I ended up adapting it into a Korean version that I now use. Today I'm writing up what mods are, how pinboard works, and what I changed and how.
Environment: Claude Code 2.1.295. I installed and modified pinboard on Windows, and checked the mod API descriptions against the built-in mod authoring docs for the same build. The mod API is in early access, so names and behavior may change in later versions.
1. What a Claude Code mod is
A mod is a plugin that changes Claude Code's screen and behavior by plugging TypeScript code into it. Roughly, you can make:
- Screen: a side pane (Pane), a band above the prompt (AbovePrompt), a status line entry, a toast
- Commands: slash commands, and tools the model can call
- Behavior: blocking or rewriting tool calls, changing prompts and the system prompt
The shape of a hook
The core of a mod is one line: on(event, matcher?, hook). You attach a hook to an event, and every hook receives the same three arguments, ($, e, next).
$: the engine interface. Anything that reaches the outside world goes through it, such as opening a pane ($.ui.open), registering a command ($.command.register), or registering a tool ($.tool.register).e: the event's input, a plain frozen value.next(e): runs the other plugins below and then the engine's own behavior, and returns the result.
on('tool.call', { tool: 'Bash' }, async ($, e, next) => { // 1) Answer without calling next, and you can block it // return { deny: 'This command is not allowed' } // 2) Pass a modified copy so subsequent handlers receive the modified input // return next({ ...e, command: e.command.trim() }) // 3) Or pass it through as is and react to the result const result = await next(e) return result})As a backend developer, I read this like a servlet filter or a Spring interceptor chain. Call next and the chain continues; don't, and the response ends there.
File layout
A mod starts with three files.
<mod>/├── .claude-plugin/plugin.json # name, version, description├── hooks/hooks.json # { "modules": ["./register.tsx"] }└── hooks/register.tsx # export const register: Register = (on) => { ... }To hold values during a session ($.state), you add a fourth file, a type contract at types/index.d.ts, and point to it from plugin.json. There is no DOM and no Node inside a mod. You draw with JSX, but elements like Box, Text, and Button come from $.ui.resolve(e). Mod UI appears in the terminal and the Code tab of the Claude Desktop app. In the VS Code extension's chat panel, hooks run, but mod UI does not appear. Running the Claude Code CLI in VS Code's integrated terminal does display the UI. See the official support matrix for this distinction.
What to check before installing
A mod can read and write files, run processes, and reach the network. So for a mod someone else wrote, it's a good idea to look at the source and its access scope before installing. The unofficial catalog awesome-claude-code-mods (web: mods.aidojo.si) shows each mod's access scope and its claude plugin validate result, which was useful for this check. It only inspects the source statically, though, so passing it doesn't guarantee the mod is safe.
2. pinboard: a board pinned next to the conversation
pinboard (Jerad Bitner, MIT) is a mod that pins open decisions, todos, and links you created in a side pane, so they don't disappear as the conversation scrolls. My reason for picking it out of many mods was simple. It was a feature I wanted to try, and its validate result showed no file, process, or network access, so the risk was small. Instead of installing several at once, I installed only this one, in user scope, to try it.
Reading its single register.tsx file (241 lines) shows almost the whole mod API, which also made it good study material.
| What it does | Hook it uses |
|---|---|
Register the /pinboard command and the update tool | session.start |
| Open the pane when the command runs | command.run → $.ui.open |
| Append the current board to the end of the system prompt on every request | prompt.compose |
Update the board through the update tool the model calls | tool.call |
| Send the model back once if the reply ends with a question but there is no open decision | classic.Stop |
Catch links from the results of gh pr create, git push | tool.call (all tools) |
| Draw the pane | ui.render (Pane) |
Two parts stood out to me.
- A Stop hook so questions aren't lost: if the reply ends with a question for the user but the board has no open decision, it stops the turn from ending and sends back a message saying "pin that question to the board." It keeps questions from scrolling up and out of sight.
- Todos are not forced: todos aren't enforced by a hook. The model decides on its own to add them, based on rules written in the tool description (use them for three or more steps, keep only one in progress at a time, mark done after checking).
3. Adapting it into pinboard-ko
Why I changed it
Two things bothered me once I used it.
- The pane and the conversation lines were all in English, which was hard to read. I work in Korean, and having only the board in English felt off.
- When a todo was finished, the pane kept only a single line like
✓ 3 done, so what I had just finished disappeared.
pinboard has no settings menu, so changing it means editing hooks/register.tsx directly. It's MIT licensed, so modifying it is fine.
Edit a copy, not the cache
Installed plugins live under ~/.claude/plugins/cache/..., and edits there get overwritten on update. So I copied the source into my study folder and edited the copy.
If the copy used the same names as the original, the two would collide, so I changed every place a name appears to the new one.
-const PANE = 'pinboard'-const TITLE = 'Pinboard'-const TOOL = 'mcp__pinboard__update'+const PANE = 'pinboard-ko'+const TITLE = '핀보드'+const TOOL = 'mcp__pinboard-ko__update'-const decisions = atom({ plugin: 'pinboard', key: 'decisions' } as const, [] as Decision[])+const decisions = atom({ plugin: 'pinboard-ko', key: 'decisions' } as const, [] as Decision[])(핀보드 is "Pinboard" in Korean.)
The places that are easy to miss:
- The plugin name (
plugin.json) and the pane id - The tool name: it's registered as
update, but its real name ismcp__<plugin>__update, so it changes with the plugin name. The hard-codedTOOLconstant and the tool name inside the Stop hook message have to change too. - The
pluginkey of eachatomand thePluginStatekey in the type contract - The slash command (
/pinboard→/pinboard-ko) and the system prompt section id
If you add the local folder as a marketplace with claude plugin marketplace add <folder> and install from it, the installed plugin reads that folder directly. After an edit, /reload-plugins is all it takes to apply it. I turned off the original pinboard with claude plugin disable.
Korean on screen, English for the model
I changed every string shown on screen to Korean: the pane headings (결정 대기 "awaiting decision", 할 일 "todos", 링크 "links"), the empty-state messages, the 비우기 ("clear") button, the one-line summary printed in the conversation (핀보드: 할 일 +2, 1개 완료, "Pinboard: todos +2, 1 done"), and the GitHub link label (issue → 이슈).
But changing only the pane text is half the job: the items still come in English, because the model writes them. So I added one rule to the end of the tool description the model reads.
// 한글판: 패널에 그대로 보이는 항목 글을 한국어로 쓰게 한다// (Korean version: have the model write the items shown in the pane in Korean)'Write every todo, decision and decide answer in Korean (keep code, commands, file paths and proper nouns as they are). Keep each one short, one line.',I also changed the message the Stop hook uses to send the model back into Korean. On the other hand, I left the board summary appended to the system prompt (Pinboard now: ...) in English. Only the model reads it, not a person, so there was no reason to change it.
Keeping finished todos
After the on-screen text, this was the part I changed. The original folded finished todos into just a count.
- {doneCount > 0 && <Text dimColor>{` ✓ ${doneCount} done`}</Text>}+ {/* 한글판: 끝난 할 일도 글을 남긴다. 남은 일 아래에 흐리게, 배열 순서상 마지막 DONE_SHOWN개까지만 */}+ {doneCount > DONE_SHOWN && <Text dimColor>{` ✓ 이전 ${doneCount - DONE_SHOWN}개 완료`}</Text>}+ {allTodos.filter(t => t.isDone).slice(-DONE_SHOWN).map(t => item('✓', t.text, true))}(The comment says: "Korean version: keep the text of finished todos too, dimmed below the remaining ones, up to the last DONE_SHOWN items in array order." 이전 N개 완료 means "N earlier done.")
Now finished todos stay below the remaining ones as dimmed ✓ <text> lines. DONE_SHOWN = 8 limits the displayed text to the last eight completed items in array order; the rest fold into a single ✓ 이전 N개 완료 line. This code does not sort by completion time. The original keeps todos in the order they were added, so these eight items may differ from the eight most recently completed.
Verification
claude plugin validate --strict: passedclaude plugin test: 10 passed, the original tests plus one I added for the Korean version. When you change on-screen strings, you have to update the expected values in the existing tests as well.
After that, I used it for real and confirmed that the Korean pane and the list of finished todos show up correctly.
4. What I learned: when does the board disappear
The board data lives in $.state. It's not a variable in the mod's code but a value the host holds for the session, so its lifetime splits like this.
| Situation | $.state |
|---|---|
Hot reload after editing code, /reload-plugins | Stays |
/clear, /resume, a new session | Gone |
On reload the module runs fresh, so its ordinary variables start over, but $.state is on the host side and stays. To keep something across sessions, you need $.store. The state space is also separated by plugin name, so after renaming to pinboard-ko, the original pinboard's board doesn't carry over.
I thought about saving to $.store so the board would carry into the next session, but decided to leave it as is. If the board carried over, old todos would follow me into every new session and I'd need a separate rule for clearing them. And anything worth keeping long term, I already write down separately in progress notes. I decided pinboard is best used only as the workbench for the current session.
Summary
- A mod is a TypeScript plugin that hooks into Claude Code's screen and behavior with
on(event, matcher?, hook)and($, e, next). Callnextand the chain continues; don't, and it ends there. - When modifying someone else's mod, edit a copy rather than the install cache, and change every place the name appears.
- Changing the on-screen text is only half the job. To change the text the model writes, change the rules in the tool description.
$.stateis session-scoped. It survives a reload but disappears on/clearor in a new session.
Next, I plan to try other mods one at a time, or build one of the ideas I had at the start myself, like a band showing what's currently in progress.
Report an error or share feedback
Open a draft with this article’s title and URL. Review the message and recipient before sending.
Open email draftIf no email app opens, copy these details into your usual email service.
Related posts
Today I Learned 2026-10-06 Practical Claude Code Commands for Developers
The habits heavy Claude Code users keep recommending, and the commands and shortcuts that put them into practice, from the basics to recent additions, checked against the official docs.
Today I Learned 2026-10-07 Kotlin Basics: Types, Functions, Unit, File Structure, if and when
Getting started with Kotlin: basic types, writing functions, Unit, file structure, and if and when, with every example compiled and run to check the results.