Tutorials

Write your first mod

In this tutorial you build bash-logger, a mod that shows each Bash command Claude runs in the status line. Three small files, a few commands to check it, and it is ready to publish.

3 min read

What you need

  • Claude Code 2.1.287 or later, which is the first version with function hooks. Check with claude --version.
  • A terminal and an editor. No build step and no package.json: Claude Code loads the TypeScript module itself.

By the end you will have this folder:

text
bash-logger/
  .claude-plugin/plugin.json
  hooks/hooks.json
  hooks/register.tsx

Step 1: the manifest

Every Claude Code plugin has a manifest. Create the folder and .claude-plugin/plugin.json:

bash
mkdir -p bash-logger/.claude-plugin bash-logger/hooks
json
{
  "name": "bash-logger",
  "version": "0.1.0",
  "description": "Shows each Bash command Claude runs in the status line"
}

The name uses lowercase letters, digits and hyphens, and the version is x.y.z. Both rules matter later: Modyard rejects anything else when you publish.

Step 2: declare the modules

hooks/hooks.json tells Claude Code which modules to load. For function hooks it lists them under modules, with paths relative to the hooks folder:

json
{ "modules": ["./register.tsx"] }

A mod can have several modules. Keep one for now.

Step 3: the register function

hooks/register.tsx exports a function named register. Claude Code calls it once, when the plugin loads, and passes it on, which you use to attach handlers to events, and options, the plugin's options:

tsx
import type { Register } from 'claude-code'

export const register: Register = (on) => {
  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
    $.ui.status(`Bash: ${e.command.slice(0, 40)}`)
    return next(e)
  })
}

Line by line:

  • import type { Register } from 'claude-code' brings in the type only, so your editor knows the shape of on, $ and e. Nothing is imported at run time.
  • on('tool.call', { tool: 'Bash' }, handler) attaches a handler to the tool.call event. The second argument is the matcher: only calls to the Bash tool reach this handler. It is optional; without it, the handler sees every tool call.
  • The handler receives three things. $ is the engine interface, a set of nouns such as $.ui, $.fs, $.http or $.state. e is the event input: for tool.call, the tool and its input. next passes the event on.
  • $.ui.status(...) writes to the status line.
  • return next(e) runs the plugins beneath this one and then Claude Code's own behavior, which here is running the command. Always call next unless you mean to answer yourself.

Step 4: answering for yourself

Returning without calling next stops the chain. For tool.call, returning { deny: 'reason' } blocks the call and tells Claude why. Calling next with a changed event rewrites it on the way through. Try both in the same handler:

tsx
import type { Register } from 'claude-code'

export const register: Register = (on) => {
  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
    const command = e.command
    $.ui.status(`Bash: ${command.slice(0, 40)}`)
    if (command.includes('rm -rf /')) return { deny: 'bash-logger refuses rm -rf /' }
    if (command.startsWith('ls')) return next({ ...e, command: `${command} -la` })
    return next(e)
  })
}

Other events work the same way: prompt.submit when you send a prompt, session.start when a session opens, ui.render when the interface draws, command.run for slash commands. Keep bash-logger to its one job for now; the first version without the extra branches is the one we publish below.

Step 5: validate and test

Two commands check the plugin before anyone else sees it:

bash
claude plugin validate ./bash-logger
claude plugin test ./bash-logger

validate checks the manifest, the hooks file and that every module exists. test loads the modules and runs them through Claude Code's checks. Fix whatever they report before you go on.

Step 6: try it in a real session

Load the folder for one session, without installing it:

bash
claude --plugin-dir ./bash-logger

Ask Claude to list the files in the current folder. When it runs ls, the status line shows Bash: ls. Edit register.tsx, start a new session, and try again until you are happy with it.

Step 7: look at what it touches

Before publishing, ask yourself what someone reading your mod will see. This module uses a tool.call hook and $.ui, so Modyard will list tool calls and display. If you had added a request with $.http, network would appear too. What a mod can touch explains every category. A short list that matches the description is what earns trust.

Step 8: zip it

Zip the folder. Files at the root of the zip or inside one wrapping folder both work:

bash
zip -r bash-logger.zip bash-logger

Limits to keep in mind: 2 MB compressed, 10 MB unpacked, 500 files.

Step 9: publish

Sign in with Google; your first sign-in creates your publisher, say ana. Then pick one way:

  • Web: open publish and drop bash-logger.zip. Check the touch list and confirm.
  • curl: create a read and write key in settings and run:
bash
curl -X POST https://mods.gonzaloverdugo.com/api/v1/mods \
  -H "Authorization: Bearer mdy_..." \
  -F archive=@bash-logger.zip

Mod names are unique across Modyard, so if bash-logger is taken, change name in plugin.json and zip again. Publish a mod covers every rule and what happens after.

Step 10: install it like anyone else

Your publisher is now a marketplace. Install your own mod the way your users will:

text
/plugin marketplace add https://mods.gonzaloverdugo.com/publishers/ana/marketplace.json
bash
claude plugin install bash-logger@ana --scope user

To ship a change, raise version to 0.1.1, zip and publish again. Versions are immutable, so each fix is a new one, and your users get it with /plugin marketplace update ana.

Your first mod, installed in two commands

Sign in with Google and you get a publisher with its own marketplace. Upload a zip, read what Modyard found in it, and share the install command.