PageSourceSearch

https://ethosagent.ai/assets/js/b99b9fca.8459785b.js

js ethosagent.ai collected 2026-10-05 15:01:02 UTC 27,519 bytes, 1 lines download raw bytes

1"use strict";(self.webpackChunkdocs=self.webpackChunkdocs||[]).push([["7053"],{6548(e,n,t){t.r(n),t.d(n,{metadata:()=>o,default:()=>c,frontMatter:()=>r,contentTitle:()=>a,toc:()=>d,assets:()=>l});var o=JSON.parse('{"id":"building/explanation/hook-execution-models","title":"Why are hooks split into three execution models?","description":"Void, Modifying, and Claiming hooks each fit a different kind of cross-cutting work. Mixing them breaks failure semantics and ordering.","source":"@site/content/building/explanation/hook-execution-models.md","sourceDirName":"building/explanation","slug":"/building/explanation/hook-execution-models","permalink":"/docs/building/explanation/hook-execution-models","draft":false,"unlisted":false,"editUrl":"https://github.com/ethosagent/ethos/tree/main/docs/content/building/explanation/hook-execution-models.md","tags":[],"version":"current","frontMatter":{"title":"Why are hooks split into three execution models?","description":"Void, Modifying, and Claiming hooks each fit a different kind of cross-cutting work. Mixing them breaks failure semantics and ordering.","kind":"explanation","audience":"developer","slug":"hook-execution-models","updated":"2026-05-12T00:00:00.000Z"},"sidebar":"docsSidebar","previous":{"title":"Why does AgentLoop receive every dependency at construction?","permalink":"/docs/building/explanation/injection-at-construction"},"next":{"title":"Why is there an 80k tool result budget?","permalink":"/docs/building/explanation/tool-result-budget"}}'),i=t(8265),s=t(7214);let r={title:"Why are hooks split into three execution models?",description:"Void, Modifying, and Claiming hooks each fit a different kind of cross-cutting work. Mixing them breaks failure semantics and ordering.",kind:"explanation",audience:"developer",slug:"hook-execution-models",updated:new Date("2026-05-12T00:00:00.000Z")},a,l={},d=[{value:"Context",id:"context",level:2},{value:"Discussion",id:"discussion",level:2},{value:"Three answers to "what does a hook do"",id:"three-answers-to-what-does-a-hook-do",level:3},{value:"The Void model: fan-out, ignore failures",id:"the-void-model-fan-out-ignore-failures",level:3},{value:"The Modifying model: sequential, first-wins merge",id:"the-modifying-model-sequential-first-wins-merge",level:3},{value:"The Claiming model: sequential, stop at first claim",id:"the-claiming-model-sequential-stop-at-first-claim",level:3},{value:"How the model determines failure semantics",id:"how-the-model-determines-failure-semantics",level:3},{value:"What the registry exposes",id:"what-the-registry-exposes",level:3},{value:"Picking the right model for a new hook point",id:"picking-the-right-model-for-a-new-hook-point",level:3},{value:"Trade-offs",id:"trade-offs",level:2},{value:"See also",id:"see-also",level:2}];function h(e){let n={a:"a",code:"code",em:"em",h2:"h2",h3:"h3",li:"li",p:"p",pre:"pre",strong:"strong",ul:"ul",...(0,s.R)(),...e.components};return(0,i.jsxs)(i.Fragment,{children:[(0,i.jsx)(n.h2,{id:"context",children:"Context"}),"\n",(0,i.jsxs)(n.p,{children:["Ethos lets you intercept the ",(0,i.jsx)(n.a,{href:"/docs/getting-started/glossary#turn",children:"turn"}
1)," cycle at fixed boundaries. The boundaries are named \u2014 ",(0,i.jsx)(n.code,{children:"session_start"}),", ",(0,i.jsx)(n.code,{children:"before_prompt_build"}),", ",(0,i.jsx)(n.code,{children:"before_tool_call"}),", ",(0,i.jsx)(n.code,{children:"after_tool_call"}),", ",(0,i.jsx)(n.code,{children:"agent_done"}),", plus channel-level points like ",(0,i.jsx)(n.code,{children:"inbound_claim"})," and ",(0,i.jsx)(n.code,{children:"message_sending"})," \u2014 and code can register handlers against each."]}),"\n",(0,i.jsxs)(n.p,{children:["The framework calls a registered handler a ",(0,i.jsx)(n.a,{href:"/docs/getting-started/glossary#hook",children:"hook"}),". What surprises new contributors is that not every hook fires the same way. There are three execution models \u2014 Void, Modifying, and Claiming \u2014 and the model is not a runtime flag. It is a property of the hook point itself, baked into the type system. ",(0,i.jsx)(n.code,{children:"session_start"})," is a Void hook. ",(0,i.jsx)(n.code,{children:"before_prompt_build"})," is a Modifying hook. ",(0,i.jsx)(n.code,{children:"inbound_claim"})," is a Claiming hook. You do not get to choose."]}),"\n",(0,i.jsx)(n.p,{children:"This page is about why three rather than one. What each model is for. The failure mode each one is designed to handle. And the small set of consequences for code that registers them."}),"\n",(0,i.jsx)(n.h2,{id:"discussion",children:"Discussion"}),"\n",(0,i.jsx)(n.h3,{id:"three-answers-to-what-does-a-hook-do",children:'Three answers to "what does a hook do"'}),"\n",(0,i.jsx)(n.p,{children:'When you ask "what should happen when a hook fires", you get one of three answers:'}),"\n",(0,i.jsxs)(n.ul,{children:["\n",(0,i.jsxs)(n.li,{children:[(0,i.jsx)(n.em,{children:"I want to know it happened."})," Log it. Send it to telemetry. Notify another system. The thing the hook does is ",(0,i.jsx)(n.strong,{children:"react"})," \u2014 it does not change what the agent will do next. Examples: writing an audit log on ",(0,i.jsx)(n.code,{children:"session_start"}),", recording usage on ",(0,i.jsx)(n.code,{children:"agent_done"}),", tracking errors."]}),"\n",(0,i.jsxs)(n.li,{children:[(0,i.jsx)(n.em,{children:"I want to amend the thing the agent is about to do."})," Add to the system prompt. Override a ",(0,i.jsx)(n.a,{href:"/docs/getting-started/glossary#tool",children:"tool"})," argument. Replace the prompt entirely. The hook ",(0,i.jsx)(n.strong,{children:"mutates the next step's input"}),', sequentially building up the effective payload. Examples: a plugin injecting "today is Tuesday" into the system prompt, a guard hook overriding a tool\'s ',(0,i.jsx)(n.code,{children:"path"})," argument to point at a sandbox."]}),"\n",(0,i.jsxs)(n.li,{children:[(0,i.jsx)(n.em,{children:"I want to decide whether someone else handles this."}),' A channel adapter claiming an inbound message ("yes, this Telegram update is mine"). A routing hook deciding which surface dispatches the response. The hook ',(0,i.jsx)(n.strong,{children:"terminates the chain"}),' when one handler says "I\'ve got it"; otherwise the next handler gets a shot. Examples: ',(0,i.jsx)(n.code,{children:"inbound_claim"}),", ",(0,i.jsx)(n.code,{children:"before_dispatch"}),"."]}),"\n"]}),"\n",(0,i.jsx)(n.p,{children:"Three different failure modes, three different ordering guarantees, three different shapes of return value. Conflating them into one model \u2014 say, every hook is fire-and-forget \u2014 would force registration code to invent its own discipline for the other two cases. The framework picks the discipline up front and exposes it in the type."}),"\n",(0,i.jsx)(n.h3,{id:"the-void-model-fan-out-ignore-failures",children:"The Void model: fan-out, ignore failures"}),"\n",(0,i.jsxs)(n.p,{children:[(0,i.jsx)(n.code,{children:"HookRegistry.fireVoid<K>"})," is the model for side effects. Concretely, in ",(0,i.jsx)(n.code,{children:"packages/core/src/hook-registry.ts"}),":"]}),"\n",(0,i.jsx)(n.pre,{children:(0,i.jsx)(n.code,{className:"language-typescript",children:"const handlers = (this.voidHandlers.get(name) ?? []).filter(...);\nawait Promise.allSettled(handlers.map((h) => h.handler(payload)));\n"})}),"\n",(0,i.jsxs)(n.p,{children:[(0,i.jsx)(n.code,{children:"Promise.allSettled"})," is the load-bearing word. Every registered handler fires in parallel. If one throws, the others still complete. The rejection is swallowed \u2014 the hook fires the handlers, waits for them all to settle, and returns ",(0,i.jsx)(n.code,{children:"void"}),". The agent loop never learns whether your analytics handler crashed."]}),"\n",(0,i.jsx)(n.p,{children:"This is the right shape for the cases where you want it: logging, analytics, notifications, telemetry, observability events. None of these should be able to abort an agent turn. A flaky analytics service should not cost a user their reply. The fail-open default is a property of the framework, not a discipline you have to remember."}),"\n",(0,i.jsxs)(n.p,{children:["The cost: you cannot use a Void hook to ",(0,i.jsx)(n.em,{children:"prevent"})," something. Returning ",(0,i.jsx)(n.code,{children:"false"})," from a Void handler does nothing \u2014 the type system rejects it. If you need to reject a tool call, the hook point is ",(0,i.jsx)(n.code,{children:"before_tool_call"})," (Modifying), not ",(0,i.jsx)(n.code,{children:"after_tool_call"})," (Void)."]}),"\n",(0,i.jsxs)(n.p,{children:["Void hook points: ",(0,i.jsx)(n.code,{children:"session_start"}),", ",(0,i.jsx)(n.code,{children:"before_llm_call"}),", ",(0,i.jsx)(n.code,{children:"after_llm_call"}),", ",(0,i.jsx)(n.code,{children:"after_tool_call"}),", ",(0,i.jsx)(n.code,{children:"tool_end_with_path"}),", ",(0,i.jsx)(n.code,{children:"agent_done"}),", ",(0,i.jsx)(n.code,{children:"message_received"}),", ",(0,i.jsx)(n.code,{children:"message_sent"}),", ",(0,i.jsx)(n.code,{children:"subagent_spawned"}),", ",(0,i.jsx)(n.code,{children:"subagent_ended"}),"."]}),"\n",(0,i.jsx)(n.p,{children:"Pattern of use:"}),"\n",(0,i.jsx)(n.pre,{children:(0,i.jsx)(n.code,{className:"language-typescript",children:"hooks.registerVoid('agent_done', async (payload) => {\n  await analytics.track('turn_completed', {\n    sessionId: payload.sessionId,\n    turnCount: payload.turnCount,\n  });\n});\n"})}),"\n",(0,i.jsx)(n.p,{children:"Failure mode by design: handler errors are dropped. The framework does not retry. The framework does not surface them to the caller. If you need observability into your hook's reliability, your hook implementation is responsible for that (a try/catch with a self-report)."}),"\n",(0,i.jsx)(n.h3,{id:"the-modifying-model-sequential-first-wins-merge",children:"The Modifying model: sequential, first-wins merge"}),"\n",(0,i.jsxs)(n.p,{children:[(0,i.jsx)(n.code,{children:"HookRegistry.fireModifying<K>"})," is the model for amending the agent's next step. Each handler receives the payload, returns a partial result, and the framework merges the results sequentially \u2014 ",(0,i.jsx)(n.em,{children:"first non-null value per key wins"}),"."]}),"\n",(0,i.jsx)(n.pre,{children:(0,i.jsx)(n.code,{className:"language-typescript",children:"for (const h of handlers) {\n  try {\n    const result = await h.handler(payload);\n    if (result && typeof result === 'object') {\n      for (const [k, v] of Object.entries(result)) {\n        if (!(k in merged) && v !== null && v !== undefined) {\n          merged[k] = v;\n        }\n      }\n    }\n  } catch {\n    // fail-open: continue with other handlers\n  }\n}\n"})}),"\n",(0,i.jsxs)(n.p,{children:["The first registered handler that sets a key sets it. Later handlers can fill in keys the first one left null, but they cannot override. This is the discipline that makes plugin composition predictable: if you register a hook that sets ",(0,i.jsx)(n.code,{children:"prependSystem"}),", and a second plugin also tries to set ",(0,i.jsx)(n.code,{children:"prependSystem"}),", yours wins (you ran first). The second plugin's change does not silently overwrite yours."]}),"\n",(0,i.jsxs)(n.p,{children:["The execution is ",(0,i.jsx)(n.em,{children:"sequential"})," on purpose. Modifying handlers may read the partial state that earlier handlers produced. The order matters; running them in parallel would break the first-wins guarantee."]}),"\n",(0,i.jsxs)(n.p,{children:["Modifying hook points: ",(0,i.jsx)(n.code,{children:"before_prompt_build"}),", ",(0,i.jsx)(n.code,{children:"before_tool_call"}),", ",(0,i.jsx)(n.code,{children:"message_sending"}),", ",(0,i.jsx)(n.code,{children:"personality_switched"}),", ",(0,i.jsx)(n.code,{children:"subagent_spawning"}),"."]}),"\n",(0,i.jsx)(n.p,{children:"Pattern of use \u2014 the prompt-build hook from a plugin:"}),"\n",(0,i.jsx)(n.pre,{children:(0,i.jsx)(n.code,{className:"language-typescript",children:"hooks.registerModifying('before_prompt_build', async (payload) => {\n  return {\n    prependSystem: `Today is ${new Date().toDateString()}.`,\n  };\n});\n"})}),"\n",(0,i.jsxs)(n.p,{children:["Pattern of use \u2014 ",(0,i.jsx)(n.code,{children:"before_tool_call"})," rejecting a dangerous command:"]}),"\n",(0,i.jsx)(n.pre,{children:(0,i.jsx)(n.code,{className:"language-typescript",children:"hooks.registerModifying('before_tool_call', async (payload) => {\n  if (payload.toolName !== 'terminal') return null;\n  const args = payload.args as { command: string };\n  if (isDangerousCommand(args.command)) {\n    return { error: `Blocked: '${args.command}' matches a dangerous pattern.` };\n  }\n  return null;\n});\n"})}),"\n",(0,i.jsx)(n.p,{children:"Failure mode by design: handler errors are caught, the merged result keeps what it had, and the next handler still runs. A buggy plugin cannot prevent later handlers from amending the prompt; it also cannot quietly corrupt state \u2014 its contribution simply does not appear in the merged result."}),"\n",(0,i.jsxs)(n.p,{children:["There is a non-obvious failure mode worth calling out. A ",(0,i.jsx)(n.code,{children:"before_tool_call"})," hook that wants to reject a tool call ",(0,i.jsxs)(n.strong,{children:["must return ",(0,i.jsx)(n.code,{children:"{ error: '...' }"})]}),", not just log and continue. The ",(0,i.jsx)(n.code,{children:"AgentLoop"})," reads the merged result and, when ",(0,i.jsx)(n.code,{children:"error"})," is set, persists a synthetic ",(0,i.jsx)(n.code,{children:"tool_result"})," with ",(0,i.jsx)(n.code,{children:"is_error: true"})," (every ",(0,i.jsx)(n.code,{children:"tool_use"})," block needs a matching ",(0,i.jsx)(n.code,{children:"tool_result"})," block \u2014 the Anthropic message contract requires it). If the hook only emits a ",(0,i.jsx)(n.code,{children:"tool_end"})," event but leaves the rejection out of the merged result, the tool still runs."]}),"\n",(0,i.jsxs)(n.p,{children:['The CLAUDE.md "Learnings from building this codebase" calls this out explicitly: ',(0,i.jsxs)(n.em,{children:["the hook fires before ",(0,i.jsx)(n.code,{children:"executeParallel"}),". If you only emit ",(0,i.jsx)(n.code,{children:"tool_end ok:false"})," but still add the tool to ",(0,i.jsx)(n.code,{children:"execInputs"}),", the tool runs anyway. The correct pattern: check ",(0,i.jsx)(n.code,{children:"beforeResult.error"})," \u2192 add to a rejected list \u2192 exclude from ",(0,i.jsx)(n.code,{children:"execInputs"}),"."]})]}),"\n",(0,i.jsx)(n.h3,{id:"the-claiming-model-sequential-stop-at-first-claim",children:"The Claiming model: sequential, stop at first claim"}),"\n",(0,i.jsxs)(n.p,{children:[(0,i.jsx)(n.code,{children:"HookRegistry.fireClaiming<K>"})," is the model for routing decisions. Handlers run sequentially; the chain stops as soon as one returns ",(0,i.jsx)(n.code,{children:"{ handled: true }"}),"."]}),"\n",(0,i.jsx)(n.pre,{children:(0,i.jsx)(n.code,{className:"language-typescript",children:"for (const h of handlers) {\n  try {\n    const result = (await h.handler(payload)) as ClaimingHooks[K][1];\n    if (result && (result as { handled: boolean }).handled) {\n      return result;\n    }\n  } catch {\n    // fail-open: try next handler\n  }\n}\nreturn { handled: false } as ClaimingHooks[K][1];\n"})}),"\n",(0,i.jsxs)(n.p,{children:["If no handler claims, the framework returns ",(0,i.jsx)(n.code,{children:"{ handled: false }"}),' and the caller proceeds with default behaviour. The pattern is "first one to claim wins"; the design is the answer to "which subsystem owns this".']}
1),"\n",(0,i.jsxs)(n.p,{children:["Claiming hook points: ",(0,i.jsx)(n.code,{children:"inbound_claim"}),", ",(0,i.jsx)(n.code,{children:"before_dispatch"}),"."]}),"\n",(0,i.jsx)(n.p,{children:"Pattern of use \u2014 the gateway's telegram adapter claiming a Telegram update:"}),"\n",(0,i.jsx)(n.pre,{children:(0,i.jsx)(n.code,{className:"language-typescript",children:"hooks.registerClaiming('inbound_claim', async (payload) => {\n  const msg = payload.message;\n  if (msg.platform !== 'telegram') return { handled: false };\n  await handleTelegramUpdate(msg);\n  return { handled: true };\n});\n"})}),"\n",(0,i.jsxs)(n.p,{children:["Failure mode by design: handler errors are caught, the chain continues. A handler that claims by accident (it intended to return ",(0,i.jsx)(n.code,{children:"false"})," but threw) gets skipped, and the next handler has a chance. The framework's contract is \"the first handler that successfully returns ",(0,i.jsx)(n.code,{children:"{ handled: true }"}),' wins"; bugs that prevent a handler from returning ',(0,i.jsx)(n.code,{children:"true"})," mean the next handler gets a shot."]}),"\n",(0,i.jsxs)(n.p,{children:["The non-obvious property: order of registration matters. Claiming hooks are not commutative. Two adapters that both register an ",(0,i.jsx)(n.code,{children:"inbound_claim"}),' for "anything" \u2014 the first-registered wins. The wiring layer is where you control which adapter registers first; the framework deliberately exposes the ordering to the caller because routing decisions are first-class.']}),"\n",(0,i.jsx)(n.h3,{id:"how-the-model-determines-failure-semantics",children:"How the model determines failure semantics"}),"\n",(0,i.jsx)(n.p,{children:"The three models exist because the right failure semantics are different in each case."}),"\n",(0,i.jsx)(n.p,{children:"A logging handler should fail open \u2014 the agent should not stop running because the audit log is down. Void hooks fail open, fan out in parallel, swallow exceptions."}),"\n",(0,i.jsx)(n.p,{children:'A prompt-amending handler should also fail open \u2014 a buggy plugin should not prevent the agent from building a prompt at all. Modifying hooks catch exceptions, but they preserve first-wins ordering: later handlers cannot overwrite earlier handlers, and an exception does not "fall through" to a later handler that happens to set the same key. The merged result is what it was before the failed handler ran.'}),"\n",(0,i.jsxs)(n.p,{children:['A routing handler should fail open in the sense of "if you crash, the next adapter gets a shot at claiming this message". But it should ',(0,i.jsx)(n.em,{children:"not"}),' fail open in the sense of "default to the agent processing the message" if the right adapter is down \u2014 if you want that behaviour you do not register a claim, because the chain returning ',(0,i.jsx)(n.code,{children:"{ handled: false }"}),' is the contract for "nobody claimed". Claiming hooks catch exceptions, but the absence of a claim is a meaningful signal.']}),"\n",(0,i.jsx)(n.p,{children:'The split is not "three for the sake of three". It is three because the failure modes do not compose. A registry that tried to be a single one-size-fits-all model would force every caller to reinvent the discipline.'}),"\n",(0,i.jsx)(n.h3,{id:"what-the-registry-exposes",children:"What the registry exposes"}),"\n",(0,i.jsxs)(n.p,{children:[(0,i.jsx)(n.code,{children:"HookRegistry"})," is one interface (defined in ",(0,i.jsx)(n.code,{children:"packages/types/src/hooks.ts"}),"). The three models are exposed as three pairs of methods:"]}),"\n",(0,i.jsxs)(n.ul,{children:["\n",(0,i.jsxs)(n.li,{children:[(0,i.jsx)(n.code,{children:"registerVoid<K>(name, handler, opts?)"})," \u2194 ",(0,i.jsx)(n.code,{children:"fireVoid<K>(name, payload, allowedPlugins?)"})]}),"\n",(0,i.jsxs)(n.li,{children:[(0,i.jsx)(n.code,{children:"registerModifying<K>(name, handler, opts?)"})," \u2194 ",(0,i.jsx)(n.code,{children:"fireModifying<K>(name, payload, allowedPlugins?)"})]}),"\n",(0,i.jsxs)(n.li,{children:[(0,i.jsx)(n.code,{children:"registerClaiming<K>(name, handler, opts?)"})," \u2194 ",(0,i.jsx)(n.code,{children:"fireClaiming<K>(name, payload, allowedPlugins?)"})]}),"\n"]}),"\n",(0,i.jsxs)(n.p,{children:["The type system enforces that you cannot ",(0,i.jsx)(n.code,{children:"registerVoid"})," against a hook point that the type map declares as Modifying. The compile error is the first line of defence; the implementation in ",(0,i.jsx)(n.code,{children:"packages/core/src/hook-registry.ts"})," is the second. You cannot pick the execution model \u2014 the hook point picks it for you."]}),"\n",(0,i.jsxs)(n.p,{children:["All three ",(0,i.jsx)(n.code,{children:"register*"})," calls return a cleanup function (",(0,i.jsx)(n.code,{children:"(
1) => void"}),"). Call it to deregister. The pattern matches: long-lived registrations (the gateway's telegram claim) live for the process lifetime; short-lived ones (a per-turn audit guard) cleanup explicitly."]}),"\n",(0,i.jsxs)(n.p,{children:["The ",(0,i.jsx)(n.code,{children:"allowedPlugins"})," argument on every ",(0,i.jsx)(n.code,{children:"fire*"})," method is the per-personality gate. Built-in handlers always fire; plugin-registered handlers fire only when the active personality's ",(0,i.jsx)(n.code,{children:"plugins"})," allowlist includes their ",(0,i.jsx)(n.code,{children:"pluginId"}),". The point at which gating happens is the framework; the hook handler does not need to check whether the active personality allows it."]}),"\n",(0,i.jsx)(n.h3,{id:"picking-the-right-model-for-a-new-hook-point",children:"Picking the right model for a new hook point"}),"\n",(0,i.jsx)(n.p,{children:"When a new hook point is proposed, the question is which model fits. The decision tree:"}),"\n",(0,i.jsxs)(n.ul,{children:["\n",(0,i.jsxs)(n.li,{children:[(0,i.jsx)(n.em,{children:"Does the handler need to abort or amend what the agent does next?"})," If yes \u2014 Modifying. Read the merged result downstream."]}),"\n",(0,i.jsxs)(n.li,{children:[(0,i.jsx)(n.em,{children:"Does the handler need to claim ownership and prevent default behaviour?"})," If yes \u2014 Claiming. The first claim wins."]}),"\n",(0,i.jsxs)(n.li,{children:[(0,i.jsx)(n.em,{children:"Otherwise the handler is a side effect."})," Void."]}),"\n"]}),"\n",(0,i.jsx)(n.p,{children:'The wrong answer is usually "Modifying" when the right answer is "Claiming", or vice versa. If you want "the first adapter to claim a message wins, others must not run", you want Claiming. If you want "every plugin can amend the prompt, first to set a key wins", you want Modifying.'}),"\n",(0,i.jsxs)(n.p,{children:["A Modifying hook used for routing produces handler interference \u2014 two handlers both try to set ",(0,i.jsx)(n.code,{children:"handled"}),", and the merge picks one but does not stop the chain. A Claiming hook used for amendment cannot accumulate state \u2014 once a handler claims, no later handler gets a shot. The mismatch is real; the framework's job is to prevent you from making it."]}),"\n",(0,i.jsx)(n.h2,{id:"trade-offs",children:"Trade-offs"}),"\n",(0,i.jsxs)(n.p,{children:[(0,i.jsx)(n.strong,{children:"Three models are three things to learn."})," A simpler framework would have one hook model and let you reach for global state to coordinate cases that do not fit. The trade Ethos makes: the type system tells you which model applies, and the failure semantics are correct by default. The cost is the up-front learning curve."]}),"\n",(0,i.jsxs)(n.p,{children:[(0,i.jsx)(n.strong,{children:"You cannot fail closed by default."}),' All three models swallow handler exceptions. This is intentional \u2014 a bug in your audit hook should not crash the agent \u2014 but it means you cannot register a Modifying hook and expect "if I throw, the turn aborts". The way to abort a turn from a hook is to return ',(0,i.jsx)(n.code,{children:"{ error: '...' }"})," from a ",(0,i.jsx)(n.code,{children:"before_tool_call"}),", not to throw. The framework's defaults are fail-open; closed failure is opt-in via the payload contract."]}),"\n",(0,i.jsxs)(n.p,{children:[(0,i.jsx)(n.strong,{children:"Order of registration is observable."})," Modifying hooks merge first-wins; Claiming hooks stop at first claim. Two plugins registering the same hook can interfere if neither knows about the other. The mitigation is the per-personality plugin allowlist \u2014 a personality that only allows plugin A does not see plugin B's handlers \u2014 but inside one personality, the order is what it is. Plugins that need a specific ordering ship documentation rather than runtime enforcement."]}),"\n",(0,i.jsxs)(n.p,{children:[(0,i.jsxs)(n.strong,{children:[(0,i.jsx)(n.code,{children:"Promise.allSettled"})," for Void hooks means handler latency is visible."]})," A slow handler holds up the agent loop until it settles. Two-second analytics call on every ",(0,i.jsx)(n.code,{children:"agent_done"})," makes every turn two seconds slower. Void hooks are fire-and-forget in ",(0,i.jsx)(n.em,{children:"failure semantics"}),", not in ",(0,i.jsx)(n.em,{children:"latency"}),". If you need true fire-and-forget, the handler is responsible for kicking off a background task and returning immediately."]}),"\n",(0,i.jsx)(n.p,{children:"Alternatives considered:"}),"\n",(0,i.jsxs)(n.ul,{children:["\n",(0,i.jsxs)(n.li,{children:["One unified model with a ",(0,i.jsx)(n.code,{children:"failureMode"})," flag per handler. Rejected: callers would always pick the wrong default for the case they were not looking at, and the type system could not enforce correctness."]}
1),"\n",(0,i.jsx)(n.li,{children:"A bus pattern (publish/subscribe) for all hooks. Rejected: amendment and routing cannot be modelled as a bus without inventing a side-channel for return values."}),"\n",(0,i.jsxs)(n.li,{children:["Hook chain ",(0,i.jsx)(n.code,{children:"await reduce"})," semantics (each handler sees the previous result). Rejected for Modifying: makes plugin composition fragile (a plugin's effect depends on the order other plugins registered), and the first-wins merge is more predictable."]}),"\n",(0,i.jsx)(n.li,{children:'Letting handlers opt into closed failure on Void hooks. Rejected: makes the framework\'s contract conditional. The opt-in is "return an error from a Modifying hook", which is already supported.'}),"\n"]}),"\n",(0,i.jsx)(n.h2,{id:"see-also",children:"See also"}),"\n",(0,i.jsxs)(n.ul,{children:["\n",(0,i.jsxs)(n.li,{children:[(0,i.jsx)(n.a,{href:"/docs/building/explanation/injection-at-construction",children:"Why does AgentLoop receive every dependency at construction?"})," \u2014 the wiring that exposes the registry"]}),"\n",(0,i.jsxs)(n.li,{children:[(0,i.jsx)(n.a,{href:"/docs/building/reference/hook-registry",children:"HookRegistry reference"})," \u2014 every hook point and its payload type"]}),"\n",(0,i.jsxs)(n.li,{children:[(0,i.jsx)(n.a,{href:"/docs/building/how-to/add-a-hook",children:"Add a hook"})," \u2014 concrete walkthrough of registering one"]}),"\n",(0,i.jsxs)(n.li,{children:[(0,i.jsx)(n.a,{href:"/docs/building/reference/plugin-sdk",children:"Plugin SDK reference"})," \u2014 how a plugin packages hooks for distribution"]}),"\n",(0,i.jsxs)(n.li,{children:[(0,i.jsx)(n.a,{href:"/docs/getting-started/architecture-90-seconds",children:"Architecture in 90 seconds"})," \u2014 where the hook boundaries sit in the turn cycle"]}),"\n"]})]})}function c(e={}){let{wrapper:n}={...(0,s.R)(),...e.components};return n?(0,i.jsx)(n,{...e,children:(0,i.jsx)(h,{...e})}):h(e)}},7214(e,n,t){t.d(n,{R:()=>r,x:()=>a});var o=t(141);let i={},s=o.createContext(i);function r(e){let n=o.useContext(s);return o.useMemo(function(){return"function"==typeof e?e(n):{...n,...e}},[n,e])}function a(e){let n;return n=e.disableParentContext?"function"==typeof e.components?e.components(i):e.components||i:r(e.components),o.createElement(s.Provider,{value:n},e.children)}}}]);

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.