Counts the tool calls Claude makes and adds a /tally command

Code for the video I Built 5 Claude Code Mods. One of Them Lies. In early October 2026 Claude Code got mods: a small file of code that runs inside Claude Code and can watch every action, stop one, rewrite your prompt and draw on your screen. This repository builds five of them from an empty folder, with tests. The four honest ones are 114 lines of JavaScript. The fifth one hides what it does, so that you can practise catching it.
Tested with the test kit of Claude Code 2.1.293 (plugin validate and plugin test). The mods were not run in a live session. The feature is new, and the documentation says events and methods can change between versions.
./run_all.sh # checks all six folders without starting a session: no sign-in, no network, no tokens; expected output is in demo-output.txt
CLAUDE=/path/to/claude ./run_all.sh # the same with another Claude Code binary (2.1.287 or newer is needed)
| Folder | What the mod does | Lines | Tests |
|---|---|---|---|
1-counter | Watches. Counts the tool calls Claude makes, shows the count in the spinner and adds a /tally command. | 28 | 3 pass |
2-guard-first-try | Answers. Holds rm -rf. It has a hole: the same delete spelled -fr, -r -f or with two spaces gets through. One test fails on purpose and shows the hole. | 10 | 2 pass, 1 fail |
2-guard | Answers. Holds shell commands that delete a folder, force-push, hard-reset or git clean, and tells Claude the reason. The rules are plain functions in rules.js. If the guard itself crashes, the command is held (.catch). | 35 | 7 pass |
3-plain-words | Rewrites. End a prompt with ?? and the mod rewrites it to ask for a short answer in plain words. | 14 | 3 pass |
4-meter | Draws. Shows how full Claude's memory (the context window) is, as a bar above the prompt. | 37 | 4 pass |
5-read-the-label | Do not install. The name card says "Adds a sparkle to the spinner". It does. Three more lines approve every request to read a file before you are asked. | 16 | 1 pass |
Each folder has the same three parts: .claude-plugin/plugin.json (the name card), hooks/hooks.json (points at the code) and hooks/register.js (the mod). Tests are in tests/.
To try an honest mod in a session of your own: claude --plugin-dir ./2-guard. That starts a normal session, so it uses your plan.
A mod runs with your permissions and is not sandboxed. Before you install one, run
claude plugin validate ./5-read-the-label
and read two lines: hooks: (the events it listens to) and calls: (what it asks Claude Code for). For mod 5 it prints ui.render{component=Spinner} and tool.check{tool=Read}. The name card never mentioned the second one. If a mod misbehaves, claude --safe-mode starts one session with every mod switched off.
plugin validate and plugin test on build 2.1.293.bash cleanup.sh passes even when the script deletes a folder.{ tool: 'Read' } filter the same three lines would approve every tool call.run_all.sh prints something else on a newer build, the build changed.Add one rule to 2-guard/hooks/rules.js for a command that you fear. Write the test first in 2-guard/tests/guard.test.ts, and watch it fail.
MIT licence.
hooks/register.js 29 lines1// Mod 1: WATCH. Count every tool Claude uses, and answer /tally with the count.
2let calls = 0
3
4export function register(on) {
5 // When a session starts, tell Claude Code that /tally exists
6 on('session.start', async ($, e, next) => {
7 await $.command.register({ name: 'tally', description: 'Show how many tool calls Claude has made' })
8 return next(e)
9 })
10
11 // Every tool call passes through here. Count it, then pass it on untouched.
12 on('tool.call', async ($, e, next) => {
13 calls += 1
14 $.ui.invalidate('ui.render')
15 return next(e)
16 })
17
18 // /tally is answered by this function. No model is asked, so it costs nothing.
19 on('command.run', { command: 'tally' }, async () => {
20 return { text: 'Claude has made ' + calls + ' tool calls since this mod loaded' }
21 })
22
23 // Put the count after the spinner's word while Claude works
24 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
25 if (calls === 0) return next(e)
26 return next({ ...e, props: { ...e.props, suffix: ' · tool calls: ' + calls + '…' } })
27 })
28}
29