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.
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 documentshooks/hooks.jsonwith nomoduleskey. 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-authoringskill, and a/plugin-typescommand 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:
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":
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.
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.
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.eis the event, the method's argument as frozen plain data. Ontool.callit is what$.tool.callwas called with: the tool and its arguments as own fields, which is whye.commandexists once the matcher has narrowede.tooltoBash.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.
| Capability | Shell hook (command) | Function hook |
|---|---|---|
| Input | JSON on stdin | e, typed per event and narrowed by matcher |
| Output | exit code plus stdout JSON | a return value typed per event |
| Failure signal | exit code only | a typed result, or a throw the engine routes around |
| Pre and post | two events, two scripts | one event, five placements |
| Rewrite a tool's arguments | updatedInput on PreToolUse | next({ ...e, command }) on tool.call |
| Read the tool's result | PostToolUse, after the fact | await next(e) in the same hook |
| Draw anything | not possible | ui.render returns a JSX tree |
| Ask the user a question | only as a permission prompt (ask) | $.ui.ask(question, options), any question |
| Register a tool the model can call | not possible | $.tool.register(spec) |
| Rewrite a tool's description | not possible | tool.describe |
| State across hooks | a file you manage | module variables, plus $.store across sessions |
| Cross-platform | your problem (and it bites in practice) | one runtime, no shell, no spawn |
| Composition with other plugins | none; hooks run side by side | registration 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:
| Noun | What it gives you |
|---|---|
$.ui | log, notice, toast, status, ask, invalidate, resolve |
$.model | complete (one completion, default 256 tokens), classify, fork (tool-less, over the session's own transcript, sharing its prompt cache) |
$.session | messages (newest 4096), cwd, model, turnCount, id, repo, surface |
$.tool | list, call, register |
$.agent | list, spawn (always background, budgeted) |
$.fs | readFile, writeFile, listDir, exists, stat, ancestors |
$.store | get, set, delete, keys, persisted under ~/.claude/plugins/store/ |
$.clock | now, sleep, after, every |
$.http | fetch |
$.process | run(argv), no shell, 30 s default and ten minutes maximum |
$.mcp | call(server, tool, args) on the engine's own connection |
$.audio | play, speak |
$.turn | abort({ turnId }) |
$.prompt | submit({ text }), once the session is idle |
$.plugin | name 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.
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.
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.
$.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 }.
$.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 onengine.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:
| Case | Median |
|---|---|
| No hooks | 15 ms |
| 1 function hook, pass-through | 16 ms |
| 25 function hooks, pass-through | 20 ms |
| 8 command hooks, 50 ms of work each | 401 ms |
| 8 function hooks, 50 ms of work each | 424 ms |
| 8 command hooks, 300 ms of work each | 640 ms |
| 8 function hooks, 300 ms of work each | 2427 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.
--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
nextrejecting only for an invalide, 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
- Get the current, shipping event list in the complete hooks guide
- Package what you build with Claude Code plugins
- See why the shell is the portability problem in cross-platform hook patterns
- Compare the LLM-driven approach in the permission hook guide
- Move rules out of prose with CLAUDE.md mastery
Last updated on