Skip to content
Get Started

Hooks

Hooks let your code observe and steer the agent loop while AgentRunner drives the model and tool IO. Use hooks for logging, metrics, audit trails, approval flows, guardrails, request shaping, invalid tool call recovery, and streaming UI integration.

A hook implements one method: AgentHook::on_event. It receives a StepEvent and returns a Flow.

  • StepEvent tells you what is happening: a model call, a model response, a tool call, a tool result, a streamed delta, or an invalid tool call.
  • Flow tells Rig what to do next: continue, terminate, skip, rewrite, retry, repair, or override the next request.

For one request, append a hook to the prompt builder:

let answer = agent
.prompt("Check the account balance, then summarize it.")
.max_turns(3)
.add_hook(ToolAudit)
.await?;

For an explicit runner, append hooks before run() or stream():

let response = agent
.runner("Check the account balance, then summarize it.")
.max_turns(3)
.add_hook(ToolAudit)
.run()
.await?;

For every request created by an agent, install default hooks on the agent builder:

let agent = openai::Client::from_env()?
.agent(openai::GPT_4O)
.add_hook(ToolAudit)
.build();

Agent-level hooks run first. Per-request or per-run hooks are appended after the defaults.

Hooks match on the events they care about and return Flow::cont() for everything else.

use rig::agent::{AgentHook, Flow, StepEvent};
use rig::completion::CompletionModel;
struct ToolAudit;
impl<M: CompletionModel> AgentHook<M> for ToolAudit {
async fn on_event(&self, event: StepEvent<'_, M>) -> Flow {
match event {
StepEvent::ToolCall { tool_name, args, .. } => {
println!("calling {tool_name} with {args}");
}
StepEvent::ToolResult { tool_name, result, .. } => {
println!("{tool_name} returned {result}");
}
_ => {}
}
Flow::cont()
}
}

StepEvent borrows its payload, so hooks inspect event data without taking ownership.

EventWhen it firesCommon uses
CompletionCall { prompt, history, turn }Before each model requestlogging, metrics, per-turn request overrides
CompletionResponse { prompt, response }After a non-streaming model responseaudit raw responses, count calls
InvalidToolCall(ctx)The model called an unknown or disallowed toolfail, retry with feedback, repair the name, or skip
ToolCall { tool_name, args, ... }Before executing a valid toolapprovals, argument rewriting, deny/skip policies
ToolResult { tool_name, result, ... }After a tool returnsredact, truncate, normalize, or log results
TextDelta { delta, aggregated }Streaming onlylive UI updates, cancellation on content policies
ToolCallDelta { ... }Streaming onlydisplay partial tool-call arguments
StreamResponseFinish { prompt, response }Streaming text response finishedstreaming-side metrics and cleanup

CompletionResponse and StreamResponseFinish are suppressed for turns recovered by invalid tool-call repair, skip, or retry. Streaming-only events fire only when using the streaming runner/request surface.

A hook returns a Flow. Flow::cont() means “observe only”. Other actions steer the run.

FlowValid eventsEffect
cont()allContinue normally.
terminate(reason)allStop the run with a cancellation error containing the current history.
override_request(RequestOverride)CompletionCallPatch this turn’s request only.
rewrite_args(json)ToolCallExecute the tool with replacement JSON arguments.
skip(reason)ToolCall, InvalidToolCallDo not execute the tool; return reason to the model as the tool result.
rewrite_result(result)ToolResultReplace what the model sees as the tool output.
fail()InvalidToolCallPreserve Rig’s default fail-fast behavior.
retry(feedback)InvalidToolCallAppend corrective feedback and ask the model again.
repair(tool_name)InvalidToolCallRewrite the emitted tool name, then revalidate it.

The runner is fail-closed: if a hook returns an action an event cannot honor, Rig terminates the run with a diagnostic instead of silently ignoring it. For example, returning Flow::fail() from a ToolCall event stops the run rather than letting the tool execute.

Use Flow::override_request from CompletionCall to patch one turn of the model request without mutating the agent. This is useful for phased agents: force a search on the first turn, lower temperature for a critical step, or shrink the advertised tool list.

use rig::agent::{AgentHook, Flow, RequestOverride, StepEvent};
use rig::completion::CompletionModel;
use rig::message::ToolChoice;
struct ForceSearchFirst;
impl<M: CompletionModel> AgentHook<M> for ForceSearchFirst {
async fn on_event(&self, event: StepEvent<'_, M>) -> Flow {
match event {
StepEvent::CompletionCall { turn: 1, .. } => Flow::override_request(
RequestOverride::new()
.active_tools(["search_web"])
.tool_choice(ToolChoice::Specific {
function_names: vec!["search_web".to_string()],
})
.temperature(0.0),
),
_ => Flow::cont(),
}
}
}

Overrides are per-turn and non-sticky. additional_params are shallow-merged with the agent’s configured params; the other fields replace the baseline for that turn. If you narrow active_tools, make sure any tool_choice still names an advertised tool.

Hooks are a good place for UX and policy controls around tools. A hook can approve safe tools, skip risky calls, rewrite arguments, or terminate the run.

use rig::agent::{AgentHook, Flow, StepEvent};
use rig::completion::CompletionModel;
struct TransferPolicy {
max_auto_transfer: u64,
}
impl<M: CompletionModel> AgentHook<M> for TransferPolicy {
async fn on_event(&self, event: StepEvent<'_, M>) -> Flow {
let StepEvent::ToolCall { tool_name, args, .. } = event else {
return Flow::cont();
};
if tool_name != "transfer_funds" {
return Flow::cont();
}
let amount = serde_json::from_str::<serde_json::Value>(args)
.ok()
.and_then(|v| v.get("amount").and_then(|a| a.as_u64()));
match amount {
Some(n) if n <= self.max_auto_transfer => Flow::cont(),
Some(n) => Flow::skip(format!(
"denied by policy: ${n} exceeds the ${} automatic transfer limit",
self.max_auto_transfer
)),
None => Flow::skip("denied by policy: missing transfer amount"),
}
}
}

This is a guardrail, not a security boundary. Enforce real authorization inside the tool implementation or the downstream service as well.

An invalid tool call is a tool name that is unknown, not advertised for the turn, or disallowed by the active ToolChoice. By default, Rig fails fast. A hook can opt into recovery:

use rig::agent::{AgentHook, Flow, StepEvent};
use rig::completion::CompletionModel;
struct RepairDefaultApi;
impl<M: CompletionModel> AgentHook<M> for RepairDefaultApi {
async fn on_event(&self, event: StepEvent<'_, M>) -> Flow {
match event {
StepEvent::InvalidToolCall(ctx) if ctx.tool_name == "default_api" => {
Flow::repair("search_web")
}
StepEvent::InvalidToolCall(ctx) => {
Flow::retry(format!("Use one of these tools: {:?}", ctx.available_tools))
}
_ => Flow::cont(),
}
}
}

Recovery options:

  • Flow::fail() preserves the default fail-fast behavior.
  • Flow::retry(feedback) appends corrective feedback and asks the model again. Configure the budget with max_invalid_tool_call_retries(n) on the prompt request or runner.
  • Flow::repair(tool_name) rewrites only the tool name and revalidates it against the allowed tools.
  • Flow::skip(reason) records a synthetic tool result without executing the invalid tool. If any invalid call in a model turn is skipped, Rig suppresses execution of the turn’s other tool calls too and returns synthetic “not executed” results for them. Skip recovery is rejected under ToolChoice::None.

Returning Flow::cont() for InvalidToolCall is treated as Flow::fail().

Hooks are stored in a HookStack and run in registration order. The first hook that returns anything other than Flow::cont() (Flow::Continue) wins for that event; later hooks are not called for that event.

That short-circuiting keeps policy outcomes clear: a hook that skips, rewrites, or terminates has made the decision. If several policies need to combine into one action, compose them inside a single hook.

let response = agent
.runner("Process this request.")
.add_hook(AuditLog)
.add_hook(TransferPolicy { max_auto_transfer: 500 })
.run()
.await?;

In this example, AuditLog sees events first. If it returns Flow::cont(), the transfer policy sees the same event. If it returns a non-continue action, the policy is skipped for that event.

Text and tool-call deltas can be frequent. Override observes so Rig can skip work when no hook cares about a high-frequency event kind:

use rig::agent::{AgentHook, Flow, StepEvent, StepEventKind};
use rig::completion::CompletionModel;
struct ToolOnlyHook;
impl<M: CompletionModel> AgentHook<M> for ToolOnlyHook {
fn observes(&self, kind: StepEventKind) -> bool {
matches!(kind, StepEventKind::ToolCall | StepEventKind::ToolResult)
}
async fn on_event(&self, event: StepEvent<'_, M>) -> Flow {
if let StepEvent::ToolCall { tool_name, .. } = event {
println!("tool: {tool_name}");
}
Flow::cont()
}
}

Even with observes, on_event should still return Flow::cont() for events it ignores: hook stacks OR-combine interest across hooks, so a sibling hook may cause an event to be dispatched.

  • Keep hooks lightweight. Hooks are awaited inline, so slow hooks delay the next model or tool step.
  • Offload heavy logging, network writes, and audit persistence to background tasks.
  • Treat hook guardrails as UX/policy controls, not authorization boundaries.
  • Make hook state concurrency-safe when using tool_concurrency > 1.
  • Prefer one composed policy hook when multiple rules need to produce one decision.
  • AgentRunner — the driver that runs hooks.
  • Agents — the high-level agent abstraction.
  • Tools — define tools hooks can inspect, rewrite, or skip.
  • Streaming — receive streaming deltas and final responses.
  • agent::hook source on GitHub — exact main-branch hook, event, and flow signatures.