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.
Adding hooks
Section titled “Adding hooks”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.
Events and actions
Section titled “Events and actions”| Method | When it fires | Returns | What a hook can do |
|---|---|---|---|
on_run_start | Once, before the first model call | RunStartAction | continue_run(), rewrite(prompt), stop(reason) |
on_completion_call | Before each model request | CompletionCallAction | continue_run(), patch(RequestPatch) for this turn, stop(reason) |
on_model_select | After completion-call hooks, before each model request (synchronous) | ModelSelectionAction | continue_run(), select(label), stop(reason) |
on_model_turn_finished | After each model turn is received, before tools run or the run finishes | ModelTurnAction | continue_run(), repeat(), retry_with_feedback(text), stop(reason) |
on_invalid_tool_call | The model called an unknown or disallowed tool, or sent arguments that aren’t a JSON object | Option<InvalidToolCallAction> | None to defer, or fail(), retry(feedback), repair(name), skip(reason), stop(reason) |
on_dispatch | Before a model call or tool call is executed | DispatchAction | proceed(), skip(reason) (tool calls), stop(reason), rewrite_tool_args(..), patch(..), deny(report) |
on_outcome | After a model call or tool call returns | OutcomeAction | proceed(), rewrite_tool_result(..), rewrite_tool_output(..), replace(..), stop(reason) |
on_text_delta | Streaming only: each text fragment | ObservationAction | continue_run(), stop(reason) |
on_reasoning_delta | Streaming only: each reasoning fragment | ObservationAction | continue_run(), stop(reason) |
on_tool_call_delta | Streaming only: each tool-call argument fragment | ObservationAction | continue_run(), stop(reason) |
on_run_settled | Once, 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(), andagent_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.
Request patches
Section titled “Request patches”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.
Choosing the model
Section titled “Choosing the model”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.
Retrying a response
Section titled “Retrying a response”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.
Tool calls: approvals and guardrails
Section titled “Tool calls: approvals and guardrails”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 receivesreasonas 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.
Tool results
Section titled “Tool results”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.
Invalid tool calls
Section titled “Invalid tool calls”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 withPromptError::UnknownToolCall.retry(feedback)adds corrective feedback and asks the model again. Allow it withmax_invalid_tool_call_retries(n)on the run; each retry also counts againstmax_turns.repair(tool_name)rewrites the tool name and checks it again against the allowed tools.skip(reason)answers the call withreasoninstead 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.
Streaming deltas
Section titled “Streaming deltas”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.
Composing hooks
Section titled “Composing hooks”Hooks run in registration order, and each event combines their answers in the way that suits it:
- Chained:
on_run_startrewrites,on_model_selectselections, andon_dispatch/on_outcomerewrites 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_callpatches 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 (orNone) decides, and later hooks aren’t called for that event. - Everyone observes:
on_run_settledruns every hook.
If several policies must combine into one decision, compose them inside a single hook.
Opting out of events with observes
Section titled “Opting out of events with observes”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.
Best practices
Section titled “Best practices”- 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.
See also
Section titled “See also”- 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.
