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:
bash-logger/
.claude-plugin/plugin.json
hooks/hooks.json
hooks/register.tsxStep 1: the manifest
Every Claude Code plugin has a manifest. Create the folder and .claude-plugin/plugin.json:
mkdir -p bash-logger/.claude-plugin bash-logger/hooks{
"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:
{ "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:
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 ofon,$ande. Nothing is imported at run time.on('tool.call', { tool: 'Bash' }, handler)attaches a handler to thetool.callevent. The second argument is the matcher: only calls to theBashtool 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,$.httpor$.state.eis the event input: fortool.call, the tool and itsinput.nextpasses 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 callnextunless 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:
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:
claude plugin validate ./bash-logger
claude plugin test ./bash-loggervalidate 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:
claude --plugin-dir ./bash-loggerAsk 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:
zip -r bash-logger.zip bash-loggerLimits 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:
curl -X POST https://mods.gonzaloverdugo.com/api/v1/mods \
-H "Authorization: Bearer mdy_..." \
-F archive=@bash-logger.zip- An agent: connect Claude Code to Modyard's MCP and ask it to publish; see use Modyard from an agent.
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:
/plugin marketplace add https://mods.gonzaloverdugo.com/publishers/ana/marketplace.jsonclaude plugin install bash-logger@ana --scope userTo 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.