You need to add audit logging to every file-write tool call. Or validate tool arguments against a security policy before execution. Or send a webhook notification every time the agent completes a task. The naive approach is to add this logic directly into each tool's implementation: an if block here, a logging call there, a webhook POST at the end.
That approach does not scale. When you have 20 tools and want to add audit logging to all of them, you edit 20 files. When the audit format changes, you edit 20 files again. When you want to add a new cross-cutting concern (cost tracking, content moderation, input sanitization), you edit all 20 files a third time. The core loop and tool implementations become entangled with concerns they should not know about.
Hooks solve this by providing lifecycle extension points: named events that fire at defined moments in the agent's execution. You register a hook for an event, optionally with a condition, and the hook runner invokes it at the right time. The agent loop does not know your hook exists. The tools do not know your hook exists. You add observability, validation, and transformation without touching a single line of existing code.
The Four Execution Modes
Every hook has an execution mode that determines how it runs. The mode is the primary design decision. It sets the cost, capability, and latency of your hook:
Command. Runs a shell script or binary. Fast, deterministic, and cheap. Use for validation logic you can express in a script: linting, format checking, file existence checks, policy enforcement via external tools.
Prompt. Makes a single LLM call with a small, fast model. Use for classification that requires language understanding: "Is this tool input requesting a destructive operation?" "Does this user prompt contain sensitive information?" The LLM call adds latency but handles cases that string matching cannot.
Agent. Spawns a full multi-turn sub-agent with access to tools. Use for complex verification that requires exploration: "Read the test output and verify that all tests pass." "Check that the implementation matches the specification." Expensive, so use sparingly.
HTTP. Sends an HTTP POST to an external endpoint. Use for webhooks, audit logs, and third-party integrations. Can run asynchronously (fire-and-forget) to avoid adding latency to the agent loop.
The following shows how mode selection maps to common use cases:
# Command mode: validate TypeScript files with a linter before write
hook_config = {
event: "PreToolUse",
matcher: "Write",
mode: "command",
command: "lint-check --file $tool_input_path",
condition: "Write(src/**/*.ts)",
timeout: 10
}
# Prompt mode: classify user input for content moderation
hook_config = {
event: "UserPromptSubmit",
mode: "prompt",
prompt: "Is this user input requesting something harmful? Respond YES or NO.",
timeout: 5
}
# HTTP mode: send audit event on every tool completion (fire-and-forget)
hook_config = {
event: "PostToolUse",
mode: "http",
url: "http://localhost:9000/audit",
async_mode: True # fire-and-forget, do not block the agent
}Tip: Start with
commandmode. If your validation logic can be expressed as a script or CLI tool, it runs faster and more predictably than an LLM call. Reach forpromptandagentmodes only when you need language understanding or multi-step reasoning.
Register Hooks
A hook declaration binds an execution mode to a lifecycle event. The event determines when the hook fires. The optional condition determines whether it fires for this specific invocation.
The key lifecycle events:
- PreToolUse. Fires before a tool executes. Can modify arguments or block execution.
- PostToolUse. Fires after a tool succeeds. Can modify or augment the output.
- Stop. Fires when the agent decides to stop. Use for end-of-task verification.
- SessionStart / SessionEnd. Fire at session boundaries. Use for setup and cleanup.
- UserPromptSubmit. Fires before the user's prompt reaches the agent. Use for input validation.
The following registers a pre/post hook pair that audits file writes:
class HookRegistry:
hooks: dict = {} # event_name -> list of hook configs
function register(self, event: str, hook: HookConfig):
if event not in self.hooks:
self.hooks[event] = []
self.hooks[event].append(hook)
function get_hooks(self, event: str) -> list:
return self.hooks.get(event, [])
registry = HookRegistry()
# Pre-hook: log the write attempt before it happens
registry.register("PreToolUse", HookConfig(
matcher="Write",
mode="command",
command="echo 'AUDIT: Write attempt to $tool_input_path' >> /var/log/agent-audit.log",
condition="Write(src/**/*)"
))
# Post-hook: verify the written file is valid
registry.register("PostToolUse", HookConfig(
matcher="Write",
mode="command",
command="file-validator $tool_input_path",
condition="Write(src/**/*.ts)",
timeout=5
))Condition Syntax
The condition field gates when a hook fires. Without a condition, the hook fires for every invocation of the matched event. With a condition, it fires only when the condition matches.
Conditions use a simple pattern syntax:
# Match any Write to TypeScript files in src/
condition: "Write(src/**/*.ts)"
# Match any Bash command starting with 'git push'
condition: "Bash(git push*)"
# No condition: matches every invocation of the matched event
condition: NoneThe condition is evaluated before the hook executor is spawned. A non-matching condition means no process, no LLM call, no HTTP request. This makes conditions a cost-saving mechanism. A PreToolUse hook that fires for every Write call to every file wastes resources on build artifacts, logs, and temporary files that you do not care about. Write the most specific condition you can.
Error Isolation
A hook that crashes, times out, or produces an error must not crash the main agent loop. Error isolation is the property that makes hooks safe to add in production. You can register a hook that occasionally fails without worrying about destabilizing the agent.
Every hook execution produces one of four outcomes:
- Success. Hook ran and returned a result.
- Blocking. Hook explicitly blocked the operation (e.g., a pre-tool hook that rejects the arguments).
- Non-blocking error. Hook failed but execution continues. The error is logged.
- Cancelled. Hook was aborted (timeout expired, parent operation cancelled).
The hook runner aggregates results from multiple hooks on the same event:
async function run_hooks(event: str, context: HookContext) -> HookResult:
hooks = registry.get_hooks(event)
results = []
for hook in hooks:
# Check condition first, skip if no match
if hook.condition and not matches(hook.condition, context):
continue
try:
result = await execute_hook(hook, context, timeout=hook.timeout)
results.append(result)
except TimeoutError:
results.append(HookOutcome(status="cancelled", hook=hook.name))
except Exception as error:
results.append(HookOutcome(status="non_blocking_error", error=str(error)))
# Aggregation: any blocking result blocks the operation
blocking = [r for r in results if r.status == "blocking"]
if blocking:
return HookResult(blocked=True, reasons=[r.reason for r in blocking])
# Non-blocking errors are logged, execution continues
errors = [r for r in results if r.status == "non_blocking_error"]
if errors:
for error in errors:
log_warning(f"Hook error (non-blocking): {error}")
return HookResult(blocked=False)Two aggregation rules:
A blocking result from any hook blocks the entire operation. If one pre-tool hook says "this argument is dangerous," the tool does not run, regardless of what other hooks returned. Blocking is a veto, not a vote.
Non-blocking errors are logged but do not stop execution. A hook that occasionally times out is not a production incident. It logs, and the agent continues. This safety property is what makes it reasonable to use prompt and agent hooks in production paths where the underlying LLM might occasionally be slow.
Wire Into the Agent Loop
The hook runner integrates into the dispatch pipeline at two points: before tool execution (PreToolUse) and after (PostToolUse):
async function dispatch_with_hooks(name: str, args: dict, context: ToolContext) -> ToolResult:
tool = registry.get(name)
# Pre-tool hooks: can block execution or modify arguments
pre_result = await run_hooks("PreToolUse", HookContext(
tool_name=name,
tool_input=args,
context=context
))
if pre_result.blocked:
return error_result(f"Blocked by hook: {pre_result.reasons}")
# Execute the tool
result = await tool.implementation(args, context)
# Post-tool hooks: can modify output (fire-and-forget for async hooks)
await run_hooks("PostToolUse", HookContext(
tool_name=name,
tool_input=args,
tool_output=result,
context=context
))
return resultThe dispatch function does not know what the hooks do. It knows when to call the hook runner (before and after execution) and how to handle the result (block if blocked, continue otherwise). Adding a new hook means adding a registry entry. The dispatch logic is unchanged.
A Complete Example: Audit Hook
Here is a complete audit hook that logs every file-write operation to an external service:
# Register an audit hook for all Write operations
registry.register("PostToolUse", HookConfig(
matcher="Write",
mode="http",
url="http://localhost:9000/audit",
async_mode=True, # fire-and-forget
payload_template={
"event": "file_written",
"path": "$tool_input_path",
"timestamp": "$timestamp",
"agent_session": "$session_id"
}
))This hook fires after every successful Write operation, sends a JSON payload to the audit service, and does not wait for a response (fire-and-forget). If the audit service is down, the hook produces a non-blocking error (logged, but the agent continues writing files). The audit hook adds observability without adding latency or fragility.
Tip: Keep hooks stateless. If a hook needs state across invocations (counting calls, accumulating data), use a separate store rather than storing state in the hook itself. Hooks that accumulate state become invisible sources of bugs. The state is not visible in the hook configuration and not tracked by the hook runner.
Related
- Hooks and Extensions. The full hook system: 27+ lifecycle events, the hook response protocol, condition syntax matching structure, SSRF protection for HTTP hooks, and agent hook sandboxing.
- Tool System. Hooks intercept the tool dispatch pipeline. Understanding dispatch is prerequisite to understanding where hooks fire.
- Command and Plugin Systems. Hooks integrate with the command registry, and the same condition syntax applies to both.