{"slug": "getting-started-with-claude-code-mods", "title": "Getting started with Claude Code mods", "summary": "Anthropic's Claude Code 2.1.287 and later ships mods enabled by default, letting developers write JavaScript or TypeScript modules that hook into every session event, rewrite or deny tool calls, and draw custom terminal or desktop UI. Mods are hooks packaged inside plugins and loaded once per session, unlike settings hooks that spawn a shell command per event; Anthropic itself builds features such as AGENTS.md support and the /diff pane as mods in its public anthropics/claude-code repository. The guide walks through building Token Weather, an approximately 80-line mod that forecasts context-window usage above the prompt, and tours two larger mods, Blast Radius and Replay Theater.", "body_md": "Build your first Claude Code mod from an empty folder, then see what else the API can do.\n\nA mod is a small JavaScript or TypeScript file that runs inside your Claude Code session. It can watch what's happening, change what Claude Code does, or draw its own UI, in the terminal or the desktop app. You don't need to learn the API to try one. Run `claude`, then describe the mod you want. Allow hot reload when it asks, and the mod shows up when the turn ends.\n\nClaude Code already lets you change a lot about how it behaves: settings, permission rules, slash commands, skills and a status line. Mods go further: they can rewrite or replace what Claude Code does, and draw custom UI. Under the hood, mods are hooks that ship inside plugins, and each one sees every event in your session as it happens.\n\nThat makes mods a way to fit Claude Code to how you work. You can add a readout you check all the time, put a guard in front of the commands that make you nervous, or build a review view for how you like to read changes.\n\nThis guide builds one mod from an empty folder, **Token Weather**, a live forecast of the context window drawn above the prompt. It's about 80 lines. Then it tours two larger mods, **Blast Radius** and **Replay Theater**, to show what else the API can do.\n\n**Claude Code 2.1.287 or later.** Mods are on by default, so there's nothing to turn on. The API can change between releases. Each time Claude Code loads a mod, it writes the type declarations for your build into the mod's `.claude-plugin/types/` folder, and those are the authority for your version.\n\nA mod is a Claude Code plugin whose behavior lives in a JavaScript or TypeScript module:\n\n`.claude-plugin/plugin.json` manifest.`hooks/hooks.json` names one module under `modules`.` register(on, options)`. Inside it, `on(event, matcher?, hook)` adds a hook.\nEvery hook has the same shape:\n\n``` js\non(\"tool.call\", { tool: \"Bash\" }, async ($, e, next) => {\n  // $    the mods API: ui, session, state, store, fs, process, clock, http, tool, command, model, ...\n  // e    this event's input, as plain data\n  // next passes e to the other plugins and then to Claude Code's own behavior\n  return next(e);\n});\n```\n\nHooks 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:\n\n| Move | How | Example | \n|---|---|---|\n| **Observe** | `const r = await next(e); /* look */ return r` | Record every file edit. Take a reading after each turn. | \n| **Rewrite** | `return next({ ...e, command: safer })` | Change what the rest of the chain sees. | \n| **Answer** | `return { deny: \"…\" }` without calling`next` | Refuse a tool call. Serve a command or a tool yourself. | \n\nThe 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 `$`.\n\n**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.\n\n**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](https://github.com/anthropics/claude-code) repository under `mods/`, so you can read how the team builds them.\n\nToken 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.\n\n| Used | Forecast | \n|---|---|\n| under 25% | ☀ Clear | \n| 25–49% | ☁ Cloudy | \n| 50–74% | ☂ Showers | \n| 75–89% | ☇ Storm | \n| 90% and up | ↯ Compact soon | \n\nHere it is in a real session. Each turn reads more files, and the band fills from ☀ Clear to ☂ Showers to ☇ Storm:\n\nYou 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:\n\n```\nMake me a Claude Code mod called token-weather: a live forecast of my context window, shown in the band above the prompt.\n\nWhat it should show, on one line:\n- 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).\n- The percentage used, then the tokens used out of the window, like \"134.4k / 200k\".\n- A small chart of the last 12 turns, drawn with ▁▂▃▄▅▆▇█.\n- How much the last turn added, like \"▲ +98.3k last turn\".\n\nIt should update after every turn.\n```\n\nClaude 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](#step-6-share-it)).\n\nNotice 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.\n\nIf you'd rather see how it's put together first, or want to check what Claude wrote, read on.\n\nCheck that your Claude Code is new enough:\n\n```\nclaude --version   # 2.1.287 or later\n```\n\nCreate this layout:\n\n```\ntoken-weather/\n├── .claude-plugin/\n│   ├── plugin.json\n│   └── types/            (written by Claude Code when it loads the mod)\n├── hooks/\n│   ├── hooks.json\n│   └── token-weather.mjs\n├── types/\n│   └── index.d.ts        (added in step 3)\n└── tests/\n    └── token-weather.test.ts   (added in step 5)\n```\n\n`.claude-plugin/plugin.json` is a standard plugin manifest:\n\n```\n{\n  \"name\": \"token-weather\",\n  \"version\": \"0.1.0\",\n  \"description\": \"A live forecast of the context window, drawn above the prompt.\",\n  \"author\": { \"name\": \"You\" }\n}\n```\n\n`hooks/hooks.json` points at the module. A mod has exactly one:\n\n```\n{\n  \"modules\": [\"./token-weather.mjs\"]\n}\n```\n\nThe 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:\n\n```\n// hooks/token-weather.mjs\nexport function register(on) {\n  on(\"ui.render\", { component: \"AbovePrompt\" }, ($, e, next) => {\n    const { Box, Text } = $.ui.resolve(e);\n    return Box({\n      paddingX: 1,\n      children: [Text({ color: \"yellow\", bold: true, children: \"☀  Clear skies\" })],\n    });\n  });\n}\n```\n\nThe 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.\n\nStart a session with the plugin loaded:\n\n```\nclaude --plugin-dir ./token-weather\n```\n\n\"☀ 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.\n\n**Tip:** Once you know the shape, describe the next mod to Claude the way [the shortcut](#the-shortcut-let-claude-build-it) does. It writes the plugin to a folder that hot-reloads in the same session.\n\n`$.state`\n`$.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`.\n\nTake a reading when the session starts and after every turn:\n\n``` js\non(\"session.start\", async ($, e, next) => {\n  const result = await next(e);\n  await takeReading($);\n  return result;\n});\n\non(\"turn.complete\", async ($, e, next) => {\n  const result = await next(e);\n  if (!e.agentId) {\n    await takeReading($); // main-loop turns only, not subagents\n  }\n  return result;\n});\n```\n\nBoth hooks call `next(e)` first and then observe. Neither one changes what happens.\n\n**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.\n\n``` js\n// Held by the host, so the history survives a hot reload of this file.\nconst readings = { plugin: \"token-weather\", key: \"readings\" };\n\nasync function takeReading($) {\n  const { context } = await $.session.usage();\n  if (!context?.window) return;\n  const tokens = context.tokens ?? 0;\n  const percent = context.percent ?? Math.round((tokens / context.window) * 100);\n  const { value: history = [] } = await $.state.get(readings);\n  await $.state.set(readings, [...history, { tokens, window: context.window, percent }].slice(-HISTORY));\n}\n```\n\nA 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`:\n\n```\nexport type TokenWeatherReading = { tokens: number; window: number; percent: number };\n\ndeclare module \"claude-code\" {\n  interface PluginState {\n    \"token-weather\": { readings: TokenWeatherReading[] };\n  }\n}\n```\n\nThen 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 { … }`.\n\nIn 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`.\n\nHere is the whole module:\n\n```\n// Token Weather: a live forecast of the context window, above the prompt.\n\nconst HISTORY = 12;\nconst BARS = \"▁▂▃▄▅▆▇█\";\nconst FORECAST = [\n  { upTo: 25, icon: \"☀\", word: \"Clear\", color: \"yellow\" },\n  { upTo: 50, icon: \"☁\", word: \"Cloudy\", color: \"cyan\" },\n  { upTo: 75, icon: \"☂\", word: \"Showers\", color: \"blue\" },\n  { upTo: 90, icon: \"☇\", word: \"Storm\", color: \"magenta\" },\n  { upTo: Infinity, icon: \"↯\", word: \"Compact soon\", color: \"red\" },\n];\n\n// Held by the host, so the history survives a hot reload of this file.\nconst readings = { plugin: \"token-weather\", key: \"readings\" };\n\nexport function register(on) {\n  on(\"session.start\", async ($, e, next) => {\n    const result = await next(e);\n    await takeReading($);\n    return result;\n  });\n\n  on(\"turn.complete\", async ($, e, next) => {\n    const result = await next(e);\n    if (!e.agentId) {\n      await takeReading($); // main-loop turns only, not subagents\n    }\n    return result;\n  });\n\n  on(\"ui.render\", { component: \"AbovePrompt\" }, async ($, e, next) => {\n    const { value: history = [] } = await $.state.get(readings);\n    if (e.props.hasSurvey || history.length === 0) {\n      return next(e);\n    }\n    const { Box, Text } = $.ui.resolve(e);\n    return band(Box, Text, history, e.props.bodyColumns);\n  });\n}\n\nasync function takeReading($) {\n  const { context } = await $.session.usage();\n  if (!context?.window) return;\n  const tokens = context.tokens ?? 0;\n  const percent = context.percent ?? Math.round((tokens / context.window) * 100);\n  const { value: history = [] } = await $.state.get(readings);\n  await $.state.set(readings, [...history, { tokens, window: context.window, percent }].slice(-HISTORY));\n}\n\nfunction band(Box, Text, history, columns) {\n  const now = history[history.length - 1];\n  const f = FORECAST.find((b) => now.percent < b.upTo);\n  const parts = [\n    Text({ color: f.color, bold: true, children: `${f.icon}  ${f.word}` }),\n    Text({ children: `  ${now.percent}% of context` }),\n    Text({ dimColor: true, children: `  ${short(now.tokens)} / ${short(now.window)}` }),\n  ];\n  if (columns >= 60) {\n    parts.push(Text({ dimColor: true, children: \"   last turns \" }));\n    parts.push(Text({ color: f.color, children: sparkline(history) }));\n    if (history.length > 1) {\n      parts.push(Text({ dimColor: true, children: trend(history) }));\n    }\n  }\n  return Box({ flexDirection: \"row\", paddingX: 1, children: parts });\n}\n\nfunction sparkline(history) {\n  const top = Math.max(...history.map((r) => r.tokens), 1);\n  return history.map((r) => BARS[Math.floor((r.tokens / top) * (BARS.length - 1))]).join(\"\");\n}\n\nfunction trend(history) {\n  const delta = history[history.length - 1].tokens - history[history.length - 2].tokens;\n  if (delta === 0) return \"  steady\";\n  return delta > 0 ? `  ▲ +${short(delta)} last turn` : `  ▼ ${short(-delta)} last turn`;\n}\n\nfunction short(n) {\n  if (n >= 1_000_000) return `${+(n / 1_000_000).toFixed(1)}M`;\n  if (n >= 1_000) return `${+(n / 1_000).toFixed(1)}k`;\n  return String(n);\n}\n```\n\nThree details are worth copying into your own mods:\n\n`e.props`.` hasSurvey` tells you a survey wants the band, so the hook yields to it with `next(e)`. `bodyColumns` is the band's real width, which is narrower than the terminal while a pane is docked beside the transcript. Size the tree to it. Only `e.component`, `e.surface`, `e.requestId` and `e.viewport` sit at the top level of `e`.\nSave 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.\n\n`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:\n\n``` bash\n$ claude plugin validate ./token-weather\n  > types ./types/index.d.ts declares state: token-weather.readings\n  > ./token-weather.mjs hooks: session.start, turn.complete, ui.render{component=AbovePrompt}\n  > ./token-weather.mjs calls: $.session.usage (via takeReading), $.state.get, $.state.set (via takeReading), $.ui.resolve\n  > ./token-weather.mjs state writes: token-weather.readings\n  > ./token-weather.mjs state reads: token-weather.readings\n√ Validation passed\n```\n\n`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:\n\n``` js\n// tests/token-weather.test.ts\nimport { describe, expect, test } from \"claude-code/testing\";\n\ndescribe(\"token-weather\", () => {\n  test(\"the band follows the context window\", async ($, on) => {\n    // Hooks registered here run after the mod and stub what Claude Code would answer.\n    let tokens = 36_100;\n    on(\"session.start\", ($, e) => ({ cwd: e.cwd }));\n    on(\"session.usage\", () => ({\n      value: { startedAt: 0, rateLimits: [], context: { tokens, window: 200_000, percent: Math.round(tokens / 2_000) } },\n    }));\n    on(\"turn.complete\", () => ({ text: \"\" }));\n\n    await $.session.start({ surface: \"terminal\", isInteractive: true, cwd: \"/work\" } as any);\n    const ui = await $.ui.mount({\n      plugin: \"token-weather\",\n      surface: \"terminal\",\n      component: \"AbovePrompt\",\n      props: { hasSurvey: false, isWorking: false, maxRows: 10, bodyColumns: 120 },\n    } as any);\n    expect(await ui.find({ type: \"Text\", text: /Clear/ })).toBeDefined();\n\n    tokens = 134_400;\n    await $.turn.complete({ reason: \"answer\", answer: \"ok\", durationMs: 1 } as any);\n    expect(await ui.find({ type: \"Text\", text: /Showers/ })).toBeDefined();\n    expect(await ui.find({ type: \"Text\", text: /67% of context/ })).toBeDefined();\n    expect(await ui.find({ type: \"Text\", text: /▲ \\+98\\.3k last turn/ })).toBeDefined();\n    await ui.unmount();\n  });\n});\nbash\n$ claude plugin test ./token-weather\n(pass) token-weather > the band follows the context window\n 1 pass\n 0 fail\n```\n\nThe test also checks the redraw behavior from step 3. The band updates after `turn.complete` without the mod ever asking for a redraw.\n\nA 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`:\n\n```\n{\n  \"name\": \"my-mods\",\n  \"owner\": { \"name\": \"You\" },\n  \"plugins\": [{ \"name\": \"token-weather\", \"source\": \"./token-weather\" }]\n}\nclaude plugin marketplace add ./my-mods\nclaude plugin install token-weather@my-mods --scope user\n```\n\nA mod is a Claude Code plugin, so you share it the same way as any other plugin, and there's nothing new to learn. Put the mod in a GitHub repo with a marketplace file and that repo becomes your marketplace. Anyone can install from it, and you can update it with a normal push.\n\nInstalling takes three commands in Claude Code:\n\n```\n/plugin marketplace add your-org/my-mods\n/plugin install token-weather@my-mods\n/reload-plugins\n```\n\nThe mod starts when you reload. If it doesn't show up, restart Claude Code.\n\nA mod is code that runs inside Claude Code on your machine, with the same access Claude Code has, and it's written by its publisher, not Anthropic. So install mods the way you'd install a package: read the repo first and only install from people you trust. Nothing gets installed until you run the command.\n\nThe Claude directory accepts plugins that include mods, and you can submit yours at [claude.ai/directory/manage](https://claude.ai/directory/manage), so people can find it without a link from you.\n\nToken Weather only watches and draws. The next two mods step into events, open panes, and take input.\n\nWhen 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.\n\nIt 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:\n\n``` js\non(\"tool.call\", { tool: \"Bash\" }, async ($, e, next) => {\n  const risk = classify(String(e.command ?? \"\"));\n  if (risk === null) return next(e);                 // everything else runs as normal\n\n  const report = await measure($, risk, await $.session.cwd());  // git status, git clean -n, du, ...\n  held = { command: e.command, risk, report, decision: null };\n  const opened = await $.ui.open({ id: \"blast-radius\", title: \"Blast Radius\", focus: true });\n  if (!opened.isPlaced) held.where = \"band\";         // too narrow for a pane: draw above the prompt\n\n  while (held.decision === null && !next.signal.aborted) {\n    await $.process.run([\"sleep\", \"0.25\"]);          // time inside $ calls doesn't count against the hook's time limit\n  }\n  if (held.decision === \"proceed\") return next(e);   // let it run\n  return { deny: `Blast Radius held this command: the user pressed Cancel. It would have: ${report.summary}.` };\n});\n```\n\nWhat it teaches:\n\n`$.process.run` for dry runs.`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.`$` call doesn't count. The loop waits on short `sleep` processes until a button's `onPress` sets the decision, and it gives up when `next.signal` aborts (you pressed Esc).`Button({ label: \"Proceed\", hotkey: \"1\", onPress })` works by click, by Tab and Enter, or by the digit.`$.ui.open` answers `isPlaced: false`, the same report is drawn above the prompt:\nIt'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.\n\nWhile 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.\n\nIt never blocks or changes an edit. It observes:\n\n``` js\non(\"tool.call\", async ($, e, next) => {\n  if (EDIT_TOOLS.has(e.tool)) state.pending.push(...(await stepsFor($, e)));  // old/new text → diff\n  return next(e);                                                              // the edit runs untouched\n});\n\non(\"turn.start\", ($, e, next) => { if (!e.agentId) state.pending = []; return next(e); });\n\non(\"turn.complete\", async ($, e, next) => {\n  const r = await next(e);\n  if (!e.agentId && state.pending.length) state.replay = state.pending;       // one replay per turn\n  return r;\n});\n\non(\"session.start\", async ($, e, next) => {\n  const r = await next(e);\n  await $.command.register({ name: \"replay\", description: \"Step through the last turn's file edits\" });\n  return r;\n});\non(\"command.run\", { command: \"replay\" }, async ($, e) => ({ text: (await openReplay($)) ? \"Replaying\" : \"No edits\" }));\n```\n\n`turn.start` and `turn.complete` bracket the edits into one replay per turn, and `e.agentId` keeps subagent turns out of the grouping.`$.command.register` in `session.start`, then answer it on `command.run`.`$.fs.read` gets the old contents just before the write lands, so the diff is real.`.claude-plugin/types/` folder, so your editor and `tsc -p` work with no extra step. They're the reference for every event, every method on `register` and `claude --debug` and look for a line saying a hook returned a tree that does not validate.\nThe three mods here came from one question each: *how full is my context?*, *what is this command about to delete?*, and *what did Claude just change?* Your questions will be different, and that's the point. Some ideas to start from:\n\n`$.session.usage()`, as a status line with `$.ui.status`` prompt.submit` hook that adds your team's conventions to every prompt`$.ui.toast` when a long turn finishes`tool.call` guard tuned to your stack, such as production kubectl contexts or `terraform apply`\nMade a mod you now use every day? Post it on X or LinkedIn with a GIF or a screenshot of it running, so other developers can see what's possible. Put the plugin in a marketplace ([Sharing your mod](#sharing-your-mod)) and link to it, so anyone who likes it can install it with three commands.", "url": "https://wpnews.pro/news/getting-started-with-claude-code-mods", "canonical_source": "https://claude.dev/blog/getting-started-with-claude-code-mods/", "published_at": "2026-10-03 00:05:19+00:00", "updated_at": "2026-10-03 00:36:18.220295+00:00", "lang": "en", "topics": ["ai-tools", "developer-tools", "ai-agents", "ai-products"], "entities": ["Anthropic", "Claude Code", "Token Weather", "Blast Radius", "Replay Theater", "anthropics/claude-code"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/getting-started-with-claude-code-mods", "markdown": "https://wpnews.pro/news/getting-started-with-claude-code-mods.md", "text": "https://wpnews.pro/news/getting-started-with-claude-code-mods.txt", "jsonld": "https://wpnews.pro/news/getting-started-with-claude-code-mods.jsonld"}}