# How Claude Code mods change the agent loop

> Build a small Claude Code mod that pauses Bash calls for an explicit decision. Understand event handlers, fail-closed behavior, and the limits of previews.

Author: [Flavio Copes](https://flaviocopes.com/about/) | Published: 2026-11-01 | Topics: [AI](https://flaviocopes.com/tags/ai/) | Canonical: https://flaviocopes.com/claude-code-mods/

Claude Code mods let you change what happens inside a coding session. A mod can react to a tool call, add interface elements, or rewrite an event before the rest of Claude Code receives it.

That makes them useful for custom workflows. It also gives them substantial authority: a mod is executable code running with access to your session and machine.

We'll build a small mod that holds each Bash call until you explicitly choose to continue. The example uses the [official mods API](https://code.claude.com/docs/en/plugins/mods/overview), checked in October 2026. Its handler tests run locally without a model or shell execution. I haven't run this example inside a live Claude Code session.

## How a mod differs from a settings hook

A settings hook is configured around events such as tool use. Claude Code can invoke an external command or another supported hook handler, passing the relevant input and reading its result.

A mod registers JavaScript or TypeScript functions inside Claude Code. Its handlers form a chain: call `next(e)` to continue with an event, pass a changed event to rewrite it, or return a result directly to handle it yourself.

Anthropic's [mods announcement](https://claude.com/resources/articles/claude-code-mods) describes interface customization and replacing built-in behavior as reasons to use this mechanism. If an ordinary [settings hook](https://code.claude.com/docs/en/hooks) already meets your need, you can keep that simpler integration.

For our example, the important point is when the tool continues. We need to ask before calling `next`, then return a denial if we don't receive the exact approval choice.

## Make a minimal plugin

A mod is packaged as a plugin. Create this layout in a new directory:

```text
bash-preview/
  .claude-plugin/
    plugin.json
  hooks/
    hooks.json
    preview.mjs
  preview.test.mjs
```

Save this manifest as `.claude-plugin/plugin.json`:

```json
{
  "name": "bash-preview",
  "version": "0.1.0",
  "description": "Ask before passing a Bash call to Claude Code."
}
```

The version identifies your own plugin. `hooks/hooks.json` points to the module relative to that file:

```json
{
  "modules": ["./preview.mjs"]
}
```

The [create-a-mod guide](https://code.claude.com/docs/en/plugins/mods/create) covers these files and loading a plugin from a local directory. Use a Claude Code build that supports mods, and follow the guide's requirements for the interface you use.

## Hold the call until the answer arrives

Save this complete module as `hooks/preview.mjs`:

```javascript
export function register(on) {
  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
    let answer
    try {
      answer = await $.ui.ask(
        'Run this exact Bash command?\n' + e.command,
        ['Run once', 'Cancel']
      )
    } catch {
      return { deny: 'No approval was received. The command was not run.' }
    }
    if (answer !== 'Run once') {
      return { deny: 'The command was not approved.' }
    }
    return next(e)
  }).catch(async ($, e, next) => {
    if (next.called) return next(e)
    return { deny: 'The command guard failed: ' + next.error.kind }
  })
}
```

Claude Code names this event `tool.call`, with `e.tool` identifying the tool and `e.command` holding a Bash command. Our matcher limits the handler to Bash calls. The [events guide](https://code.claude.com/docs/en/plugins/mods/events) documents both that shape and `$.ui.ask`.

The handler previews every Bash call. It doesn't try to recognize dangerous shell syntax with a regular expression. A pattern matching `--force` can miss `-f`, aliases, or another program that has the same effect.

A declined answer returns `{ deny: ... }` without calling `next`. Dismissal or an unavailable question interface also denies the call. An answer applies to this one call.

The registration's `.catch` is essential. By default, Claude Code [skips a hook that fails before calling `next`](https://code.claude.com/docs/en/plugins/mods/events#handle-a-hook-that-fails), allowing the remaining chain to proceed. That includes a host-enforced timeout or an invalid result. Our error handler denies the command when the guard hasn't continued. If `next` already ran, it retrieves the existing result instead of claiming the command was prevented or running it again. Waiting inside `$.ui.ask` doesn't count against the hook time limit.

Choosing Run once continues the chain with the same event. Claude Code still applies its normal downstream permission handling. A mod's question and the tool's underlying permissions are separate decisions.

## Test the decision without running a command

The handler is a JavaScript function, so we can test its branches with Node. These tests substitute the question and continuation; they never execute the proposed command.

Save `preview.test.mjs`:

```javascript
import test from 'node:test'
import assert from 'node:assert/strict'
import { register } from './hooks/preview.mjs'

let handler
let failureHandler
register((event, matcher, callback) => {
  assert.equal(event, 'tool.call')
  assert.deepEqual(matcher, { tool: 'Bash' })
  handler = callback
  return { catch: (callback) => { failureHandler = callback } }
})

const event = Object.freeze({ tool: 'Bash', command: 'git diff --stat' })

for (const answer of ['Cancel', 'Maybe later', undefined]) {
  test('deny answer: ' + String(answer), async () => {
    let continued = false
    const result = await handler(
      { ui: { ask: async () => answer } },
      event,
      async () => { continued = true }
    )
    assert.ok(result.deny)
    assert.equal(continued, false)
  })
}

test('a failed question denies the call', async () => {
  const result = await handler(
    { ui: { ask: async () => { throw new Error('No interface') } } },
    event,
    async () => assert.fail('Must not continue')
  )
  assert.ok(result.deny)
})

test('approval forwards the identical event', async () => {
  const marker = { result: 'Simulated tool result' }
  const result = await handler(
    { ui: { ask: async (question, options) => {
      assert.ok(question.includes(event.command))
      assert.deepEqual(options, ['Run once', 'Cancel'])
      return 'Run once'
    } } },
    event,
    async (forwarded) => {
      assert.equal(forwarded, event)
      return marker
    }
  )
  assert.equal(result, marker)
})

for (const kind of ['throw', 'timeout']) {
  test('host failure before next: ' + kind, async () => {
    const next = Object.assign(
      async () => assert.fail('Must not continue'),
      { called: false, error: { kind } }
    )
    const result = await failureHandler({}, event, next)
    assert.ok(result.deny.includes(kind))
  })
}

test('host failure after next preserves the existing result', async () => {
  const marker = { result: 'Already completed' }
  let retrievals = 0
  const next = Object.assign(async (forwarded) => {
    assert.equal(forwarded, event)
    retrievals += 1
    return marker
  }, { called: true, error: { kind: 'throw' } })
  assert.equal(await failureHandler({}, event, next), marker)
  assert.equal(retrievals, 1)
})
```

Run the eight tests:

```sh
node --test preview.test.mjs
```

They check the branch decisions, event forwarding, and registered error policy. The final test models the host's cached `next` result; it doesn't run a tool again. These tests don't verify the installed Claude Code build, the real dialog, or interactions with other mods.

## Validate before loading

On a compatible Claude Code installation, inspect the plugin with the documented validator:

```sh
claude plugin validate ./bash-preview
```

Read its reported hooks and calls, then try the plugin in a disposable project with no sensitive credentials:

```sh
claude --plugin-dir ./bash-preview
```

Ask for a harmless `git diff --stat` and try both choices. Dismiss the question too. Confirm that cancellation leaves the command unexecuted and that approval reaches the ordinary tool path. Avoid making your first trial a destructive command.

## A preview has limits

A shell command's meaning depends on its working directory, environment, and the files it reads. Showing its text helps you review it, but doesn't fully describe its effects. A serious production control should use a structured operation with a known target and a preview bound to that exact intent.

The [mods overview](https://code.claude.com/docs/en/plugins/mods/overview) also explains that mods have access to files, processes, network requests, and secrets available to the user. They aren't a sandbox. A mod's own process launch is a different path from the Bash tool this handler watches.

Other mods can also participate in the chain. Review their ordering and whether they can rewrite an event after your question. For organization-wide enforcement, use the documented managed controls and an appropriate isolation boundary rather than treating this small user-installed mod as one.

Keep the first experiment narrow. Once you have verified its real behavior, add one workflow feature at a time and keep the cancellation tests alongside it.
