Build your first Claude Mod
Turn on function hooks, then test and run a small starter mod that refuses force pushes.
Warning
Function hooks are early access, and Anthropic says the API may change between releases without notice. The commands here were run with Claude Code 2.1.278 on 2026-09-21. If your version is newer, check them against the Mods README (opens in a new tab).
Turn function hooks on
Hooks modules load only where function hooks are enabled. Start Claude Code with this environment variable set:
Start Claude Code with function hooks on
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claudeThe flag is named in the Sep 9 update of the announcement issue (opens in a new tab), not in documentation, so it may change. Put it in front of each command that needs it, as the examples below do, instead of exporting it. On Claude Code 2.1.278, claude plugin test does not exist until the variable is set (the command answers unknown command 'test'), while claude plugin validate works either way.
What a mod folder holds
A mod folder is a complete plugin: a manifest, a hooks/hooks.json that names the hooks module, TypeScript under hooks/, and tests.
mod-starter/
.claude-plugin/plugin.json the plugin manifest
hooks/hooks.json names the hooks module to load
hooks/register.ts export function register(on)
hooks/is-force-push.ts a plain helper, easy to test
tests/register.test.ts run by claude plugin test
tests/is-force-push.test.ts run by claude plugin testThe hooks module runs in an environment of its own:
- There is no DOM and no Node, so there is no fs, no process and no require.
- Every file is an ES module, whatever its suffix, and a file whose name does not end in .ts, .tsx, .jsx, .js, .mjs, .cjs, .mts or .cts is not loaded.
- Use the web APIs the environment provides, such as URL and TextEncoder, and reach the outside world only through $.
- A mod can still act through $, which gives it files, the network and processes, and it can refuse or change any tool call.
The starter mod
block-force-push refuses a Bash call that force pushes with git and passes every other tool call to the hooks beneath it. on('tool.call', …) hooks the engine's event for a tool call, e.tool === 'Bash' narrows e so e.command is a string, returning { deny } refuses the call, and next(e) lets it through. The files below are read from templates/mod-starter (opens in a new tab) in this site's repository when the site is built, and they passed the tests below on Claude Code 2.1.278 on 2026-09-21. The commands on this page run from the root of a clone of that repository:
Clone the repository
git clone https://github.com/joeVenner/claude-code-mods.gitGo to the root of the clone
cd claude-code-mods{
"name": "block-force-push",
"version": "0.1.0",
"description": "A starter mod: refuses a Bash call that force pushes with git, and passes every other tool call through.",
"author": {
"name": "Your name"
}
}{
"description": "A tool.call hook that refuses a Bash call which force pushes with git",
"modules": ["./register.ts"]
}import type { On } from 'claude-code'
import { isForcePush } from './is-force-push'
/**
* The mod's one entry. `on` hooks an event with a function `($, e, next)`:
* `e` is the event's input, `next(e)` runs the hooks beneath, and returning `{ deny }` refuses.
*
* @param on the engine's registrar
*/
export function register(on: On) {
on('tool.call', ($, e, next) => {
if (e.tool === 'Bash' && isForcePush(e.command)) {
return { deny: 'Force pushes are blocked by this mod. Ask the person to run it themselves.' }
}
return next(e)
})
}/**
* True when a shell command runs `git push` with a force flag (`--force`, `-f`, or `-f` combined with
* other short flags such as `-fu`) or a forced refspec such as `+main`.
*
* It splits on `;`, `&`, `|`, parentheses and newlines, skips leading `NAME=value` words and git's
* own options (`-C dir`, `-c key=value`), and needs `push` to be the git subcommand, so
* `cd app && git push -f` and `GIT_SSH_COMMAND=ssh git -C app push -f` are caught and
* `git branch -f push main` is not.
*
* It is a teaching example, not a security boundary: it does not read aliases or scripts, it does not
* see through wrappers such as `sudo`, `env` or `bash -c`, and it lets `--force-with-lease`,
* `--delete` and `--mirror` pass.
*/
export function isForcePush(command: string): boolean {
return command.split(/[;&|()\n]+/).some((segment) => {
const words = withoutEnvironmentAssignments(segment.trim().split(/\s+/))
if (words[0] !== 'git') return false
const subcommandIndex = gitSubcommandIndex(words)
return words[subcommandIndex] === 'push' && words.slice(subcommandIndex + 1).some(isForceWord)
})
}
function withoutEnvironmentAssignments(words: readonly string[]): readonly string[] {
const firstCommandWord = words.findIndex((word) => !/^[A-Za-z_][A-Za-z0-9_]*=/.test(word))
return firstCommandWord === -1 ? [] : words.slice(firstCommandWord)
}
/** Index of git's subcommand: the first word after `git` that is not a global option or an option's value. */
function gitSubcommandIndex(words: readonly string[]): number {
let index = 1
while (index < words.length) {
const word = words[index]
if (word === '-C' || word === '-c') index += 2
else if (word.startsWith('-')) index += 1
else return index
}
return -1
}
function isForceWord(word: string): boolean {
const isForceFlag = word === '--force' || /^-[A-Za-z]*f[A-Za-z]*$/.test(word)
const isForcedRefspec = word.length > 1 && word.startsWith('+')
return isForceFlag || isForcedRefspec
}import { describe, expect, test, tier } from 'claude-code/testing'
tier('user')
describe('register', () => {
test('refuses a force push', async ($, on) => {
on('tool.call', () => ({ result: 'ran' }))
const outcome = await $.tool.call({ tool: 'Bash', command: 'git push --force origin main' })
expect(outcome).toEqual({ deny: 'Force pushes are blocked by this mod. Ask the person to run it themselves.' })
})
test('passes any other call to the hooks beneath', async ($, on) => {
on('tool.call', () => ({ result: 'ran' }))
const outcome = await $.tool.call({ tool: 'Bash', command: 'git push origin main' })
expect(outcome).toEqual({ result: 'ran' })
})
test('passes a call to another tool to the hooks beneath', async ($, on) => {
on('tool.call', () => ({ result: 'ran' }))
const outcome = await $.tool.call({ tool: 'Read', file_path: '/tmp/notes.txt' })
expect(outcome).toEqual({ result: 'ran' })
})
})import { describe, expect, test, tier } from 'claude-code/testing'
import { isForcePush } from '../hooks/is-force-push'
tier('user')
const cases: readonly (readonly [command: string, expected: boolean])[] = [
['git push --force', true],
['git push -f origin main', true],
['git push -fu origin main', true],
['git push -uf origin main', true],
['git push origin +main', true],
['git -C app push -f', true],
['git -c core.editor=vi push -f', true],
['GIT_SSH_COMMAND=ssh git push -f', true],
['cd app && git push origin main -f', true],
['echo hi; git push -f', true],
['true || git push -f', true],
['echo x | git push -f', true],
['sleep 1 & git push -f', true],
['(git push -f)', true],
['echo hi\ngit push -f', true],
['git push origin main', false],
['git push -u origin main', false],
['git push --force-with-lease', false],
['git branch -f push main', false],
['git log --grep push -f', false],
['echo git push --force', false],
['git status', false],
['', false],
]
// What the helper does not catch. The starter's guide names these limits, so a test holds them true:
// if a case here starts to be caught, update the guide's wording with it.
const knownLimits: readonly string[] = [
'sudo git push -f',
'env git push -f',
'bash -c "git push -f"',
'git push --delete origin main',
'git push --mirror',
]
describe('is-force-push', () => {
for (const [command, expected] of cases) {
test(`${JSON.stringify(command)} is ${expected ? 'a force push' : 'not a force push'}`, () => {
expect(isForcePush(command)).toBe(expected)
})
}
for (const command of knownLimits) {
test(`known limit: ${command} is not caught`, () => {
expect(isForcePush(command)).toBe(false)
})
}
})Note
This is a teaching example, not a security boundary. It catches --force, short flags that include f such as -fu, and forced refspecs such as +main. It does not see through wrappers such as sudo, env or bash -c, scripts or aliases, and it lets --force-with-lease, --delete and --mirror pass. Do not rely on it.
Test it
Warning
Testing a mod, or loading it with --plugin-dir, runs its code with your permissions. validate reads its source and reports what it hooks without loading it.
Read every file of a folder before you run it, and run only folders you trust. A mod can refuse or change any tool call and reach the outside world through $.
Put the enable variable in front of the command, as the examples here do, so it is not left on for every later Claude Code session in your shell.
Run the starter's tests
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude plugin test templates/mod-starterAll of the starter's tests passed on Claude Code 2.1.278. What to know about the test kit:
- A test receives the engine's own $ and a registrar on. The hooks a test registers with on sit beneath your mod, where the rest of the world would be, and a call they leave unanswered throws and names its event.
- The kit has no test.each. Register the cases with a loop, as the starter's helper tests do.
- tier('user') says the mod loads as an installed plugin would. A built-in mod uses tier('builtin').
Check it and run it
Validate the manifest and the hooks module
claude plugin validate templates/mod-starterRun Claude Code with the mod loaded from source
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir templates/mod-startervalidate reads the manifest and the module the way the engine will. For the starter it reports that the module hooks tool.call and calls nothing on $. The starter was checked by its tests and by validate; it was not run in a live session for this page.
Get the types
Inside a session, the /plugin-types command writes the type declarations that a mod imports from claude-code. TypeScript 5.4 or newer reads them. Regenerate them after every Claude Code update instead of editing them.
Where to go next
Read how the four built-in mods are written in the directory, look up an event on the Hooks page, move a hook you already have with classic hooks to function hooks, and see what a mod is and how it compares with plugins, hooks and skills.