Skip to main content
Version: 3.0

Hooks

Hooks let you run custom logic at defined points of an Agent's run loop — at the start and end of a run, before each LLM call, before and after tool execution, and on exit.

Configured onThe Agent component via the hooks parameter
Key classeshook (decorator), FunctionHook, Hook (protocol)
Import pathhaystack.hooks
API referenceHooks
GitHub linkhttps://github.com/deepset-ai/haystack/blob/main/haystack/hooks/
Package namehaystack-ai

Overview

Pass hooks to the Agent as a dictionary mapping a hook point to a list of hooks the Agent runs at that point. Each hook receives the live State and influences the run by mutating it in place. Hooks for a hook point run in list order, and the same hook can be registered under multiple hook points.

This enables patterns such as building run-time system context, retrieving memories before the first LLM call, auditing or intercepting tool calls, and requiring a condition to hold before the Agent is allowed to finish.

Hook points

  • before_run: Runs once per run, after the state is initialized and before the first chat-generator call. Use it to rewrite the initial messages or seed state — for example, to turn the user query into a task brief — without re-running on every step like before_llm does.
  • before_llm: Runs before each chat-generator call.
  • before_tool: Runs after the model requests tool calls, before any tools run. After these hooks run, the Agent re-reads the current last message from state.data["messages"]. If that message contains tool calls, those calls are executed. If it does not, no tools run for that step, no tool-based exit condition is triggered, and the Agent loops back to the next LLM call unless max_agent_steps has been reached.
  • after_tool: Runs after tools execute, once their result messages are in state.data["messages"], before the exit-condition check and the next LLM call. Use it to rewrite the freshly produced tool-result messages — for example, to offload, redact, truncate, or summarize results. It does not run on the plain-text exit step. It does still run when a before_tool hook removed the pending tool calls: no tools executed on that step, so don't assume the last message is a fresh tool result.
  • on_exit: Runs when the Agent is about to stop on an exit condition. An on_exit hook can keep the Agent running by setting the continue_run control flag (state.set("continue_run", True)), usually alongside a message telling the model what to do next. on_exit hooks run when the Agent stops on an exit condition, but not when it stops because max_agent_steps is reached — use after_run for logic that must run however the run ends.
  • after_run: Runs once per run, after the step loop has ended and before the Agent builds its return value — regardless of whether the run stopped on an exit condition or because max_agent_steps was reached (unlike on_exit). Mutations to the state, such as appending a final message, are reflected in the returned messages / last_message and state_schema outputs. Setting continue_run here has no effect.

Registering a hook under an unknown hook point raises a ValueError at construction. A hook class can declare an allowed_hook_points attribute listing the hook points it supports; the Agent validates it and fails fast if the hook is registered somewhere it doesn't belong.

State keys for hooks

The Agent manages a few state keys that hooks interact with. Like the run-metadata keys (step_count, token_usage, tool_call_counts), they are reserved — using any of them in your own state_schema raises a ValueError. See State for the full list:

  • continue_run: Set by an on_exit hook to keep the Agent running.
  • tools: The tools available in the current step, for hooks to inspect.
  • hook_context: Request-scoped resources passed to Agent.run(hook_context={...}) / run_async(hook_context={...}). Hooks read it with state.data["hook_context"] or state.data.get("hook_context") — use it for per-request resources such as a user ID, a WebSocket, or a database client. Avoid the plain state.get("hook_context") here: State.get returns a deep copy of the value, which often fails for the kinds of resources stored in this dict (such as a WebSocket or a database client).

Hooks can also read the automatically tracked run metadata: step_count, token_usage, and tool_call_counts.

Creating hooks

With the @hook decorator

The @hook decorator wraps a function taking a single State argument into a hook. A regular function becomes the hook's sync path, a coroutine function its async path. To give a single hook both paths, construct a FunctionHook directly with both function and async_function.

The example below registers a hook at each of before_llm, before_tool, and on_exit to show what hooks can do:

python
from datetime import datetime, timezone
from typing import Annotated

from haystack.components.agents import Agent
from haystack.components.agents.state import State, replace_values
from haystack.components.generators.chat import OpenAIChatGenerator
from haystack.dataclasses import ChatMessage
from haystack.hooks import hook
from haystack.tools import tool


@tool
def search(query: Annotated[str, "The search query"]) -> str:
"""Search the web."""
# Placeholder: would call a real search API
return "Fusion startups reported net-energy-gain milestones this year."


@hook
def build_context(state: State) -> None:
# before_llm: build run-time system context once, before the first model call.
if state.get("step_count") == 0:
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
system = ChatMessage.from_system(
f"You are a research assistant. The current time is {now}.",
)
state.set(
"messages",
[system, *state.data["messages"]],
handler_override=replace_values,
)


@hook
def audit_tool_calls(state: State) -> None:
# before_tool: see which tools the model is about to run.
pending = state.data["messages"][-1].tool_calls
print(f"about to run: {[tc.tool_name for tc in pending]}")


@hook
def require_search(state: State) -> None:
# on_exit: keep going until the agent has actually searched.
if state.get("tool_call_counts", {}).get("search", 0) == 0:
state.set("messages", [ChatMessage.from_system("Search before answering.")])
state.set("continue_run", True)


agent = Agent(
chat_generator=OpenAIChatGenerator(model="gpt-5.4-nano"),
tools=[search],
hooks={
"before_llm": [build_context],
"before_tool": [audit_tool_calls],
"on_exit": [require_search],
},
)

result = agent.run(
messages=[
ChatMessage.from_user("What are the latest developments in fusion energy?"),
],
)
print(result["last_message"].text)

Class-based hooks

A hook is any object with a run(state) method; it may additionally define run_async(state) for true async behavior. Class-based hooks may also implement the optional lifecycle methods warm_up / warm_up_async and close / close_async. The Agent calls them from its own warm_up / close, so a hook can defer opening clients or reading credentials until warm-up and release them on close.

When a class-based hook should be serializable (so an Agent using it can be serialized), implement to_dict / from_dict: store serializable constructor arguments on the hook and rebuild runtime clients from those values.

The example below is an on_exit hook that grades the Agent's answer with its own LLM and asks the Agent to improve a weak answer before finishing:

python
from typing import Any

from haystack.components.agents import Agent
from haystack.components.agents.state import State
from haystack.components.generators.chat import OpenAIChatGenerator
from haystack.core.serialization import default_from_dict, default_to_dict
from haystack.dataclasses import ChatMessage


class GradeFinalAnswer:
"""Grade the Agent's answer with an LLM and ask it to improve a weak answer before finishing."""

def __init__(self, model: str = "gpt-5.4-nano"):
self.model = model
self._judge = OpenAIChatGenerator(model=self.model)

def warm_up(self) -> None:
# Warm up the judge's own client during the Agent's warm-up.
self._judge.warm_up()

def close(self) -> None:
# Release the judge's client during the Agent's close.
self._judge.close()

def run(self, state: State) -> None:
answer = state.data["messages"][-1].text or ""
verdict = (
self._judge.run(
messages=[
ChatMessage.from_user(
f"Reply with only PASS or FAIL. Is this answer complete?\n\n{answer}",
),
],
)["replies"][0].text
or ""
)
if "FAIL" in verdict.upper():
state.set(
"messages",
[
ChatMessage.from_user(
"Your answer was incomplete. Please improve it.",
),
],
)
state.set("continue_run", True)

def to_dict(self) -> dict[str, Any]:
return default_to_dict(self, model=self.model)

@classmethod
def from_dict(cls, data: dict[str, Any]) -> "GradeFinalAnswer":
return default_from_dict(cls, data)


agent = Agent(
chat_generator=OpenAIChatGenerator(model="gpt-5.4-nano"),
hooks={"on_exit": [GradeFinalAnswer()]},
)
result = agent.run(messages=[ChatMessage.from_user("Explain how vaccines work.")])
print(result["last_message"].text)

Ready-made hooks

Haystack ships two ready-made hooks, each in its own submodule of haystack.hooks:

  • ConfirmationHook (from haystack.hooks.human_in_the_loop): A before_tool hook that applies Human-in-the-Loop confirmation strategies to pending tool calls — a human can confirm, modify, or reject the tool calls the model requested before they run. See Human in the Loop.
  • ToolResultOffloadHook (from haystack.hooks.tool_result_offloading): An after_tool hook that offloads tool results to a ToolResultStore (such as FileSystemToolResultStore) and replaces them in the conversation with a compact pointer, so the next LLM call sees a reference instead of the full result. Per-tool policies (AlwaysOffload, NeverOffload, OffloadOverChars) control which results are offloaded. See Tool Result Offloading.