Skip to main content

Hooks

Introduction

Hooks let you intercept every phase of the agent’s execution lifecycle. They are the primary extension mechanism for cross-cutting concerns — logging, rate limiting, safety guards, telemetry, state transformation, and tool access control. Each hook receives a HookContext containing the current agent state and trigger-specific data, processes it, and returns a (potentially modified) context to continue the pipeline. Because both HookContext and AgentState are immutable, hooks compose safely — each hook in the chain works with the output of the previous one, and no hook can accidentally corrupt shared state.
Design Philosophy: Hooks follow the middleware pattern common in web frameworks, but adapted for agent execution. Instead of intercepting HTTP requests, hooks intercept the agent’s internal lifecycle events — giving you the same power to observe, modify, or short-circuit execution at precisely the right moment.

Lifecycle Events

The agent loop emits eight trigger types at well-defined points during execution. Each trigger corresponds to a specific moment in the loop’s lifecycle, and understanding when each fires is essential for placing your hooks correctly: These triggers are defined in the HookTrigger enum:
The following diagram illustrates the typical flow through these triggers during a single execution:
If an error occurs at any point, the OnError trigger fires with the accumulated error information.

Implementing a Hook

Create a class that implements HookInterface. The handle method receives a HookContext and must return one — either the original context unchanged, or a modified copy:

Understanding HookContext

The HookContext object provides access to different data depending on the trigger type. It serves as both the input and output of hook processing, carrying all the information a hook needs to make decisions: HookContext also provides convenient named constructors for each trigger type, used internally by the agent loop:

Registering Hooks

The UseHook capability provides a declarative way to register hooks during agent construction. Each UseHook instance binds a hook implementation to one or more triggers with a specified priority:
A hook can listen to multiple triggers by combining them with HookTriggers::of():
The HookTriggers class provides convenience constructors for every trigger type, as well as the ability to combine them:

Via HookStack (Manual)

When composing an AgentLoop directly without the builder, assemble hooks into a HookStack. The HookStack wraps a RegisteredHooks collection and implements the CanInterceptAgentLifecycle interface, making it pluggable into the agent loop:
The HookStack is immutable — each with() call returns a new instance with the hook added and the collection re-sorted by priority. You can chain multiple hooks fluently:
You can also add a pre-built RegisteredHook directly:

CallableHook

For quick, one-off hooks that do not warrant a dedicated class, use CallableHook with a closure. This is particularly handy for prototyping or adding simple logging during development:
CallableHook accepts any callable that takes a HookContext and returns a HookContext. It converts the callable to a Closure internally for type safety.

Hook Priority

When a trigger fires, hooks are executed in descending priority order — higher values run first. This ordering is critical when hooks have dependencies on each other. For example, guard hooks that may emit stop signals should run before business logic hooks that assume the loop will continue. The RegisteredHooks collection sorts hooks automatically when they are added. The sort is stable, so hooks with the same priority retain their registration order. The built-in guard hooks use a priority of 200 (or -200 for the finish reason guard, which runs on AfterStep), giving them precedence over custom hooks at the default priority of 0. Choose your priorities according to the following guidelines:
Tip: When in doubt, use the default priority of 0. Only assign explicit priorities when you need guaranteed ordering between hooks.

Modifying Agent State

Hooks can modify the agent’s state by returning a HookContext with an updated AgentState. Since both objects are immutable, you create modified copies using the with* methods:
State modifications flow through the hook pipeline and back into the loop. This makes hooks suitable for:
  • Injecting context — adding metadata that downstream hooks or the driver can read
  • Adjusting system prompts — dynamically modifying the system prompt based on execution state
  • Attaching metadata — tagging the state with timestamps, user IDs, or feature flags
  • Modifying the message store — adding, removing, or transforming messages before the next LLM call

Blocking Tool Execution

In a BeforeToolUse hook, you can prevent a tool from executing by calling withToolExecutionBlocked() on the context. This is a powerful safety mechanism for restricting which tools the model can invoke at runtime:
Register it on the BeforeToolUse trigger with a high priority to ensure it runs before other hooks:
When a tool is blocked, several things happen internally:
  1. The HookContext is marked with isToolExecutionBlocked = true
  2. A ToolExecution with blocked status is created and attached to the context
  3. A ToolExecutionBlockedException is recorded in the error list
  4. The loop skips the actual tool execution
  5. The rejection message is fed back to the model as the tool result, so it can adjust its approach
You can also provide a custom message when blocking. If no message is provided, a default message is generated that includes details about the hook context for debugging:

Applying Context Configuration

The built-in ApplyContextConfigHook sets the system prompt and response format on the agent context at the start of execution. This is how the builder internally applies system prompt and response format settings configured through UseContextConfig:
This hook runs on BeforeExecution and modifies the AgentContext inside the state, ensuring the system prompt and format are in place before the first LLM call. It only applies non-empty values — an empty system prompt or a null / empty response format will leave the existing context values unchanged.

Built-in Guard Hooks

Guard hooks enforce resource limits by emitting stop signals when thresholds are exceeded. They are the primary mechanism for preventing runaway agents that might otherwise consume unlimited tokens, time, or steps.

UseGuards Capability

The UseGuards capability bundles all four guards with sensible defaults, providing a convenient one-liner for common resource protection:
Each parameter is optional and nullable — pass null to disable a specific guard. The defaults are:

Individual Guard Hooks

You can also register guards individually for finer control over triggers, priorities, and configuration.

StepsLimitHook

Stops the loop after a maximum number of steps. It accepts a callable stepCounter that extracts the current step count from the agent state, making it flexible enough to count different things (e.g., total steps, steps within the current execution):
When the limit is reached, it emits a StopSignal with reason StepsLimitReached and a descriptive message like "Step limit reached: 10/10".

TokenUsageLimitHook

Stops the loop when cumulative token usage (input + output tokens across all LLM calls) exceeds a threshold. Token usage is tracked automatically by the agent state through the usage() accessor:
When the limit is reached, it emits a StopSignal with reason TokenLimitReached.

ExecutionTimeLimitHook

Stops the loop after a wall-clock duration. Unlike other guards, this hook needs to listen to two triggers: BeforeExecution to record the start time, and BeforeStep to check elapsed time before each LLM call:
The hook uses microsecond-precision timestamps (DateTimeImmutable with U.u format) for accurate timing. When the limit is reached, it emits a StopSignal with reason TimeLimitReached.
Note: The UseGuards capability handles the dual-trigger registration automatically. You only need to manage it manually when registering the hook directly.

FinishReasonHook

Stops the loop when the LLM’s finish reason matches a specified set. This is useful for stopping when the model indicates it has finished naturally (e.g., stop finish reason) rather than being cut off by a token limit. It runs on AfterStep since the finish reason is only available after the model responds:
When registered through UseGuards, this hook receives a priority of -200 (running after other AfterStep hooks) to ensure all post-step processing has completed before checking the finish reason.

How Hooks Execute

When a trigger fires, the HookStack iterates through all registered hooks sorted by priority (descending). Each hook that matches the trigger type receives the HookContext, processes it, and returns a (potentially modified) context. The returned context flows into the next hook in the chain:
Hooks that do not match the current trigger type are silently skipped. Each successful hook execution dispatches a HookExecuted event containing the trigger type, hook name, and execution timestamp — enabling external observability and performance monitoring. The HookStack implements CanInterceptAgentLifecycle, meaning it can be replaced entirely with a custom interception strategy. The PassThroughInterceptor is a no-op implementation that returns the context unchanged, useful for testing or when you want to disable all hooks:

Practical Examples

Audit Trail Hook

Record every tool invocation for compliance or debugging:

Rate Limiting Hook

Throttle tool calls to prevent excessive API usage:

Conditional Tool Access

Allow or deny tools based on metadata (e.g., user role):