← Blog

2026.10.06 · 13 min · claude-code · ai · javascript · typescript

Claude Code mods are middleware for your agent.

Claude Code now loads mods: plugins made of your own JavaScript or TypeScript that run inside the agent. Every hook gets ($, e, next), the same shape as Express and Koa middleware. What a mod is, the three things a hook can do, how to make a guard fail closed, and what you are trusting when you install one.

Your coding agent is about to run git push --force. You'd rather it didn't, and you'd rather not have to be watching when it tries. Since Claude Code v2.1.287, announced on 2026.10.01, one function can say no for you, before the command runs and without a permission prompt.

That function lives in a mod: a Claude Code plugin made of JavaScript or TypeScript event handlers that run inside Claude Code's own process. If you have written Express or Koa middleware, you already know how it works. Every handler gets ($, e, next), and what it does with next decides what happens to the event.

Watch the 60-second version on Instagram.

PUSHEDMODDENYFIG. 01 / SAME PUSH, TWO RUNS

The figure shows the whole idea. Two lanes, the same tool call on each. On the back lane nothing stands between the call and Claude Code, so the tool runs and the lamp lights. On the front lane a mod stands in the way. It looks at the call, decides it is a force push and answers it: the call goes back, and Claude Code never runs it.

A mod is a plugin with one extra line

A mod is not a new kind of package. It is an ordinary Claude Code plugin with three files:

first-mod/
first-mod/
├── .claude-plugin/
│   └── plugin.json
└── hooks/
    ├── hooks.json
    └── register.js

plugin.json is the usual manifest. hooks.json is where settings-style plugin hooks already live, and one key in it turns the plugin into a mod:

hooks/hooks.json
{
  "modules": ["./register.js"]
}

modules holds one path, relative to hooks.json, to the hooks module: the file with your code. It can be .js, .mjs, .ts, .tsx and a few other extensions, and it has to be an ES module. There is no Node install, no bundler and no build step. Claude Code loads .js and .ts files directly.

The hooks module exports one function, register. Claude Code calls it once when the mod loads and passes it on. Each call to on registers one hook:

hooks/register.js
export function register(on) {
  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
    // your code
    return next(e)
  })
}

on takes three things:

  • The event name, as a string literal: 'tool.call'. Static analysis reads it from your source, so a variable or a loop over names fails validation.
  • An optional matcher: { tool: 'Bash' }. Its fields are compared with the event's. A field can be a value, an array of allowed values or a regular expression, so { tool: /^mcp__github__/ } matches every tool from one MCP server.
  • The handler, which receives $, e and next.

$ is the mods API: every way a mod can reach outside its own code, in namespaces like $.ui, $.fs, $.process, $.store and $.model. e is the event as plain, deeply frozen data, such as a tool call's name and arguments. next passes the event on.

Sound familiar? It's middleware

Put an Express handler next to a mod hook:

same-shape.js
app.use((req, res, next) => next())          // Express
on('tool.call', ($, e, next) => next(e))     // Claude Code mod

The docs say it directly: the handlers for an event form a middleware chain. next(e) calls the next handler, which is another mod's hook or, at the end of the chain, Claude Code's own behaviour, and it resolves to the result. That last part makes it closer to Koa than to Express. In Koa, await next() runs everything downstream and then hands control back, so a middleware can act before and after the rest. A mod hook can do the same: await next(e), look at what came back, then return it or something else.

MOD AMOD BCLAUDE CODEFIG. 02 / ONE CALL, TWO MODS

Here a tool call passes through two mods. Each one does its work and calls next, the call moves on, and at the end of the chain Claude Code runs the tool. Point at a mod to make it answer instead of calling next: the call stops at that gate, the mods after it never see the event and the tool never runs.

Order matters, so it is fixed. The first mod in the chain is outermost: it sees the event before the others and the result after them. Claude Code builds the chain by where each mod comes from:

  1. The built-in security guard and your organization's mods (managed prependPlugins, and other mods that count as the organization's)
  2. Mods you install
  3. Mods your organization lists in appendPlugins
  4. Other mods built into Claude Code

Within one file, hooks run in the order register called on. Among the mods you install, a mod runs before the ones it lists as dependencies.

Three moves: observe, rewrite, answer

Claude Code runs your hook before it acts on the event, so the hook decides what happens next. There are exactly three things it can do, and each one is a different use of next.

Observe: do your work, pass the same event on

This is the example from the overview page. It counts tool calls and shows the count beside the spinner:

hooks/register.js
let calls = 0
 
export function register(on) {
  // Runs each time Claude is about to use a tool
  on('tool.call', async ($, e, next) => {
    calls += 1
    // Ask Claude Code to draw again, so the new count shows
    $.ui.invalidate('ui.render')
    return next(e)
  })
 
  // Runs each time Claude Code draws the spinner
  on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
    return next({ ...e, props: { ...e.props, suffix: ' · tool calls: ' + calls + '…' } })
  })
}

While Claude works, the spinner reads Thinking · tool calls: 1…, then 2…, then 3…. Two things to notice. The two hooks share calls, a plain module variable, which is something settings hooks can't do: each of those is a separate process. And $.ui.invalidate('ui.render') is required. Claude Code redraws when a site's props change or the terminal resizes. It can't see your variables change.

Rewrite: pass a changed copy

The second hook above is already a rewrite. Events are frozen, and assigning to a field throws, so to change one you pass a copy to next:

trim-prompt.js
on('prompt.submit', async ($, e, next) => {
  return next({ ...e, text: e.text.trim() })
})

Later handlers and Claude Code see the trimmed prompt and never the original. You can rewrite the result too: await next(e), then return a copy with a field replaced.

The frozen event is the design choice I like most here. In Express, req is a mutable object every middleware can scribble on, and "who set req.user to that?" is a classic afternoon. Here a change is always a new object handed explicitly down the chain, so the only way to affect what happens next is through next.

Answer: return without calling next

To handle the event yourself, return a result and don't call next. The chain stops there: later mods and Claude Code's own behaviour don't run. This is the guard from the docs:

hooks/register.js
on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
  if (/git push .*--force/.test(e.command)) {
    // No next: the command never runs
    return { deny: 'Force pushes are not allowed in this repository. Push to a new branch instead.' }
  }
  return next(e)
})

When Claude tries git push --force, the command doesn't run, no permission prompt appears, and Claude reads the deny text as the tool's result. So write that text as an instruction Claude can act on, not as an error code. "Push to a new branch instead" gets you a new branch. "Denied" gets you a confused retry.

A tool.call hook has more options than allow or deny. It can return { result } to answer in the tool's place without running it, call next(e) a second time to retry a failed call, or await a question to the user with $.ui.ask before deciding.

tool.call or tool.check

There are two places to stop a tool call, and they answer different questions:

tool.calltool.check
FiresWhen a tool is about to runWhen Claude Code decides whether the call may run, after the permission rules and settings hooks
next(e) gives youThe tool's result (after the permission check and the tool ran)The decision so far: allow, ask or deny
Returnnext(e), { deny } or { result }{ decision }, with an optional reason
Good forRefusing, rewriting or faking a call; acting on its resultDecisions that depend on state right now, such as the current Git branch

For a fixed command or path, the docs point you at a plain permission rule such as Bash(npm test), which needs no code. Reach for tool.check when the answer depends on something a rule can't see.

Both are text matching, not security. The guard above catches git push --force and misses git push -f. The docs say it plainly about their own branch example: treat it as a reminder for Claude, and protect the branch on your Git host if it really matters.

Make a guard fail closed

This is the pitfall I'd tell anyone first. When a hook throws, times out (the limit is 10 seconds of the hook's own time) or returns the wrong shape before calling next, Claude Code skips it and the next handler runs in its place. For an observer that is the right default. For a guard it means a bug in your regex code lets the force push through.

on returns a registration with a .catch method, and the handler you give it answers when the hook fails:

hooks/register.js
on('tool.call', { tool: 'Bash' }, guard).catch(async ($, e, next) => {
  // guard already called next: keep what came back
  if (next.called) return next(e)
  // guard failed before deciding: refuse
  return { deny: 'The command guard failed, so this command was not run: ' + next.error.kind }
})

next.called tells you whether the hook got as far as next. If it did, next(e) returns the result it already produced without running the tool again. If not, you refuse. The handler has 1 second of its own. claude plugin validate flags any gating hook without one (gating hook without .catch: tool.call), which is a nice touch.

Two smaller ones from the docs, so you don't find them the slow way:

  • Write every $ call out in full. $.ui.log(…), never const ui = $.ui or const { ui } = $. Validation reads your source to list what the mod does, and an alias hides it.
  • Don't name the plugin claude-something. Validation fails names that look like Anthropic's own.

A mod can draw

Settings hooks, skills and MCP servers work from outside Claude Code. A mod runs inside it, so it can draw. In a terminal session it can open a pane (a sidebar beside the transcript in a wide fullscreen terminal, a framed region above the prompt otherwise), fill the band above the prompt, show a toast (4 seconds unless you pass a timeout), add dim log lines to the transcript, and set a status line under the prompt. It can also restyle or replace parts Claude Code draws itself: the spinner, tool call rows, messages, and the dialog Claude uses to ask you a question.

What it can't touch is the permission prompt. That isn't a render site, so no mod can change what it shows you.

A ui.render hook returns an element tree built from Box, Text, Button, Input, Select, Markdown, Code and a few more. Raster (a grid of coloured cells, for heat maps and games) is terminal only, Svg is Desktop only. Where you keep a value decides how long it lives:

Keep it inIt lasts until
A module variableThe module reloads, which is every save during development
$.stateThe session ends or you run /clear, /resume or /branch. Reactive: writing it redraws what read it.
$.storeYour mod deletes it. Persistent JSON shared by every session on the machine, 4 MiB in total.

Panes and bands don't appear in the VS Code extension's chat panel or with claude -p, but hooks still run there. So a guard works everywhere a plugin loads, and a dashboard only where something draws.

The first mod I want to build is exactly that dashboard: a pane that shows what each agent is working on, lets me pause one or ask it for a progress update, and keeps evidence of what has actually been built.

Mods, settings hooks, skills, MCP

They overlap, and the docs include a comparison worth keeping. My short version:

PickWhen
A permission ruleThe decision is a fixed command or path
A settings hookYou want to block, allow or log an event with a script you already have
A skillYou keep pasting the same instructions into chat
An MCP serverClaude needs to reach an external system
A modYou want a pane, a command that runs your code with no Claude turn, state shared between hooks, or to rewrite an event

A plugin can carry all of them at once, so a mod can ship next to the skill and the MCP server it works with.

Mods are not sandboxed

This is the part to read twice. A mod is code that runs with your permissions. Once it loads, it can read and write any file your user can, start processes, make network requests, read your environment variables and settings (including API keys), see every prompt and tool call, rewrite them, approve a tool call before you're asked, and spend your usage on model calls. If you turn on Claude Code's sandboxing, it isolates the Bash commands Claude runs. A process a mod starts runs outside it.

So treat a mod like any dependency with install scripts: install from authors and marketplaces you trust, and read it first. Claude Code makes that easier than usual. Because a hook can only reach the outside world through $, and every $ call is written out in full, claude plugin validate can list what a mod does without running it:

claude plugin validate ./first-mod
  ❯ ./register.js hooks: session.start, tool.call, command.run{command=tally}, ui.render{component=Spinner}
  ❯ ./register.js calls: $.command.register, $.ui.invalidate
 
✔ Validation passed

The hooks: line is every event the mod handles, with its matcher. The calls: line is every API method it uses. A spinner counter that lists $.http.fetch and $.env.get deserves a closer look. To turn every installed mod off for a session, start with --safe-mode.

At work I would only run mods I wrote myself.

Try one

You need Claude Code v2.1.287 or later in the terminal (v2.1.286 in the Desktop app). Mods are on by default.

terminal
claude --plugin-dir ./first-mod     # load a directory for one session, hot-reloads on save
claude plugin validate ./first-mod  # list hooks and calls without running anything
claude plugin test                  # run the mod's *.test.ts files, no session needed

Or don't write it at all. Describe the mod in a session, such as "make a mod that shows the current git branch above the prompt", and Claude writes it using the built-in plugin-authoring skill, then asks before enabling hot reload. Anthropic also publishes samples in the claude-code-playground repository, including blast-radius, which holds a risky command and shows what it would change before you decide. When you want to keep a mod, install it like any plugin: /plugin install name@marketplace.

When Claude Code loads a mod from --plugin-dir, it writes TypeScript declarations for your exact version into .claude-plugin/types/. The events and methods can change between releases, so trust those files over any page, this one included.

The short version

  1. A mod is a plugin whose hooks/hooks.json has "modules": ["./register.js"]. The module exports register(on).
  2. Every hook is ($, e, next). Hooks on an event form a middleware chain that ends in Claude Code's own behaviour.
  3. Observe: return next(e). Rewrite: return next({ ...e, … }), because events are frozen. Answer: return a result without calling next.
  4. A hook that fails before next is skipped. Give every guard a .catch.
  5. Mods run with your full permissions. Run claude plugin validate before you install one.

Sources: Mods overview · Create a mod · React to events · Draw in the interface · Mods reference · Sample mods, claude-code-playground · Claude Code Gets Mods (news, 2026.10.01) · Koa, cascading middleware · Express, using middleware