Skip to content
Claude Code Mods

Classic hooks to function hooks

How a hook you already have as a shell command maps onto a function hook in a mod.

Note

Anthropic publishes no guide for moving classic hooks to function hooks. The migration.md in the plugin-dev hook-development skill is about moving command hooks to prompt hooks, which is a different move. This page is written from Anthropic's type declarations and the hooks docs, and its last section says how each part was checked.

A classic hook can be a shell command, an HTTP endpoint, an MCP tool call, a prompt or a subagent. This page is about command hooks, the kind that talk through an exit code and JSON on stdout.

Read the plugin-dev migration guide (opens in a new tab) if you want to move a command hook to a prompt hook instead. Function hooks are early access, and the API may change between releases without notice.

What changes

Where it lives

A classic command hook is set up in configuration and names a command. A mod is a plugin: its hooks/hooks.json names a hooks module, and the module's register entry hooks events.

Source: Hooks docs (opens in a new tab), Mods README (opens in a new tab)

What runs

A classic command hook runs a command when its event fires. A function hook is a TypeScript function that the engine calls in an environment of its own, with no DOM and no Node.

Source: Hooks docs (opens in a new tab), Mods type declarations (opens in a new tab)

Order

The settings hooks that are not managed run at the innermost end of the chain, as core. Function hooks sit above them. Across plugins they nest by seat, and within one plugin in the order it registered them, first outermost. Managed settings hooks run before any module, and a block from them ends the chain above every module.

Source: Mods type declarations (opens in a new tab)

When a hook fails

At every engine event, a hook that throws, overruns its time budget or answers a wrong shape is skipped, and the failure is reported by name. On a classic event, a result field of the wrong shape fails the hook in the same way. Types catch most wrong shapes before the mod runs.

Source: Mods type declarations (opens in a new tab)

Types and tests

A function hook is typed against the declarations that /plugin-types writes. claude plugin test can run a mod's tests, but the kit's $ has no call that fires a classic event, so a hook on a classic.* event is checked by its types and by claude plugin validate, not by a test.

Source: Mods README (opens in a new tab), Mods type declarations (opens in a new tab)

Two ways to move a hook

You can keep the event you already hook. Every classic event is also a function hooks event named classic.<Name>, so PreToolUse becomes classic.PreToolUse, and a hook on it keeps the classic results.

Or you can hook the engine's own event that surrounds it, such as tool.call, which has its own results, including answering a call yourself. This page does not recommend one over the other: the first keeps the classic results you already know.

Exit codes and JSON become returns

A command hook talks through its exit code and the JSON it prints. A function hook returns a value, and that value carries the same decisions. Read each row from left to right.

A classic command hook's output and the value a function hook returns
A classic command hookA function hookSource
Reads the event as JSON on stdin.Receives it as e, with the same fields. e is typed read-only, so return a changed copy. On classic.PreToolUse, e is the tool-call envelope: e.tool and, for Bash, e.command.

Source: Mods type declarations (opens in a new tab)

Exits 0 and prints nothing.Returns next(e), which runs the hooks beneath and, last, the settings hooks that are not managed.

Source: Mods type declarations (opens in a new tab)

On an event that can block: exits 2 with a reason on stderr, or prints a JSON decision of block.Returns { block: reason } on the classic events that can block, and { deny: reason } on classic.PreToolUse. The hooks docs list which events can block. On the others exit 2 does not block, so there is nothing to move.

Source: Hooks docs (opens in a new tab), Mods type declarations (opens in a new tab)

Prints JSON with continue set to false and a stopReason.Returns preventContinuation: true, with stopReason for the text that is shown, on every classic event except classic.PreToolUse.

Source: Hooks docs (opens in a new tab), Mods type declarations (opens in a new tab)

Prints hookSpecificOutput.additionalContext.Returns additionalContext, a list of strings with one entry for each hook, on the events that read it.

Source: Hooks docs (opens in a new tab), Mods type declarations (opens in a new tab)

PreToolUse: prints a permissionDecision of allow, ask, deny or defer.Returns { allow: true }, { ask: reason } or { deny: reason } from classic.PreToolUse. The result type has no defer, so a hook that defers has no counterpart here.

Source: Hooks docs (opens in a new tab), Mods type declarations (opens in a new tab)

PreToolUse: prints hookSpecificOutput.updatedInput.Returns updatedInput from classic.PreToolUse. It is checked against the tool's schema before the tool runs.

Source: Hooks docs (opens in a new tab), Mods type declarations (opens in a new tab)

Other hookSpecificOutput fields, such as sessionTitle or watchPaths.Returns a field of the same name. Each event reads only its own set of fields, and a field it does not read is a type error.

Source: Hooks docs (opens in a new tab), Mods type declarations (opens in a new tab)

The engine events around them

For four classic events, the declarations relate an engine event to it: sharing its envelope, running its settings hooks through next, or answering the same way when either blocks. These are those four pairs. An engine event with no documented link to a classic one is not listed.

Classic events and the engine events that surround them
Classic eventEngine eventWhat a function hook can do there
PreToolUsetool.callReturn { deny: reason } to refuse the call, or { result } to answer it yourself. classic.PreToolUse shares this event's envelope.
UserPromptSubmitprompt.submitReturn { drop: reason } to stop the prompt. next(e) runs the hooks beneath and then the UserPromptSubmit settings hooks.
PreCompactsession.compactReturn { skip: reason } to veto a compaction. A classic PreCompact hook that blocks gets the same outcome.
SessionEndsession.endIt fires after the SessionEnd settings hooks, and e.reason carries the same reason word the classic hook receives. A hook here observes: its own value changes nothing.

A worked example

A classic PreToolUse hook that blocks rm. The script reads the event from stdin and exits 2 with its reason on stderr, and the configuration attaches it to Bash calls.

.claude/hooks/block-rm.sh
#!/bin/bash
# .claude/hooks/block-rm.sh: a PreToolUse hook for Bash
command=$(jq -r '.tool_input.command')
if [[ "$command" == rm\ * ]]; then
  echo "rm is blocked by this hook" >&2
  exit 2
fi
exit 0
.claude/settings.json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [{ "type": "command", "command": ".claude/hooks/block-rm.sh" }]
      }
    ]
  }
}

As a function hook on the same event, the exit 0 becomes next(e) and the exit 2 becomes { deny }. The same file also moves a Stop hook, whose exit 2 becomes { block }, and a UserPromptSubmit hook that adds context. To hook the engine's own event instead, see the starter mod, which refuses a Bash call from tool.call.

templates/migration-example/hooks/register.ts
import type { On } from 'claude-code'

/**
 * Three classic hooks as function hooks. Each hooks the classic event under its `classic.` name.
 * Where a command hook exits 0 to let the event through, a function hook returns `next(e)`.
 * Where it exits 2 or prints a decision, a function hook returns an object.
 */
export function register(on: On) {
  // Classic PreToolUse: exit 2 blocks the tool call. The result is allow, ask or deny.
  on('classic.PreToolUse', ($, e, next) => {
    if (e.tool === 'Bash' && e.command.startsWith('rm ')) {
      return { deny: 'rm is blocked by this mod' }
    }
    return next(e)
  })

  // Classic Stop: exit 2 keeps Claude working. `block` carries the reason it is given.
  // stop_hook_active is true when Claude is already continuing because of a stop hook,
  // so a hook that always blocks must let that case through.
  on('classic.Stop', ($, e, next) => {
    if (e.stop_hook_active) return next(e)
    return { block: 'Run the tests before you stop' }
  })

  // Classic UserPromptSubmit: hookSpecificOutput.additionalContext. The result lists the notes.
  on('classic.UserPromptSubmit', async ($, e, next) => {
    const result = await next(e)
    return { ...result, additionalContext: [...(result.additionalContext ?? []), 'This repository uses pnpm'] }
  })
}

The folder also holds a manifest and a hooks/hooks.json with the same shape as the starter's. It is this site's own example, in templates/migration-example (opens in a new tab), and not one of Anthropic's mods.

The classic events

Function hooks name 33 classic events. Each links to its row on the Hooks page, with the entries that list it.

How this page was checked

Against Anthropic's type declarations at commit a92ea1c (Claude Code 2.1.277), read on 2026-09-21, the same commit the Hooks page is read at:

  • The names are the ones in the Hooks page's list: each classic event is classic.<Name>, and claude plugin validate accepts the example mod's three.
  • The result shapes are checked by typechecking against Anthropic's declarations at the pinned commit. The example mod and a positive fixture with one hook for each kind of result in the table above must compile. Eight negative fixtures must fail to compile, for example a deny returned from classic.Stop, a bare PreToolUse name, a field an event does not read and a write to e, so a change in the declarations that breaks a claim here breaks the check.
  • The exit codes and JSON fields on the classic side are from the hooks docs, and the shell script above was run: it exits 2 with its reason for an rm command and 0 for any other.
  • Not checked: how the engine behaves at run time. The test kit has no call that fires a classic event, so no test here runs a classic.* hook, and the example mod was not run in a live session.

To repeat the checks, run these from a clone of this site's repository:

Typecheck the templates against the declarations (needs the network)

npm run typecheck:templates

Validate the example mod's manifest and hooks module

CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude plugin validate templates/migration-example