Trying to Make Mods for Claude Code

Trying to Make Mods for Claude Code

Explains how to use Claude Code's mods feature to make images attached to prompts more token-efficient. Removing margins and downscaling cuts context usage by 50-69%.
2026.11.13

This page has been translated by machine translation. View original

Introduction

Claude Code has an extension mechanism called "mods" that lets you change its appearance and behavior
(to some extent) using TypeScript.

Unlike hooks, which are written with configuration files and scripts,
mods are code that runs inside Claude Code,
and can be used to intercept prompt submission or tool calls,
add slash commands, and so on.

As a subject, I'll build
"a mod that shrinks images attached to a prompt before they reach Claude, to reduce tokens."
Note: this is an example that hooks cannot do because they cannot see attached images; it can only be built with mods.

With the images I tried, the mod reduced the growth in context by 51–69%.
(Measured using the API's usage)
That said, in my own usage, the effect on the whole was rather small.

The code is on GitHub.

Claude mods?

Mods are plugins that can change how Claude Code looks and behaves.
They are event handlers written in JavaScript / TypeScript,
and when an event such as a tool call, a prompt submission, or screen rendering occurs,
Claude Code calls them.
Handlers can also change how the event is processed.

Note: enabled by default in Claude Code v2.1.287 and later

Minimal structure

A mod can be created with three files.

my-mod/
├── .claude-plugin/plugin.json   … 名前・バージョン・説明
├── hooks/hooks.json             … 読み込むモジュール({ "modules": ["./register.ts"] })
└── hooks/register.ts            … フックを登録するモジュール

In register.ts, you register hooks with on(event, hook).
A hook receives three arguments: ($, e, next).

  • $ — The interface for accessing Claude Code's features
  • e — The contents of the event
  • next(e) — A function that calls the layers below (other mods and Claude Code itself). If you rewrite e and pass it, processing continues with that content
import type { Register } from 'claude-code'

export const register: Register = on => {
  // Called every time a prompt is submitted
  on('prompt.submit', async ($, e, next) => {
    $.ui.log(e.text.length + ' 文字のプロンプト') // Output one line to the conversation (not sent to the model)
    return next(e)                             // Pass it through as is
  })
}

A mod accesses the outside world through $.
Running claude plugin validate <mod> lets you list, before loading,
which events the mod handles and what it calls on $.

Differences from hooks (settings.json)

Claude Code has long had hooks,
but they are "settings that run external commands at set timings" and are a different thing from mods.

With hooks, the command written in the settings file runs as a separate process.
Mods are TypeScript modules that run inside Claude Code.
Because of this, mods can show things on screen (panels, bands, notifications, a line in the conversation, etc.)
and add slash commands.
Hooks can also display messages, but they cannot show panels or bands, or add slash commands.

What you receive at prompt submission is also different.
The input received by the UserPromptSubmit hook contains
only prompt (text) (Hooks reference), so attached images are not visible.
The mods prompt.submit has e.attachments,
and you can even read the image's bytes with $.prompt.media().

Environment

  • MacBook : macOS 26.5.2
  • Claude Code : 2.1.322 (model : Claude Fable 5.2. A model that handles high resolution)
  • ImageMagick 7.1.2-23 (used to trim margins. If it is not available, macOS's built-in sips is used)

Note: the content of this article is as of November 2026

Image Resizing Mod

Optimizing images

You may sometimes paste a screenshot into Claude Code and ask it to "look at this error."
The tokens an image uses here are determined not by file size but by pixel count.

トークン数 = ⌈幅 / 28⌉ × ⌈高さ / 28⌉

Increasing PNG compression or lowering JPEG quality does not change this number.
The only thing you can reduce is the pixel count.
Claude Code shrinks attached images to 2000px before sending them,
but even so, a full-screen Retina screenshot (3014×1774px) becomes 2000×1177px,
roughly 3,100 tokens.

So the subject mod reduces the pixel count a bit more right before sending.

Approach

What I want to do is "replace the attached image with a smaller image."
According to the mods API, this can't be done directly at present,
so this time I implemented it as follows.

  1. In prompt.submit, take out the attached images, shrink them, and save them to temporary files
  2. Add an instruction to the prompt (context) saying "the image is in this file, so Read it"
  3. With session.append, remove the original image block from the message just before it enters the conversation

From Claude's point of view, instead of a prompt with an image, it receives an instruction to "read the file,"
and reads the small image with the Read tool. (Read method)

mods features used

Here is a list of the features used in this mod. We'll go through them in order in the following sections.

Feature What it was used for
prompt.submit hook Intercepts submission, reads attachments, and adds instructions for Claude (context). It can also cancel the submission
session.append hook Removes the original image block from the message just before it enters the conversation
prompt.mention hook Swaps the file read via @file for a shrunken copy
$.prompt.media Reads the bytes of the attached image
$.process.run Runs external commands (such as ImageMagick)
$.command.register and command.run hook Adds the slash command /imgdec
$.store Saves settings across sessions
$.ui.log Outputs a result line to the conversation
$.prompt.fill Appends text to the input field

Building the mod

Intercepting prompt submission (prompt.submit)

The prompt.submit hook is called every time a prompt is submitted.
e.attachments holds the list of attachments, and if you rewrite the e you pass to next(e),
submission proceeds with that content.
Here, instructions for Claude are added to context.

// Some parts are omitted for explanation
on('prompt.submit', async ($, e, next) => {
  const images = (e.attachments ?? []).filter(a => a.type === 'image')

  // Pass through slash command executions and prompts without images as is
  if (e.command !== undefined || images.length === 0) {
    return next(e)
  }

  const settings = await readSettings($)
  if (!settings.isOn) {
    return next(e)
  }

  const dir = await makeDir($)        // Temporary folder for each submission
  const replaced: boolean[] = []      // Whether to remove each image
  const reduced: Reduced[] = []

  for (const [index, image] of images.entries()) {
    const one = image.id === undefined ? undefined : await reduce($, settings, dir, image.id, index)
    replaced.push(one !== undefined)
    if (one !== undefined) reduced.push(one)
  }

  if (reduced.length === 0) {
    return next(e)    // If no image could be shrunk, do nothing
  }

  const note =
    `${reduced.length} image(s) the user attached to this prompt were reduced ` +
    `and saved as files; the inline copies were removed. ` +
    `Read each file to view the image, and do not read the original image file:\n${reduced.map(one => one.path).join('\n')}`

  pending.push(replaced)    // Hand over to session.append
  $.ui.log(reduced.map(one => one.summary).join(' / '))

  const entered = await next({ ...e, context: [...(e.context ?? []), note] })

  // If another mod or similar stops the prompt, cancel the record (don't leave it for the next submission)
  if (entered.drop !== undefined) {
    const at = pending.indexOf(replaced)
    if (at !== -1) pending.splice(at, 1)
  }

  return entered
})

The key points are as follows.

  • When doing nothing, return next(e) as is. If there's a problem, let the original prompt through
  • The text put in context is read by Claude and is not shown on screen
  • You can inspect the result of next(e). If another mod stopped the submission (drop), cancel your own record

Rewriting the message entering the conversation (session.append)

With prompt.submit, you can't remove the attached image itself.
The image block is removed by the session.append hook,
which is called just before a message enters the conversation.
The second argument { door: 'prompt' } is a filter,
and targets only messages that enter through a prompt.

on('session.append', { door: 'prompt' }, ($, e, next) => {
  const replaced = pending[0]

  if (replaced === undefined || !e.message.content.some(b => b.type === 'image')) {
    return next(e)
  }

  pending.shift()

  let index = 0
  const content = e.message.content.filter(
    block => block.type !== 'image' || replaced[index++] !== true,
  )

  return next({ ...e, message: { ...e.message, content } })
})

The two hooks are called separately,
so which images to remove is passed between them through a module variable (pending).
When multiple images are attached, only the images that were shrunk are removed, and images that are passed through as is remain.

Calling external commands ($.process.run)

The mod's runtime environment has no Node, so image libraries such as sharp can't be used.
Image processing is done by calling external commands with $.process.run.

// Write out the image (pass the base64 string via standard input)
await $.process.run(['sh', '-c', 'base64 --decode > "$1"', 'sh', path], { stdin: base64 })

// Check the size and the frame (%@) when margins are trimmed. Nothing is written out here
await $.process.run(['magick', path, '-fuzz', '2%', '-format', '%w %h %@', 'info:'])
//   → "1672 941 1546x737+63+102"

// Write out ① margin trimming and ② shrinking in a single pass
await $.process.run(['magick', path, '-crop', '1546x737+63+102', '+repage', '-resize', '1024x1024>', path])

Shrinking uniformly makes the text smaller too, but just cutting margins doesn't change the text size.
So margins are trimmed first, and the image is shrunk only if it's still large.
On macOS without ImageMagick, only shrinking is done with the built-in sips,
and if no tool is available, the image is passed through as is.

Shrunken images are placed in a folder created with mktemp -d.
At first it was a fixed folder, /tmp/claude-imgdec/,
but I changed it because /tmp is readable by every user on the machine.

A folder from mktemp -d is created with permissions (700) that only you can read.
Be careful, since a mod runs as-is with the permissions of the person who installed it.

Slash commands and saving settings ($.command and $.store)

I added slash commands so that you can stop the mod when you want fine text read at the original resolution.

Command Behavior
/imgdec Show the current settings and the most recent result
/imgdec off / on Stop / resume
/imgdec max 1280 Change the long-edge limit (256–2000px. Default is 1024)
/imgdec mode resend / read Change the delivery method (explained in a later section. Default is read)

The command is registered in session.start and answered with a command.run hook.
Settings are saved in $.store, so they persist across sessions.

on('session.start', async ($, e, next) => {
  await $.command.register({
    name: 'imgdec',
    description: 'Reduce attached images before Claude reads them',
    argumentHint: 'on|off|max <px>|mode read|resend',
    immediate: true,   // Can run immediately even while Claude is working
  })
  return next(e)
})

on('command.run', { command: 'imgdec' }, async ($, e) => runImgdec($, e.args))

The text in the { text } returned by the command.run hook becomes the command's output.

imgdec: ON
- Mode: read
- Max edge: 1024px
- Trim margins: on
- Image tool: ImageMagick
- Saved to: /var/folders/…/T/claude-imgdec.*/
- Last result:
  - 2000×1177 → 1024×603 (resize, ~3,096 → 814 tokens) /var/folders/…/T/claude-imgdec.UeADqs/0.jpg
Usage: /imgdec [on|off|max <px>|mode read|resend]

Intercepting @ reads (prompt.mention) and manipulating the input field ($.prompt.fill)

With the Read method, the API gets one extra request because Claude calls Read.
The longer the conversation, the higher the cost of that one request.
So I also tried a method that doesn't involve Read.
It makes use of the behavior that Claude Code attaches an image specified with @file directly, without Read.

Note: only prompts that a human submits themselves have @ expanded into an image
Note: it is not expanded if a mod appends @path to the text or re-submits from the mod

① Swapping the image specified with @
The prompt.mention hook can rewrite the path of the file to be read.

on('prompt.mention', async ($, e, next) => {
  const one = await copyAndShrink($, e.path)   // Make a copy and shrink it
  if (one === undefined) return next(e)
  const attached = await next({ ...e, path: one.path })   // Swap the file to be read
  // Add a note for Claude saying "what was attached is the shrunken copy. Do not read the original file"
  return { ...attached, context: [...(attached.context ?? []), MENTIONED_NOTE] }
})

② Resend mode (/imgdec mode resend)
For images that are dragged and dropped or pasted, the submission is canceled once,
and @shrunken-file is appended to the input field.
When the prompt.submit hook returns { drop }, the submission is canceled,
and the contents of the input field are restored.
When you press Enter again, it's now a human submission, so @ is expanded.

const filled = await $.prompt.fill({ text: mentionsOf(paths), mode: 'append' })
if (filled.isFilled) {   // Cancel the submission only if the text could be appended
  return { drop: 'Image reduced. Press Enter again to send it.' }
}

The original image also remains in the input field, so on the second submission, as with the Read method, the original image block is removed with session.append.
If it isn't removed, both the original image and the shrunken copy arrive.

Showing the result in the conversation ($.ui.log)

When an image is shrunk, one line with the result is shown with $.ui.log right above the message you sent.
This line is not sent to the model.

imgdec: 2000×1177 → 1024×603 (resize, ~3,096 → 814 tokens)

The 2000×1177 on the left is the size after Claude Code itself has shrunk it.
Note: mods can also display things in panels, a band above the input field, and the status line

Distribution and installation

Mods can be distributed as plugins.
The distributor only needs to put one "marketplace" definition in a GitHub repository.

claude-mod-imgdec/                  … リポジトリ
├── .claude-plugin/marketplace.json … マーケットプレイスの定義
└── imgdec/                         … mod 本体(前述の3ファイル)
{
  "name": "claude-mod-imgdec",
  "owner": { "name": "nakamura-shuta" },
  "plugins": [{ "name": "imgdec", "source": "./imgdec" }]
}

The user registers the marketplace, then
installs with <plugin-name>@<marketplace-name>.

# Used for trimming margins (on macOS, shrinking alone still works without it)
brew install imagemagick  # On Linux, sudo apt install imagemagick

claude plugin marketplace add nakamura-shuta/claude-mod-imgdec
claude plugin install imgdec@claude-mod-imgdec

It is applied to a running session with /reload-plugins.
Otherwise, it is loaded the next time Claude Code is started.

To update the plugin, bump the version in plugin.json and push.
Users can upgrade with the following.

claude plugin marketplace update claude-mod-imgdec
claude plugin update imgdec@claude-mod-imgdec

To uninstall, use claude plugin uninstall imgdec@claude-mod-imgdec.

Result

These are the results of trying it locally. Token counts are estimates obtained by applying the image dimensions to the formula above.

Image Without mod (after built-in shrinking) With mod Processing Reduction
Screen screenshot A (3014×1774) 2000×1177, approx. 3,096 1024×603, approx. 814 Shrink approx. 74%
Screen screenshot B (3018×1742), limit 1280px 1999×1154, approx. 3,024 1280×739, approx. 1,242 Shrink approx. 59%
Test image (2400×1600) 2000×1333, approx. 3,456 1024×682, approx. 925 Shrink approx. 73%
Diagram with lots of margin (1600×1000) 1600×1000, approx. 2,088 801×421, approx. 464 Margin trim approx. 78%
Image with only one line of text (800×600) 800×600, approx. 638 578×36, approx. 42 Margin trim approx. 93%

Readability differed depending on the limit.
For a screenshot with a narrowed range, 1024px is enough,
but when capturing the whole screen, small descriptions in lists and the like tend to get blurred.
If you want even fine text to be readable, 1280px or more seems good.

Measuring with usage

I launched a fresh session for each condition with claude -p and measured with the API's usage.
The numbers in the table are the increase in input tokens of the last request (regular input + cache writes + cache reads), relative to the no-image case.
Note: the model is Fable 5.2, and the limit is 1024px

Delivery method Requests Diagram (1672×941, with margins) Simple picture (2200×1400)
As is (no mod) 1 2,164 3,476
Read method 2 1,059 (−51%) 1,320 (−62%)
@ swap 1 849 (−61%) 1,071 (−69%)
Specifying the shrunken image directly with @ 1 828 (−62%) 1,051 (−70%)

The last row is a reference value for resend mode (in an actual resend, a short note is added, which adds a little).

With every delivery method, the growth in context dropped to less than half.
Only the Read method adds one request, so for long conversations @ or resend is more advantageous.

The effect is small overall

The cost of a single image dropped to less than half,
but in my own usage, there was almost no effect from the perspective of reducing Claude Code's tokens overall.

(In my case) When I roughly tallied the token counts of a week's worth of session records,
most of it was cache re-reads in long conversations and subagents,
and even estimating that I paste about 10 images a day, what could be reduced was less than 1% of the total token count.

If you're concerned about limits, methods like these are far more effective.
For tasks where you paste hundreds or thousands of screenshots (image-centered processing), it might help somewhat.

Tips

Some things I noticed while building the mod.

  1. $.ui.log automatically prefixes the mod's name
    When I wrote $.ui.log('imgdec: …'), the screen showed imgdec: imgdec: ….
    Claude Code adds the name, so you don't need to write it yourself.

  2. Images attachable with @ have a file size limit
    For an image specified with @, if the file exceeded 256KiB,
    nothing was attached, and no error was shown (as far as I confirmed on Claude Code 2.1.322).
    For that reason, with @ and resend, the copy is made as a JPEG of 200KB or less.

  3. External commands behave differently depending on the OS
    sips -Z 1024 means "make the long edge 1024px," so small images get stretched.
    The options for base64 also differed between macOS and Linux (base64 --decode works on both).
    Commands called with $.process.run need to be checked on each supported OS.

Other Tools

There are already various tools like the one introduced here.

Name Mechanism Difference
paste-preview A mod. Adds annotations to pasted images and has the edited file Read The mechanism is almost the same as this one, but its purpose is annotation and it doesn't shrink
token-goat Hooks and MCP. Shrinks images that are Read and images returned by tools Handling of images attached to prompts is not described
vision-squeezer MCP and hooks. Fits images that are Read within a token budget It explicitly states that pasted or dragged images cannot be shrunk

Summary

As an introduction to mods, I built a mod that shrinks attached images.
Mods themselves are easy to try,
and they can achieve things that hooks alone cannot,
so if you're curious, give them a try.

References


Claudeならクラスメソッドにお任せください

クラスメソッドは、Anthropic社とリセラー契約を締結しています。各種製品ガイドから、業種別の活用法、フェーズごとのお悩み解決などサービス支援ページにまとめております。まずはご覧いただき、お気軽にご相談ください。

サービス詳細を見る

Share this article

AI白書