DEV Community

Haruki Nakada
Haruki Nakada

Posted on

How to make a Claude Code mod with three files that work in the desktop app

You can build a Claude Code mod with just three files: .claude-plugin/plugin.json, hooks/hooks.json, and hooks/register.tsx. No Node.js and no build step. Claude Code loads .js, .ts, and .tsx files directly. To draw differently in the desktop app and the terminal, branch on e.surface === 'desktop'.

File layout

hello-band/
├── .claude-plugin/
│   └── plugin.json
└── hooks/
    ├── hooks.json
    └── register.tsx
Enter fullscreen mode Exit fullscreen mode
File Contents
.claude-plugin/plugin.json The plugin's name and version. Nothing extra is needed for a mod
hooks/hooks.json Lists one file to load under modules. This is what turns a plugin into a mod
hooks/register.tsx The main code. Exports register(on)

The main file can be any of .js, .mjs, .cjs, .jsx, .ts, .mts, .cts, or .tsx, written as an ES module (see the Files table in the official reference). This series uses register.tsx, but register.js would have the same contents.

1. plugin.json

{
  "name": "hello-band",
  "version": "0.1.0",
  "description": "A minimal mod that shows one line above the prompt",
  "author": { "name": "Your Name" }
}
Enter fullscreen mode Exit fullscreen mode

Pick a name you do not plan to change. People installing from a marketplace use hello-band@<marketplace-name>, so renaming makes it a different plugin (Part 9). Names starting with claude- look like Anthropic's own and are rejected by claude plugin validate.

2. hooks/hooks.json

{
  "modules": ["./register.tsx"]
}
Enter fullscreen mode Exit fullscreen mode

The path is relative to hooks.json, and you list exactly one file. If modules is missing or misspelled, validation still passes but the plugin is not loaded as a mod (Part 5).

3. hooks/register.tsx

This mod shows one line in the strip above the prompt (the band), with different text in the desktop app and the terminal.

// How many prompts have been sent. Shared between hooks
let sent = 0

export function register(on) {
  // Count every time a prompt is sent
  on('prompt.submit', async ($, e, next) => {
    sent += 1
    // Ask for a redraw
    $.ui.invalidate('ui.render')
    return next(e)
  })

  // Draw the strip above the prompt
  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
    const { Box, Text } = $.ui.resolve(e)

    // Which app is drawing
    if (e.surface === 'desktop') {
      return Box({
        flexDirection: 'row',
        columnGap: 2,
        children: [
          Text({ color: 'success', children: ['Desktop'] }),
          Text({ dimColor: true, children: [`Sent ${sent}`] }),
        ],
      })
    }

    // Terminal
    return Text({ children: [`[terminal] sent: ${sent}`] })
  })
}
Enter fullscreen mode Exit fullscreen mode

The three arguments

Every function you pass to on receives the same three arguments.

Argument What it is
$ The mods API. Everything that touches the outside world (UI, files, network, processes) goes through it
e The event payload. It is frozen, so to change it you pass a copy to next
next The next step. Calling it runs the later mods and Claude Code's default behavior

return next(e) means "I only looked; pass it through unchanged." If you return a value without calling next, you have answered the event yourself.

Branch on e.surface

e.surface is either 'terminal' or 'desktop'. When you draw differently on desktop and terminal, always branch on this.

The component table returned by $.ui.resolve(e) contains the names of every component. Checking whether t.Svg exists does not tell you whether you are on the desktop. Svg exists only on the desktop and Raster only in the terminal, so if you get the surface wrong, nothing is drawn (Part 6).

Use theme color names

The example uses color: 'success' so the text is readable in both the light and dark desktop themes. A hard-coded hex value like '#7FD1A6' can become unreadable in the light theme (Part 6).

4. Validate what you wrote

Before launching, run the validator from your shell.

claude plugin validate .\hello-band
Enter fullscreen mode Exit fullscreen mode

When it passes, you see lines like these in the output:

  ❯ ./register.tsx hooks: prompt.submit, ui.render{component=AbovePrompt}
  ❯ ./register.tsx calls: $.ui.invalidate, $.ui.resolve

✔ Validation passed
Enter fullscreen mode Exit fullscreen mode

hooks: lists the events you registered, and calls: lists the APIs you use. If an event you meant to register is not under hooks:, Claude Code will not call that function either. A misspelled event name shows up as an error like "tool.calls" is not an event.

(The contents of calls: can differ slightly between versions. The two lines above show the shape.)

5. Load it

Try it in the terminal

In the terminal, --plugin-dir loads the mod for a single session. It reloads every time you save.

claude --plugin-dir .\hello-band
Enter fullscreen mode Exit fullscreen mode

Try it in the desktop app

The desktop app cannot take flags at launch. The official docs describe these routes:

  1. Turn a local folder into a marketplace and install from it. Add a folder containing marketplace.json by local path, for example claude plugin marketplace add .\my-mods, and install from there (Part 9). The terminal, local desktop sessions, and VS Code read the same settings file, so installing at user scope makes the desktop app load it too
  2. The CLAUDE_CODE_PLUGIN_DIRS environment variable. The official reference describes it as folders loaded like --plugin-dir, for apps that cannot take the flag. On Windows, separate entries with ;. You can also put it in env in ~/.claude/settings.json
  3. Have Claude write it. In a session, ask something like "make a mod that shows the current branch above the prompt." Claude writes it with the built-in plugin-authoring skill, and once you approve, it loads in the same session

For route 1, the official loading page says that for a marketplace added by local path, ./ sources are read in place without copying. After editing the source, changes apply at the start of the next session or with /reload-plugins, and you do not need to bump version. (A marketplace added from GitHub installs a copy, so changes do not arrive unless you bump version.)

Whether routes 2 and 3 behave the same way in the desktop app's Code tab is not stated on the official pages, so it is not verified.

If unsure, a safe split is: build with --plugin-dir in the terminal, do the final check in the desktop app. As Part 6 shows, some things break only on the desktop, so always open it there at the end.

Check that it loaded

In the terminal, open /plugin and you will see a faint line under the tabs like 1 mod active · hello-band. On the desktop, if the band shows up, it loaded. If it does not, see Part 5.

Get the type definitions

When you load with --plugin-dir or have Claude write the mod, Claude Code writes the type definitions (.d.ts) for that version into .claude-plugin/types/ inside the mod folder. They list the events, APIs, and components accurately for that version, so when in doubt, trust them over web pages (the official create page says the same).

Rules for writing $

One rule that even a minimal mod can trip over: at load time, Claude Code statically analyzes the source to check how you use $.

  • Write $, the namespace, and the function name in full, like $.store.get(k)
  • const ui = $.ui or const { store } = $ fails
  • You may pass $ only to functions declared at the top level of the same file
  • Write event names as string literals, like 'prompt.submit'

If this check fails, the whole mod is not loaded. Part 5 goes into detail.

Next

You now have one line in the band. The next article in this series opens your own panel next to the conversation: a pane.

To see how other people write the three files, the modscode reviewed list shows each mod's code side by side with its UI.


This article was written with AI assistance (Claude) and checked against the official Claude Code docs; anything marked "not verified" could not be confirmed there.

Top comments (0)