Reinventing.AI
Claude Code hooks guide, function hooks and secret redactor
Agent OperationsSeptember 6, 2026• 19 min read

Claude Code Hooks: The Complete Guide to Building Hooks in the Desktop App, Including Function Hooks

Hooks are the part of Claude Code that does not depend on the model remembering anything. They run at fixed points in a session, they can block, rewrite, or annotate what Claude is about to do, and since September 2026 a preview of function hooks lets a TypeScript module sit inside the engine like Express middleware. This guide goes from a first hook in the desktop app to the secret redactor that has become the showcase example for the new system.

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.

EventFires whenCan it block?Typical use
SessionStartA session starts, resumes, clears, or compactsNoInject context, load environment
UserPromptSubmitThe user sends a prompt, before Claude reads itYesAdd context, reject prompts
PreToolUseBefore any tool call runsYesBlock or rewrite dangerous commands
PermissionRequestClaude Code is about to ask for permissionDecidesAuto-approve or deny known cases
PostToolUseAfter a tool call succeedsNoFormat files, log commands
PostToolUseFailureAfter a tool call failsNoAlerting, retries
NotificationClaude is waiting for input or permissionNoDesktop notifications
StopClaude finishes a turnYesKeep working until tests pass
PreCompact / PostCompactAround context compactionNoSave state, re-inject rules
ConfigChangeA settings or skills file changes mid-sessionYesAudit trail, block unauthorised edits
SessionEndThe session terminatesNoCleanup

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.

FileScopeShareable
~/.claude/settings.jsonEvery project on the machineNo
.claude/settings.jsonOne projectYes, commit it to the repo
.claude/settings.local.jsonOne project, one machineNo, gitignored
Plugin hooks/hooks.jsonWhile the plugin is enabledYes, ships with the plugin
Skill or subagent frontmatterWhile that skill or agent is activeYes, inside the file
Managed policy settingsWhole organisationAdmin-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. /hooks is 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 env key 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 as name@skills-dir, which is the route this guide uses for function hooks.

The fastest way to add any hook in the desktop app

Describe the hook in the chat and let Claude write it. Anthropic's own quickstart points to the same shortcut: describe the hook and let Claude write it. The steps below show the manual route so the file layout is clear, but in practice most hooks in this guide can be produced by prompting.

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.

  1. 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.
  2. 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, the hooks key is added next to the existing keys, and a new event is added as a sibling inside the one hooks object rather than replacing it.
  3. Paste the hook.
    .claude/settings.json
    {
      "hooks": {
        "PostToolUse": [
          {
            "matcher": "Bash",
            "hooks": [
              {
                "type": "command",
                "command": "cat >> .claude/bash-log.jsonl"
              }
            ]
          }
        ]
      }
    }
    Each line in the log is the full JSON that Claude Code handed the hook, including tool_input.command and the tool's response. Add .claude/bash-log.jsonl to .gitignore so the log stays local.
  4. Verify it registered. Send /hooks in the chat. The browser lists every event with a count next to the ones that have hooks. PostToolUse should 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.
  5. 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:

stdin (PreToolUse)
{
  "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 codeMeaningWhat Claude sees
0Success. The action proceeds.Nothing, unless stdout is JSON with a decision, or the event is one that forwards stdout (UserPromptSubmit and SessionStart do)
2Blocking error on events that can block.The stderr text, or the JSON reason, as feedback so it can adjust
Anything elseNon-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:

stdout (exit 0)
{
  "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"
}
  • permissionDecision is allow, deny, or ask. Allow skips the permission prompt; ask forces one.
  • updatedInput rewrites the tool's arguments before it runs. This is how a classic hook swaps npm for pnpm or adds a flag.
  • additionalContext injects 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

Claude Code only parses stdout as JSON when it starts with a curly brace and ends with one. If a shell profile prints a greeting before the hook runs, the output no longer starts with a brace and the decision is silently ignored. The hooks guide recommends wrapping any echo in a shell profile so it only runs in interactive shells.

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:

.claude/settings.json (excerpt)
{
  "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.

.claude/hooks/protect-files.js
// 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);
.claude/settings.json
{
  "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.

.claude/hooks/guard-commands.js
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);
.claude/settings.json (add to PreToolUse)
{
  "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.

.claude/settings.json
{
  "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.

~/.claude/settings.json (macOS)
{
  "hooks": {
    "Notification": [
      {
        "matcher": "permission_prompt|idle_prompt",
        "hooks": [
          {
            "type": "command",
            "command": "osascript -e 'display notification \"Claude Code needs your attention\" with title \"Claude Code\"'"
          }
        ]
      }
    ]
  }
}
~/.claude/settings.json (Windows)
{
  "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.

.claude/settings.json
{
  "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.

.claude/settings.json
{
  "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.

~/.claude/settings.json
{
  "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.

hooks/guard.ts (preview API)
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.

CapabilityClassic hookFunction hook
Rewrite a tool callupdatedInput on PreToolUsenext with a changed event
Rewrite the user's promptNot possibleprompt.submit handler
Short-circuit a tool with a cached resultNot possibleReturn a result without calling next
Draw a status row or buttonsNot possible$.ui.render, ui.press events
Ask the user a questionOnly through permission prompts$.ui.ask (confirmed as intended in the thread)
Register a new toolNot possible$.tool.register
Call a model from inside the hooktype: prompt hook only$.model
Remember stateWrite files yourself$.store, a JSON store per plugin
See every event at onceOne entry per eventon("*") for an audit log
Cross-platformDepends on the shellOne runtime, no shell

Preview status, stated plainly

Function hooks have not shipped. The proposal is open for feedback, the API in the binary is a prototype, and details such as event names, the store's scope, and what happens when a hook throws are still being designed in the thread. Testers reported that a module is skipped silently when the flag is off and that a throwing hook is skipped rather than failing loudly. Keep hard policy in classic hooks until this ships, and treat the code below as a working sketch against the preview, not a stable API.

Enabling the preview in the desktop app

The CLI route is one line: start Claude Code with the variable set.

Terminal (macOS or Linux)
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.

~/.claude/settings.json
{
  "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.

Chat
/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.

Layout
~/.claude/
└── skills/
    └── transcript-redactor/
        ├── .claude-plugin/
        │   └── plugin.json
        └── hooks/
            ├── hooks.json
            └── redact.ts

Build 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.

Chat
/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.

~/.claude/skills/transcript-redactor/.claude-plugin/plugin.json
{
  "name": "transcript-redactor",
  "description": "Keeps secrets, emails, and IP addresses out of the session transcript",
  "version": "0.1.0"
}
~/.claude/skills/transcript-redactor/hooks/hooks.json
{
  "modules": ["./redact.ts"]
}
~/.claude/skills/transcript-redactor/hooks/redact.ts (preview API)
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 vault is 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.submit handler is the guard. It rewrites the prompt text and passes the clean version down the chain with next, so the transcript and the model only ever see the placeholder.
  • The tool.call handler 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

  1. Send /reload-plugins, or start a new session in the project. Confirm the plugin appears in the + menu's plugin list as transcript-redactor@skills-dir.
  2. 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.
  3. 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.
  4. Watch the Bash call. The command Claude writes contains the placeholder; the request still succeeds because the hook restored the value at execution time.
  5. Open the transcript file under ~/.claude/projects/ and search for the key. It should not be there.

Two limits to respect

First, the redactor only covers what passes through the hooks it registers. Output printed by a command is a separate path; the version the skill generates in the video also scrubbed tool output, and a production version should. Second, the video notes that a persistent store exists for values that should survive restarts, and advises against using it for secrets. Testers reported the preview store is a JSON file per plugin under the user's Claude directory, which is exactly where a secret should not be written.

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.

HookPrimitiveWhat it does
Package manager guardtool.call rewriteReplaces npm with pnpm in every Bash command so the wrong lockfile never appears
Fetch cachetool.call plus $.storeChecks the store before a WebFetch and returns the cached page instead of refetching
Search provider swaptool.call short-circuit plus $.httpIntercepts 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 $.clockShows 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 gatetool.call deny plus $.ui.askBlocks 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 prompttool.call plus $.ui.askWhen an edit targets a file beyond 1,000 lines, asks whether to split it first
Spoken turn summaryturn.complete plus $.model plus $.audioPasses 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 contextprompt.submit plus $.model plus $.httpGenerates keywords from the prompt, queries an internal knowledge base, and injects the results as context
Compliance audit logon("*")Forwards every event to the organisation's log store for healthcare or payments audit trails
Newsletter send gatetool.call deny plus $.ui.ask plus $.clockRefuses 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.askGenerates 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 env key 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 with allowedHttpHookUrls. Managed settings can also set allowManagedHooksOnly so 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

Agent Ops Club by Reinventing.AI

Reading about AI agents is step one.Running them for your business is the Club.

8 open source AI Employees, the 45 lesson Agent Ops Masterclass, and 22 premium systems you can customize, deploy for clients, and charge for under a resale license.

Get Lifetime for $499See what is inside →

Pro is $99 a month or $349 a year. Lifetime is $499 until October 31, then $999.