Code Kit 5.7 is out now, rebuilt for the Claude 5 family. Includes access to our MCP: serving up our entire blog for your Claude to analyze.
Claude FastClaude Fast
Hooks

Claude Code Function Hooks: TypeScript Middleware

Claude Code function hooks are TypeScript middleware for plugins. The runtime sits in build 2.1.260 behind a flag, and it has not shipped.

Stop configuring. Start shipping.Everything you're reading about and more..
Agentic Orchestration Kit for Claude Code.

Claude Code function hooks are a proposed new hook type: a TypeScript module whose functions wrap the engine's own behaviour, Express-style, instead of a shell script reacting to it after the fact. They have not shipped. Anthropic engineer Alice Poteat opened the proposal on September 3, 2026 with the sentence that decides the whole thing: "the response from the community likely dictates whether this ships or not." What makes the proposal unusual is that the runtime is already sitting inside the binary you have installed, behind a flag, so you can run it today. This post is written against that build rather than against the pitch, and it assumes you know the current Claude Code hooks model.

What has actually shipped, and what has not

Four separate facts get collapsed into "Anthropic shipped function hooks" and they are worth keeping apart.

  • The proposal is public and open. Issue #91870, labelled enhancement, area:hooks, area:plugins, with an architecture PDF (Function Hooks: Core Architecture, Alice Poteat, August 2026) and nine demo videos. The comment thread passed eighty inside its first day.
  • Anthropic said it has not shipped. The @ClaudeDevs account posted the same day: "We're exploring a new way to let you extend and customize Claude Code: Function Hooks. Here's a couple videos showing what you'd be able to do. It hasn't shipped yet, we'd love feedback on this on our GitHub issue."
  • The documentation does not know about it. The hooks reference lists five handler types: command, http, mcp_tool, prompt, agent. The plugins reference documents hooks/hooks.json with no modules key. No entry in the public changelog through 2.1.260 mentions function hooks.
  • The runtime is in the binary anyway. Claude Code 2.1.260, the newest release, carries the engine behind a default-off flag with an environment-variable override. Turn it on and two things appear that are not in any release note: a bundled plugin-authoring skill, and a /plugin-types command that writes the API's TypeScript declarations from the running build.

Poteat's one public remark on the flag, replying in-thread on September 4, is a joke that is also a confirmation: "I disavow knowledge of any such flag. [...] If such a flag existed, I would put the typings under a /plugin-types command."

So: early access, in the shipping binary, officially unshipped, and its fate is a comment thread. Build against it to learn the shape, not to run production on it. The generated declarations say so themselves on line four: "EARLY ACCESS: this surface may change between releases without notice."

How to turn function hooks on

One environment variable, then a normal session:

CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude

With the flag set, /plugin-types writes the contract to disk. On this machine, Windows 11 with 2.1.260, it produced a 213 KB claude-code.d.ts covering "the plugin API (module 'claude-code', early access) and 35 built-in tools" plus a claude-code-mcp.d.ts carrying "13 MCP tools from 1 server":

CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude -p "/plugin-types ./types"

That second file is the reason MCP work stays typed: tool.call fires for MCP tools too, so e.tool === "mcp__github__create_issue" narrows e to that server's argument shape. If you have not wired MCP servers yet, start with MCP basics before the typings will mean much.

Regenerate rather than edit. The declarations are written from the running build, so they are correct for exactly one version.

The middleware model in one file

A plugin is unchanged: a folder with .claude-plugin/plugin.json. The new part is one key in hooks/hooks.json.

{
  "modules": ["./my-hooks.ts"]
}

That names a hooks module: one .js, .ts, .jsx or .tsx file beside hooks.json, transpiled in-process by Bun. The file exports register, and inside it you register hooks on events.

// hooks/my-hooks.ts
import type { Register } from "claude-code";
 
export const register: Register = (on, options) => {
  on("tool.call", { tool: "Bash" }, ($, e, next) => {
    if (e.command === "rm -rf /") {
      return { deny: "Destructive command blocked by hook" };
    }
    return next(e);
  });
};

Three parameters carry the whole model:

  • $ is the engine interface. Every capability a hook has: the screen, the model, the session, the filesystem, the network, host commands. The environment has no DOM and no Node, so $ is the only door out.
  • e is the event, the method's argument as frozen plain data. On tool.call it is what $.tool.call was called with: the tool and its arguments as own fields, which is why e.command exists once the matcher has narrowed e.tool to Bash.
  • next(e) is the continuation. It runs the hooks beneath yours and then the engine's own behaviour, and resolves to the event's result.

Return without calling next and you have answered for the engine. Call next({ ...e, x }) and you have rewritten what everything beneath you sees. Await next(e) and you can read and edit the result on the way back out. The architecture PDF names five placements on one event: before, after, during, instead, and modifying. Shell hooks need a PreToolUse and a PostToolUse to get two of those; a function hook gets all five from one registration.

options holds the values of the fields your manifest's userConfig declares, typed as Readonly<Record<string, string | number | boolean | readonly string[]>>. Existing command, prompt, agent, http and mcp_tool hooks keep running beside the module. This is additive, which matters for anyone who has already invested in a plugin distribution setup.

Shell hooks vs function hooks

The comparison that matters is not "TypeScript is nicer than bash." It is what each contract can express.

CapabilityShell hook (command)Function hook
InputJSON on stdine, typed per event and narrowed by matcher
Outputexit code plus stdout JSONa return value typed per event
Failure signalexit code onlya typed result, or a throw the engine routes around
Pre and posttwo events, two scriptsone event, five placements
Rewrite a tool's argumentsupdatedInput on PreToolUsenext({ ...e, command }) on tool.call
Read the tool's resultPostToolUse, after the factawait next(e) in the same hook
Draw anythingnot possibleui.render returns a JSX tree
Ask the user a questiononly as a permission prompt (ask)$.ui.ask(question, options), any question
Register a tool the model can callnot possible$.tool.register(spec)
Rewrite a tool's descriptionnot possibletool.describe
State across hooksa file you managemodule variables, plus $.store across sessions
Cross-platformyour problem (and it bites in practice)one runtime, no shell, no spawn
Composition with other pluginsnone; hooks run side by sideregistration order is nesting

The last row is the structural one, and the cross-platform row is the one most teams will feel first. A command hook is a contract with an operating system, which is why cmd /c wrappers and python3 shebangs break the moment a repo is shared. A hooks module has no shell in the picture at all.

The events, and the $ interface

Read the generated declarations and the surface is exactly two lists.

The twenty events the engine raises

PreToolUse (kept for parity with settings hooks), tool.call, ui.render, ui.resolve, ui.press, ui.input, ui.select, agent.offer, agent.spawn, prompt.submit, prompt.section, prompt.context, tool.describe, skill.prompt, attribution.text, session.start, turn.start, turn.step, turn.complete, and engine.create.

Several of those have no equivalent in the current hook set. prompt.section fires once per named section of the system prompt and resolves to { text }, so a hook can replace what a section says. tool.describe fires once per tool when the engine first renders its schema for the model, so you can rewrite the description the model reads. attribution.text fires when the engine composes a commit message or a PR body. skill.prompt fires when a skill expands.

Both prompt.section and tool.describe are cached per session, and the declarations warn why: "an unstable answer spends the model's prompt cache on every call." Rewriting either one on a timer is how you accidentally pay for a full prefix rebuild every turn.

The fifteen nouns on $

Most of what a hook can call is also an event. Reading $.session.messages() dispatches session.messages through every plugin above you, which is why an audit log can see a plugin's own filesystem writes. The declarations name thirty-four such calls in this build. The exceptions are few: $.clock's timers run inside the plugin's own environment with no host round trip, and $.ui.ask is a convenience over the engine's own dialog, so neither dispatches an event of its own.

Fifteen nouns, and what each gives you:

NounWhat it gives you
$.uilog, notice, toast, status, ask, invalidate, resolve
$.modelcomplete (one completion, default 256 tokens), classify, fork (tool-less, over the session's own transcript, sharing its prompt cache)
$.sessionmessages (newest 4096), cwd, model, turnCount, id, repo, surface
$.toollist, call, register
$.agentlist, spawn (always background, budgeted)
$.fsreadFile, writeFile, listDir, exists, stat, ancestors
$.storeget, set, delete, keys, persisted under ~/.claude/plugins/store/
$.clocknow, sleep, after, every
$.httpfetch
$.processrun(argv), no shell, 30 s default and ten minutes maximum
$.mcpcall(server, tool, args) on the engine's own connection
$.audioplay, speak
$.turnabort({ turnId })
$.promptsubmit({ text }), once the session is idle
$.pluginname and root, this plugin's own manifest name and absolute directory

Twenty engine events plus thirty-four calls is fifty-four hookable names, and on("*") registers on all of them. That is the property that makes an audit log one hook instead of a project.

Two consequences worth internalising. First, $.mcp.call runs with the engine's credentials and no permission prompt: as the declarations put it, "the plugin's call, seen by the hooks above it, is the grant." Permission moves from a dialog to plugin order, which is a real change to how you think about permission rules and modes. Second, session-scoped state is just a module-level variable, dropped when the module reloads, while $.store survives sessions. Choosing between them is a design decision, not an implementation detail.

Four patterns worth stealing

Each of these uses only fields and methods present in the 2.1.260 declarations. Ray Amjad demonstrated versions of the second, third and fourth in a walkthrough of the feature; the code below is written against the type declarations rather than transcribed from the video.

1. Refuse or rewrite a tool call

The single most common shell-hook job, done in one place. This one rewrites npm to pnpm and tells the model it did.

on("tool.call", { tool: "Bash" }, async ($, e, next) => {
  if (!e.command.startsWith("npm ")) return next(e);
 
  const command = e.command.replace(/^npm /, "pnpm ");
  const result = await next({ ...e, command });
  if (result.deny !== undefined) return result;
 
  return {
    ...result,
    context: [...(result.context ?? []), `Ran ${command} instead of npm.`],
  };
});

The context array is the part people miss. Asked directly whether a rewritten write shows the model the new content, Poteat answered: "The model sees what it asked to write, for fundamental prompt caching reasons. However, you can append your own small system reminder message to prevent Claude from getting confused, mentioning what you changed if anything." Poteat hedged the mechanism as a belief about the internal prototype; the 2.1.260 declarations settle it, since context is a field on the tool.call result. So the model reads its own command and your note, and the prompt cache survives. A rewrite with no context is a model reasoning about a command it never saw.

2. Redact a secret going in, restore it going out

The pattern that most obviously cannot be built with shell hooks, because it needs two events to share state.

const secrets = new Map<string, string>();
 
export const register: Register = (on) => {
  on("session.start", async ($, e, next) => {
    const saved = (await $.store.get("secrets")) as Record<string, string>;
    for (const [id, value] of Object.entries(saved ?? {}))
      secrets.set(id, value);
    return next(e);
  });
 
  on("prompt.submit", ($, e, next) => {
    let text = e.text;
    for (const [id, value] of secrets) text = text.replaceAll(value, id);
    return next({ ...e, text });
  });
 
  on("tool.call", { tool: "Bash" }, ($, e, next) => {
    let command = e.command;
    for (const [id, value] of secrets) command = command.replaceAll(id, value);
    return next({ ...e, command });
  });
};

The secret never reaches the model; the real value is put back at the moment the command runs. session.start fires once when the session is ready and is awaited before the first prompt, which is why a $.tool.register placed there is listed by turn one. It is the function-hook equivalent of the setup work covered in session lifecycle hooks.

Note the asymmetry between the two stores. secrets is a module variable and dies on reload; $.store is a JSON file under ~/.claude/plugins/store/ and does not.

3. Draw your own row above the prompt

ui.render fires per component instance. e.component is one of twelve names, e.props is the component's plain data, e.surface is terminal or desktop, and e.viewport gives you columns and rows.

on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
  if (deploy.stage === "idle") return next(e);
 
  const t = await $.ui.resolve(e);
  const seconds = Math.round((Date.now() - deploy.since) / 1000);
 
  return (
    <t.Box>
      <t.Text>{`deploy: ${deploy.stage} (${seconds}s)`}</t.Text>
      <t.Button
        key="hide"
        label="Hide"
        onPress={() => {
          deploy.stage = "idle";
          $.ui.invalidate("ui.render");
        }}
      />
    </t.Box>
  );
});

$.ui.resolve(e) returns the element table for the surface e is drawn on, because the terminal and the desktop do not draw the same primitives. A tree that does not validate is not drawn: the engine draws its own and writes ui.render (<Component>): a hook returned a tree that does not validate to the debug log, which is the first place to look when your row silently does not appear.

The onPress closure stays in your plugin's environment and raises a ui.press event carrying { plugin, element, component, surface }, where element is the button's key. Another plugin can hook that press by matcher and act before, after, or instead of your handler. Redraws are throttled to ten a second, so a $.clock.every(1000, ...) ticking a countdown is fine and a tighter loop is wasted.

JSX uses h as the factory, so the file needs the .tsx extension and a tsconfig with "jsx": "react", "jsxFactory": "h", "jsxFragmentFactory": "Fragment". The generated declarations ship that config in their header comment.

4. Speak the turn summary

turn.complete carries e.answer, e.durationMs, e.turnId and e.reason, and resolves to { text }.

on("turn.complete", async ($, e, next) => {
  const result = await next(e);
  if (e.reason !== "answer" || e.answer === "") return result;
 
  const line = await $.model.complete({
    model: "haiku",
    prompt: `One spoken sentence summarising this answer:\n\n${e.answer}`,
    maxTokens: 60,
  });
 
  void $.audio.speak(line);
  return result;
});

$.model.complete runs through the session's own API client and credentials, with no tools and no history. Per-plugin budgets exist: the binary carries the string "the session's model budget for this plugin is spent." $.audio.speak uses the platform synthesizer, which is say on macOS and speechSynthesis in the browser build, and rejects when there is no synthesizer, so a Linux user gets a rejected promise rather than silence you cannot explain.

Returning a different { text } shows that text beneath the answer. It never rewrites the transcript.

Order is nesting, and that is the admin story

on(X, A), on(X, B), on(X, C) folds to X = A(B(C(bottom))). Earlier registration wraps more and therefore controls more. Plugin order is: the plugins an administrator put first, then dependency order, then the plugins an administrator put last. Within a plugin, registration order.

Asked whether an admin-registered hook is immutable to downstream code, Poteat was unambiguous: "Yes. The Koa-inspired model means that the plugin registered first 'owns' all subsequent hooks on a given event instance. There is no capability for a plugin 'further down the chain' to inhibit a plugin above it; it's an 'onion model'."

Poteat also corrected the obvious misreading of "registration order": "by registration order we do not mean temporal; the order that they're configured to register in (however that is done), not when you installed them." Install date does not buy authority. Configuration does.

That gives an organisation three levers, all of them ordinary hooks in a plugin the admin prepends:

  • Which nouns exist on $ is a hook on engine.create. A step may add nouns and may withhold them, and it may not replace one another step added.
  • The audit log is a hook on *, four lines in the architecture PDF, seeing every call every plugin beneath makes.
  • Which plugins may exist at all is a hook on plugin.register. That one is proposal only: the PDF names it, and it is not in the 2.1.260 declarations, so the first two are the levers you can actually try today.

One caveat that the design documents do not spell out and a community test on 2.1.260 did: withholding a noun constrains plugins, not the model. An engine.create step that leaves out fs suppresses $.fs for the plugins beneath it, and the model's own Write tool still writes the file. $ is the plugins' capability table, not the agent's sandbox. For the agent's own boundary you still want sandboxing and permission rules.

Poteat was equally clear about where the boundary sits for capabilities themselves: "$.fs, $.http, and $.process will very likely exist. Our prerogative is not to restrict what plugins can do; that's your org admin's job. The point is that there's no ambient support, everything goes through $." That property is enforced statically. A module that touches $ any way other than $.noun.event(...) is refused at load, which means a plugin's side effects are listable before it runs. claude plugin validate <path> prints them.

What the first measurements say

This is where a launch post usually stops and repeats the vendor's number. The thread did better than that, and the results are worth knowing before you plan a migration.

Poteat's figure is dispatch cost: "keeping it all in-process on Bun is extraordinarily fast. we're looking at a p99 of 50μs per hook tbh." Independent runs on the shipped 2.1.260 binary, native Windows, with the flag on and one plugin directory per chained hook, back the dispatch claim and then complicate the conclusion. Median transcript gap from tool_use to tool_result on a six-byte Read, three runs each, community-reported:

CaseMedian
No hooks15 ms
1 function hook, pass-through16 ms
25 function hooks, pass-through20 ms
8 command hooks, 50 ms of work each401 ms
8 function hooks, 50 ms of work each424 ms
8 command hooks, 300 ms of work each640 ms
8 function hooks, 300 ms of work each2427 ms

Twenty-five pass-through hooks cost 5 ms over none at all, so the fold really is close to free. The last two rows are the finding. Command hooks in one matcher group run in parallel; a function-hook chain is strictly serial, because nothing above a link can proceed until its next(e) resolves. Eight parallel 300 ms hooks are one 640 ms wait. The same eight folded into a chain are 2427 ms.

That inverts the naive migration story for anyone who currently runs several independent guards on the same event. The gain from moving in-process is real, but it comes from amortisation, not dispatch: a rules engine that compiles once at register instead of once per spawn wins big, while eight slow independent checks folded into an onion get slower. A separate macOS profile in the same thread put spawn at 2 to 14 ms there against roughly 90 ms on Windows, which is also the honest version of the cross-platform argument: the people who gain most from losing the shell are the people paying the worst spawn tax.

Two more things that test surfaced, both consistent with the documents. A hook that throws is skipped and what is below it runs in its place, and the only record is one line in the debug file, matching the declarations: "a hook that fails (throws, overruns its budget, answers a wrong shape) is skipped." And the debug file already writes per-dispatch timings naming every link in the chain, which is the timing half of the next.trace Poteat floated in the thread.

Treat all of the numbers above as one machine's report on a preview build, not a benchmark. The shape is the transferable part.

The developer loop

The loop is the most finished part of the preview, which is telling about how it was built.

claude --plugin-dir ./my-plugin      # load from disk for this session, watched
claude --debug                       # the engine names what it refused and why
claude plugin validate ./my-plugin   # what the module hooks and what it calls
/plugin-types ./types                # regenerate the declarations
/reload-plugins                      # reload installed plugins

--plugin-dir watches the folder: saving a file reloads the module, register runs again in a fresh environment, and the previous environment's timers are dropped. That last clause is why a $.clock.every started outside session.start quietly stops surviving your own edits.

--debug is not optional. Function hooks fail by being skipped, so a plugin that appears to do nothing has almost always been told why in the debug log: a module that did not load, a hook that threw or overran its budget, a render tree that did not validate, or a result whose shape was refused. This is the same verification discipline that hook loops and self-validating agents both land on, for the same reason: when a hook modifies the conversation, the log is your instrument, not the agent's behaviour.

What is still open

Poteat named these in the thread, and they are the honest list of what you cannot rely on yet.

  • Whether it ships. "whether this ships at all or not depends on community feedback."
  • What lives on $. Still being designed with early access partners.
  • Error handling. Poteat is "very much leaning towards catching all errors, logging them, and routing around that hook," with next rejecting only for an invalid e, a call at the bottom, or a cancelled dispatch. That is what the current build does, but the design is not settled.
  • next.trace. Proposed, with per-link timing and value snapshots. Not in the declarations.
  • Typed errors. Named as "state of the art" and needing deep thought.
  • Surfaces. The prototype covers the CLI and Claude Code Desktop. "other surfaces are top of mind but tbd."
  • An agent completion event. Requested repeatedly in the thread. Not present.

A practical one the thread raised and nobody has answered: an older Claude Code that meets a modules key must skip it rather than reject the file. On 2.1.250, an unknown event key in hooks.json made the loader drop the whole file, so every hook in that plugin went silent with no message. If you ship a plugin carrying both kinds of hook, that is your compatibility cliff.

Where this leaves your CLAUDE.md

The most interesting second-order effect has nothing to do with plugins. A rule written in prose holds most of the time and fails exactly when it matters, which is the entire argument behind what to delete from your CLAUDE.md. Function hooks widen the set of rules that can stop being prose. "Never run npm" becomes a matcher. "Always check the file length first" becomes a tool.call hook that appends context. "Ask before touching production" becomes $.ui.ask. A guard is a net, not a teacher, and each rule you can move from instruction to hook is one you stop paying context for.

That is also the case for keeping skill activation and task enforcement as hooks rather than as paragraphs, and it is why ClaudeFast's Code Kit ships its rules as hooks and skills rather than as a longer CLAUDE.md. It is $89 if you would rather start from a working set than a blank .claude folder. If function hooks land, the shell hooks in that kit become the smaller half of the design, and the interesting question becomes which of them collapse into a single module.

Frequently asked questions

Are Claude Code function hooks released?

No. They are an open proposal, issue #91870, opened September 3, 2026. Anthropic's own post says "It hasn't shipped yet." The runtime exists in build 2.1.260 behind a default-off flag, which is early access, not release. Nothing in the public documentation or changelog mentions them, and the generated declarations carry an explicit early-access warning.

How do I enable function hooks in Claude Code?

Set CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 before launching, on a build that carries the runtime (2.1.260 does). With the flag on, /plugin-types writes the TypeScript declarations for your build, and a bundled plugin-authoring skill appears. Load a plugin under development with claude --plugin-dir <folder>.

What is the difference between function hooks and shell hooks?

A shell hook is a contract with an operating system: JSON on stdin, an exit code out, one process per event. A function hook is a contract with the engine: a typed event in, a typed result out, in-process, with next(e) giving you before, after, during, instead and modifying placements on a single event. Function hooks can also draw, ask questions, register tools and rewrite tool descriptions, none of which a shell hook can do.

How do I write hooks in Claude Code?

Today, in settings.json or a plugin's hooks/hooks.json: pick a lifecycle event, add a matcher, and point it at a command, http, mcp_tool, prompt or agent handler. The hooks guide's event table walks every event with a working example. With the function-hooks flag on, the same file gains a modules entry naming a TypeScript module, and each hook becomes an on(event, matcher, ($, e, next) => ...) registration like the ones above.

How do I develop Claude Code plugins?

A plugin is a folder with a .claude-plugin/plugin.json manifest plus any of skills, agents, MCP servers and hooks. Load it from disk with claude --plugin-dir <folder> while you work, check it with claude plugin validate <folder>, and distribute it through a marketplace once it settles; Claude Code plugins covers the packaging and rollout side. A function-hooks module is one more file in that folder's hooks/ directory.

Do function hooks replace hooks.json?

No. hooks.json gains one key, modules, naming a single hooks module per plugin. Existing command, http, mcp_tool, prompt and agent hooks keep running beside it, and the file must carry hooks, modules, or both.

Do function hooks work on Windows?

The runtime is in the cross-platform binary and there is no shell in the execution path, which removes the single largest source of hook portability bugs. Two profiles in the proposal thread put command-hook process spawn at roughly 90 ms on one Windows box against 2 to 14 ms on macOS, and the thread itself flags that a single machine cannot carry that generalisation. Directionally, the people paying the worst spawn tax gain the most. $.process.run takes an argument vector and never a shell string, so quoting differences stop mattering.

What are all the hooks in Claude Code right now?

Thirty-three lifecycle events with five handler types, all documented and all shipping today (the count in the September 2026 hooks reference). That list, with blocking semantics for each event, is in the complete hooks guide. Function hooks would add one more handler type over a different, larger event set of fifty-four names.

What to do with this today

Turn on the flag, run /plugin-types, and read the declarations. Two hours in that file teaches you more about how Claude Code actually works than the public docs do, because every event the engine raises and every capability it hands a plugin is named and commented in one place.

Then, if you want it to exist, say so on the issue with a use case attached. The comments that visibly moved Poteat's stated position in the first day were the ones carrying a measurement or a real workload, not the ones saying ship it. Anthropic asked a specific question about whether this is worth building, and the answer is being written in that thread right now.

Next Steps

Last updated on