Hooks

Hooks are your code at fixed points of the agent loop: at the start and end of a run, and before and after every model call and tool call. Use them for rules that must hold whatever the model does:

  • block a tool call, or change its arguments (refunds over 500 need a manager);
  • hide data from the model (card numbers, identifiers);
  • answer a tool call from a cache, without running the tool;
  • stop a run early (too many calls, a budget, a stop button);
  • count, log or measure.

For rules about who must approve a call, use approval instead: it pauses the run and waits for a person. For removing identifiers from every text the model sees, use redact (details), which runs before all hooks.

A hook#

A hook is any object with some of these methods, each sync or async:

from criogenai import Agent, HookAction, HookContext, ToolCall, ToolError

class Guard:
    """Blocks large refunds, hides card numbers, stops long runs."""

    def before_tool(self, call: ToolCall) -> None:
        if call.name == "issue_refund" and call.args["amount"] > 500:
            raise ToolError("Refunds over 500 need a manager.")  # the model sees this

    def after_tool(self, call: ToolCall, result: object) -> object:
        # The model sees the returned value instead (None keeps the result).
        return result.replace("4111-1111-1111-1111", "[card]") if isinstance(result, str) else None

    def before_model(self, ctx: HookContext) -> Optional[HookAction]:
        if ctx.model_calls >= 10:
            return HookAction(end=True, output="Stopped after 10 model calls.")
        return None

support = Agent("openai:gpt-5-mini", tools=[issue_refund], hooks=[Guard()])
import type { Hook } from "criogenai";

const guard: Hook = {
  name: "guard",
  beforeTool(call) {
    if (call.name === "issue_refund" && Number(call.args.amount) > 500) {
      throw new ToolError("Refunds over 500 need a manager."); // the model sees this
    }
    return undefined;
  },
  afterTool(call, result) {
    // The model sees the returned value instead (undefined keeps the result).
    return typeof result === "string" ? result.replace("4111-1111-1111-1111", "[card]") : undefined;
  },
  beforeModel(ctx) {
    if (ctx.modelCalls >= 10) return { end: true, output: "Stopped after 10 model calls." };
    return undefined;
  },
};
const guarded = new Agent({ model: "openai:gpt-5-mini", tools: [issueRefund], hooks: [guard] });
use criogenai_agent::{
    Agent, Hook, HookAction, HookContext, HookError, ToolCallRequest, ToolError, ToolNext,
    ToolOutput,
};
use criogenai_providers::BoxFuture;

/// Blocks large refunds and stops long runs.
struct Guard;

impl Hook for Guard {
    fn name(&self) -> &str {
        "guard"
    }

    fn before_model<'a>(
        &'a self,
        ctx: &'a HookContext,
    ) -> BoxFuture<'a, Result<HookAction, HookError>> {
        Box::pin(async move {
            if ctx.model_calls >= 10 {
                let output = "Stopped after 10 model calls.".into();
                return Ok(HookAction::proceed().end(Some(output)));
            }
            Ok(HookAction::proceed())
        })
    }

    // Rust has one method around each tool call (and `wrap_model` around
    // each model call); `next.call` runs the tool.
    fn wrap_tool<'a>(
        &'a self,
        call: ToolCallRequest,
        next: ToolNext<'a>,
    ) -> BoxFuture<'a, Result<ToolOutput, ToolError>> {
        Box::pin(async move {
            let amount = call.args.get("amount").and_then(|a| a.as_f64());
            if call.name == "issue_refund" && amount > Some(500.0) {
                return Err(ToolError::new("Refunds over 500 need a manager."));
            }
            next.call(call).await
        })
    }
}

let support = Agent::builder(model()?).hook(Guard).build()?;
PythonTypeScriptWhenIt may
on_start(ctx)onStart(ctx)once per run (and per resume or retry)save state for later calls: HookAction(state=…) / { state }
before_model(ctx)beforeModel(ctx)before each model callreplace the transcript (messages), or end the run (end, output)
after_model(ctx)afterModel(ctx)after each model reply (the last message of ctx.messages)replace the reply (message), or end the run
before_tool(call)beforeTool(call)before each tool callchange the call (return it), skip the tool (ToolResult(value)), or raise ToolError
after_tool(call, result)afterTool(call, result)after each tool callreturn a replacement result
on_end(ctx, outcome)onEnd(ctx, outcome)always, once per run: "completed", "suspended" or "failed"nothing (it cannot change the outcome)

Return None / undefined to change nothing. A hook may have a name attribute; it appears in errors and traces (Python uses the class name if there is none).

What a hook sees#

ctx (HookContext) has:

PythonTypeScriptMeaning
run_idrunIdThe run's id
messagesmessagesThe transcript so far, as JSON messages
model_callsmodelCallsModel calls made so far in this run
hook_statehookStateWhat this hook saved with state, if anything
statestateThe run state, read-only (for example user_id)

A tool hook gets a ToolCall with call_id, name and args (callId, name, args). To change the call, change name or args and return it.

Order#

Hooks run in list order, and the "after" methods in reverse order, like layers around the call: with hooks=[a, b], the order is a.before_tool, b.before_tool, the tool, b.after_tool, a.after_tool.

Examples#

Answer from a cache. Return a ToolResult from before_tool and the tool does not run; the model sees the value as the tool's result.

from criogenai import ToolCall, ToolResult

class Cache:
    def __init__(self) -> None:
        self.seen: dict = {}

    def before_tool(self, call: ToolCall):
        key = (call.name, repr(sorted(call.args.items())))
        return ToolResult(self.seen[key]) if key in self.seen else None

    def after_tool(self, call: ToolCall, result: object):
        self.seen[(call.name, repr(sorted(call.args.items())))] = result
        return None

Keep state across the run. on_start can return HookAction(state=...); later calls see it in ctx.hook_state. The state is saved with the run, so it survives an approval pause or a retry().

Errors#

  • An exception in on_start, before_model or after_model fails the run with hook_failed; the exception is the error's cause.
  • ToolError from before_tool goes to the model as the tool's result (the tool does not run).
  • Any other exception in a tool hook counts as the tool's own exception, and follows the agent's on_tool_exception.
  • on_end cannot fail the run.

Hook methods run on your event loop (Python) or your Node.js thread: keep sync ones quick, and make slow ones async.

In Rust, a hook implements Hook, with wrap_tool and wrap_model around each call instead of a "before" and an "after" method.

Next#