- Home
- Documentation
- configuration
- Hooks
Hooks
This document describes the current hook subsystem code in src/extensibility/hooks/*.
Current status in runtime
Section titled “Current status in runtime”The hook package (src/extensibility/hooks/) is still exported and usable as an API surface, but the default CLI runtime now initializes the extension runner path. In current startup flow:
--hookis treated as an alias for--extension(CLI paths are merged intoadditionalExtensionPaths)- tools are wrapped by
ExtensionToolWrapper, notHookToolWrapper - context transforms and lifecycle emissions go through
ExtensionRunner
So this file documents the hook subsystem implementation itself (types/loader/runner/wrapper), including legacy behavior and constraints.
Key files
Section titled “Key files”src/extensibility/hooks/types.ts— hook context, event types, and result contractssrc/extensibility/hooks/loader.ts— module loading and hook discovery bridgesrc/extensibility/hooks/runner.ts— event dispatch, command lookup, error signalingsrc/extensibility/hooks/tool-wrapper.ts— pre/post tool interception wrappersrc/extensibility/hooks/index.ts— exports/re-exports
What a hook module is
Section titled “What a hook module is”A hook module must default-export a factory:
import type { HookAPI } from "@f5xc-salesdemos/xcsh/hooks";
export default function hook(pi: HookAPI): void { pi.on("tool_call", async (event, ctx) => { if (event.toolName === "bash" && String(event.input.command ?? "").includes("rm -rf")) { return { block: true, reason: "blocked by policy" }; } });}The factory can:
- register event handlers with
pi.on(...) - send persistent custom messages with
pi.sendMessage(...) - persist non-LLM state with
pi.appendEntry(...) - register slash commands via
pi.registerCommand(...) - register custom message renderers via
pi.registerMessageRenderer(...) - run shell commands via
pi.exec(...)
Discovery and loading
Section titled “Discovery and loading”discoverAndLoadHooks(configuredPaths, cwd) does:
- Load discovered hooks from capability registry (
loadCapability("hooks")) - Append explicitly configured paths (deduped by absolute path)
- Call
loadHooks(allPaths, cwd)
loadHooks then imports each path and expects a default function.
Path resolution
Section titled “Path resolution”loader.ts resolves hook paths as:
- absolute path: used as-is
~path: expanded- relative path: resolved against
cwd
Important legacy mismatch
Section titled “Important legacy mismatch”Discovery providers for hookCapability still model pre/post shell-style hook files (for example .claude/hooks/pre/*, .xcsh/.../hooks/pre/*).
The hook loader here uses dynamic module import and requires a default JS/TS hook factory. If a discovered hook path is not importable as a module, load fails and is reported in LoadHooksResult.errors.
Event surfaces
Section titled “Event surfaces”Hook events are strongly typed in types.ts.
Session events
Section titled “Session events”session_startsession_before_switch→ can return{ cancel?: boolean }session_switchsession_before_branch→ can return{ cancel?: boolean; skipConversationRestore?: boolean }session_branchsession_before_compact→ can return{ cancel?: boolean; compaction?: CompactionResult }session.compacting→ can return{ context?: string[]; prompt?: string; preserveData?: Record<string, unknown> }session_compactsession_before_tree→ can return{ cancel?: boolean; summary?: { summary: string; details?: unknown } }session_treesession_shutdown
Agent/context events
Section titled “Agent/context events”context→ can return{ messages?: Message[] }before_agent_start→ can return{ message?: { customType; content; display; details } }agent_startagent_endturn_startturn_endauto_compaction_startauto_compaction_endauto_retry_startauto_retry_endttsr_triggeredtodo_reminder
Tool events (pre/post model)
Section titled “Tool events (pre/post model)”tool_call(pre-execution) → can return{ block?: boolean; reason?: string }tool_result(post-execution) → can return{ content?; details?; isError? }
This is the hook subsystem’s core pre/post interception model.
Hook tool interception flow
tool_call handlers │ ├─ any { block: true }? ── yes ──> throw (tool blocked) │ └─ no │ ▼ execute underlying tool │ ├─ success ──> tool_result handlers can override { content, details } │ └─ error ──> emit tool_result(isError=true) then rethrow original errorExecution model and mutation semantics
Section titled “Execution model and mutation semantics”1) Pre-execution: tool_call
Section titled “1) Pre-execution: tool_call”HookToolWrapper.execute() emits tool_call before tool execution.
- if any handler returns
{ block: true }, execution stops - if handler throws, wrapper fails closed and blocks execution
- returned
reasonbecomes the thrown error text
2) Tool execution
Section titled “2) Tool execution”Underlying tool executes normally if not blocked.
3) Post-execution: tool_result
Section titled “3) Post-execution: tool_result”After success, wrapper emits tool_result with:
toolName,toolCallId,inputcontentdetailsisError: false
If handler returns overrides:
contentcan replace result contentdetailscan replace result details
On tool failure, wrapper emits tool_result with isError: true and error text content, then rethrows original error.
What hooks can mutate
Section titled “What hooks can mutate”- LLM context for a single call via
context(messagesreplacement chain) - tool output content/details on successful tool calls (
tool_resultpath) - pre-agent injected message via
before_agent_start - cancellation/custom compaction/tree behavior via
session_before_*andsession.compacting
What hooks cannot mutate in this implementation
Section titled “What hooks cannot mutate in this implementation”- raw tool input parameters in-place (only block/allow on
tool_call) - execution continuation after thrown tool errors (error path rethrows)
- final success/error status in wrapper behavior (returned
isErroris typed but not applied byHookToolWrapper)
Ordering and conflict behavior
Section titled “Ordering and conflict behavior”Discovery-level ordering
Section titled “Discovery-level ordering”Capability providers are priority-sorted (higher first). Dedupe is by capability key, first wins.
For hooks, capability key is ${type}:${tool}:${name}. Shadowed duplicates from lower-priority providers are marked and excluded from effective discovered list.
Load order
Section titled “Load order”discoverAndLoadHooks builds a flat allPaths list, deduped by resolved absolute path, then loadHooks iterates in that order.
File order within each discovered directory depends on readdir output; the hook loader does not perform an additional sort.
Runtime handler order
Section titled “Runtime handler order”Inside HookRunner, order is deterministic by registration sequence:
- hooks array order
- handler registration order per hook/event
Conflict behavior by event type:
tool_call: last returned result wins unless a handler blocks; first block short-circuitstool_result: last returned override wins (no short-circuit)context: chained; each handler receives prior handler’s message outputbefore_agent_start: first returned message is kept; later messages ignoredsession_before_*: latest returned result is tracked;cancel: trueshort-circuits immediatelysession.compacting: latest returned result wins
Command/renderer conflicts:
getCommand(name)returns first match across hooks (first loaded wins)getMessageRenderer(customType)returns first matchgetRegisteredCommands()returns all commands (no dedupe)
UI interactions (HookContext.ui)
Section titled “UI interactions (HookContext.ui)”HookUIContext includes:
select,confirm,input,editornotifysetStatuscustomsetEditorText,getEditorTextthemegetter
ctx.hasUI indicates whether interactive UI is available.
When running with no UI, the default no-op context behavior is:
select/input/editorreturnundefinedconfirmreturnsfalsenotify,setStatus,setEditorTextare no-opsgetEditorTextreturns""
Status line behavior
Section titled “Status line behavior”Hook status text set via ctx.ui.setStatus(key, text) is:
- stored per key
- sorted by key name
- sanitized (
\r,\n,\t→ spaces; repeated spaces collapsed) - joined and width-truncated for display
Error propagation and fallback
Section titled “Error propagation and fallback”Load-time
Section titled “Load-time”- invalid module or missing default export → captured in
LoadHooksResult.errors - loading continues for other hooks
Event-time
Section titled “Event-time”HookRunner.emit(...) catches handler errors for most events and emits HookError to listeners (hookPath, event, error), then continues.
emitToolCall(...) is stricter: handler errors are not swallowed there; they propagate to caller. In HookToolWrapper, this blocks the tool call (fail-safe).
Realistic API examples
Section titled “Realistic API examples”Block unsafe bash commands
Section titled “Block unsafe bash commands”import type { HookAPI } from "@f5xc-salesdemos/xcsh/hooks";
export default function (pi: HookAPI): void { pi.on("tool_call", async (event, ctx) => { if (event.toolName !== "bash") return; const cmd = String(event.input.command ?? ""); if (!cmd.includes("rm -rf")) return;
if (!ctx.hasUI) return { block: true, reason: "rm -rf blocked (no UI)" }; const ok = await ctx.ui.confirm("Dangerous command", `Allow: ${cmd}`); if (!ok) return { block: true, reason: "user denied command" }; });}Redact tool output on post-execution
Section titled “Redact tool output on post-execution”import type { HookAPI } from "@f5xc-salesdemos/xcsh/hooks";
export default function (pi: HookAPI): void { pi.on("tool_result", async event => { if (event.toolName !== "read" || event.isError) return;
const redacted = event.content.map(chunk => { if (chunk.type !== "text") return chunk; return { ...chunk, text: chunk.text.replaceAll(/API_KEY=\S+/g, "API_KEY=[REDACTED]") }; });
return { content: redacted }; });}Modify model context per LLM call
Section titled “Modify model context per LLM call”import type { HookAPI } from "@f5xc-salesdemos/xcsh/hooks";
export default function (pi: HookAPI): void { pi.on("context", async event => { const filtered = event.messages.filter(msg => !(msg.role === "custom" && msg.customType === "debug-only")); return { messages: filtered }; });}Register slash command with command-safe context methods
Section titled “Register slash command with command-safe context methods”import type { HookAPI } from "@f5xc-salesdemos/xcsh/hooks";
export default function (pi: HookAPI): void { pi.registerCommand("handoff", { description: "Create a new session with setup message", handler: async (_args, ctx) => { await ctx.waitForIdle(); await ctx.newSession({ parentSession: ctx.sessionManager.getSessionFile(), setup: async sm => { sm.appendMessage({ role: "user", content: [{ type: "text", text: "Continue from prior session summary." }], timestamp: Date.now(), }); }, }); }, });}Export surface
Section titled “Export surface”src/extensibility/hooks/index.ts exports:
- loading APIs (
discoverAndLoadHooks,loadHooks) - runner and wrapper (
HookRunner,HookToolWrapper) - all hook types
execCommandre-export
And package root (src/index.ts) re-exports hook types as a legacy compatibility surface.