Claude Code mods make the harness programmable
Claude Code mods are TypeScript plugins that load into the running session and sit on every tool call, prompt and piece of the screen. Because they run in the session, they can answer a call themselves, edit the system prompt or draw a pane, which a settings hook running as a separate process can't.
Connect Clawnify
A mod runs inside the engine
Claude Code mods are small TypeScript plugins that load into a running Claude Code session. Each one hooks the engine's own events: a tool call, a submitted prompt, the system prompt being assembled, a piece of the screen being drawn. A hook can let the event through, change it on the way, or answer it without the engine. They're marked early access in Claude Code 2.1.287, the build we wrote this against. There's no public doc page for them yet, so the reference is the type declaration file the CLI writes for each build.
A mod is three files. .claude-plugin/plugin.json names it, hooks/hooks.json points at one module, and that module exports register. Every hook has the same shape, ($, e, next). e is the event's input. next(e) passes it on to the other mods and then to Claude Code's own behaviour. $ is everything else the mod can reach. The module runs in an environment of its own with no Node and no DOM, so a file read, a shell command or a network call goes through $ as well.
export const register: Register = on => {
on('tool.call', { tool: 'Edit' }, ($, e, next) =>
e.file_path.endsWith('.env')
? { deny: 'Edit .env by hand.' }
: next(e),
)
}
That's a complete mod, apart from the two JSON files. Returning { deny } refuses the edit, and the model reads the reason as the tool's error. Calling next({ ...e, file_path }) would rewrite the call instead, and await next(e) would let it run and then look at the result.
Claude Code already had hooks, configured in settings and run as shell commands. Mods keep them reachable, as classic.PreToolUse, classic.Stop and the rest, and change where the code runs:
| Command hooks | Mods | |
|---|---|---|
| Runs as | a process per event | a function in the session |
| Talks to Claude Code | JSON in, JSON out | the event itself |
| Can do with a tool call | allow, deny, ask, new input | all of that, or answer it |
| Reaches | tool and session events | also prompts, rendering, commands |
| Draws | nothing | panes, bands, status, toasts |
| Remembers | whatever it writes to disk | session state and a store |
Every harness ships an agent loop, and the part worth owning is what goes into each turn. Mods let you put code at exactly that point: in the system prompt as it's assembled, in each row before it's stored, in the call itself.
More than forty events, and a noun for everything else
The declaration file for 2.1.287 lists more than forty events. They group into a handful of families:
| Family | Events | What a mod does there |
|---|---|---|
| Tools | tool.call, tool.describe | block, rewrite or wrap any call, MCP tools included |
| Prompt | prompt.submit, prompt.compose | rewrite what was typed, edit system prompt sections |
| Session | session.append, session.compact | see every stored row, redact it |
| Turn | turn.step, turn.complete | stream each model request |
| Screen | ui.render, ui.press | draw, and handle buttons |
| Commands | command.run, agent.spawn | add slash commands and subagent types |
| Text | attribution.text | set the attribution lines on commits and PRs |
| Classic | classic.Stop and the rest | everything a settings hook sees |
session.append is the one with the widest reach. Every row the conversation keeps passes through it once before it's stored: your prompt, each block of the model's reply, every tool result. A mod can take a secret out of the transcript before it's written anywhere.
The other half is $, about twenty nouns, each a family of calls:
| Noun | What it reaches |
|---|---|
$.ui | panes, the status line, toasts, a question |
$.model | a completion, a fork, a classifier |
$.tool, $.agent, $.command | tools, subagents and commands the mod declares |
$.mcp | any connected MCP server's tools |
$.fs, $.process, $.http | files, host commands, HTTP |
$.state, $.store, $.clock | values that survive a reload, values across sessions, timers |
$.session, $.prompt | the transcript, the prompt box |
$.audio | a sound file, or text spoken aloud |
$.model.fork is worth knowing about. It asks one question over the session's own transcript with the same model and system prompt, so the prefix is served from the prompt cache, and a hook can cheaply ask "did the last turn touch anything outside this folder?"
Four places a mod can draw
A mod draws by hooking ui.render for a named component and returning a tree. There are four places to put one:
- A pane, a framed region docked beside the transcript. Opened by something the person did, a command or a button, it appears at any width. Opened by the mod on its own, it waits for a terminal at least 144 columns wide.
- A band above the prompt, a row or two that can carry buttons.
- A status line entry, short text that stays at the bottom under the mod's name.
- A toast, a notice that shows for a few seconds and goes.
The tree is built from the drawing surface's own elements, Box, Text, Button, Input, Markdown and a few more, and a hook gets told which surface it's drawing on: the terminal, the desktop app, VS Code or mobile. The terminal adds Raster for grids of coloured cells, a sparkline or a heat map, and Image, which shows a real picture on terminals that speak the kitty graphics protocol, such as kitty and Ghostty.
Buttons keep their handlers in the mod. A press runs a function in the module, which can write a file, fill the prompt box or start a new turn.
Three mods we run
We built three mods in one working session, all now loaded in every session we run:
| Mod | Hooks | What it does |
|---|---|---|
| Git identity guard | tool.call on Bash | refuses commits under another identity |
| Em dash guard | tool.call on writes | refuses em dashes a change adds |
| Sidenotes pane | ui.render, command.run | shows parked notes, with two buttons |
The first exists because of a rule that kept breaking. Our agent instructions said, in bold, never to override the configured git identity when committing. Within twelve days an agent did it three times, passing another identity on the command line: once caught before the push, twice pushed to public repositories. An instruction is something the model can forget, and three times it did. The hook refuses the command before it reaches the shell. The guard parses the command, follows it into bash -c strings and command substitutions, and leaves a commit message free to mention the flags. It's there for accidents. Someone set on getting round it still can, with a flag hidden in a variable for instance.
The second enforces a house rule, no em dashes in anything we publish, and it's why this article has none. It counts dashes before and after each change and refuses only the ones a change adds, so a file that already has some can still be edited. It refused the first version of the script that built this page, because the script's own dash check contained the character.
The third is the visual one. We park passing thoughts in a notes file inside the repository's .git folder, shared by every worktree, and nobody saw them unless they asked. The mod keeps a count in the status line and opens a pane listing each note: Pick up puts it in the prompt box, Done ticks it off in the file. The pane rereads the file every five seconds, so a note parked from another session shows up on its own.
Together they come to about 460 lines of code and 230 of tests. claude plugin test runs the tests against the engine itself, so the guard tests drive real tool calls through the hooks and the pane test mounts it on the terminal and the desktop surface.
From one session to every session
A mod Claude Code writes for you starts in a folder that belongs to one session. Making it permanent took us six steps:
| # | What you do | Where it stops |
|---|---|---|
| 1 | Ask Claude Code for the mod | it writes to the session's mods folder |
| 2 | Answer "Enable hot reloading for this session?" | nothing loads until you do |
| 3 | Run claude plugin validate | reads the source as the engine will |
| 4 | Run claude plugin test | tests run against the real engine |
| 5 | Move the folder somewhere you keep | a dotfiles repo works |
| 6 | List it in CLAUDE_CODE_PLUGIN_DIRS | user settings only, not a project's |
The variable goes in the env block of ~/.claude/settings.json, paths separated by colons, and every new session loads the mods from there. A folder loaded that way reloads when its files change, so editing a mod in place updates it without a restart.
What's still early:
- The API is early access and moves between releases. The CLI rewrites its declaration file with each build, so check any example against it. One written for an older build may not load.
- The validator traces every use of
$. A helper that receives it has to be a function declared at the top of the file. Our first draft of the pane failed on exactly that. - Every hook runs inside a time budget. Work meant to outlive one event belongs on a timer started from
session.start. - A guard can only refuse what hasn't happened yet, and the model's reply is already on screen by the time any hook could object.