Welcome back! In the previous chapter, Tool Execution Pipeline, we looked at how the application receives a request from the AI and executes a tool like bash.
But there is a missing piece. What if the AI tries to run a dangerous command? Or what if we want to log every single time a file is written, without modifying the code of every single tool?
This is where Lifecycle Hooks come in.
Think of the Tool Execution Pipeline as a flight. The "Tool" is the plane execution itself. But you can't just walk onto a plane.
Without hooks, our bash tool would have to handle security, logging, and error handling all inside itself. Hooks let us separate these concerns cleanly.
In this chapter, we will solve this specific problem:
The AI tries to run rm -rf / (delete everything). We want a Pre-Hook to spot this and stop it before the command runs.
These run before tool.call(). They are the gatekeepers.
These run after tool.call() finishes.
Let's visualize where hooks sit in the pipeline we built in Chapter 1.
The logic for running these hooks is located in toolHooks.ts, and they are called from toolExecution.ts. Let's look at the simplified implementation.
Before the tool runs, we iterate through all registered pre-hooks.
// Inside runPreToolUseHooks (toolHooks.ts)
export async function* runPreToolUseHooks(tool, input, ...) {
// Loop through every hook (e.g., SecurityHook, LoggingHook)
for await (const result of executePreToolHooks(tool.name, input, ...)) {
// Check if a hook decided to BLOCK the tool
if (result.blockingError) {
// Create a denial message
yield {
type: 'hookPermissionResult',
hookPermissionResult: { behavior: 'deny', message: result.blockingError }
};
// Stop the pipeline immediately!
yield { type: 'stop' };
return;
}
// Hooks can also just modify the input (e.g. trimming whitespace)
if (result.updatedInput) {
yield { type: 'hookUpdatedInput', updatedInput: result.updatedInput };
}
}
}
Explanation:
executePreToolHooks, which runs the logic for every hook.blockingError (like our security check finding rm -rf), we immediately yield { type: 'stop' }.
Back in the main pipeline (toolExecution.ts), we wait for these hooks to finish before doing anything else.
// Inside checkPermissionsAndCallTool (toolExecution.ts)
// 1. Run Pre-Hooks
for await (const result of runPreToolUseHooks(context, tool, input...)) {
if (result.type === 'stop') {
// A hook blocked us!
// Return the error message to the AI and EXIT the function.
return resultingMessages;
}
// If a hook modified the input (e.g. fixed a file path), update it here
if (result.type === 'hookUpdatedInput') {
processedInput = result.updatedInput;
}
}
// 2. If we survived the hooks, NOW we check permissions and run the tool...
Explanation: This confirms that the pipeline is strictly sequential. If runPreToolUseHooks says "stop," the code hits a return statement and the tool.call() function lower down is never reached.
If the tool runs successfully, we want to record what happened.
// Inside runPostToolUseHooks (toolHooks.ts)
export async function* runPostToolUseHooks(tool, input, toolOutput, ...) {
// Loop through post-hooks (e.g. AnalyticsHook)
for await (const result of executePostToolHooks(tool.name, input, toolOutput...)) {
// If the hook has something to say to the AI (like "Files saved"), send it
if (result.message) {
yield { message: result.message };
}
// Advanced: A hook can even change the tool's output before the AI sees it
if (result.updatedMCPToolOutput) {
yield { updatedMCPToolOutput: result.updatedMCPToolOutput };
}
}
}
Explanation:
Post-hooks have access to toolOutput. This allows an analytics hook to calculate how many bytes of data were returned, or how long the operation took, and log that to a database or file.
What if the tool crashes? We have a special set of hooks for that: runPostToolUseFailureHooks.
// Inside toolExecution.ts catch block
catch (error) {
// The tool crashed!
// Run failure hooks (e.g., to log the stack trace)
for await (const hookMsg of runPostToolUseFailureHooks(..., error)) {
// Add hook messages to the output
hookMessages.push(hookMsg);
}
// Return the error to the AI
return createErrorResult(error);
}
Explanation: Even if the flight crashes (the tool errors), we still need "Post-Hooks" (the crash investigation team) to run to log exactly what went wrong.
Let's look at our "Dangerous Command" scenario one last time with our new understanding:
rm -rf /"runPreToolUseHooks.rm -rf. Sets blockingError = "Too dangerous".blockingError. It sends a message to the AI: "Error: Too dangerous."return. The bash tool never runs. The system is safe.Lifecycle Hooks turn our pipeline from a simple script runner into a robust, secure system. They act as the "nervous system" around the "muscle" of the tool, allowing us to:
However, simply "blocking" or "allowing" isn't always enough. Sometimes, the hook needs to say: "I don't know if this is safe... ask the human user."
This complex decision-making process is called Permission Resolution, and that is the topic of our next chapter.
Next Chapter: Permission Resolution
Generated by Code IQ