In this guide
- What hooks are and why they exist
- The lifecycle events worth knowing
- Where hooks live, and what the desktop app changes
- Step by step: a first hook in the desktop app
- How a hook talks back: input, exit codes, JSON
- Matchers and the if field
- Seven hooks worth copying
- Prompt, agent, and HTTP hooks
- Function hooks: what changed in September 2026
- Enabling the preview in the desktop app
- Build the secret redactor
- More function hooks to build
- Debugging and troubleshooting
- Security notes
- FAQ
What hooks are and why they exist
Anthropic's documentation defines hooks as user-defined shell commands that Claude Code runs at specific points in its lifecycle. The stated purpose is "deterministic control": certain actions always happen, instead of depending on the model choosing to run them. That single idea is the whole reason to learn them.
Every long Claude Code session has the same failure shape. A rule at the top of CLAUDE.md says never to run a destructive database command, never to push to main, always to run the formatter. Twenty turns later the context has filled with file contents and tool output, the rule has faded, and the model does the thing the rule forbade. Ray Amjad, who runs the Agentic Coding School and reviewed the new hook system in a September 2026 video, frames the problem the same way: rules in a system prompt fade as the context fills, a lazy prompt gets misread, or a workflow the operator wrote down is not followed. Hooks close that gap because they are code, not instructions.
There are two generations of hooks in Claude Code today, and this guide covers both:
- →Classic hooks are entries in a settings file. Each one runs a shell command, an HTTP request, an MCP tool, or a model prompt when an event fires. They receive JSON on stdin and answer with an exit code or a JSON object. They are stable, documented, and work in the CLI and the desktop app.
- →Function hooks are a TypeScript module that a plugin loads into the engine itself. Instead of reacting to an event after the fact, a function hook wraps the event and decides whether to pass it on, change it, or stop it, in the style of Express or Koa middleware. As of September 2026 they are a preview behind an environment variable, not a shipped feature.
The lifecycle events worth knowing
The hooks reference lists more than thirty events. Most solo builders will only ever use ten of them. The table below is the working subset; the full list, including agent-team and worktree events, is in the official hooks reference.
| Event | Fires when | Can it block? | Typical use |
|---|---|---|---|
| SessionStart | A session starts, resumes, clears, or compacts | No | Inject context, load environment |
| UserPromptSubmit | The user sends a prompt, before Claude reads it | Yes | Add context, reject prompts |
| PreToolUse | Before any tool call runs | Yes | Block or rewrite dangerous commands |
| PermissionRequest | Claude Code is about to ask for permission | Decides | Auto-approve or deny known cases |
| PostToolUse | After a tool call succeeds | No | Format files, log commands |
| PostToolUseFailure | After a tool call fails | No | Alerting, retries |
| Notification | Claude is waiting for input or permission | No | Desktop notifications |
| Stop | Claude finishes a turn | Yes | Keep working until tests pass |
| PreCompact / PostCompact | Around context compaction | No | Save state, re-inject rules |
| ConfigChange | A settings or skills file changes mid-session | Yes | Audit trail, block unauthorised edits |
| SessionEnd | The session terminates | No | Cleanup |
One detail matters more than the rest: according to Anthropic's hooks guide, PreToolUse hooks fire before any permission-mode check, in every mode. A hook that returns a deny decision blocks the tool even in bypass-permissions mode. That is what makes hooks a real policy layer rather than a suggestion.
Where hooks live, and what the desktop app changes
A hook is a block of JSON inside a settings file. Which file determines the scope.
| File | Scope | Shareable |
|---|---|---|
| ~/.claude/settings.json | Every project on the machine | No |
| .claude/settings.json | One project | Yes, commit it to the repo |
| .claude/settings.local.json | One project, one machine | No, gitignored |
| Plugin hooks/hooks.json | While the plugin is enabled | Yes, ships with the plugin |
| Skill or subagent frontmatter | While that skill or agent is active | Yes, inside the file |
| Managed policy settings | Whole organisation | Admin-controlled |
On Windows the user file is at %USERPROFILE%\.claude\settings.json. On macOS it is ~/.claude/settings.json. Project files sit inside the repository next to the code.
What the desktop app does differently
The Claude desktop app's Code tab and the CLI read the same configuration. Anthropic's desktop documentation states that hooks and skills defined in settings apply to both, and that permission rules and other keys in settings.json apply to desktop sessions. Four practical differences follow from the same documentation:
- 1.Terminal-dialog commands are not available. Commands that open an interactive panel in the terminal, such as
/permissions, reply that they are not available in this environment. The documented advice is to edit the settings files directly./hooksis a read-only browser in every surface, so it still works for checking what is configured. - 2.There is a file pane and an integrated terminal. Clicking a file path in the chat opens it in the file pane, where spot edits can be saved back. The terminal opens from the Views menu or with
Ctrl+`and shares the session's working directory and environment. Both are local-session features. - 3.Environment variables are set in the local environment editor. The desktop app does not inherit a full shell profile. Variables for local sessions go in the environment dropdown next to the prompt box (hover Local, click the gear) or under the
envkey of~/.claude/settings.json. This matters for function hooks, which need one variable set. - 4.Plugins have a graphical manager. The + button next to the prompt box lists installed plugins and opens the plugin browser. Plugins in
~/.claude/skills/load automatically asname@skills-dir, which is the route this guide uses for function hooks.
The fastest way to add any hook in the desktop app
Step by step: a first hook in the desktop app
The first hook logs every Bash command Claude runs to a file. It is useful on its own, it needs no extra tools, and it works on macOS, Linux, and Windows because the command only appends stdin to a file.
- Open the project in the Code tab. Start a local session in the repository where the hook should apply. Project hooks only load from the session's working directory.
- Create or open the settings file. Ask Claude to open
.claude/settings.json, or open the integrated terminal and create it. If the file already exists, thehookskey is added next to the existing keys, and a new event is added as a sibling inside the onehooksobject rather than replacing it. - Paste the hook.Each line in the log is the full JSON that Claude Code handed the hook, including.claude/settings.json
{ "hooks": { "PostToolUse": [ { "matcher": "Bash", "hooks": [ { "type": "command", "command": "cat >> .claude/bash-log.jsonl" } ] } ] } }tool_input.commandand the tool's response. Add.claude/bash-log.jsonlto.gitignoreso the log stays local. - Verify it registered. Send
/hooksin the chat. The browser lists every event with a count next to the ones that have hooks.PostToolUseshould show one entry sourced from Project Settings. Claude Code's file watcher normally picks up settings edits within a few seconds; if the count stays at zero, the JSON is invalid (trailing commas and comments are not allowed) or the session needs a restart. - Test it. Ask Claude to run a harmless command such as listing the directory, then open the log file. A successful hook shows nothing in the conversation; the effect is the evidence.
How a hook talks back: input, exit codes, JSON
Every command hook receives one JSON object on stdin. The common fields are the session id, the transcript path, the working directory, the permission mode, and the event name. Tool events add the tool name, the tool input, and a tool use id. A PreToolUse payload for a Bash call looks like this, trimmed from the reference:
{
"session_id": "abc123",
"cwd": "/home/user/my-project",
"permission_mode": "default",
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": {
"command": "rm -rf /tmp/build",
"description": "Remove build artifacts"
},
"tool_use_id": "toolu_01ABC123"
}The hook replies in one of three ways, and the exit code is what Claude Code reads first:
| Exit code | Meaning | What Claude sees |
|---|---|---|
| 0 | Success. The action proceeds. | Nothing, unless stdout is JSON with a decision, or the event is one that forwards stdout (UserPromptSubmit and SessionStart do) |
| 2 | Blocking error on events that can block. | The stderr text, or the JSON reason, as feedback so it can adjust |
| Anything else | Non-blocking error. The action proceeds. | A short hook error notice in the transcript |
For finer control the hook prints a JSON object on stdout and exits 0. The fields that matter most on tool events live under hookSpecificOutput:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Destructive command blocked by hook",
"updatedInput": { "command": "echo 'safer command'" },
"additionalContext": "Text Claude reads alongside the tool result"
},
"systemMessage": "Shown to the user in the transcript"
}- →
permissionDecisionisallow,deny, orask. Allow skips the permission prompt; ask forces one. - →
updatedInputrewrites the tool's arguments before it runs. This is how a classic hook swaps npm for pnpm or adds a flag. - →
additionalContextinjects text the model reads. The docs cap the field at 10,000 characters. - →Exit code 2 always wins. Even a JSON allow cannot override a hook that exits 2.
The output must start with a brace
Matchers and the if field
A matcher filters which tool, or which reason, a hook group applies to. The rules from the reference are short: an empty string or * matches everything, plain names like Bash or Edit|Write match exactly, and anything with other characters is treated as a regular expression, so mcp__github__.* matches every tool from a GitHub MCP server. Matchers are case-sensitive.
The if field goes one level deeper on tool events. It uses the same syntax as permission rules, so a hook can fire only for git commands rather than for every Bash call:
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"if": "Bash(git *)",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/check-git-policy.sh"
}
]
}The reference is candid that if matching is best effort. Commands hidden behind a variable, a subshell, or a chain are checked where possible, and when Claude Code cannot tell what will run it runs the hook anyway. For a hard allow or deny, the documentation says to use the permission system rather than a hook.
Seven hooks worth copying
Each of these is a complete settings block. They are adapted from the examples in Anthropic's hooks guide and from the patterns Ray Amjad demonstrates, with one change: where the official examples depend on jq, the versions here use Node.js so they run unchanged on a Windows desktop install.
1. Protect .env, lockfiles, and .git from edits
A PreToolUse hook on Edit|Write that exits 2 when the target path matches a protected pattern. Claude receives the reason and changes course. The exec form (an args array) skips the shell entirely, which avoids quoting problems on every platform.
// Reads the hook payload from stdin and blocks edits to protected paths.
const input = JSON.parse(require("fs").readFileSync(0, "utf8"));
const file = String(input.tool_input?.file_path ?? "").replace(/\\/g, "/");
const protectedPatterns = [".env", "package-lock.json", ".git/"];
const hit = protectedPatterns.find((p) => file.includes(p));
if (hit) {
console.error("Blocked: " + file + " matches protected pattern " + hit);
process.exit(2);
}
process.exit(0);{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "node",
"args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/protect-files.js"],
"timeout": 10
}
]
}
]
}
}2. Block destructive commands, including a Supabase guard
Ray Amjad's opening example is a hook that refuses dangerous Supabase CLI commands such as deleting a project or a branch, because a rule in CLAUDE.md had not been enough. The same shape covers rm -rf, force pushes, and dropped tables. The script returns a JSON deny so the reason reaches the model cleanly.
const input = JSON.parse(require("fs").readFileSync(0, "utf8"));
const command = String(input.tool_input?.command ?? "");
const blocked = [
{ test: /\brm\s+-rf?\s+(\/|~|\.)(\s|$)/, why: "recursive delete of a root, home, or project directory" },
{ test: /git\s+push\s+.*--force(?!-with-lease)/, why: "force push without a lease" },
{ test: /supabase\s+projects\s+delete/, why: "Supabase project deletion" },
{ test: /supabase\s+branches\s+delete/, why: "Supabase branch deletion" },
{ test: /supabase\s+db\s+reset/, why: "Supabase database reset" },
{ test: /\bDROP\s+(TABLE|DATABASE|SCHEMA)\b/i, why: "destructive SQL" },
];
const hit = blocked.find((rule) => rule.test.test(command));
if (hit) {
console.log(JSON.stringify({
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: "Blocked by guard-commands hook: " + hit.why + ". Ask the user before running this.",
},
}));
}
process.exit(0);{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "node",
"args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/guard-commands.js"],
"statusMessage": "Checking command safety..."
}
]
}3. Format every file Claude edits
The canonical PostToolUse example. Prettier runs on the edited file and the model never has to remember the style guide. The official version pipes through jq; this one reads the path in Node and is otherwise identical.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "node -e \"const i=JSON.parse(require('fs').readFileSync(0,'utf8'));require('child_process').spawnSync('npx',['prettier','--write',i.tool_input.file_path],{stdio:'inherit',shell:true})\"",
"async": true
}
]
}
]
}
}async: true lets the session continue while the formatter runs. It is the right default for anything cosmetic.
4. Get a desktop notification when Claude needs input
The Notification event fires when Claude is waiting for permission or has gone idle. The desktop app already sends an OS notification when a session finishes while it is not in view, so this hook matters most for CLI users and for anyone who wants a different sound or channel. Anthropic's guide gives three platform variants; the macOS and Windows ones are below.
{
"hooks": {
"Notification": [
{
"matcher": "permission_prompt|idle_prompt",
"hooks": [
{
"type": "command",
"command": "osascript -e 'display notification \"Claude Code needs your attention\" with title \"Claude Code\"'"
}
]
}
]
}
}{
"hooks": {
"Notification": [
{
"matcher": "permission_prompt|idle_prompt",
"hooks": [
{
"type": "command",
"command": "powershell.exe -Command \"[System.Reflection.Assembly]::LoadWithPartialName('System.Windows.Forms'); [System.Windows.Forms.MessageBox]::Show('Claude Code needs your attention', 'Claude Code')\""
}
]
}
]
}
}5. Re-inject the rules after compaction
Compaction summarises the conversation to free space and can drop the details that mattered. A SessionStart hook with a compact matcher writes plain text to stdout, and Claude Code adds that text to the context.
{
"hooks": {
"SessionStart": [
{
"matcher": "compact",
"hooks": [
{
"type": "command",
"command": "echo 'Reminder: use pnpm, not npm. Run pnpm test before committing. Never edit files under supabase/migrations by hand.'"
}
]
}
]
}
}6. Keep working until the job is actually done
A prompt-based Stop hook sends the hook input to a small model (Haiku by default) and asks whether the requested work is complete. If the answer is no, the reason is fed back to Claude as its next instruction. Claude Code caps this at eight consecutive blocks so a bad hook cannot loop forever.
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "prompt",
"prompt": "Check whether every task the user asked for in this turn is complete and verified. If not, respond with {\"ok\": false, \"reason\": \"what remains to be done\"}."
}
]
}
]
}
}7. Audit every configuration change
The ConfigChange event fires when a settings or skills file is modified during a session, whether by Claude, by an editor, or by another process. Appending each event to a log gives a paper trail, and exiting 2 would block the change outright.
{
"hooks": {
"ConfigChange": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "cat >> ~/.claude/config-audit.jsonl"
}
]
}
]
}
}Prompt, agent, and HTTP hooks
Command hooks are deterministic. Three other hook types exist for cases where judgment or an external service is needed, and all three use the same settings structure with a different type.
type: prompt
Sends the hook input plus a prompt to a Claude model and expects a JSON answer with ok true or false. Good for "is this edit appropriate" or "is the task finished" questions where the input alone is enough to decide. Default timeout is 30 seconds. A model field picks a stronger model when needed.
type: agent
Spawns a subagent that can read files and run commands before answering, up to 50 tool turns with a 60-second default timeout. Anthropic marks agent hooks experimental and recommends command hooks for production policy. The documented example runs the test suite before allowing Claude to stop.
type: http
Posts the same JSON a command hook would receive to a URL and reads the decision from the response body. Header values can interpolate environment variables, but only the ones listed in allowedEnvVars. This is the shape teams use for a shared audit service. Status codes alone cannot block; the response has to carry the JSON decision.
Function hooks: what changed in September 2026
On September 3, 2026, Anthropic engineer Alice Poteat opened issue #91870, "Function Hooks - make plugins 10x more powerful" on the Claude Code repository, describing an internal proposal and asking for feedback. The one-line summary in the issue says function hooks let a plugin modify Claude Code very deeply while staying safe through side-effect tracking over a parameterised $ object, composing through a registration-order "next" continuation model in the style of Express or Koa. The issue states plainly that community response would likely decide whether it ships. Within three days it had more than a hundred comments.
The runtime was already inside the binary. Community testers on the thread confirmed that Claude Code 2.1.260 and 2.1.261 load a hooks module when the environment variable CLAUDE_CODE_ENABLE_FUNCTION_HOOKS is set to 1, and that /plugin-types writes a TypeScript declaration file of several thousand lines describing the API. Ray Amjad's video, recorded the day the proposal went public, called it the best feature in Claude Code yet.
The shape of a function hook
A hooks module exports a register function. Inside it, on(event, filter, handler) attaches a handler that receives three things: $, the engine interface with namespaces such as ui, model, store, clock, http, process, tool, and session; e, the frozen event data; and next, which passes the event down the chain. Returning next(e) lets the action proceed, returning next with a changed event rewrites it, and returning a deny object stops it.
import type { Register } from "claude-code";
export const register: Register = (on) => {
on("tool.call", { tool: "Bash" }, ($, e, next) => {
if (/rm -rf \//.test(e.command)) {
return { deny: "Recursive delete of root blocked by guard plugin" };
}
if (e.command.startsWith("npm ")) {
return next({ ...e, command: e.command.replace(/^npm /, "pnpm ") });
}
return next(e);
});
};That second branch is the example Ray Amjad uses to explain the middleware analogy: on every Bash call, npm is replaced with pnpm before the command runs. A classic hook can do the same with updatedInput, but the function version is typed, needs no shell, and runs identically on Windows and macOS.
What function hooks add over shell hooks
The video lists the limits of classic hooks directly: they cannot rewrite a prompt, cannot append context from an external knowledge base at the right moment, cannot draw anything in the interface, cannot ask the user a question, cannot add tools or edit tool descriptions, and have no memory across hooks or sessions. The proposal addresses each one.
| Capability | Classic hook | Function hook |
|---|---|---|
| Rewrite a tool call | updatedInput on PreToolUse | next with a changed event |
| Rewrite the user's prompt | Not possible | prompt.submit handler |
| Short-circuit a tool with a cached result | Not possible | Return a result without calling next |
| Draw a status row or buttons | Not possible | $.ui.render, ui.press events |
| Ask the user a question | Only through permission prompts | $.ui.ask (confirmed as intended in the thread) |
| Register a new tool | Not possible | $.tool.register |
| Call a model from inside the hook | type: prompt hook only | $.model |
| Remember state | Write files yourself | $.store, a JSON store per plugin |
| See every event at once | One entry per event | on("*") for an audit log |
| Cross-platform | Depends on the shell | One runtime, no shell |
Preview status, stated plainly
Enabling the preview in the desktop app
The CLI route is one line: start Claude Code with the variable set.
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude
The desktop app does not read a shell profile for arbitrary variables, so the flag is set one of two documented ways instead. Either open the environment dropdown next to the prompt box, hover Local, click the gear icon, and add CLAUDE_CODE_ENABLE_FUNCTION_HOOKS with the value 1, or add it under the env key in the user settings file. A community tester on the proposal thread confirmed that the settings-file route enables the module loader.
{
"env": {
"CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1"
}
}Start a new local session afterwards. Two things appear once the flag is active. A built-in skill called /plugin-authoring shows up in the slash-command list, described as writing or debugging a Claude Code plugin made of function hooks. And /plugin-types writes claude-code.d.ts into a folder of choice, which gives an editor full autocompletion for the $ object and every event.
/plugin-types ./types
Where the plugin goes
Function hooks live inside a plugin, and the plugin declares its module in hooks/hooks.json under a modules key. Older Claude Code versions ignore that key. In the CLI a plugin folder is loaded with --plugin-dir. In the desktop app the route is a user-level skills-directory plugin: a folder under ~/.claude/skills/ that contains a .claude-plugin/plugin.json manifest loads automatically on the next session as name@skills-dir, in every project. A project's own .claude/skills/ folder is not scanned for function hook modules in the current preview, so keep the plugin at the user level. After editing a hooks module, send /reload-plugins to pick up the change without restarting.
~/.claude/
└── skills/
└── transcript-redactor/
├── .claude-plugin/
│ └── plugin.json
└── hooks/
├── hooks.json
└── redact.tsBuild the secret redactor
This is the example from the video, and it is also the eighth demo Alice Poteat attached to the proposal ("One sentence. One plugin"). The problem it solves is familiar to anyone who has pasted an API key into a chat: the key lands in the session transcript on disk, the model sees it, and the only honest fix is to delete the transcript and rotate the key. The redactor intercepts the prompt before it reaches the transcript, swaps every secret for a placeholder id, keeps the real value in memory, and swaps it back only inside the command that needs it. In Ray Amjad's demo, an Anthropic API key pasted into the prompt was replaced by an id the moment he pressed Enter, and the curl request Claude built with that id still succeeded because the hook restored the real key at execution time.
Step 1. Ask the built-in skill to write it
With the flag enabled, invoke the skill and describe the outcome. The video's prompt asked for two hooks: one that blocks secrets from entering the transcript by measuring entropy, and one that also catches emails and anything that looks like an IP address.
/plugin-authoring Make a function hook plugin called transcript-redactor. Before any prompt enters the transcript, replace API keys and other high-entropy tokens, email addresses, and IPv4 addresses with short placeholder ids, and keep the real values in memory only, never on disk. When a Bash command uses one of those ids, substitute the real value back just before the command runs. Show a one-line notice in the UI each time something is redacted.
In the video the skill produced a plugin of roughly 300 lines, including an in-memory store at the top of the module so the session could use secrets without ever seeing them. The point of the exercise is not to type the code by hand; it is to read what the skill generates and understand the two hooks it contains.
Step 2. Understand the module
A minimal version of the same idea, written against the preview API, is short enough to read in full. The file names and manifest match the layout above.
{
"name": "transcript-redactor",
"description": "Keeps secrets, emails, and IP addresses out of the session transcript",
"version": "0.1.0"
}{
"modules": ["./redact.ts"]
}import type { Register } from "claude-code";
// Real values live here, in process memory, and nowhere else.
const vault = new Map<string, string>();
let counter = 0;
const KNOWN_KEY = /\b(sk-ant-[A-Za-z0-9_-]{20,}|sk-[A-Za-z0-9]{20,}|ghp_[A-Za-z0-9]{30,}|AKIA[A-Z0-9]{16})\b/g;
const EMAIL = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g;
const IPV4 = /\b(?:\d{1,3}\.){3}\d{1,3}\b/g;
const LONG_TOKEN = /\b[A-Za-z0-9_-]{32,}\b/g;
// Shannon entropy in bits per character. Random keys score high; words and paths do not.
function entropy(value: string): number {
const counts = new Map<string, number>();
for (const ch of value) counts.set(ch, (counts.get(ch) ?? 0) + 1);
let bits = 0;
for (const n of counts.values()) {
const p = n / value.length;
bits -= p * Math.log2(p);
}
return bits;
}
function stash(value: string, kind: string): string {
for (const [id, stored] of vault) if (stored === value) return id;
const id = "<" + kind + "_" + ++counter + ">";
vault.set(id, value);
return id;
}
function redact(text: string): string {
return text
.replace(KNOWN_KEY, (m) => stash(m, "SECRET"))
.replace(EMAIL, (m) => stash(m, "EMAIL"))
.replace(IPV4, (m) => stash(m, "IP"))
.replace(LONG_TOKEN, (m) => (entropy(m) > 4 ? stash(m, "SECRET") : m));
}
function restore(text: string): string {
let out = text;
for (const [id, value] of vault) out = out.replaceAll(id, value);
return out;
}
export const register: Register = (on) => {
// Hook 1: scrub the prompt before it is written to the transcript or shown to the model.
on("prompt.submit", ($, e, next) => {
const text = redact(e.text);
if (text !== e.text) {
$.ui.log("transcript-redactor: replaced " + vault.size + " sensitive value(s) with placeholder ids");
}
return next({ ...e, text });
});
// Hook 2: put the real value back only inside the command that runs it.
on("tool.call", { tool: "Bash" }, ($, e, next) => {
return next({ ...e, command: restore(e.command) });
});
};Reading it top to bottom:
- →The
vaultis a plain Map in module scope. It survives across turns within the session and disappears when the session ends. That is the correct lifetime for a secret. - →Detection runs in two passes. Known key prefixes, emails, and IPv4 addresses are matched by shape. Anything else that is 32 characters or longer is scored by Shannon entropy, and only random-looking strings are stashed, so a long file path or a slug is left alone.
- →The
prompt.submithandler is the guard. It rewrites the prompt text and passes the clean version down the chain withnext, so the transcript and the model only ever see the placeholder. - →The
tool.callhandler is the reverse. When Claude writes a curl command containing<SECRET_1>, the hook restores the real key in the command that executes, and the transcript still records the placeholder.
Step 3. Load and test it
- Send
/reload-plugins, or start a new session in the project. Confirm the plugin appears in the + menu's plugin list astranscript-redactor@skills-dir. - Paste a test key into the prompt. A throwaway key from a provider dashboard that will be deleted afterwards is the right test material; never a production key. Ask Claude to make a request with it, as the video does with a short-story request to the Anthropic API.
- Watch the prompt. The key should be replaced by a placeholder such as
<SECRET_1>as soon as the message is sent, and the notice line should appear. - Watch the Bash call. The command Claude writes contains the placeholder; the request still succeeds because the hook restored the value at execution time.
- Open the transcript file under
~/.claude/projects/and search for the key. It should not be there.
Two limits to respect
More function hooks to build
The rest of the video is a tour of what the primitives make possible. Each of these is a one-paragraph ask to /plugin-authoring, and several were built on camera.
| Hook | Primitive | What it does |
|---|---|---|
| Package manager guard | tool.call rewrite | Replaces npm with pnpm in every Bash command so the wrong lockfile never appears |
| Fetch cache | tool.call plus $.store | Checks the store before a WebFetch and returns the cached page instead of refetching |
| Search provider swap | tool.call short-circuit plus $.http | Intercepts the built-in WebSearch tool, calls a preferred search API when a key is present, falls back otherwise, and returns results in place. The MCP server for that provider can then be uninstalled |
| Deploy status row | $.ui.render plus $.clock | Shows the current Vercel deployment stage and elapsed time in a new row beneath the prompt while a deploy runs, with a button to hide it. Built live in the video; a deploy showed queued, building, and ready over five minutes fifty seconds |
| Dry-run gate | tool.call deny plus $.ui.ask | Blocks a real data-analysis command until the dry-run version has run and been shown, and prints a large warning when running against production |
| Long-file refactor prompt | tool.call plus $.ui.ask | When an edit targets a file beyond 1,000 lines, asks whether to split it first |
| Spoken turn summary | turn.complete plus $.model plus $.audio | Passes the finished turn to a Haiku model for a summary and reads it aloud with the operating system voice. Built live in the video |
| Knowledge-base context | prompt.submit plus $.model plus $.http | Generates keywords from the prompt, queries an internal knowledge base, and injects the results as context |
| Compliance audit log | on("*") | Forwards every event to the organisation's log store for healthcare or payments audit trails |
| Newsletter send gate | tool.call deny plus $.ui.ask plus $.clock | Refuses to send a newsletter until the user has confirmed through a question and enough time has passed to have read the draft |
| PR comprehension quiz | $.model plus $.ui.ask | Generates a short quiz about the changes before allowing a pull request to open |
The video's closing suggestion is the useful mindset: point /plugin-authoring at an existing CLAUDE.md and ask which of its rules could become hooks. Rules that move into a hooks file stop depending on the model's attention, and because function hooks are packaged as plugins they can be shared with a team through a git repository or a private marketplace.
Debugging and troubleshooting
The hook never fires
Send /hooks and check the event has a count. Matchers are case-sensitive and must match the tool name exactly. Confirm the event is the right one: PreToolUse runs before the tool, PostToolUse after it. Project hooks only load from the session's working directory.
A hook error appears in the transcript
The script exited with an unexpected code. Test it by hand in the integrated terminal by piping sample JSON into it and printing the exit code. A "command not found" usually means a relative path; use ${CLAUDE_PROJECT_DIR} or the exec form with an args array. A JSON parse or validation message means stdout looked like JSON but was not valid, which is why the examples above build output with JSON.stringify rather than string concatenation.
/hooks shows nothing after an edit
The file watcher may have missed the change, or the JSON is invalid. Trailing commas and comments break the file. Restart the session to force a reload.
A Stop hook loops
Claude Code overrides a Stop hook after eight consecutive blocks without progress. A command Stop hook should read stop_hook_active from its input and exit 0 when it is true.
Reading the full log
In the CLI, claude --debug-file /tmp/claude.log writes every matched hook, its exit code, stdout, and stderr to a known path, and /debug turns logging on mid-session. The same log is where function-hook problems surface: testers reported that a module skipped because the flag is off logs a "hooks modules not loaded" line, and that a throwing function hook logs "hook failed ... skipped" and is routed around.
Security notes
Hooks run with the permissions of the user account. Anthropic's reference asks users to review hook code before adding it, and the same applies to plugins from a marketplace, which can carry hooks of their own. Three more points from the documentation and the proposal thread are worth keeping in view:
- →The
envkey in a project's settings file is committed to the repository. It is the wrong place for any secret. The local environment editor in the desktop app stores values encrypted on the machine. - →HTTP hooks only resolve environment variables listed in
allowedEnvVars, and organisations can restrict endpoints withallowedHttpHookUrls. Managed settings can also setallowManagedHooksOnlyso that only admin-deployed hooks run. - →In the function-hook model, the first plugin registered wraps everything below it. The proposal describes this as an onion: an administrator prepends a plugin and no plugin further down the chain can inhibit it, and admins can remove affordances from
$so that later plugins cannot invoke that side effect at all.
For a wider view of how Claude Code compares with other agent runtimes on exactly this kind of control, the AI agent harnesses reference and the Claude Code vs OpenClaw guide cover the trade-offs. Readers still choosing a coding agent can start with choosing an AI coding assistant.
FAQ
Do Claude Code hooks work in the desktop app?+
Yes. The desktop app's Code tab and the CLI read the same settings files, so a hook in ~/.claude/settings.json or in a project's .claude/settings.json runs in both. The /hooks command lists what is configured. Terminal-dialog commands such as /permissions are not available in the Code tab, so hooks are added by editing the settings file directly or by asking Claude to write the hook.
What is the difference between a hook and a rule in CLAUDE.md?+
A CLAUDE.md rule is advice the model may forget as the context fills up. A hook is code that Claude Code runs at a fixed point in its lifecycle, so it happens every time regardless of what the model decides. Anthropic's documentation describes hooks as deterministic control for exactly this reason.
Can a hook block a command even in bypass permissions mode?+
Yes. According to Anthropic's hooks guide, PreToolUse hooks fire before any permission-mode check, and a hook that returns permissionDecision deny blocks the tool even in bypassPermissions mode. Hooks can tighten restrictions but cannot loosen them past what permission rules allow.
What are function hooks and are they released?+
Function hooks are a proposed hook type where a TypeScript module wraps Claude Code's own behaviour like Express middleware. Anthropic engineer Alice Poteat opened the proposal on GitHub on September 3, 2026 and said community response would decide whether it ships. A preview runtime is present in Claude Code 2.1.260 and later behind the CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 environment variable. The API is a preview and can change.
Does a secret redactor hook replace rotating a leaked API key?+
No. The redactor keeps a pasted key out of the session transcript and out of the model's context. If a key has already been exposed anywhere, the correct response is still to rotate it. The hook is a guardrail, not a recovery tool.
Do hooks need jq or Bash on Windows?+
No. Shell-form hooks run through Git Bash on Windows, which the desktop app already requires, but jq is not installed by default. A hook can call a Node.js or PowerShell script instead. Using the exec form with an args array skips the shell entirely, which is the most portable option.
Sources
- Anthropic, Automate actions with hooks (Claude Code documentation)
- Anthropic, Hooks reference (Claude Code documentation)
- Anthropic, Desktop application (Claude Code documentation)
- Anthropic, Create plugins and Plugins reference (Claude Code documentation)
- Alice Poteat, Function Hooks - make plugins 10x more powerful, anthropics/claude-code issue #91870, September 3, 2026, including community test reports in the thread
- Ray Amjad, Anthropic Just Dropped the Biggest Claude Code Update Yet, YouTube, September 2026
- Prettier and jq, the tools used in the official formatting examples
Related Guides
Install the Secret Redactor With One Prompt →
Turn on the preview and let the built-in skill build the plugin.
What /plugin-authoring Unlocks →
Everything the skill can build once the flag is on.
Claude Code Scheduled Tasks and /loop →
Self-checking development workflows that pair well with a Stop hook.
Claude Code topic hub →
Every article, guide, and knowledge page on Claude Code in one place.

