You Can Now Customize Claude Code: Change the Interface, Stop Dangerous Commands, and Review Code Changes Step by Step

Place context readings above the prompt, inspect the blast radius before dangerous commands run, and review a round of edits step by step: three official examples explaining what Mods do and how to write them.

By Anthropic / Addy Osmani10-02 · 15 min read
Official launch video from ClaudeDevs: See how Mods transform Claude Code's interface and workflow.

When programming with Claude Code in the terminal, developers often have three specific workflow needs:

  • Keeping key readings in sight at all times: Having a persistent line of readings right above the prompt to intuitively monitor current context consumption without having to hunt through the status line;
  • Intercepting nerve-racking commands: Pausing before high-risk operations—such as cleanups, resets, or migrations—to inspect exactly which files will be touched before deciding whether to proceed;
  • Reviewing changes on your own terms: Stepping through diffs across multiple files after a single round of edits, rather than combing through scattered Git diffs after everything has already finished.

Previously, Claude Code supported customizing behavior through settings, permission rules, slash commands, skills, and the status line. Enabled by default in version 2.1.287 or higher, Mods take this a step further: they allow you to rewrite or replace behaviors, and even render custom UI, using JavaScript or TypeScript modules.

Think of it as adding features of your choice to Claude Code—just describe what you want directly to Claude:

  • An extra line of usage above the prompt: For instance, you can describe your requirement like this: "Add a line of context usage above the prompt, change its color when it's nearly full, and show recent changes." Claude generates the Mod, you approve hot reloading in the current session (which takes effect as soon as the code is saved), and a usage display updating after each turn appears above the prompt. If you then say "change the storm threshold from 75% to 70%" and save the updated code, the new threshold takes effect immediately.
  • See what will be deleted before deleting files: With Blast Radius installed, before executing a command that deletes the build directory, the interface opens a pane listing the files to be deleted and the space they occupy, waiting for you to press a number key to choose Proceed or Cancel.
  • Review what changed step by step after editing: With Replay Theater installed, when multiple files are modified in a single turn, a replay hint appears above the prompt. Simply press r or type /replay to step through the diff of each edit in a side pane. When you are done, close the pane, and the files remain in their modified state.

Under the hood, a Mod is essentially a hook packaged inside a Plugin, running as a module within the session and receiving various events. Some of Anthropic's own built-in capabilities (such as the sidebar /diff pane and support for the AGENTS.md specification) are implemented directly as Mods. For example, the built-in /diff feature is now a Mod; users can disable it in /plugin or replace it with a custom version. The team plans to migrate more built-in features to Mods in the future, allowing users to streamline the core or expand capabilities as needed.

The official tutorial demonstrates building a context usage bar in roughly 80 lines of code, followed by two real-world cases illustrating command interception and edit replay. Understanding these examples reveals which events custom tools need to hook into and what state they need to manage.

1. Core Concepts: What Is a Mod, and How Does It Fundamentally Differ from Hooks?

In a single sentence: A Mod is essentially a JavaScript/TypeScript module packaged inside a Plugin that stays resident in your session, hooks into events provided by Claude Code, and can observe, rewrite inputs, or directly return results.

1. How Does It Run?

In terms of file structure, a Mod follows the standard Claude Code Plugin specification:

  • A .claude-plugin/plugin.json manifest file in the root directory;
  • A modules field in hooks/hooks.json pointing to specific JS/TS files;
  • The module exports a register(on, options) function;
  • Inside the function, various hooks are registered via on(event, matcher?, hook).

The basic skeleton of every hook looks like this:

on("tool.call", { tool: "Bash" }, async ($, e, next) => {
  // $    the Mods API: provides ui, session, state, store, fs, process, clock, and other capabilities
  // e    input data for this event (a plain data object)
  // next passes the event to the next plugin, and ultimately to Claude Code itself
  return next(e);
});

2. An Event Can Be Observed, Rewritten, or Directly Answered

When an event triggers (for example, when Claude is about to call Bash to run a command), it flows through your Mod, other plugins, and ultimately reaches Claude Code's default executor—much like middleware in Express or Koa.

Within this chain, your Mod has three distinct modes of control:

Observe: Observe and retain original behavior. Read the input token count after a turn completes, or log files modified by Claude.

const r = await next(e); // let the rest of the chain execute first
// inspect the result
return r;               // return unchanged

Rewrite: Modify the input before passing it downstream. For example, swap command parameters or prepend team conventions when a prompt is submitted. Subsequent plugins and Claude Code receive the modified data.

return next({ ...e, command: saferCommand });

Answer: Return your own result directly. For example, deny a tool call or handle a slash command. Without calling next, the event will not propagate further down the chain.

return { deny: "Reason for denial" }; // do not call next
One Event, Three Handling Approaches

An illustration of the event chain concept. Switch modes to see whether the event continues down the chain; no actual commands will be executed here.

  1. Tool call eventOriginal input e
  2. Your Modawait next(e), awaiting result
  3. Other pluginsReceives original input e
  4. Claude CodeExecutes default behavior, result bubbles back up the chain

Observe: The event continues along the chain; the Mod inspects the result and passes it through unchanged, leaving default behavior intact.

Rewrite: next(modified input), downstream plugins and Claude Code receive new input. Answer directly: returns a result without calling next, halting downstream execution.

3. Differences Between Mods and Settings Hooks

A Settings hook runs a Shell command each time an event occurs, exchanging JSON data via standard input (stdin) and standard output (stdout).

In contrast, a Mod is loaded once at startup and remains resident throughout the session:

  • State persistence: It can maintain internal state across the entire session;
  • UI rendering and interaction: It can render and update UI in real time alongside events, supporting both terminal and desktop applications; beyond custom panes or prompts, it can also add buttons and input elements, with other Mods reacting to input interactions;
  • Event intervention and rewriting: It can rewrite prompts before they are sent to the model, intercept and retry tool calls, approve or deny permission requests, or actively redact secrets before the model reads tool output (requiring filtering rules implemented by the Mod);
  • Host callbacks: It can invoke Claude Code host capabilities, such as opening a side pane, running processes, registering new slash commands, or registering custom tools for the model to call.

A Mod's runtime environment has no browser DOM and no Node.js global objects; all external interactions occur through APIs provided on the $ object. This runtime isolation does not restrict the Mod's machine access—a Mod still operates with the exact same local system permissions as Claude Code.

2. 80 Lines of Code in Action: Building "Token Weather" Step by Step

The official tutorial builds a Mod named Token Weather from scratch: it reads context window usage after each turn and renders a single line of information above the prompt, including a weather icon, usage percentage, current readings relative to the window limit, a sparkline showing the recent trend across turns, and the tokens added in the last turn.

☀ Clear   18% of context    36.1k / 200k     last turns ▁▂▃▄   ▲ +36.1k last turn

The weather icon and description map directly to the current usage percentage:

  • Under 25%: ☀ Clear
  • 25%–49%: ☁ Cloudy
  • 50%–74%: ☂ Showers
  • 75%–89%: ☇ Storm
  • 90% and above: ↯ Compact soon
Token Weather in action during a terminal session: As files are read, context usage climbs from Clear (18%) to Showers (67%) and Storm (81%).

Try It First: Describe What You Want and Let Claude Write It

Claude Code includes built-in guidelines for writing Mods and can generate a plugin based on your requirements. Launch claude in your terminal and paste the following prompt:

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 will ask once whether to enable hot reloading in the current session. Once approved, as soon as that turn finishes, the weather band appears above the prompt. Every subsequent save reloads in place, allowing you to ask Claude for fine-tuning directly (e.g., "change the storm threshold to 70%" or "add dollar cost at the end") and see changes immediately. A Mod created via this shortcut is loaded only in the current session, and its directory will eventually be cleaned up by the system; to keep it for the long term, copy out the generated directory and install it as shown in Step 6.

If you want to understand its construction deeply or build more complex features by hand, let's break down its internal implementation step by step.

Step 1: Set Up the Plugin Directory Structure

First, verify that your local Claude Code version meets the requirement:

claude --version   # must be version 2.1.287 or higher

Then create the following standard directory structure:

token-weather/
├── .claude-plugin/
│   ├── plugin.json
│   └── types/            (Claude Code writes this version's type declarations on each load)
├── hooks/
│   ├── hooks.json
│   └── token-weather.mjs
├── types/
│   └── index.d.ts        (declare plugin state types in Step 3)
└── tests/
    └── token-weather.test.ts

.claude-plugin/plugin.json is the standard manifest description:

{
  "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 tells Claude Code where the hook script is located (one Mod corresponds to one entry module):

{
  "modules": ["./token-weather.mjs"]
}

Step 2: Render the First Line on the UI (UI Mounting and Hot Reloading)

Claude Code's interface is divided into distinct component slots. Directly above the prompt is an empty space called AbovePrompt. By default, nothing is rendered here, making it ideal for a persistent status band.

We intercept the ui.render event in hooks/token-weather.mjs:

// hooks/token-weather.mjs
export function register(on) {
  on("ui.render", { component: "AbovePrompt" }, ($, e, next) => {
    // UI components are not globals; resolve factories for the current renderer via $.ui.resolve(e)
    const { Box, Text } = $.ui.resolve(e);
    return Box({
      paddingX: 1,
      children: [Text({ color: "yellow", bold: true, children: "☀  Clear skies" })],
    });
  });
}

Important Note: Mods run in a sandbox where UI components like Box and Text are not globally available objects. Because Claude Code may run in a pure terminal character interface or in a desktop graphical client, component support varies slightly across host environments. You must dynamically obtain component factories for the current environment via $.ui.resolve(e).

Test run this plugin:

claude --plugin-dir ./token-weather

A yellow "☀ Clear skies" will appear above the terminal prompt. Keep the session open; when you edit and save token-weather.mjs, the directory is watched, and the code reloads in place without restarting the session.

Step 3: Collect Real Data and Persist State ($.state and Reactive Rendering)

With the UI shell ready, the next step is fetching actual token usage.

Calling $.session.usage() returns statistics identical to the status line:

  • context.tokens: The number of input tokens on which the last turn's answer was based;
  • context.window: The model's context window limit (e.g., 200k);
  • context.percent: The usage percentage derived by dividing the two.

This call has very low overhead because, unless you explicitly request a detailed breakdown, it does not send additional token computation requests to the remote server.

We need to record a reading when the session starts (session.start) and whenever a turn completes (turn.complete):

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($); // key detail: count only the main session, ignoring internal turns from sub-Agents
  }
  return result;
});

Why Can't Normal Module Variables Store History?

Defining top-level module variables (like let readings = []) seems intuitive, but it cannot survive hot reloading. Hot reloading is a clean reload: register executes again, session.start fires again, and module-scope variables reinitialize.

The correct approach is storing history in $.state. It is managed centrally by the host throughout the session and preserves data across hot reloads.

// declare the host-managed state key
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);
  
  // read history from $.state, append a reading, and keep the last 12 turns
  const { value: history = [] } = await $.state.get(readings);
  await $.state.set(readings, [...history, { tokens, window: context.window, percent }].slice(-HISTORY));
}

Type Contracts and Automatic Re-rendering

When using $.state, Claude Code requires an explicit declaration in the plugin's type contract. Add this in types/index.d.ts:

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. Skipping this step will cause claude plugin validate to warn that declarations are missing from the type contract.

Calling $.state.get while the ui.render hook runs causes the host to automatically establish a subscription to that state. Whenever $.state.set updates the state thereafter, the status band re-renders automatically without needing a manual call to $.ui.invalidate.

Step 4: The Complete Assembly — Weather Mapping, Sparklines, and Trend Display

Now let's assemble all the logic. Below is the complete source code for hooks/token-weather.mjs:

// 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" },
];

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($);
    }
    return result;
  });

  on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
    const { value: history = [] } = await $.state.get(readings);
    
    // yield to default behavior if a satisfaction survey is active or no readings exist
    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)}` }),
  ];
  
  // responsive layout: show the sparkline and delta only when at least 60 columns are available
  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 });
}

// build a sparkline for the last 12 turns using single Unicode block characters
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("");
}

// calculate the token delta from the previous turn
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 Engineering Details Worth Learning from This Code:

  1. Extracting component properties from e.props: e.props.hasSurvey informs you whether an official satisfaction survey is currently occupying this band; if so, yield gracefully by returning next(e). e.props.bodyColumns represents the actual available column width of this line in the terminal (for instance, when a diff pane is open alongside, the available width narrows).
  2. Always call return next(e) when rendering nothing: Do not return null or empty nodes. Calling next(e) hands control back to other plugins and official default behavior.
  3. Stick to single-width characters and avoid emojis: The code uses ☀ ☁ ☂ ☇ ↯ and ▁▂▃▄▅▆▇█. In terminals, complex emojis frequently cause double-width mismatches, glyph clipping, or cursor misalignments, whereas standard single-width characters align predictably across all monospaced terminal fonts.

Step 5: Plugin Validation and Automated Testing

Claude Code provides plugin validation and testing commands to inspect declarations, invocations, and actual runtime behavior.

Static Validation: claude plugin validate

Run claude plugin validate ./token-weather, which performs a static scan of your module:

$ 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

It clearly outlines which events you listen to, which APIs you call, what state you read or write, and whether that state is declared in a TypeScript type contract.

Unit Testing: claude plugin test

Mods support automated tests. Test scripts execute directly inside the real Claude Code runtime environment. Hooks registered via on in tests automatically queue behind your Mod, making them ideal for mocking host API return values:

// tests/token-weather.test.ts
import { describe, expect, test } from "claude-code/testing";

describe("token-weather", () => {
  test("weather band updates correctly as context grows", async ($, on) => {
    let tokens = 36_100;
    // mock the session environment and token usage
    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);

    // initial state: 36.1k / 200k (18%), should display Clear
    expect(await ui.find({ type: "Text", text: /Clear/ })).toBeDefined();

    // simulate a second turn: tokens increase to 134.4k (67%)
    tokens = 134_400;
    await $.turn.complete({ reason: "answer", answer: "ok", durationMs: 1 } as any);

    // verify reactive UI updates without manually calling invalidate
    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();
  });
});

In the official example test, claude plugin test ./token-weather verifies that the status band above the prompt correctly updates text as tokens grow and turns complete. It also validates the automatic re-rendering mechanism from Step 3: after turn.complete fires, the interface updates without the Mod having to request a redraw manually.

Step 6: Sharing and Installation

Because a Mod is a standard Claude Code plugin, distribution works exactly the same way. You can create a directory or Git repository containing .claude-plugin/marketplace.json to act as a marketplace:

{
  "name": "my-mods",
  "owner": { "name": "You" },
  "plugins": [{ "name": "token-weather", "source": "./token-weather" }]
}

For a local plugin directory, add the marketplace and install via the CLI:

claude plugin marketplace add ./my-mods
claude plugin install token-weather@my-mods --scope user

If the Mod and marketplace configuration are hosted in a GitHub repository, other users can install them in a Claude Code session with three commands:

/plugin marketplace add your-org/my-mods
/plugin install token-weather@my-mods
/reload-plugins

Once reloaded, the Mod takes effect; if it does not display properly, restart Claude Code.

The Mods API may evolve across releases. Each time Claude Code loads a Mod, it writes the type definitions corresponding to the current version into .claude-plugin/types/, which should serve as the source of truth for local coding and validation. In addition, plugins containing Mods can be submitted to the official directory at claude.ai/directory/manage.

3. Advanced Walkthrough: What Else Can You Do with the Two Official Advanced Mods?

Token Weather is merely a lightweight, "read-only and render" Mod. The official tutorial proceeds to showcase two significantly more capable Mods that demonstrate event interception, modal dialogs, and complex data flows.

Case 1: Blast Radius (Dry-Running and Intercepting High-Risk Commands)

When Claude prepares to invoke Bash for rm -rf, git reset --hard, git clean, force pushes, or database migrations, Blast Radius pauses the call. It first inspects which files the command will touch, then opens a confirmation pane presenting Proceed and Cancel options. Pressing 2 returns a denial reason to Claude, while pressing 1 allows the command to execute.

Blast Radius terminal demo: Intercepts rm -rf build, lists the 9 files (1.1 MB) that would be deleted, rejects execution on Cancel, and allows execution when Proceed is selected on a second attempt.

This Mod registers three hooks: tool.call targeting Bash, plus ui.render targeting Pane and AbovePrompt. Its core logic belongs to the "Answer" mechanism mentioned earlier:

on("tool.call", { tool: "Bash" }, async ($, e, next) => {
  const risk = classify(String(e.command ?? ""));
  if (risk === null) return next(e); // pass through commands not recognized by the classifier

  const report = await measure($, risk, await $.session.cwd()); // dry-run: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"; // terminal too narrow to dock a side pane: fall back to rendering above the prompt

  while (held.decision === null && !next.signal.aborted) {
    await $.process.run(["sleep", "0.25"]); // wait for user input
  }
  if (held.decision === "proceed") return next(e); // user confirmed: allow execution
  return { deny: `Blast Radius held this command: the user pressed Cancel. It would have: ${report.summary}.` };
});

Core Mechanisms and Design Considerations:

  1. Running dry-runs with $.process.run using argument arrays: Impact reports come from the underlying tools' own dry-run commands (e.g., git status --porcelain, git clean -n, git log HEAD..origin/main, showmigrations). Arguments are passed as an argv array, so path contents are not parsed as Shell code.
  2. How wait timeouts are calculated: Each hook dispatch has a 10-second execution time limit, but time spent waiting inside $ API calls (such as the sleep process here) does not count against the hook's own limit. The loop waits until a button triggers a decision or exits if next.signal.aborted fires (e.g., when the user presses Esc).
  3. Buttons support multiple interaction methods: Button({ label: "Proceed", hotkey: "1", onPress }) supports mouse clicks, pressing Enter when focused via Tab, or pressing hotkeys 1 and 2 directly.
  4. Graceful fallback to rendering above the prompt: When terminal width permits, the pane docks to the right of the transcript. When $.ui.open returns isPlaced: false (for example, in a 120-column terminal where width is insufficient for a side pane), the identical report falls back to render above the prompt.
Blast Radius expanding above the prompt in a 120-column terminal
Blast Radius falling back to render above the prompt in a 120-column terminal: Showing 2 uncommitted files that would be discarded by git reset --hard, with numbered Proceed and Cancel options.

Blast Radius illustrates command impact beforehand and leaves the decision to the user. Because it relies on command text matching, constructs like $(…), shell aliases, or scripts that internally invoke rm can bypass this check. For operations that must be strictly prevented, use Claude Code's native Permission Rules instead.

Case 2: Replay Theater (Step-by-Step Review of Single-Turn Changes)

During a single turn, Replay Theater logs every file touched by Edit and Write tool calls, along with before-and-after text. When the turn completes, a hint appears above the prompt; pressing r (or entering /replay) opens a pane to step through the diff of each edit. The pane provides a numbered step counter alongside Prev, Next, and Close buttons.

This tool is strictly for replaying and reviewing changes; it does not perform rollbacks, and closing the pane leaves all files untouched.

Replay Theater terminal demo: During a rename operation touching 5 edits across 3 files (greet.js, main.js, and README.md, renaming greet to welcome while preserving greetAll), a prompt appears at the end of the turn to step through the diff, keeping files untouched upon closing.

It never blocks or alters edits, operating purely as an Observe hook:

on("tool.call", async ($, e, next) => {
  if (EDIT_TOOLS.has(e.tool)) state.pending.push(...(await stepsFor($, e))); // generate a diff from old/new text
  return next(e);                                                             // execute edits unchanged
});

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;       // create one replay group 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" }));

Core Mechanisms and Implementation Details:

  • Paired events structure the lifecycle: turn.start and turn.complete scope edits to a single turn, while checking !e.agentId filters out internal turns generated by sub-Agents.
  • Registering slash commands: session.start registers commands via $.command.register, which are then handled inside the command.run hook.
  • Reading original contents prior to write: For Write operations, reading old contents via $.fs.read before the write occurs allows computing accurate diffs.
  • Layout driven by host environment: In full-screen terminals, the pane docks to the right; in an 80-column terminal, it opens inline above the prompt, with the Mod returning the exact same component tree in both scenarios.
Replay Theater expanding inline in an 80-column terminal
Replay Theater inline display above the prompt in an 80-column terminal: Currently at step 2/5, showing the single-line diff in greet.js renaming greet to welcome, alongside Prev, Next, and Close buttons.

4. Four Habits Worth Adopting When Writing Mods

Based on official implementations, four development habits are well worth adopting:

  1. Consult system-generated type definitions: Whenever Claude Code loads a Mod, it writes type declarations for the current build into .claude-plugin/types/. Event fields, API methods on $, and properties supported by UI elements can all be inspected directly there.
  2. Retrieve component properties from e.props: In a ui.render event, only e.component, e.surface, e.requestId, and e.viewport live at the top level of e; specific layout and contextual parameters (such as bodyColumns and hasSurvey) reside in e.props.
  3. Plan data storage around hot reloading: Every save that triggers a hot reload causes register and session.start to re-execute. Data that needs to persist across reloads within a session should be stored in $.state, rather than solely in top-level module variables.
  4. Check debug logs when UI fails to render: If components fail to render as expected, launch with claude --debug to inspect logs and diagnose whether the returned component tree failed validation.

5. Extension Ideas and Security Considerations

Token Weather, Blast Radius, and Replay Theater address three concrete concerns: how much context has been used, what high-risk commands are about to touch, and what code changed during the previous turn. In practice, the official documentation suggests several promising directions for further exploration:

  • Cost and usage monitoring: Combine $.session.usage() with $.ui.status to display ongoing spend or rate limits in the status line;
  • Prompt convention injection: Use the prompt.submit hook to attach team conventions before prompts are submitted;
  • Read files list: Display files read during the session in a pane to provide an intuitive context map;
  • Long turn completion alerts: Use $.ui.toast to display toast notifications when time-consuming turns conclude;
  • Command invocation guards: Tailor specific tool.call interception rules for production contexts (such as kubectl production cluster configs or terraform apply).

Team Uses and Centralized Governance

Beyond individual developer productivity, Mods also provide unified standards and security oversight for team and enterprise engineering:

  • CI/CD status display: Render live build statuses in a conversation side pane, updating dynamically on success or failure without switching to a web browser;
  • Pre-execution confirmation for production configs: Insert a secondary confirmation pane before commands that touch production configurations, letting operators inspect impacts before deciding to proceed;
  • Auditing calls by loading Mods first: Because the first Mod loaded in the event chain receives events earliest and sees results latest, teams can position an auditing Mod first to log calls made by other Mods.

At the organizational level, because Mods are packaged and distributed inside standard plugins, existing enterprise plugin management rules apply seamlessly:

  • Admin console and managed settings: On Team and Enterprise plans, organization owners can allow or block plugin marketplaces globally via the admin console; for Claude API or third-party API setups, administrators can enforce centralized governance by deploying managed settings to user devices;
  • Built-in security default Mod (sec-default): On Team and Enterprise plans, as well as any machine with managed settings applied, the system loads a built-in Mod named sec-default (security default) first by default. Its primary role is constraining user-installed Mods from executing unauthorized high-risk operations, such as preventing user Mods from overriding enterprise permission deny rules;
  • Preservation requirement when administrators customize lists: Administrators may also configure custom enterprise Mods to load first for internal governance; however, if administrators specify a custom priority list, they must explicitly retain sec-default in the list to preserve its guardrails.

Security and Trust Boundaries

Understanding Mod security requires distinguishing between the runtime environment and system permissions:

At runtime, a Mod executes without a browser DOM or Node.js global objects; all external interactions must use the APIs on the $ object. However, this runtime environment isolation does not imply that access to the local machine is sandboxed—Mods run on the local machine with the exact same system permissions as Claude Code itself.

Therefore, installing third-party Mods requires the same caution as installing software packages: code is authored by publishers, so review repository source code prior to installation and only install Mods from trusted sources. External Mods are never automatically loaded without an explicit installation command.

At the same time, this does not mean Mods operate without organizational oversight. In enterprise and team environments, administrators can disable untrusted marketplaces via the admin console, distribute policies through managed settings, and enforce guardrails and audit trails using first-in-line sec-default and auditing Mods.

A Mod can begin with a single concrete need: keeping frequently checked readings within reach, previewing the impact before a command runs, or reviewing modifications on your own terms. Events tell it when to act, state remembers data, and UI lets you see and interact.

Source
Getting started with Claude Code modsAddy Osmani / Anthropic·Read the source
↑ End of article
✓ This article is free to read in full

Members can copy or download the full Markdown, and let their AI subscribe and ask.

Read the original ↗
📮 New explainers, straight to your inbox

Found this useful? Leave your email — every new explainer, the moment it's out. Unsubscribe anytime.