Claude Code hooks used to be shell scripts. Now they can be TypeScript functions that run inside the engine, with typed events, shared memory, and a next() call like Express middleware. This post explains the model in plain terms, then walks through five small plugins that were actually run: a command guard, a secrets shield, a memory that injects context, a fetch cache, and an audit log.
A Claude Code hook is a piece of code that runs when something happens: a tool is about to run, a prompt was submitted, a turn ended. Until now, a hook was a shell command. Claude Code spawned it, piped JSON in, and read JSON back.
A function hook is the same idea written as a TypeScript function. The function runs inside the engine, gets the event as a typed object, and decides what happens next by returning a value. If you have written Express or Koa middleware, you already know the shape.
Function hooks are in Claude Code 2.1.260 and later, behind an environment variable, and marked early access. Everything below was run on that version. The API will move, so treat the generated types as the truth and this post as the map.
!A developer at a terminal, a chain of small functions wrapping one tool call
The same guard, both ways
Here is a hook that blocks rm -rf as a shell script. It lives in .claude/hooks/block-rm.sh and is wired up in settings.json:
``bash
#!/bin/bash
INPUT=$(cat)
CMD=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
if echo "$CMD" | grep -qE 'rm -rf'; then
jq -n '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:"rm -rf is blocked"}}'
fi
exit 0
`
`json
{ "hooks": { "PreToolUse": [ { "matcher": "Bash",
"hooks": [ { "type": "command", "command": ".claude/hooks/block-rm.sh" } ] } ] } }
`
The same hook as a function. hooks/hooks.json names a module instead of a command:
`json
{ "modules": ["./guard.ts"] }
`
And hooks/guard.ts is:
`ts
export function register(on) {
on("tool.call", { tool: "Bash" }, ($, e, next) ={
if (/\brm\s+-rf\b/.test(e.command)) {
return { deny: "rm -rf is blocked in this repo." }
}
return next(e)
})
}
`
What changed is not the line count. It is what each side can do:
The three arguments
Every hook has the signature ($, e, next).
e is the event. For tool.call it is the tool name plus the tool's own arguments as top-level fields. With the matcher { tool: "Bash" }, TypeScript knows e.command is a string. With { tool: "Write" }, it knows e.file_path and e.content.
next(e) runs everything beneath you, the other plugins and then the engine itself, and resolves to the result. Return without calling it and you have answered the call yourself. Call it with a changed copy and you have rewritten what everything below sees.
$ is the engine object, the only way a hook reaches the outside world. There is no Node and no filesystem inside the module. Reading a file is $.fs.readFile. Saving state is $.store.set. Asking the model a side question is $.model.complete. Showing a toast is $.ui.toast. Because $ is the only door, claude plugin validate can read your source and list exactly what your plugin hooks and what it calls.
Where you put next decides when your code runs:
!One tool call passing through nested hooks, the outer one seeing everything
Setup
`bash
claude --version # 2.1.260 or later
`
A plugin is a folder:
`
my-plugin/
├── .claude-plugin/
│ └── plugin.json # { "name": "my-plugin", "version": "0.1.0", "description": "..." }
└── hooks/
├── hooks.json # { "modules": ["./main.ts"] }
└── main.ts # export function register(on) { ... }
`
Check it, then load it for one session:
`bash
claude plugin validate ./my-plugin
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir ./my-plugin
`
In an interactive session the folder is watched. Save the file and the module reloads. To get autocomplete, run /plugin-types inside the session. It writes .claude/types/claude-code.d.ts with every event, every method on $, and the tsconfig.json to use, all for the exact build you are running. Regenerate it after each update.
That is the whole setup. Now five plugins, each solving one real problem, each run for real.
A guard that fixes instead of blocking
The problem: this repo uses bun. The model keeps typing npm. A deny would work, but a rewrite is better, because the model never has to retry.
`ts
export function register(on) {
on("tool.call", { tool: "Bash" }, ($, e, next) ={
if (/\brm\s+-rf\b/.test(e.command)) {
return { deny: "bun-guard: rm -rf is blocked in this repo." }
}
if (/^npm\b/.test(e.command)) {
return next({ ...e, command: e.command.replace(/^npm/, "bun") })
}
return next(e)
})
}
`
Asked to run npm --version and then rm -rf ./nothing-here, the model reported:
`
npm --version -1.3.5
rm -rf ./nothing-here -bun-guard: rm -rf is blocked in this repo.
`
1.3.5 is bun's version. The shell never saw npm. The deny string went back to the model as a tool error it can read and act on.
A shield for secrets files
The problem: the agent should never write to .env files. With shell hooks you would regex the whole JSON blob. Here you match the tool and read the path as a field. The matcher takes an array, so one hook covers both Write and Edit.
`ts
export function register(on) {
on("tool.call", { tool: ["Write", "Edit"] }, ($, e, next) ={
if (/(^|\/)\.env(\..*)?$/.test(e.file_path)) {
return { deny: env-shield: ${e.file_path} holds secrets. Ask the user to edit it by hand. }
}
return next(e)
})
}
`
Asked to create .env.local and then notes.txt:
`
notes.txt is done. .env.local was blocked with this error:
env-shield: .../.env.local holds secrets. Ask the user to edit it by hand.
Make it yourself with: printf 'API_KEY=test123\n' .../.env.local
`
The model did the allowed step, quoted the deny, and handed the secret step back to the human. That is the behavior you want, and it came from a five-line rule, not a paragraph in CLAUDE.md.
A memory that injects context
The problem: repo conventions live in CLAUDE.md and fade as the context fills up. A prompt.submit hook attaches a context block to every prompt, and $.store remembers things across sessions.
`ts
import type { Register } from "claude-code"
export const register: Register = (on) ={
on("session.start", async ($, e, next) ={
const count = Number((await $.store.get("sessions")) ?? 0) + 1
await $.store.set("sessions", count)
return next(e)
})
on("prompt.submit", async ($, e, next) ={
const r = await next(e)
if (r.drop) return r
const count = await $.store.get("sessions")
const note = Repo conventions: the package manager is bun, never npm. This is session #${count} with this plugin loaded.
return { ...r, context: [...(r.context ?? []), note] }
})
}
`
The second hook awaits next(e) first, so any plugin beneath it can rewrite or drop the prompt, then appends its block to whatever came back. The user never sees the block. The model does, on every prompt.
Run twice with claude -p "Which package manager does this repo use, and which session is this?":
`
run 1: Bun. Session #1.
run 2: This repo uses bun, not npm. This is session number 2.
`
The store is a JSON file under ~/.claude/plugins/store/, one per plugin.
A cache for tool results
The problem: the agent fetches the same URL over and over. This hook runs the call once, saves the result, and answers from the store next time. It uses both "after" and "replace" in one function.
`ts
export function register(on) {
on("tool.call", { tool: "WebFetch" }, async ($, e, next) ={
const key = "fetch:" + e.url
const hit = await $.store.get(key)
if (hit) return { result: hit }
const r = await next(e)
if (!("deny" in r) && !r.isError) await $.store.set(key, r.result)
return r
})
}
`
Two separate sessions, same prompt, Fetch https://example.com and reply with its h1. Both answered Example Domain. The second one never made a network request. The engine validates a hook's result against the tool's output schema, so what you return has to be the shape the tool would have produced, which is why the hook stores r.result and nothing else.
The same shape works for anything expensive: an MCP tool that hits a rate-limited API, a slow test runner, a search.
An audit log, and why plugin order matters
The problem: you want a record of every tool call, including the ones other plugins denied, with timing. One hook on tool.call with no matcher sees them all.
`ts
export function register(on) {
on("tool.call", async ($, e, next) ={
const started = $.clock.now()
const r = await next(e)
const line = JSON.stringify({
at: new Date(started).toISOString(),
tool: e.tool,
ms: $.clock.now() - started,
denied: "deny" in r ? r.deny : undefined,
})
const path = (await $.session.cwd()) + "/audit.jsonl"
const prev = (await $.fs.exists(path)) ? await $.fs.readFile(path) : ""
await $.fs.writeFile(path, prev + line + "\n")
return r
})
}
`
Loaded together with the secrets shield from example 2, and asked again to write .env.local and notes.txt:
`json
{"at":"2026-09-04T13:55:09.694Z","tool":"Write","ms":0,"denied":"env-shield: .../.env.local holds secrets. Ask the user to edit it by hand."}
{"at":"2026-09-04T13:55:10.487Z","tool":"Write","ms":140}
`
The denied call shows up with the deny reason. But only because the audit plugin was loaded first. Plugins nest in registration order: the first one wraps all the others. When the shield was loaded first, it denied without calling next, and the audit plugin beneath it never ran. The log had one line.
That is the rule to remember. Whatever you load first sees every event first and every result last. Put logging and admin controls first. Put defaults last. In managed settings, an admin can prepend a plugin that removes a method from $ entirely, and nothing beneath it can call that method. Not a rule the agent is asked to follow. A method that is not there.
!An admin switching off one capability so no plugin beneath can use it
What else is on $
The five plugins used store, fs, clock, and session. The rest of the engine object, from the generated types:
• tool.call, tool.list, tool.register to add a tool the model can use
• model.complete, model.classify, model.fork for a side conversation
• ui.ask, ui.toast, ui.status, ui.log, and ui.resolve for drawing your own components
• http.fetch, process.run (argv only, no shell), mcp.call
• agent.spawn, agent.list, prompt.submit, audio.speak
And events beyond tool.call and prompt.submit: tool.describe to rewrite a tool's description, prompt.context and prompt.section to edit what the model reads at the start, turn.complete to post-process an answer, ui.render and ui.press to draw and react, agent.spawn to control subagents, session.start for setup.
Where the limits are
A failing hook is skipped. The chain continues without it and the reason goes to claude --debug. For a logger that is right. For a deny it means fail-open, so keep your most important denies in the old command hooks too. Both kinds run on the same events.
MCP calls go through tool.call. A matcher like { tool: "mcp__github__create_issue" } sees that call's structured arguments. This is what makes example 5 a complete audit log.
It is early access. The header of the type file says so. Build plugins now to learn the model. Do not make one your only security boundary yet.
The bottom line
A shell hook is a contract with your operating system. A function hook is a contract with the engine. The event is a typed object, the result is a return value, memory is a method call, and the order you load plugins in is the order they wrap each other.
Every plugin in this post is under 25 lines. Together they replace a CLAUDE.md paragraph, a regex over JSON, a hand-rolled cache, and a log script that never saw the calls that mattered.
Set the flag. Copy the guard. Run /plugin-types` and read the file it writes. The documentation is in there.
Sources:
• Function Hooks proposal, anthropics/claude-code #91870
• Function Hooks: Core Architecture (PDF)
• Hooks reference, Claude Code Docs