Skip to content

Hooks

Hooks let your code observe and steer the agent loop while the AgentRunner drives model calls and tools. Use them for logging, metrics, audit trails, approval flows, guardrails, per-turn request changes, model routing, response retries, invalid tool-call recovery, and streaming UI integration.

A hook is a type that implements AgentHook. The trait has one method per kind of event, and every method has a default that lets the run continue, so you implement only the events you care about. Each method returns an action type that says what the run should do next.

For one run, add hooks on the prompt; for every run, add them on the agent builder:

use rig::agent::{AgentHook, DispatchAction, DispatchEvent, HookContext};
struct ToolAudit;
impl AgentHook for ToolAudit {
async fn on_dispatch(&self, _ctx: &HookContext, event: DispatchEvent<'_>) -> DispatchAction {
// `on_dispatch` fires for model calls too; only log tool calls.
if let (Some(name), Some(args)) = (event.tool_name(), event.tool_args()) {
println!("calling {name} with {args}");
}
DispatchAction::proceed()
}
}
// Every run of this agent is audited...
let agent = AgentBuilder::new(OpenAI::from_env()?.completion(openai::GPT_5_5))
.add_hook(ToolAudit)
.build();
// ...and this one run gets an extra hook on top.
let response = agent
.prompt("Check the account balance, then summarize it.")
.max_turns(3)
.add_hook(ToolAudit)
.await?;

Hooks run in registration order: the agent’s hooks first, then the run’s. How several hooks’ answers combine depends on the event — see Composing hooks.

MethodWhen it firesReturnsWhat a hook can do
on_run_startOnce, before the first model callRunStartActioncontinue_run(), rewrite(prompt), stop(reason)
on_completion_callBefore each model requestCompletionCallActioncontinue_run(), patch(RequestPatch) for this turn, stop(reason)
on_model_selectAfter completion-call hooks, before each model request (synchronous)ModelSelectionActioncontinue_run(), select(label), stop(reason)
on_model_turn_finishedAfter each model turn is received, before tools run or the run finishesModelTurnActioncontinue_run(), repeat(), retry_with_feedback(text), stop(reason)
on_invalid_tool_callThe model called an unknown or disallowed tool, or sent arguments that aren’t a JSON objectOption<InvalidToolCallAction>None to defer, or fail(), retry(feedback), repair(name), skip(reason), stop(reason)
on_dispatchBefore a model call or tool call is executedDispatchActionproceed(), skip(reason) (tool calls), stop(reason), rewrite_tool_args(..), patch(..), deny(report)
on_outcomeAfter a model call or tool call returnsOutcomeActionproceed(), rewrite_tool_result(..), rewrite_tool_output(..), replace(..), stop(reason)
on_text_deltaStreaming only: each text fragmentObservationActioncontinue_run(), stop(reason)
on_reasoning_deltaStreaming only: each reasoning fragmentObservationActioncontinue_run(), stop(reason)
on_tool_call_deltaStreaming only: each tool-call argument fragmentObservationActioncontinue_run(), stop(reason)
on_run_settledOnce, when the run ends with a response or an error()Observe only

A stop(reason) from any event ends the run with PromptError::Cancelled, which carries the history so far. Blocking and streaming runs fire the same events; streaming adds the three delta events, whose content stays provisional until the model turn is accepted.

Every method receives a HookContext with run-scoped information:

  • run_id(), turn() (the one-based model-call index), is_streaming(), and agent_name();
  • scratchpad() — typed in-memory state shared by all hooks in the run, such as retry counters;
  • append_entry(kind, &value) / entries(kind) / last_entry(kind) — records kept in the run’s serializable state. When a durable run is resumed, hooks see the entries saved before the pause.

Return CompletionCallAction::patch from on_completion_call to change one turn’s request without touching the agent: force a tool on the first turn, lower the temperature for a critical step, narrow the advertised tools, add a context document, or replace the history sent for that turn.

use rig::agent::{AgentHook, CompletionCallAction, CompletionCallEvent, HookContext, RequestPatch};
use rig::message::ToolChoice;
struct SearchFirst;
impl AgentHook for SearchFirst {
async fn on_completion_call(
&self,
ctx: &HookContext,
_event: CompletionCallEvent<'_>,
) -> CompletionCallAction {
if ctx.turn() == 1 {
CompletionCallAction::patch(
RequestPatch::new()
.active_tools(["search_web"])
.tool_choice(ToolChoice::Required)
.temperature(0.0),
)
} else {
CompletionCallAction::continue_run()
}
}
}

Patches apply to one turn only and are not sticky: the event fires again on every turn. That’s why the example checks ctx.turn() — a hook that patched ToolChoice::Required on every turn would never let the model stop calling tools and answer, and the run would end in PromptError::MaxTurns.

RequestPatch can set preamble, temperature, max_tokens, tool_choice, active_tools (an allow-list of advertised tools), additional_params (shallow-merged into the agent’s), context / extra_context (documents added for the turn), and history. If you narrow active_tools, make sure any tool_choice still names an advertised tool.

on_model_select picks which registered model serves the next call, by the label it was registered under with AgentBuilder::named_model or model_route. The event carries the default_model, the selected_model so far, and the previous_model used by the last call, so a hook can fall back to another model after a failure or escalate once tool results arrive. It is synchronous: decide from the event, the context, and the scratchpad, without I/O. See Choosing models at runtime for a full example.

on_model_turn_finished sees each model turn’s content, usage, finish reason, and raw provider response before Rig acts on it. Reject a tool-free turn to ask again:

  • ModelTurnAction::repeat() discards the response and asks again with the same prompt and history.
  • ModelTurnAction::retry_with_feedback(text) keeps the response and adds your feedback as a user message, so the model can correct itself.
use rig::agent::{AgentHook, HookContext, ModelTurnAction, ModelTurnFinished};
use rig::completion::FinishReason;
/// Ask for a shorter answer when the model ran out of output tokens, at most twice.
struct RetryTruncated;
#[derive(Clone, Default)]
struct Retries(usize);
impl AgentHook for RetryTruncated {
async fn on_model_turn_finished(
&self,
ctx: &HookContext,
event: ModelTurnFinished<'_>,
) -> ModelTurnAction {
if event.finish_reason != Some(&FinishReason::Length) {
return ModelTurnAction::continue_run();
}
let attempt = ctx.scratchpad().update(|retries: &mut Retries| {
retries.0 += 1;
retries.0
});
if attempt > 2 {
return ModelTurnAction::stop("response kept getting truncated");
}
ModelTurnAction::retry_with_feedback("That was cut off. Answer again, more briefly.")
}
}

Every retry is a model call and counts against max_turns, so give the run room for it. Rig has no separate retry limit; keep your own count in the scratchpad as above. Retrying a turn that contains tool calls is rejected — steer those calls with on_dispatch instead.

on_dispatch runs before every model call and tool call is executed. For tool calls, event.tool_name() and event.tool_args() return the call, and the hook decides:

  • DispatchAction::proceed() — run the tool.
  • DispatchAction::skip(reason) — don’t run it; the model receives reason as the tool result and can adapt.
  • DispatchAction::rewrite_tool_args(event.kind, args) — run it with replacement JSON arguments.
  • DispatchAction::stop(reason) — end the run.
use rig::agent::{AgentHook, DispatchAction, DispatchEvent, HookContext};
struct TransferPolicy {
max_auto_transfer: u64,
}
impl AgentHook for TransferPolicy {
async fn on_dispatch(&self, _ctx: &HookContext, event: DispatchEvent<'_>) -> DispatchAction {
let (Some(tool_name), Some(args)) = (event.tool_name(), event.tool_args()) else {
return DispatchAction::proceed(); // not a tool call
};
if tool_name != "transfer_funds" {
return DispatchAction::proceed();
}
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 => DispatchAction::proceed(),
Some(n) => DispatchAction::skip(format!(
"denied by policy: ${n} exceeds the ${} automatic transfer limit",
self.max_auto_transfer
)),
None => DispatchAction::skip("denied by policy: missing transfer amount"),
}
}
}

Because hooks are async, on_dispatch can also wait for a person: print the call, read an approve / deny / edit answer, and return the matching action. Make such gates fail-closed — treat no answer or an unclear one as a denial, never as approval. An inline approval only lasts as long as the running task; when the decision may take hours or come from another process, persist the run instead (see Durable runs). Rig’s agent_with_approval_policy and agent_with_human_in_the_loop examples show both inline styles in full.

Hook guardrails are UX and policy controls, not a security boundary. Enforce real authorization inside the tool or the service behind it as well.

on_outcome runs after a model call or tool call returns. For tool calls, event.tool_result() gives the result, and OutcomeAction::rewrite_tool_result(&event, text) replaces what the model sees — to redact, truncate, or normalize output:

use rig::agent::{AgentHook, HookContext, OutcomeAction, OutcomeEvent};
struct TruncateResults;
impl AgentHook for TruncateResults {
async fn on_outcome(&self, _ctx: &HookContext, event: OutcomeEvent<'_>) -> OutcomeAction {
let Some(result) = event.tool_result() else {
return OutcomeAction::proceed(); // a model response, or a failed call
};
let text = result.output().render();
if text.len() <= 4_000 {
return OutcomeAction::proceed();
}
let cut = text.char_indices().nth(4_000).map_or(text.len(), |(i, _)| i);
OutcomeAction::rewrite_tool_result(&event, format!("{}… [truncated]", &text[..cut]))
}
}

For model calls, event.completion() returns the provider response (content, usage, response id), which is the place to log or meter raw responses.

on_invalid_tool_call fires when the model calls a tool that is unknown or not allowed by the active ToolChoice this turn, or sends arguments that aren’t a JSON object. The InvalidToolCallContext carries the emitted tool_name, args, the available_tools, the reason, and the history. Return None to leave the decision to the next hook, or an action:

use rig::agent::{
AgentHook, HookContext, InvalidToolCallAction, InvalidToolCallContext, InvalidToolCallReason,
};
struct RepairDefaultApi;
impl AgentHook for RepairDefaultApi {
async fn on_invalid_tool_call(
&self,
_ctx: &HookContext,
event: &InvalidToolCallContext,
) -> Option<InvalidToolCallAction> {
match event.reason {
InvalidToolCallReason::UnknownTool if event.tool_name == "default_api" => {
Some(InvalidToolCallAction::repair("search_web"))
}
InvalidToolCallReason::UnknownTool => Some(InvalidToolCallAction::retry(format!(
"Use one of these tools: {:?}",
event.available_tools
))),
_ => None,
}
}
}
  • fail() fails the run with PromptError::UnknownToolCall.
  • retry(feedback) adds corrective feedback and asks the model again. Allow it with max_invalid_tool_call_retries(n) on the run; each retry also counts against max_turns.
  • repair(tool_name) rewrites the tool name and checks it again against the allowed tools.
  • skip(reason) answers the call with reason instead of running anything. Rig then doesn’t run the turn’s other tool calls either, and answers them as not executed.
  • stop(reason) ends the run.

When every hook returns None, an unknown or disallowed tool fails the run (or is dropped, under unhandled_invalid_tool_call(UnhandledInvalidToolCall::Ignore)), and malformed arguments are answered with feedback naming the problem. Malformed arguments can’t be repaired — renaming the tool doesn’t fix them — so check event.reason before returning repair.

On a streamed run, on_text_delta, on_reasoning_delta, and on_tool_call_delta fire for every fragment. Each event carries the new delta and the aggregated text of its part so far, which is handy for live UI updates or for stopping a run whose output breaks a content policy. Deltas are provisional until the model turn is accepted. Consuming the stream itself is covered in Streaming.

Hooks run in registration order, and each event combines their answers in the way that suits it:

  • Chained: on_run_start rewrites, on_model_select selections, and on_dispatch / on_outcome rewrites each pass to the next hook, which sees the updated prompt, model, arguments, or result. The last change wins; a stop or denial ends the chain.
  • Merged: on_completion_call patches from all hooks are merged in order, so a context hook and a sampling hook both apply. A stop ends it.
  • First decision wins: for on_model_turn_finished, on_invalid_tool_call, and the delta events, the first hook that returns something other than continue (or None) decides, and later hooks aren’t called for that event.
  • Everyone observes: on_run_settled runs every hook.

If several policies must combine into one decision, compose them inside a single hook.

observes(kind) tells Rig which events a hook wants. By default a hook observes everything except dispatches for embeddings, reranking, memory, retrieval, and custom effects; override observes to opt into those or to let Rig skip work for high-frequency events your hook ignores:

use rig::agent::{AgentHook, DispatchAction, DispatchEvent, HookContext, StepEventKind};
struct ToolOnlyHook;
impl AgentHook for ToolOnlyHook {
fn observes(&self, kind: StepEventKind) -> bool {
matches!(kind, StepEventKind::ToolDispatch)
}
async fn on_dispatch(&self, _ctx: &HookContext, event: DispatchEvent<'_>) -> DispatchAction {
if let Some(name) = event.tool_name() {
println!("tool: {name}");
}
DispatchAction::proceed()
}
}

A hook that answers false for ToolDispatch or CompletionDispatch is not called for those dispatches at all, so it can’t gate them. Keep the kinds a hook needs to steer.

  • Keep hooks lightweight. They are awaited inline, so a slow hook delays the next model call or tool.
  • Offload heavy logging, network writes, and audit persistence to background tasks.
  • Make hook state safe to share when using tool_concurrency > 1; per-run state belongs in the scratchpad, not in the hook struct.
  • Treat hook guardrails as UX and policy controls, not authorization boundaries.
  • Prefer one composed policy hook when several rules must produce one decision.
  • AgentRunner — the per-run driver that fires hooks.
  • Agents — the high-level agent abstraction.
  • Durable runs — approvals that outlive the process.
  • Tools — define tools hooks can approve, skip, or rewrite.
  • Streaming — receive streaming deltas and final responses.