Claude Code mods 入门指南
英文标题:ArticleOct 1, 2026Getting started with Claude Code modsClaude Code(opens in new tab)
Anthropic 介绍了 Claude Code mods:这是一类以 JavaScript 或 TypeScript 模块实现的插件,可通过事件钩子观察、改写或直接响应 Claude Code 的行为。与每次事件都启动 Shell 命令的 settings hooks 不同,mod 会在整个会话中持续运行,能够保存状态、渲染动态 UI,并注册命令、工具或进程调用。文章以 Token Weather 为例,演示如何监控上下文窗口使用率,并在提示词上方实时显示天气状态、Token 用量、历史趋势和单轮增量。开发者可直接让 Claude Code 生成 mod,也可按照目录、状态类型契约、事件钩子和 UI 渲染等步骤自行构建。
9.4 KB
3.1 KB
114.3 KB
92.5 KB
12.3 KB
中文
MOD 的工作方式
mod 是一种 Claude Code 插件,其行为定义在 JavaScript 或 TypeScript 模块中:
- 文件夹是一个普通插件,包含
.claude-plugin/plugin.json清单文件。 hooks/hooks.json会在modules下指定一个模块。- 模块导出
register(on, options)。在其中,on(event, matcher?, hook)用于添加钩子。
每个钩子都采用相同的形式:
javascript on("tool.call", { tool: "Bash" }, async ($, e, next) => { // $ mods API:ui、session、state、store、fs、process、clock、http、tool、command、model,等等 // e 此事件的输入,以普通数据形式提供 // next 将 e 传给其他插件,然后传给 Claude Code 自身的行为 return next(e); });
钩子会形成一条链,类似中间件。你的钩子先运行,next(e) 将事件交给下一个插件;在链条底部,Claude Code 会执行它原本就会执行的行为。一个钩子可以做以下三件事之一:
事件依次经过你的钩子、其他插件和 Claude Code
事件 tool.call,e = { command } → 你的钩子 ($, e, next) → 其他插件(相同形态)→ Claude Code 运行工具 → next(e) 结果 → next(e) 结果 → 回应
| 操作 | 方法 | 示例 |
|---|---|---|
| 观察(Observe) | const r = await next(e); /* look */ return r |
记录每一次文件编辑。在每轮结束后读取一次数据。 |
| 改写(Rewrite) | return next({ ...e, command: safer }) |
修改链条其余部分所看到的内容。 |
| 响应(Answer) | 不调用 next,直接 return { deny: "…" } |
拒绝一次工具调用。自行提供命令或工具服务。 |
这些事件覆盖工具调用、提交的提示词、回合开始和结束、会话开始和结束、斜杠命令,以及 ui.render——即界面被绘制时的每一个部分。模块在独立沙箱中运行,没有 DOM,也没有 Node,因此所有外部操作都必须通过 $ 完成。
这与 settings hooks 有何不同。 settings hook 会针对每个事件运行一条 Shell 命令,并通过 stdin 和 stdout 传递 JSON。mod 只加载一次,并在整个会话期间保持运行。它可以保存状态,绘制会随事件更新的 UI,并回调进入 Claude Code:打开面板、运行进程、注册斜杠命令,或者注册可供模型调用的工具。
Claude Code 自己也在使用它们。 Claude Code 的部分功能本身就是以 mod 构建的,包括 AGENTS.md 支持,以及会话旁边的 /diff 面板。其带测试的源代码位于公开的 anthropics/claude-code 仓库 mods/ 目录下,因此你可以了解团队是如何构建这些功能的。
构建你的第一个 MOD:TOKEN WEATHER
Token Weather 会在每轮结束后读取上下文窗口的占用情况,并在提示词上方绘制一行内容:天气图标、百分比、已用 Token 与窗口总量、近期回合的小图表,以及最近一轮新增的 Token 数。
| 已使用比例 | 预报 |
|---|---|
| 低于 25% | ☀ Clear |
| 25–49% | ☁ Cloudy |
| 50–74% | ☂ Showers |
| 75–89% | ☇ Storm |
| 90% 及以上 | ↯ Compact soon |
以下是在真实会话中的效果。每一轮都会读取更多文件,状态条会从 ☀ Clear 逐步变为 ☂ Showers,再变为 ☇ Storm:
TerminalDesktop
图 C 完整终端会话中,Token Weather 跨三轮显示的效果:在 200k 窗口中,使用率依次为 18%、67% 和 81%
快捷方式:让 Claude 来构建它
你可以跳过这六个步骤。Claude Code 知道如何编写 mod,因此你只需描述自己想要的 mod,让它完成工作即可。使用 claude 启动会话,并粘贴以下提示词:
text Make me a Claude Code mod called token-weather: a live forecast of my context window, shown in the band above the prompt.
What it should show, on one line:
- A weather icon and word for how full the context window is: under 25% ☀ Clear (yellow), 25–49% ☁ Cloudy (cyan), 50–74% ☂ Showers (blue), 75–89% ☇ Storm (magenta), 90% and up ↯ Compact soon (red).
- The percentage used, then the tokens used out of the window, like "134.4k / 200k".
- A small chart of the last 12 turns, drawn with ▁▂▃▄▅▆▇█.
- How much the last turn added, like "▲ +98.3k last turn".
It should update after every turn.
Claude 会询问一次是否要在当前会话中启用热重载。允许后,当 Claude 的回合结束时,状态条便会显示在提示词上方。此后,每一次修改都会原地重载,因此你可以持续要求调整(例如“让 Storm 从 70% 开始”“在末尾加入美元成本”),并观察状态条发生变化。该 mod 仅在当前会话中加载,之后其文件夹会被清理;若要保留它,请将文件夹复制出来,并像安装其他插件一样安装它(第 6 步)。
请注意,提示词只描述了你希望看到什么。你不需要了解 API 也能编写一个 mod。Claude Code 内置的 mod 编写指南会解释实现方法:在哪里保存状态以使其在重载后依然存在、如何使用 claude plugin validate 检查插件,以及该钩住哪些事件。修改“它应显示什么”部分的内容,这就是你的 mod,而不是我们的。
如果你想先了解它的构成方式,或想检查 Claude 写出的内容,请继续阅读。
第 1 步:创建文件夹
确认你的 Claude Code 版本足够新:
shell claude --version # 2.1.287 or later
创建以下目录结构:
text token-weather/ ├── .claude-plugin/ │ ├── plugin.json │ └── types/ (written by Claude Code when it loads the mod) ├── hooks/ │ ├── hooks.json │ └── token-weather.mjs ├── types/ │ └── index.d.ts (added in step 3) └── tests/ └── token-weather.test.ts (added in step 5)
.claude-plugin/plugin.json 是一个标准插件清单:
{ "name": "token-weather", "version": "0.1.0", "description": "A live forecast of the context window, drawn above the prompt.", "author": { "name": "You" } }
hooks/hooks.json 指向该模块。一个 mod 有且仅有一个这样的配置:
{ "modules": ["./token-weather.mjs"] }
第 2 步:绘制内容
提示词正上方的状态条是一个名为 AbovePrompt 的组件。Claude Code 本身不会在这里绘制任何内容,因此它是很好的第一个目标。钩住其 ui.render 事件,并返回一个元素树:
javascript // hooks/token-weather.mjs export function register(on) { on("ui.render", { component: "AbovePrompt" }, ($, e, next) => { const { Box, Text } = $.ui.resolve(e); return Box({ paddingX: 1, children: [Text({ color: "yellow", bold: true, children: "â Clear skies" })], }); }); }
这些元素不是全局对象。$.ui.resolve(e) 会返回当前绘制表面对应的构造器,因为 Claude Code 所绘制的每种表面支持的组件集合略有不同。也可以使用 JSX,并将 h 作为工厂函数。
启动一个已加载该插件的会话:
shell claude --plugin-dir ./token-weather
“☀ Clear skies” 会显示在提示词上方。请保持该会话打开。该文件夹会被监视,因此每次保存都会原地重载模块,无需重启。这个快速反馈循环正是编写 mod 的大部分乐趣所在。
提示: 一旦了解了基本结构,你就可以像快捷方式中那样向 Claude 描述下一个 mod。它会将插件写入一个文件夹,并在同一会话中热重载。
第 3 步:读取真实数据并将其保存在 $.state 中
$.session.usage() 返回与状态栏相同的数据。context.tokens 是上一条回复生成时所使用的输入 Token,context.window 是模型的上下文窗口,context.percent 则是前者除以后者的百分比。此调用免费:除非你请求 breakdown,否则它只会发送一个 Token 计数请求。
在会话开始时和每轮结束后各读取一次:
javascript on("session.start", async ($, e, next) => { const result = await next(e); await takeReading($); return result; });
on("turn.complete", async ($, e, next) => { const result = await next(e); if (!e.agentId) { await takeReading($); // main-loop turns only, not subagents } return result; });
两个钩子都会先调用 next(e),然后进行观察。两者都不会改变实际发生的行为。
将读数保存在何处。 模块级的 let readings = [] 看上去是显而易见的选择,但热重载会重新加载模块:register 会再次运行,session.start 也会再次触发,模块变量会重新开始。应将历史数据放入 $.state。它会在宿主中保存具名值,贯穿整个会话,并且能在重载后继续保留。
javascript // Held by the host, so the history survives a hot reload of this file. const readings = { plugin: "token-weather", key: "readings" };
async function takeReading($) { const { context } = await $.session.usage(); if (!context?.window) return; const tokens = context.tokens ?? 0; const percent = context.percent ?? Math.round((tokens / context.window) * 100); const { value: history = [] } = await $.state.get(readings); await $.state.set(readings, [...history, { tokens, window: context.window, percent }].slice(-HISTORY)); }
状态值需要在插件的**类型契约(type contract)**中声明。类型契约是由清单文件指向的一个小型 .d.ts 文件。添加 types/index.d.ts:
typescript export type TokenWeatherReading = { tokens: number; window: number; percent: number };
declare module "claude-code" { interface PluginState { "token-weather": { readings: TokenWeatherReading[] }; } }
然后将 "types": "./types/index.d.ts" 添加到 plugin.json。如果跳过此步骤,claude plugin validate 会通过一条指明修复方法的错误阻止你继续:token-weather.readings is not declared: the manifest's types contract must name it in interface PluginState { … }。
作为回报,你可以免费获得重新渲染。当渲染钩子运行期间调用 $.state.get 时,该绘制会订阅对应状态,因此后续每一次 $.state.set 都会重新绘制状态条。你永远无需调用 $.ui.invalidate。
第 4 步:绘制预报
以下是完整模块:
javascript // Token Weather: a live forecast of the context window, above the prompt.
const HISTORY = 12; const BARS = "âââââ âââ"; const FORECAST = [ { upTo: 25, icon: "â", word: "Clear", color: "yellow" }, { upTo: 50, icon: "â", word: "Cloudy", color: "cyan" }, { upTo: 75, icon: "â", word: "Showers", color: "blue" }, { upTo: 90, icon: "â", word: "Storm", color: "magenta" }, { upTo: Infinity, icon: "â¯", word: "Compact soon", color: "red" }, ];
// Held by the host, so the history survives a hot reload of this file. const readings = { plugin: "token-weather", key: "readings" };
export function register(on) { on("session.start", async ($, e, next) => { const result = await next(e); await takeReading($); return result; });
on("turn.complete", async ($, e, next) => { const result = await next(e); if (!e.agentId) { await takeReading($); // main-loop turns only, not subagents } return result; });
on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => { const { value: history = [] } = await $.state.get(readings); if (e.props.hasSurvey || history.length === 0) { return next(e); } const { Box, Text } = $.ui.resolve(e); return band(Box, Text, history, e.props.bodyColumns); }); }
async function takeReading($) { const { context } = await $.session.usage(); if (!context?.window) return; const tokens = context.tokens ?? 0; const percent = context.percent ?? Math.round((tokens / context.window) * 100); const { value: history = [] } = await $.state.get(readings); await $.state.set(readings, [...history, { tokens, window: context.window, percent }].slice(-HISTORY)); }
function band(Box, Text, history, columns) {
const now = history[history.length - 1];
const f = FORECAST.find((b) => now.percent < b.upTo);
const parts = [
Text({ color: f.color, bold: true, children: ${f.icon} ${f.word} }),
Text({ children: ${now.percent}% of context }),
Text({ dimColor: true, children: ${short(now.tokens)} / ${short(now.window)} }),
];
if (columns >= 60) {
parts.push(Text({ dimColor: true, children: " last turns " }));
parts.push(Text({ color: f.color, children: sparkline(history) }));
if (history.length > 1) {
parts.push(Text({ dimColor: true, children: trend(history) }));
}
}
return Box({ flexDirection: "row", paddingX: 1, children: parts });
}
function sparkline(history) { const top = Math.max(...history.map((r) => r.tokens), 1); return history.map((r) => BARS[Math.floor((r.tokens / top) * (BARS.length - 1))]).join(""); }
function trend(history) {
const delta = history[history.length - 1].tokens - history[history.length - 2].tokens;
if (delta === 0) return " steady";
return delta > 0 ? â² +${short(delta)} last turn : â¼ ${short(-delta)} last turn;
}
function short(n) {
if (n >= 1_000_000) return ${+(n / 1_000_000).toFixed(1)}M;
if (n >= 1_000) return ${+(n / 1_000).toFixed(1)}k;
return String(n);
}
以下三个细节值得在你自己的 mod 中复用:
- 组件的 props 位于
e.props上。 `ha
English / 原文
HOW A MOD WORKS
A mod is a Claude Code plugin whose behavior lives in a JavaScript or TypeScript module:
- The folder is a normal plugin, with a
.claude-plugin/plugin.jsonmanifest. hooks/hooks.jsonnames one module undermodules.- The module exports
register(on, options). Inside it,on(event, matcher?, hook)adds a hook.
Every hook has the same shape:
CODEJavaScriptCopy
on("tool.call", { tool: "Bash" }, async ($, e, next) => {
// $ the mods API: ui, session, state, store, fs, process, clock, http, tool, command, model, ...
// e this event's input, as plain data
// next passes e to the other plugins and then to Claude Code's own behavior
return next(e);
});
Hooks form a chain, like middleware. Yours runs, next(e) hands the event to the next plugin, and at the bottom Claude Code does what it would have done anyway. A hook can do one of three things:
An event passes through your hook, then other plugins, then Claude Code
EVENT tool.call e = { command } your hook ($, e, next) other plugins same shape Claude Code runs the tool next(e) result next(e) result answer: return { deny } without next observe: await next(e), then look rewrite: next({ ...e, command }) EVENT tool.call e = { command } your hook ($, e, next) other plugins same shape Claude Code runs the tool next(e) result next(e) result answer answer: return { deny } without next observe: await next(e), then look rewrite: next({ ...e, command })
| Move | How | Example |
|---|---|---|
| Observe | const r = await next(e); /* look */ return r |
Record every file edit. Take a reading after each turn. |
| Rewrite | return next({ ...e, command: safer }) |
Change what the rest of the chain sees. |
| Answer | return { deny: "â¦" } without calling next |
Refuse a tool call. Serve a command or a tool yourself. |
The events cover tool calls, the prompt as submitted, turns starting and finishing, the session starting and ending, slash commands, and ui.render: every piece of the interface as it's drawn. The module runs in a sandbox of its own, with no DOM and no Node, so everything outside it goes through $.
How this differs from settings hooks. A settings hook runs a shell command for each event and passes JSON over stdin and stdout. A mod is loaded once and stays in the session. It can keep state, draw UI that updates as events happen, and call back into Claude Code: open a pane, run a process, register a slash command, or register a tool the model can call.
Claude Code uses them itself. Some of Claude Code's own features are built as mods, including AGENTS.md support and the /diff pane beside the conversation. Their source, with tests, is in the public anthropics/claude-code repository under mods/, so you can read how the team builds them.
BUILD YOUR FIRST MOD: TOKEN WEATHER
Token Weather reads how full the context window is after each turn and draws one line above the prompt: a weather icon, the percentage, the tokens used out of the window, a small chart of recent turns, and how much the last turn added.
| Used | Forecast |
|---|---|
| under 25% | â Clear |
| 25â49% | â Cloudy |
| 50â74% | â Showers |
| 75â89% | â Storm |
| 90% and up | ⯠Compact soon |
Here it is in a real session. Each turn reads more files, and the band fills from â Clear to â Showers to â Storm:
TerminalDesktop
FIG C Token Weather in a full terminal session over three turns: 18%, then 67%, then 81% of a 200k window
The shortcut: let Claude build it
You can skip the six steps. Claude Code knows how to write mods, so you can describe the one you want and let it do the work. Start a session with claude and paste the prompt below:
CODETextCopy
Make me a Claude Code mod called token-weather: a live forecast of my context window, shown in the band above the prompt.
What it should show, on one line:
- A weather icon and word for how full the context window is: under 25% â Clear (yellow), 25â49% â Cloudy (cyan), 50â74% â Showers (blue), 75â89% â Storm (magenta), 90% and up ⯠Compact soon (red).
- The percentage used, then the tokens used out of the window, like "134.4k / 200k".
- A small chart of the last 12 turns, drawn with âââââ
âââ.
- How much the last turn added, like "â² +98.3k last turn".
It should update after every turn.
Claude asks once whether to turn on hot reloading for the session. Allow it, and the band appears above the prompt when Claude's turn ends. From then on, every change reloads in place, so you can keep asking for tweaks ("make Storm start at 70%", "add the dollar cost at the end") and watch the band change. The mod loads only in this session, and its folder is cleaned up later, so to keep it, copy the folder out and install it like any plugin (Step 6).
Notice that the prompt only describes what you want to see. You don't need to know the API to write one. Claude Code's built-in guide for writing mods covers the how: where to keep state so it survives a reload, how to check the plugin with claude plugin validate, and which events to hook. Change the "What it should show" lines and it's your mod, not ours.
If you'd rather see how it's put together first, or want to check what Claude wrote, read on.
Step 1: Create the folder
Check that your Claude Code is new enough:
CODEShellCopy
claude --version # 2.1.287 or later
Create this layout:
CODETextCopy
token-weather/
âââ .claude-plugin/
â âââ plugin.json
â âââ types/ (written by Claude Code when it loads the mod)
âââ hooks/
â âââ hooks.json
â âââ token-weather.mjs
âââ types/
â âââ index.d.ts (added in step 3)
âââ tests/
âââ token-weather.test.ts (added in step 5)
.claude-plugin/plugin.json is a standard plugin manifest:
CODEJSONCopy
{
"name": "token-weather",
"version": "0.1.0",
"description": "A live forecast of the context window, drawn above the prompt.",
"author": { "name": "You" }
}
hooks/hooks.json points at the module. A mod has exactly one:
CODEJSONCopy
{
"modules": ["./token-weather.mjs"]
}
Step 2: Draw something
The band directly above the prompt is a component called AbovePrompt. Claude Code draws nothing there itself, so it's a good first target. Hook its ui.render event and return a tree of elements:
CODEJavaScriptCopy
// hooks/token-weather.mjs
export function register(on) {
on("ui.render", { component: "AbovePrompt" }, ($, e, next) => {
const { Box, Text } = $.ui.resolve(e);
return Box({
paddingX: 1,
children: [Text({ color: "yellow", bold: true, children: "â Clear skies" })],
});
});
}
The elements aren't globals. $.ui.resolve(e) returns the constructors for the surface being drawn, because each surface Claude Code draws on supports a slightly different set. JSX works too, with h as the factory.
Start a session with the plugin loaded:
CODEShellCopy
claude --plugin-dir ./token-weather
"â Clear skies" appears above the prompt. Keep the session open. The folder is watched, so every save reloads the module in place, with no restart. That quick feedback loop is most of what makes mods fun to write.
Tip: Once you know the shape, describe the next mod to Claude the way the shortcut does. It writes the plugin to a folder that hot-reloads in the same session.
Step 3: Read real numbers and keep them in $.state
$.session.usage() returns the same figures as the status line. context.tokens is the input the last response was answered over, context.window is the model's window, and context.percent is one over the other. The call is free: it only sends a token-count request if you ask for a breakdown.
Take a reading when the session starts and after every turn:
CODEJavaScriptCopy
on("session.start", async ($, e, next) => {
const result = await next(e);
await takeReading($);
return result;
});
on("turn.complete", async ($, e, next) => {
const result = await next(e);
if (!e.agentId) {
await takeReading($); // main-loop turns only, not subagents
}
return result;
});
Both hooks call next(e) first and then observe. Neither one changes what happens.
Where to keep the readings. A module-level let readings = [] looks like the obvious choice, but a hot reload is a fresh load: register runs again, session.start fires again, and module variables start over. Put the history in $.state instead. It holds named values in the host for the whole session, and they survive reloads.
CODEJavaScriptCopy
// Held by the host, so the history survives a hot reload of this file.
const readings = { plugin: "token-weather", key: "readings" };
async function takeReading($) {
const { context } = await $.session.usage();
if (!context?.window) return;
const tokens = context.tokens ?? 0;
const percent = context.percent ?? Math.round((tokens / context.window) * 100);
const { value: history = [] } = await $.state.get(readings);
await $.state.set(readings, [...history, { tokens, window: context.window, percent }].slice(-HISTORY));
}
A state value is declared in the plugin's type contract , which is a small .d.ts file the manifest points to. Add types/index.d.ts:
CODETypeScriptCopy
export type TokenWeatherReading = { tokens: number; window: number; percent: number };
declare module "claude-code" {
interface PluginState {
"token-weather": { readings: TokenWeatherReading[] };
}
}
Then add "types": "./types/index.d.ts" to plugin.json. If you skip this step, claude plugin validate stops you with an error that names the fix: token-weather.readings is not declared: the manifest's types contract must name it in interface PluginState { ⦠}.
In return, you get redraws for free. A $.state.get made while a render hook runs subscribes that drawing, so every later $.state.set redraws the band. You never call $.ui.invalidate.
Step 4: Draw the forecast
Here is the whole module:
CODEJavaScriptCopy
// Token Weather: a live forecast of the context window, above the prompt.
const HISTORY = 12;
const BARS = "âââââ
âââ";
const FORECAST = [
{ upTo: 25, icon: "â", word: "Clear", color: "yellow" },
{ upTo: 50, icon: "â", word: "Cloudy", color: "cyan" },
{ upTo: 75, icon: "â", word: "Showers", color: "blue" },
{ upTo: 90, icon: "â", word: "Storm", color: "magenta" },
{ upTo: Infinity, icon: "â¯", word: "Compact soon", color: "red" },
];
// Held by the host, so the history survives a hot reload of this file.
const readings = { plugin: "token-weather", key: "readings" };
export function register(on) {
on("session.start", async ($, e, next) => {
const result = await next(e);
await takeReading($);
return result;
});
on("turn.complete", async ($, e, next) => {
const result = await next(e);
if (!e.agentId) {
await takeReading($); // main-loop turns only, not subagents
}
return result;
});
on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
const { value: history = [] } = await $.state.get(readings);
if (e.props.hasSurvey || history.length === 0) {
return next(e);
}
const { Box, Text } = $.ui.resolve(e);
return band(Box, Text, history, e.props.bodyColumns);
});
}
async function takeReading($) {
const { context } = await $.session.usage();
if (!context?.window) return;
const tokens = context.tokens ?? 0;
const percent = context.percent ?? Math.round((tokens / context.window) * 100);
const { value: history = [] } = await $.state.get(readings);
await $.state.set(readings, [...history, { tokens, window: context.window, percent }].slice(-HISTORY));
}
function band(Box, Text, history, columns) {
const now = history[history.length - 1];
const f = FORECAST.find((b) => now.percent < b.upTo);
const parts = [
Text({ color: f.color, bold: true, children: `${f.icon} ${f.word}` }),
Text({ children: ` ${now.percent}% of context` }),
Text({ dimColor: true, children: ` ${short(now.tokens)} / ${short(now.window)}` }),
];
if (columns >= 60) {
parts.push(Text({ dimColor: true, children: " last turns " }));
parts.push(Text({ color: f.color, children: sparkline(history) }));
if (history.length > 1) {
parts.push(Text({ dimColor: true, children: trend(history) }));
}
}
return Box({ flexDirection: "row", paddingX: 1, children: parts });
}
function sparkline(history) {
const top = Math.max(...history.map((r) => r.tokens), 1);
return history.map((r) => BARS[Math.floor((r.tokens / top) * (BARS.length - 1))]).join("");
}
function trend(history) {
const delta = history[history.length - 1].tokens - history[history.length - 2].tokens;
if (delta === 0) return " steady";
return delta > 0 ? ` â² +${short(delta)} last turn` : ` â¼ ${short(-delta)} last turn`;
}
function short(n) {
if (n >= 1_000_000) return `${+(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${+(n / 1_000).toFixed(1)}k`;
return String(n);
}
Three details are worth copying into your own mods:
- The component's props are on
e.props.hasSurveytells you a survey wants the band, so the hook yields to it withnext(e).bodyColumnsis the band's real width, which is narrower than the terminal while a pane is docked beside the transcript. Size the tree to it. Onlye.component,e.surface,e.requestIdande.viewportsit at the top level ofe. - Pass when you have nothing to draw. Returning
next(e)gives the band back to Claude Code and to other mods. - Use single-width symbols, not emoji. â â â â ⯠line up in every terminal font.
Save the file and the running session picks it up. After a few turns that read large files, the band moves from Clear to Showers to Storm, as in the recording at the start of this section.
Step 5: Validate and test
claude plugin validate reads the manifest and the module's source the same way Claude Code will, and reports what the module hooks and calls:
CODETextCopy
$ claude plugin validate ./token-weather
> types ./types/index.d.ts declares state: token-weather.readings
> ./token-weather.mjs hooks: session.start, turn.complete, ui.render{component=AbovePrompt}
> ./token-weather.mjs calls: $.session.usage (via takeReading), $.state.get, $.state.set (via takeReading), $.ui.resolve
> ./token-weather.mjs state writes: token-weather.readings
> ./token-weather.mjs state reads: token-weather.readings
â Validation passed
claude plugin test runs the plugin's *.test.ts files against the real Claude Code runtime. Hooks that a test registers with on run after the mod in the chain and stub what Claude Code would answer, so you control exactly what $.session.usage() returns:
CODETypeScriptCopy
// tests/token-weather.test.ts
import { describe, expect, test } from "claude-code/testing";
describe("token-weather", () => {
test("the band follows the context window", async ($, on) => {
// Hooks registered here run after the mod and stub what Claude Code would answer.
let tokens = 36_100;
on("session.start", ($, e) => ({ cwd: e.cwd }));
on("session.usage", () => ({
value: { startedAt: 0, rateLimits: [], context: { tokens, window: 200_000, percent: Math.round(tokens / 2_000) } },
}));
on("turn.complete", () => ({ text: "" }));
await $.session.start({ surface: "terminal", isInteractive: true, cwd: "/work" } as any);
const ui = await $.ui.mount({
plugin: "token-weather",
surface: "terminal",
component: "AbovePrompt",
props: { hasSurvey: false, isWorking: false, maxRows: 10, bodyColumns: 120 },
} as any);
expect(await ui.find({ type: "Text", text: /Clear/ })).toBeDefined();
tokens = 134_400;
await $.turn.complete({ reason: "answer", answer: "ok", durationMs: 1 } as any);
expect(await ui.find({ type: "Text", text: /Showers/ })).toBeDefined();
expect(await ui.find({ type: "Text", text: /67% of context/ })).toBeDefined();
expect(await ui.find({ type: "Text", text: /â² \+98\.3k last turn/ })).toBeDefined();
await ui.unmount();
});
});
CODETextCopy
$ claude plugin test ./token-weather
(pass) token-weather > the band follows the context window
1 pass
0 fail
The test also checks the redraw behavior from step 3. The band updates after turn.complete without the mod ever asking for a redraw.
Step 6: Share it
A mod is a plugin, so it ships the same way. Put it in a marketplace, which can be as simple as a folder with a .claude-plugin/marketplace.json:
CODEJSONCopy
{
"name": "my-mods",
"owner": { "name": "You" },
"plugins": [{ "name": "token-weather", "source": "./token-weather" }]
}
CODEShellCopy
claude plugin marketplace add ./my-mods
claude plugin install token-weather@my-mods --scope user
To compare your version with a finished one, see the complete Token Weather mod in anthropics/claude-code-playground.
TWO MORE MODS
Token Weather only watches and draws. The next two mods step into events, open panes, and take input.
The complete code for both is in anthropics/claude-code-playground: Blast Radius and Replay Theater, each with a README that explains how it was built.
Blast Radius: see what a risky command would change before it runs
When Claude calls Bash with rm -rf, git reset --hard, git clean, a force push, or a database migration, Blast Radius holds the call. It works out what the command would touch and opens a pane with Proceed and Cancel. Press 2 and Claude gets a refusal with the reason. Press 1 and the command runs as written.
TerminalDesktop
FIG D Blast Radius holds rm -rf build and lists the 9 files (1.1 MB) it would delete. Cancel refuses it; on the second try, Proceed runs it.
It uses three hooks: tool.call on Bash, and ui.render on Pane and on AbovePrompt. The core of it is the "answer" move from the table above:
CODEJavaScriptCopy
on("tool.call", { tool: "Bash" }, async ($, e, next) => {
const risk = classify(String(e.command ?? ""));
if (risk === null) return next(e); // everything else runs as normal
const report = await measure($, risk, await $.session.cwd()); // git status, git clean -n, du, ...
held = { command: e.command, risk, report, decision: null };
const opened = await $.ui.open({ id: "blast-radius", title: "Blast Radius", focus: true });
if (!opened.isPlaced) held.where = "band"; // too narrow for a pane: draw above the prompt
while (held.decision === null && !next.signal.aborted) {
await $.process.run(["sleep", "0.25"]); // time inside $ calls doesn't count against the hook's time limit
}
if (held.decision === "proceed") return next(e); // let it run
return { deny: `Blast Radius held this command: the user pressed Cancel. It would have: ${report.summary}.` };
});
What it teaches:
$.process.runfor dry runs. The report comes from the tools' own commands:git status --porcelain,git clean -n,git log HEAD..origin/main,showmigrations. Arguments go in as an argv array, so nothing in a path is run as shell code.- Holding a call. A hook gets 10 seconds of its own time per dispatch, but time spent waiting inside a
$call doesn't count. The loop waits on shortsleepprocesses until a button'sonPresssets the decision, and it gives up whennext.signalaborts (you pressed Esc). - Buttons with hotkeys.
Button({ label: "Proceed", hotkey: "1", onPress })works by click, by Tab and Enter, or by the digit. - Degrade to the band. The terminal docks a pane beside the transcript when it's wide enough. When
$.ui.openanswersisPlaced: false, the same report is drawn above the prompt:
FIG E At 120 columns, Blast Radius draws its report for git reset --hard in the band above the prompt
It's a safety net, not a permission system. It reads the command text, so $(â¦), aliases and scripts that call rm get past it. Use permission rules for a hard block.
Replay Theater: step through the last turn's edits
While a turn runs, Replay Theater records every Edit and Write call: the file, plus the text before and after. When the turn ends, a hint appears above the prompt. Press r (or type /replay) and a pane walks through the edits one diff at a time, with a strip of numbered steps and Prev , Next and Close buttons.
TerminalDesktop
FIG F Replay Theater: the hint after a 5-edit rename across 3 files, then steps 1 to 5 in a pane
It never blocks or changes an edit. It observes:
CODEJavaScriptCopy
on("tool.call", async ($, e, next) => {
if (EDIT_TOOLS.has(e.tool)) state.pending.push(...(await stepsFor($, e))); // old/new text â diff
return next(e); // the edit runs untouched
});
on("turn.start", ($, e, next) => { if (!e.agentId) state.pending = []; return next(e); });
on("turn.complete", async ($, e, next) => {
const r = await next(e);
if (!e.agentId && state.pending.length) state.replay = state.pending; // one replay per turn
return r;
});
on("session.start", async ($, e, next) => {
const r = await next(e);
await $.command.register({ name: "replay", description: "Step through the last turn's file edits" });
return r;
});
on("command.run", { command: "replay" }, async ($, e) => ({ text: (await openReplay($)) ? "Replaying" : "No edits" }));
What it teaches:
- Pairing events.
turn.startandturn.completebracket the edits into one replay per turn, ande.agentIdkeeps subagent turns out of the grouping. - Registering a slash command.
$.command.registerinsession.start, then answer it oncommand.run. - Reading files. For a Write,
$.fs.readgets the old contents just before the write lands, so the diff is real. - Placement is the surface's job. In fullscreen the pane docks on the right. At 80 columns it opens inline above the prompt. The mod draws the same tree either way.
FIG G Replay Theater at 80 columns, drawn inline above the prompt