Function Hooks: Core Architecture (inspired by) Alice Poteat · August 2026 · (not) Anthropic 1 Function hooks 1.1 Hooks module 1.2 Terms 2 Hook composition 2.1 Earlier hooks wrap later hooks 2.2 Five placements 2.3 Plugin registration order 3 Events 3.1 Event fields 3.2 Render events 4 Engine interface 4.1 Engine creation 4.2 Noun filter 4.3 EngineInterface 5 Managed plugin order 6 Hook call rules 6.1 Matchers 6.2 Innermost next 6.3 Hooks on all events 6.4 Re-entry 7 Sources 1. Function hooks A function hook calls next to run the hooks registered after it, and skips those hooks by returning without next . The hook is a function of $ , e , and next on one event. modules in hooks.json names the hooks module. The hooks module is a .js , .ts , .jsx , or .tsx file in the plugin’s hooks/ directory. The hooks module registers the plugin’s function hooks. hooks.json also names command , prompt , agent , and http hooks. The other four hook types run beside the hooks module. A command hook runs a shell command in an environment. A function hook calls methods on $ The hook environment has no ambient file system or network. A command hook, as the public documentation shows it: { " hooks " : { " PreToolUse " : [ { " matcher " : " Bash " , " hooks " : [ { " type " : " command " , " command " : " ./hooks/block-rm.sh " } ] } ] } } The same hook, as a function hook. hooks/hooks.json adds modules beside the entries above: { " modules " : [ " ./my-hooks.ts " ] } hooks/my-hooks.ts exports register register calls on once per hook. 1 export function register ( on ) { 2 ... 3 on ( " tool.call " , ( $ , e , next ) => { 4 if ( e tool === " Bash " && e command == " rm -rf / " ) 5 return { deny : " Destructive command blocked by hook " } 6 return next ( e ) 7 } ) 8 ... 9 } Listing 1 : The Bash rm -rf / hook. • A literal event name, such as "tool.call" , narrows e and the hook’s result to that event. • on accepts a matcher between the event name and the hook ( §6.1 ). 1.1 Hooks module The hooks module exports register(on, options) options comes from the plugin’s userConfig • register runs before any hook runs. • claude plugin validate lists the events register registered. 1.2 Terms term meaning plugin The unit of install and discovery: a manifest, plus its skills, agents, MCP servers, and hooks. This design does not change install, discovery, or the manifest. hooks module The .js , .ts , .jsx , or .tsx file under hooks/ that modules names. The file exports register(on, options) hook A function registered on an event. The parameters are $ , e , and next event A call to a method on $ , such as $.tool.call . The call runs the hooks registered on that event, earliest registration first. The engine skips a hook whose frame is on the stack ( §6.4 ). $ The engine interface, type EngineInterface . A method is spelled $.noun.event , for example $.tool.call . Calling it runs the hooks registered on that event. 2. Hook composition The hook and next both return R or Promise<R> R is the event’s result. The chain on an event is the hooks registered on it. next runs the rest of that chain. Koa middleware uses this same next parameter meaning $ The engine interface, type EngineInterface . The object is frozen. A write to one of its fields does not change the object. e The event argument. An immutable plain value. To change a field, the hook passes next a copy. next The rest of the chain. next(e) returns a promise of the remaining hooks’ result. The hook may call next once, more than once, or not at all. The hook may be async. next.signal and next.is are defined in §3.1 2.1 Earlier hooks wrap later hooks A hook registered earlier is the outer hook. It wraps the hooks registered later. The outer hook can skip next or replace the inner result. on(X, A) , then on(X, B) , then on(X, C) , then the core plugin, runs as A(B(C(core( ⊥ )))) ⊥ is the innermost next ( §6.2 ). The core plugin is registered last and supplies the event’s default behavior. It runs when each outer hook calls next 𝑋 = 𝐴 ∘ 𝐵 ∘ 𝐶 ∘ 𝑐𝑜𝑟𝑒 = 𝐴 ( 𝐵 ( 𝐶 ( 𝑐𝑜𝑟𝑒 ( ⊥ ) ) ) ) A B next(e) ↓ ↑ result C ↓ ↑ core ↓ ↑ ⊥ Figure 1 : Four hooks on one event. A is the outer hook. A bar is shaded while that hook runs and empty while it waits on next 2.2 Five placements The five placements are where the hook’s code runs relative to one call to next , on tool.call . A hook may also call next more than once. placement behavior example diagram before Run the hook’s code, then return next(e) $ ui log ( " about to run " + e tool ) return next ( e ) after Await next(e) , run the hook’s code, then return the result. const result = await next ( e ) $ ui log ( e tool + " ran " ) return result during Call next(e) , run the hook’s code without awaiting, and return that promise. const pending = next ( e ) $ ui log ( " running " + e tool ) return pending instead Return a result and do not call next return { deny : " no tools allowed " } modifying Call next with a copy of e that changes a field. return next ( { ... e , timeout : 30 } ) One tool.call event covers the before-and-after split that a command hook expresses as a pre-event and a post-event. 2.3 Plugin registration order The engine registers plugins in three groups. An earlier group wraps a later group. 1. Prepended plugins are the plugins an administrator listed to register first. 2. Plugins in dependency order follow the prepended plugins. 3. Appended plugins are the plugins an administrator listed to register last. Inside one plugin, hooks run in the order register called on The engine registers plugins named in manifest dependencies after the plugin that names them. An allowlist or a denylist is a prepended plugin that registers a hook on plugin.register ( §5 ). 3. Events 3.1 Event fields e is the argument of the method. On tool.call , e is the object the caller passed to $.tool.call . The tool and its arguments are own fields of e To change a field, the hook passes next a copy ( §2.2 ). Fields about this call are on next next(e) Runs the rest of the chain with this e . Resolves to the call’s result. next.signal An AbortSignal for this call. It fires when the chain has returned or the call has been cancelled. The hook uses it to cancel work that outlasts the call. next.is(type, e) A type predicate. True when this call is type . Under * , a true result narrows e ( §6.3 ). next.event This call’s event name. A hook on * uses it to log or to branch. next.origin The plugin whose hook started this call, or engine when the engine started it. next.signal , next.event , next.origin , and next.is are set once for the call. They are fields on next . A method on $ would start a new call. The engine builds a new frozen next for each hook on the call. An outer hook’s fields on next are absent from the inner hook’s next The hook’s return value is the method’s return value. tool.call returns the tool result or { deny } plugin.register is the event for a plugin being registered. An outer plugin hooks it to decide which inner plugins load ( §5 ). 3.2 Render events ui.render is the draw event. on registers a ui.render hook: on("ui.render", { component: "AskUserQuestion" }, ...) 3.2.1 Render fields e.component The component being drawn. The type is the union of component names the engine declares. A match on e.component narrows e.props and the result. e.props The component’s render arguments: serializable, typed data. e.surface The surface name, such as "terminal" , "desktop" , or an artifact’s surface name. next(e) The component the rest of the chain returns. • Change e.props , then call next • Wrap or edit the component next returns. • Return a replacement and skip next 3.2.2 Public component API Component names and the props on e at ui.render are public plugin API. Each component declares and documents its props, as a tool declares its input schema. A prop that is an internal pass-through is outside this API. The hook does not receive rendered surface state. 3.2.3 Per-surface elements A surface declares jsx , elements , and components . An artifact makes that declaration for a custom surface. A plugin does not declare a surface. name meaning jsx The function JSX compiles to on this surface. Ink on the terminal, the DOM on desktop and web, Block Kit in Slack. In the hook, JSX evaluates to a plain object. elements The tags jsx accepts, and the prop type of each tag. components The drawings a plugin can hook on this surface. Each drawing is built from the surface’s elements. The names are the values of e.component , such as ToolUse and AskUserQuestion A hook draws with that surface’s elements. $.ui.resolve(e) returns that surface’s elements. $.ui.resolve is an event, typed by e.surface . An outer plugin can hook $.ui.resolve and restyle, restrict, or wrap the elements an inner plugin draws with. In the hook, an element is a plain object. The surface renders the returned tree with the surface’s jsx . The surface’s jsx function does not enter the hook. Element types follow the surface the hook fixed. • A matcher such as { surface: "desktop" } , or a branch on e.surface , selects that surface’s element types. • When the hook has not fixed the surface, an element type-checks when each surface the plugin was compiled against includes that element. • On a surface the plugin was not compiled against, an element that surface does not define renders as a fragment. A hook may return JSX. 1 on ( " ui.render " , { component : " ToolUse " } , async ( $ , e , next ) => { 2 const { Row , Badge } = $ ui resolve ( e ) 3 const rendered = await next ( e ) 4 return ( 5 < Row > 6 { rendered } 7 < Badge text = { ` ${ e props output length } chars ` } /> 8 </ Row > 9 ) 10 } ) Listing 2 : A ui.render hook wraps ToolUse and adds a Badge 3.2.4 Interaction events Button and Input have these handlers: • onPress • onHover • onScroll • onFocus • onBlur • onInput An interaction fires the matching event. onPress fires ui.press . The other handlers fire the matching event the same way. The onPress closure is not a hook. Calling next(e) runs that closure after the ui.press hooks ( §6.2 ). A plugin uses the placements in §2.2 around that closure. e.plugin The plugin whose hook drew the element. e.element The path of sibling indices from the root, such as 0.1.2 . A key on the element replaces the last index with that name. e.component The component the element belongs to ( §3.2.1 ). Another plugin matches the element by plugin name and path: on("ui.press", { plugin: "explainer", element: "0.1.explain" }, ...) The handler stays in the plugin that created it. The host keeps a handle for the component’s lifetime. The terminal emits hover and scroll, along with the other interaction events. 4. Engine interface $ is the object of methods a hook may call. Examples: read a file, call the model, draw on the screen, speak. Each noun is an object of events. The spelling is $.noun.event(input) The hook environment has no ambient file system and no ambient network. A side effect is a call on $ claude plugin validate rejects a hooks module that uses $ other than as a call $.noun.event(...) A method on $ is an event a plugin can hook. on("tool.call", h) wraps $.tool.call . The call runs that event’s chain ( §2 ), outer plugin first. The method’s implementation is the last step of that chain. The engine calls $ to raise its own events. For example: • The REPL calls $.prompt.submit • The query loop calls $.tool.call • A render site calls $.ui.render A method a plugin can call is a method another plugin can hook. 4.1 Engine creation The engine builds $ once, at startup, by running the engine.create chain. The innermost next on engine.create returns an empty object ( §6.2 ). The core plugin is registered last. It calls next , receives that empty object, and adds the primitive nouns: files, network, model, terminal, and permissions. Each outer step receives the object next(e) returns. • The step may add a noun. • The step may drop a noun. • The step does not replace a noun. The plugin that adds a noun owns that name for as long as that $ exists. To change what a method does, hook that method ( §4.2 ). A step that adds or drops a noun returns a new object. The step does not mutate the object next returned. 1 on ( " engine.create " , async ( $ , e , next ) => { 2 const below = await next ( e ) 3 return { ... below , store : createStore ( below ) } 4 } ) Listing 3 : Add store on engine.create During engine.create , each step receives an empty frozen $ . The $ later hooks receive is the frozen object the outermost step returned. A plugin registers a hook on engine.create with on . There is no $.engine.create method. 4.2 Noun filter The engine.create hook chooses which nouns the returned $ contains. An organization plugin registered first returns last. From the object next(e) returns, that plugin may: • Return a subset of the nouns, chosen by name. • Drop nouns that inner plugins added, and return the core plugin’s nouns. The primitive methods in that object are the methods the core plugin added. engine.create adds or drops nouns. A hook on the method changes what the method does, for example a hook on fs.read or on * 4.3 EngineInterface $ has the TypeScript type EngineInterface . A plugin adds nouns by declaration merging. 1 declare module " claude-code " { 2 interface EngineInterface { cache : Cache } 3 interface Cache { evict ( input : { key : string } ) : Promise < void > } 4 } Listing 4 : Declaration merging for cache A declaration merged into EngineInterface types the matching hook. In on("cache.evict", ...) , e is the argument of cache.evict , and the hook’s result is the return type of cache.evict The engine writes the installed plugins’ declarations into the plugin author’s project. Completion of on(" in the plugin author’s project lists the events in those declarations. The plugin author’s project fails to compile when two plugins declare different types for one event. 5. Managed plugin order An administrator sets plugin order in the managed settings file: a list to prepend and a list to append. A prepended plugin wraps the plugins registered after it. • On plugin.register , the prepended plugin decides which later plugins load. • On plugin.register , the prepended plugin decides which teams receive which plugins. • On engine.create , the prepended plugin decides which nouns $ contains. • On * , the prepended plugin observes each call. Code in the prepended plugin before next runs before any inner hook. Code after await next(e) runs when the chain returns. An inner hook runs when the prepended plugin calls next 1 on ( " * " , ( $ , e , next ) => { 2 $ ui log ( ` ${ next origin } called ${ next event } at ${ Date now ( ) } ` ) 3 return next ( e ) 4 } ) Listing 5 : A prepended * hook logs each call. A consent step for the user would hook placement ( §2.2 ), render hooks ( §3.2.3 ), and the noun filter ( §4.2 ). 6. Hook call rules 6.1 Matchers on accepts an optional matcher between the event name and the hook. The matcher is a partial of e • An object matches when each matcher key matches that key on e . Keys present on e and absent from the matcher do not affect the match. • An array matches when any element matches. { surface: ["cci", "ccd"] } matches either surface. • An empty array matches nothing. • Any other value matches by equality. The engine skips the hook when the matcher does not match. A match narrows the type of e to that partial. The Bash hook from §1 , limited to Bash : 1 on ( " tool.call " , { tool : " Bash " } , ( $ , e , next ) => { 2 if ( e command == " rm -rf / " ) 3 return { deny : " Destructive command blocked by hook " } 4 return next ( e ) 5 } ) Listing 6 : A tool.call hook limited to Bash 6.2 Innermost next The innermost next has the same signature as any other next . A hook calls it without testing whether it exists. Below the last hook, next throws when called, except in these cases: • On engine.create , the innermost next returns an empty object ( §4.1 ). • On ui.press , the innermost next runs the onPress closure ( §3.2.4 ). 6.3 Hooks on all events A hook registered on * joins the chain of each event, including events a plugin adds, at that plugin’s position. Under * : • e has type unknown , because the installed plugins are not known when the plugin is compiled. • next.is(type, e) narrows e to the argument of an event the plugin’s types include. • After next.is narrows e , next(e) returns that event’s result type. • Without that narrowing, next(e) returns unknown A call the hook makes on $ runs that method’s chain ( §6.4 ). 6.4 Re-entry While a hook’s frame is on the stack, the engine skips that hook and does not report the skip. The engine runs the other hooks on that event. A prompt.submit hook that calls $.prompt.submit(...) runs that chain without entering itself again. A * hook from another plugin runs on that call and can log that call. 7. Sources These parts of the design follow existing web mechanisms. behavior modeled on next runs the rest of the chain. Koa middleware e is a plain value. the DOM event next.signal cancels work when the call ends. AbortSignal A plugin adds fields to EngineInterface TypeScript declaration merging A render hook returns elements. The surface renders those elements. JSX The hook environment has no ambient file system or network. workers Values that cross the hook boundary are plain data. structured clone