Resources · 2 Oct 2026

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 hooksMods
Runs asa process per eventa function in the session
Talks to Claude CodeJSON in, JSON outthe event itself
Can do with a tool callallow, deny, ask, new inputall of that, or answer it
Reachestool and session eventsalso prompts, rendering, commands
Drawsnothingpanes, bands, status, toasts
Rememberswhatever it writes to disksession 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.

A tool call passes through every mod before Claude Code runs itModelasks to run BashMod Agit identity guardMod Bem dash guardClaude Codechecks, then runs{ deny }the model reads whyEach arrow is a call to next(e). The resultreturns up the same chain, so a mod canalso act after the tool has run.The two mods named are ones we run, described below.

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:

FamilyEventsWhat a mod does there
Toolstool.call, tool.describeblock, rewrite or wrap any call, MCP tools included
Promptprompt.submit, prompt.composerewrite what was typed, edit system prompt sections
Sessionsession.append, session.compactsee every stored row, redact it
Turnturn.step, turn.completestream each model request
Screenui.render, ui.pressdraw, and handle buttons
Commandscommand.run, agent.spawnadd slash commands and subagent types
Textattribution.textset the attribution lines on commits and PRs
Classicclassic.Stop and the resteverything 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:

NounWhat it reaches
$.uipanes, the status line, toasts, a question
$.modela completion, a fork, a classifier
$.tool, $.agent, $.commandtools, subagents and commands the mod declares
$.mcpany connected MCP server's tools
$.fs, $.process, $.httpfiles, host commands, HTTP
$.state, $.store, $.clockvalues that survive a reload, values across sessions, timers
$.session, $.promptthe transcript, the prompt box
$.audioa 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.

Four places a mod can draw in the terminal● Bash git -c user.email=… commitgit-identity-guard: refused, the commitidentity is already configured.That turn took 131stoast3 open2026-09-30 Rate-limit theexport endpoint↳ billing PR · api/export.ts[ Pick up ] [ Done ]2026-10-01 Try the new imagemodel on covers↳ cover pass · art/[ Pick up ] [ Done ]…pane, opened by /sidenotesLast turn: 42s, 6 tool calls [ Hide ]band❯sidenotes: 3 openstatus lineSimplified recreation of the layout. The notes and the turn are illustrative.

Three mods we run

We built three mods in one working session, all now loaded in every session we run:

ModHooksWhat it does
Git identity guardtool.call on Bashrefuses commits under another identity
Em dash guardtool.call on writesrefuses em dashes a change adds
Sidenotes paneui.render, command.runshows 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 doWhere it stops
1Ask Claude Code for the modit writes to the session's mods folder
2Answer "Enable hot reloading for this session?"nothing loads until you do
3Run claude plugin validatereads the source as the engine will
4Run claude plugin testtests run against the real engine
5Move the folder somewhere you keepa dotfiles repo works
6List it in CLAUDE_CODE_PLUGIN_DIRSuser 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.