Hooks
Hooks let your code see, change, or stop things the agent does.
Tool hooks work like game mods. Each hook gets an original function. original calls the next hook or the real tool.
This lets you run code before and after the tool call in the same method.
Examples
Observe and wrap a tool call
A hook can modify the request before the tool sees it, or rewrite the result after the real tool runs.
The full example takes the result path: a real read runs, and the hook scrubs the API_KEY= and TOKEN= values from the result before the model sees them. Permission rules can allow or deny the call; they cannot rewrite what the tool returns.
$HOME in string arguments expands to the user's home directory:
use std::env;
use serde_json::Value;
use reloaded_code_core::{
HookSet, ToolCallContext, ToolHook, ToolHookFuture, ToolOriginal,
ToolOutput, ToolRequest,
};
struct HomeExpander;
impl ToolHook for HomeExpander {
fn hook<'a>(
&'a self,
ctx: &'a ToolCallContext<'a>,
mut req: ToolRequest,
original: ToolOriginal<'a>,
) -> ToolHookFuture<'a> {
Box::pin(async move {
// Expand $HOME → real home directory in all string args
if let Some(home) = env::var("HOME").ok() {
if let Some(map) = req.args.as_object_mut() {
for val in map.values_mut() {
if let Value::String(s) = val {
*s = s.replace("$HOME", &home);
}
}
}
}
original.call(ctx, req).await
})
}
}
let hooks = HookSet::builder()
.tool_hook(HomeExpander)
.build();
Full example: serdesai-tool-hook (cargo run --example serdesai-tool-hook -p reloaded-code-serdesai --features mock).
Block a tool call
To block or replace a tool call, do not call original.
The full example keeps state across calls: the hook records every file the run reads, then denies a write to a file that was never read, so the real write never executes. Permission rules cannot make a decision depend on earlier calls.
A common case: prevent credential leaks by blocking read/write access to .env files.
use reloaded_code_core::{
HookSet, ToolCallContext, ToolHook, ToolHookFuture, ToolOriginal,
ToolOutput, ToolRequest,
};
struct EnvFileGuard;
impl ToolHook for EnvFileGuard {
fn hook<'a>(
&'a self,
ctx: &'a ToolCallContext<'a>,
req: ToolRequest,
original: ToolOriginal<'a>,
) -> ToolHookFuture<'a> {
Box::pin(async move {
// Block access to .env files
if let Some(path) = req.args.get("path").and_then(|v| v.as_str()) {
if path.starts_with(".env") || path.contains("/.env") {
return Ok(ToolOutput::new(
"Blocked: .env files contain secrets"
));
}
}
// All other calls pass through
original.call(ctx, req).await
})
}
}
let hooks = HookSet::builder()
.tool_hook(EnvFileGuard)
.build();
Full example: serdesai-tool-block (cargo run --example serdesai-tool-block -p reloaded-code-serdesai --features mock).
Stack hooks
Hooks run in registration order. Each hook wraps the next one, so code after original.call(...) runs in reverse order.
The full example stacks an audit hook that logs the original bash arguments with a hardening hook that injects a timeout_ms before the real tool runs.
tool_hook takes ownership of the hook. shared_tool_hook registers an existing Arc<dyn ToolHook> when the same instance must be used in several hook sets:
use std::sync::Arc;
use reloaded_code_core::{
HookSet, ToolCallContext, ToolHook, ToolHookFuture, ToolOriginal, ToolRequest,
};
struct AuditHook(&'static str);
impl ToolHook for AuditHook {
fn hook<'a>(
&'a self,
ctx: &'a ToolCallContext<'a>,
req: ToolRequest,
original: ToolOriginal<'a>,
) -> ToolHookFuture<'a> {
Box::pin(async move {
println!("{}: before {}", self.0, ctx.tool_name);
let output = original.call(ctx, req).await?;
println!("{}: after {}", self.0, ctx.tool_name);
Ok(output)
})
}
}
let shared: Arc<dyn ToolHook> = Arc::new(AuditHook("inner"));
let hooks = HookSet::builder()
.tool_hook(AuditHook("outer"))
.shared_tool_hook(shared)
.build();
Full example: serdesai-tool-chain (cargo run --example serdesai-tool-chain -p reloaded-code-serdesai --features mock).
Amend run config
RunConfigHook changes a run's config before the run starts. RunConfig holds the system prompt, preamble messages, and model settings overrides (temperature, top_p). configure mutates the config in place:
use reloaded_code_core::{
HookRunContext, HookSet, PreambleMessage, PreambleRole, RunConfig,
RunConfigHook, RunConfigHookFuture,
};
struct PreambleInjector;
impl RunConfigHook for PreambleInjector {
fn configure<'a>(
&'a self,
_ctx: &'a HookRunContext<'a>,
config: &'a mut RunConfig,
) -> RunConfigHookFuture<'a> {
Box::pin(async move {
config.preamble_messages.push(PreambleMessage {
role: PreambleRole::System,
content: "You are a helpful assistant.".into(),
});
Ok(())
})
}
}
let hooks = HookSet::builder()
.run_config_hook(PreambleInjector)
.build();
A RunConfigHook runs before the request is made. Hooks run in registration order; the chain stops at the first error, and the run does not start.
Full example: serdesai-run-config-hook (cargo run --example serdesai-run-config-hook -p reloaded-code-serdesai --features mock).
Observe run start and end
A RunHook observes without changing anything: log before calling original, inspect the result after.
The config argument is a read-only view of the final RunConfig. Register a RunConfigHook to change it:
use reloaded_code_core::{
EndReason, HookRunContext, HookSet, RunConfig, RunHook, RunHookFuture,
RunOriginal,
};
struct RunObserver;
impl RunHook for RunObserver {
fn hook<'a>(
&'a self,
ctx: &'a HookRunContext<'a>,
_config: &'a RunConfig,
original: RunOriginal<'a>,
) -> RunHookFuture<'a> {
Box::pin(async move {
println!("run starting for {}", ctx.agent_name);
let result = original.call(ctx).await;
let reason = match &result {
Ok(output) => output.reason,
Err(_) => EndReason::Failed,
};
println!("run ended for {} ({:?})", ctx.agent_name, reason);
result
})
}
}
let hooks = HookSet::builder()
.run_hook(RunObserver)
.build();
Code after original runs when the wrapped continuation finishes, including failure: a failed run reports EndReason::Failed and the error still propagates to the caller. An outer hook that skips original never reaches this hook, so do not rely on it for cleanup that must run on every path.
Full example: serdesai-run-hook (cargo run --example serdesai-run-hook -p reloaded-code-serdesai --features mock).
Intercept streamed events
Run-event hooks fire only on run_stream(). Each event passes the registered RunEventHooks, in registration order, only until one suppresses it with Ok(None) or rejects it with Err(ToolError); a rejection also terminates the stream.
Per event, a hook returns one of three decisions:
Ok(Some(event))publishes it, changed or unchanged.Ok(None)suppresses it. Later hooks and the consumer never see it.Err(ToolError)stops dispatch; the stream ends withErr(AgentRunError::Other).
This hook forwards the events a TUI renders. The consumer still sees every event:
use reloaded_code_core::{HookSet, RunEventContext, RunEventHookResult};
use reloaded_code_serdesai::{RunEvent, RunEventHook};
struct TuiSender { /* channel to the UI thread */ }
impl TuiSender {
/// Stub: cheap, non-blocking send; the UI thread draws.
fn send_to_tui(&self, _event: &RunEvent) {}
}
struct ForwardToTui {
tui: TuiSender,
}
impl RunEventHook for ForwardToTui {
fn hook(&self, _ctx: &RunEventContext<'_>, event: RunEvent) -> RunEventHookResult {
// Forward only what the TUI renders
match &event {
RunEvent::TextDelta { .. } | RunEvent::RunComplete { .. } => {
self.tui.send_to_tui(&event);
}
_ => {}
}
Ok(Some(event))
}
}
let hooks = HookSet::builder()
.run_event_hook(ForwardToTui { tui: TuiSender {} })
.build();
Hooks run synchronously at token rate; keep per-event work cheap. Each call sees one event. Text can split across several TextDeltas, so buffer cross-event context inside the hook.
Full example: serdesai-run-event-hook rewrites text deltas to uppercase and suppresses the output-ready milestone (cargo run --example serdesai-run-event-hook -p reloaded-code-serdesai --features mock).
How tool hooks stack
This diagram assumes you register two hooks. If you set no hooks, the tool call goes straight to the real tool (fast path).
Tool hooks run in registration order. Each hook receives an original function. That function calls the next hook or the real tool.
flowchart TD
TC[Tool call] --> H1[Hook 1]
H1 -->|"original.call(ctx, req)"| H2[Hook 2]
H1 -->|"skip original"| Done[Result to agent]
H2 -->|"original.call(ctx, req)"| Exec[Real tool]
H2 -->|"skip original"| H1A
Exec --> H2A[Hook 2 after]
H2A --> H1A[Hook 1 after]
H1A --> Done This works like game mods. Your hook gets a function. That function is original. It calls the next hook, not always the real tool. Getting started
Build a HookSet with your hooks, then pass it to the runtime:
use reloaded_code_agents::AgentRuntimeBuilder;
use reloaded_code_core::HookSet;
let hooks = HookSet::builder()
.tool_hook(EnvFileGuard)
.build();
let runtime = AgentRuntimeBuilder::new()
.hooks(hooks)
.build()?;
assert!(!runtime.hooks().is_empty());
Calling .hooks(set) replaces any existing HookSet. Omitting it passes HookSet::default().
Design notes
-
Everything is a hook: Observers are plain
RunHooks. Code beforeoriginalis "start", code after is "end". They participate in the same hook chain with the same ordering rules. -
Natural unwind order. Hook code after
original.call(...)runs in reverse order. Later hooks run first after the operation. -
Blocking by omission. A hook blocks or replaces a call by not calling
original. -
Empty fast path.
dispatch_toolcalls the real tool directly when you set no hooks.