Agent lifecycle interception and policies.
Hooks observe or modify agent behavior at key lifecycle points. They let you log events, enforce safety rules, rate-limit turns, audit tool calls, and recover from errors β all without modifying the core agent loop.
The Python SDK splits hooks into separate base classes by category:
| Python Category | Python Base Classes | Rust Equivalent |
|---|---|---|
| Inspect (read-only) | OnSessionStartHook, PostToolCallHook, OnSessionEndHook, PostTurnHook, OnCompactionHook |
Default no-op methods on Hook |
| Decide (blocking) | PreTurnHook, PreToolCallDecideHook |
pre_turn(), pre_tool_call() return HookResult |
| Transform (modifying) | OnToolErrorHook, OnInteractionHook |
on_tool_error(), on_interaction() return recovery data |
The Rust SDK merges all of these into a single Hook trait with 9 async
methods. Every method has a default no-op implementation, so you only override
what you need.
All 9 lifecycle methods are defined on the Hook trait. Each has a default
no-op implementation so you only need to override the methods relevant to your
use case:
use antigravity_sdk_rust::hooks::Hook;
use antigravity_sdk_rust::types::{
AskQuestionEntry, ChatResponse, HookResult, QuestionHookResult,
ToolCall, ToolResult,
};
pub trait Hook: Send + Sync {
// ββ Session lifecycle ββββββββββββββββββββββββββββββββββββββββββ
/// Called when the agent establishes a connection and starts a session.
async fn on_session_start(&self) -> Result<(), anyhow::Error> {
Ok(())
}
/// Called when the session is ending (agent shutdown or disconnect).
async fn on_session_end(&self) -> Result<(), anyhow::Error> {
Ok(())
}
// ββ Turn lifecycle βββββββββββββββββββββββββββββββββββββββββββββ
/// Intercepts the start of a user turn before the LLM processes the prompt.
/// Return `allow: false` to halt execution.
async fn pre_turn(&self) -> Result<HookResult, anyhow::Error> {
Ok(HookResult { allow: true, message: String::new() })
}
/// Called after a turn completes, receiving the full response.
async fn post_turn(&self, _response: &ChatResponse) -> Result<(), anyhow::Error> {
Ok(())
}
// ββ Tool lifecycle βββββββββββββββββββββββββββββββββββββββββββββ
/// Intercepts a tool call before execution.
/// Return `allow: false` to prevent the tool from running.
async fn pre_tool_call(&self, _tool_call: &ToolCall) -> Result<HookResult, anyhow::Error> {
Ok(HookResult { allow: true, message: String::new() })
}
/// Called after a tool successfully returns a result.
async fn post_tool_call(&self, _result: &ToolResult) -> Result<(), anyhow::Error> {
Ok(())
}
// ββ Error recovery βββββββββββββββββββββββββββββββββββββββββββββ
/// Called when a tool execution encounters an error.
/// Return `(HookResult { allow: true, .. }, Some(value))` to provide a
/// recovery payload instead of propagating the error.
async fn on_tool_error(
&self,
error: &anyhow::Error,
) -> Result<(HookResult, Option<serde_json::Value>), anyhow::Error> {
Ok((
HookResult { allow: false, message: error.to_string() },
None,
))
}
// ββ User interaction βββββββββββββββββββββββββββββββββββββββββββ
/// Intercepts interactive user questions.
/// Return `Some(result)` to answer programmatically.
async fn on_interaction(
&self,
_questions: &[AskQuestionEntry],
) -> Result<Option<QuestionHookResult>, anyhow::Error> {
Ok(None)
}
// ββ History compaction βββββββββββββββββββββββββββββββββββββββββ
/// Called when the conversation history is compacted/summarized.
async fn on_compaction(&self, _summary: &str) -> Result<(), anyhow::Error> {
Ok(())
}
}| Method | Category | Return | Short-Circuit? |
|---|---|---|---|
on_session_start |
Inspect | () |
No |
on_session_end |
Inspect | () |
No |
pre_turn |
Decide | HookResult |
Yes β first allow: false |
post_turn |
Inspect | () |
No |
pre_tool_call |
Decide | HookResult |
Yes β first allow: false |
post_tool_call |
Inspect | () |
No |
on_tool_error |
Transform | (HookResult, Option<Value>) |
Yes β first allow: true |
on_interaction |
Transform | Option<QuestionHookResult> |
Yes β first Some(result) |
on_compaction |
Inspect | () |
No |
Rust's async fn in traits produces impl Future return types, which are not
object-safe β you cannot use dyn Hook directly. The SDK solves this with a
companion DynHook trait that wraps every method in a BoxFuture:
use futures_util::future::BoxFuture;
use antigravity_sdk_rust::types::{HookResult, ToolCall, ToolResult, ChatResponse, AskQuestionEntry, QuestionHookResult};
/// Object-safe version of `Hook`, used internally for dynamic dispatch.
pub trait DynHook: Send + Sync {
fn on_session_start(&self) -> BoxFuture<'_, Result<(), anyhow::Error>>;
fn pre_turn(&self) -> BoxFuture<'_, Result<HookResult, anyhow::Error>>;
fn pre_tool_call<'a>(&'a self, tool_call: &'a ToolCall) -> BoxFuture<'a, Result<HookResult, anyhow::Error>>;
fn post_tool_call<'a>(&'a self, result: &'a ToolResult) -> BoxFuture<'a, Result<(), anyhow::Error>>;
fn on_tool_error<'a>(&'a self, error: &'a anyhow::Error) -> BoxFuture<'a, Result<(HookResult, Option<serde_json::Value>), anyhow::Error>>;
fn on_interaction<'a>(&'a self, questions: &'a [AskQuestionEntry]) -> BoxFuture<'a, Result<Option<QuestionHookResult>, anyhow::Error>>;
fn on_session_end(&self) -> BoxFuture<'_, Result<(), anyhow::Error>>;
fn post_turn<'a>(&'a self, response: &'a ChatResponse) -> BoxFuture<'a, Result<(), anyhow::Error>>;
fn on_compaction<'a>(&'a self, summary: &'a str) -> BoxFuture<'a, Result<(), anyhow::Error>>;
}A blanket implementation automatically bridges the two traits:
// Any type that implements Hook also implements DynHook β for free.
// impl<T: Hook + ?Sized> DynHook for T { ... }This means you always implement Hook (the ergonomic trait) and the SDK
converts it to Arc<dyn DynHook> for storage and dispatch. You never need to
implement DynHook directly.
The HookRunner is the internal dispatch engine that manages registered hooks
and dispatches lifecycle events sequentially. It stores hooks as
Vec<Arc<dyn DynHook>> behind a tokio::sync::RwLock.
| Dispatch Method | Behavior |
|---|---|
dispatch_session_start |
Calls all hooks sequentially |
dispatch_session_end |
Calls all hooks sequentially |
dispatch_pre_turn |
Short-circuits at first allow: false |
dispatch_post_turn |
Calls all hooks sequentially |
dispatch_pre_tool_call |
Short-circuits at first allow: false |
dispatch_post_tool_call |
Calls all hooks sequentially |
dispatch_on_tool_error |
Short-circuits at first allow: true (recovery) |
dispatch_interaction |
Short-circuits at first Some(result) |
dispatch_on_compaction |
Calls all hooks sequentially |
Short-circuiting means that once a decisive result is found, remaining hooks are skipped for that event. This allows safety hooks to block execution without later hooks overriding the decision.
use antigravity_sdk_rust::hooks::{HookRunner, DynHook};
use std::sync::Arc;
let runner = HookRunner::new();
// Register a hook
// runner.register(Arc::new(my_hook)).await;HookContext provides a parent-chaining key-value store for sharing state
across hook invocations at different scopes. Values are stored as
serde_json::Value internally.
βββββββββββββββββββββββββββββββββββ
β Session Context (root) β β HookContext::new()
β "session_id" = "abc-123" β
β β
β βββββββββββββββββββββββββββββ β
β β Turn Context β β β HookContext::child(session)
β β "turn_count" = 5 β β
β β β β
β β βββββββββββββββββββββββ β β
β β β Operation Context β β β β HookContext::child(turn)
β β β "tool_name" = "X" β β β
β β βββββββββββββββββββββββ β β
β βββββββββββββββββββββββββββββ β
βββββββββββββββββββββββββββββββββββ
get(key)β searches the local store first, then walks up the parent chain. ReturnsNoneif the key is not found at any level.set(key, value)β writes only to the local store. This shadows (but does not mutate) any parent value with the same key.has_parent()β returnstrueif this context is not the root.
use antigravity_sdk_rust::context::HookContext;
use std::sync::Arc;
// Session-level context (root)
let session = Arc::new(HookContext::new());
session.set("user_id", "user-42");
// Turn-level context
let turn = Arc::new(HookContext::child(session.clone()));
turn.set("turn_number", 1u32);
// Operation-level context
let operation = HookContext::child(turn.clone());
operation.set("tool_name", "read_file");
// get() walks up the chain:
assert_eq!(operation.get::<String>("tool_name"), Some("read_file".to_string()));
assert_eq!(operation.get::<u32>("turn_number"), Some(1));
assert_eq!(operation.get::<String>("user_id"), Some("user-42".to_string()));
// set() is local-only β parent is unchanged:
operation.set("user_id", "override");
assert_eq!(operation.get::<String>("user_id"), Some("override".to_string()));
assert_eq!(session.get::<String>("user_id"), Some("user-42".to_string()));PolicyEnforcer is a built-in Hook implementation that enforces safety
policies on tool calls. It intercepts pre_tool_call and evaluates registered
policies against a 9-bucket priority system.
Policies are sorted into 9 buckets organized by specificity (3 levels) Γ decision type (3 types). Lower bucket index = higher priority:
| Bucket | Specificity | Decision | Index |
|---|---|---|---|
| Specific Deny | Exact tool name | Deny |
0 (highest) |
| Specific Ask | Exact tool name | AskUser |
1 |
| Specific Allow | Exact tool name | Approve |
2 |
| Prefix Deny | Server wildcard (server/*) |
Deny |
3 |
| Prefix Ask | Server wildcard | AskUser |
4 |
| Prefix Allow | Server wildcard | Approve |
5 |
| Global Deny | Global wildcard (*) |
Deny |
6 |
| Global Ask | Global wildcard | AskUser |
7 |
| Global Allow | Global wildcard | Approve |
8 (lowest) |
Key rules:
- Specific rules always override prefix and global rules.
- Prefix rules (
server/*) override global wildcards (*). - At the same specificity, Deny beats Ask beats Allow.
- If no policy matches, the tool call is allowed (open by default).
use antigravity_sdk_rust::policy;
// ββ Single-tool policies βββββββββββββββββββββββββββββββββββββββββββ
let _ = policy::allow("read_file"); // Approve a specific tool
let _ = policy::deny("run_command"); // Deny a specific tool
let _ = policy::ask_user("run_command", |_tc| {
// Return true = user approved, false = user denied
true
});
// ββ Wildcard policies ββββββββββββββββββββββββββββββββββββββββββββββ
let _ = policy::allow_all(); // Approve all tools (wildcard)
let _ = policy::deny_all(); // Deny all tools (wildcard)
// ββ Composite policies ββββββββββββββββββββββββββββββββββββββββββββ
// Require user confirmation for RUN_COMMAND, allow everything else:
let _ = policy::confirm_run_command(Some(std::sync::Arc::new(|_| true)));
// Restrict filesystem tools to specific workspace directories:
let _ = policy::workspace_only(vec!["/my/project".to_string()]);For controlling tools exposed by MCP (Model Context Protocol) servers:
use antigravity_sdk_rust::policy;
use antigravity_sdk_rust::types::McpServerConfig;
let server = McpServerConfig::Stdio {
name: "math".to_string(),
command: "math-server".to_string(),
args: vec![],
enabled_tools: None,
disabled_tools: None,
};
// Allow all tools from the server
let _ = policy::allow_mcp(&server, None);
// Deny specific tools from the server
let _ = policy::deny_mcp(&server, Some(&["dangerous_calc"]));
// Ask user for specific MCP tools
let _ = policy::ask_user_mcp(&server, Some(&["execute"]), |_| true);Policies can include a predicate that narrows when they apply:
use antigravity_sdk_rust::policy;
// Only deny run_command when the command contains "rm"
let _ = policy::deny("run_command").when(|tc| {
tc.args
.get("CommandLine")
.and_then(|v| v.as_str())
.map_or(false, |s| s.contains("rm"))
});Use policy::enforce() to validate and compile policies:
use antigravity_sdk_rust::policy;
// Without MCP servers
let enforcer = policy::enforce(
vec![
policy::deny("run_command"),
policy::allow_all(),
],
None, // no MCP servers
).expect("policy validation failed");
// enforce() will return an error if:
// - An AskUser policy is missing its handler callback
// - MCP policies are present but no MCP servers are registered (fail-closed)A simple hook that logs all lifecycle events:
use antigravity_sdk_rust::hooks::Hook;
use antigravity_sdk_rust::types::{ChatResponse, HookResult, ToolCall, ToolResult};
struct LoggingHook;
impl Hook for LoggingHook {
async fn on_session_start(&self) -> Result<(), anyhow::Error> {
println!("π’ Session started");
Ok(())
}
async fn on_session_end(&self) -> Result<(), anyhow::Error> {
println!("π΄ Session ended");
Ok(())
}
async fn pre_tool_call(&self, tool_call: &ToolCall) -> Result<HookResult, anyhow::Error> {
println!("π§ Calling tool: {}", tool_call.name);
Ok(HookResult { allow: true, message: String::new() })
}
async fn post_tool_call(&self, result: &ToolResult) -> Result<(), anyhow::Error> {
println!("β
Tool {} completed", result.name);
Ok(())
}
async fn post_turn(&self, response: &ChatResponse) -> Result<(), anyhow::Error> {
println!("π¬ Response length: {} chars", response.text.len());
Ok(())
}
}A pre_turn hook that enforces a maximum number of turns per session:
use antigravity_sdk_rust::hooks::Hook;
use antigravity_sdk_rust::types::HookResult;
use std::sync::atomic::{AtomicU32, Ordering};
struct RateLimitHook {
max_turns: u32,
turn_count: AtomicU32,
}
impl RateLimitHook {
fn new(max_turns: u32) -> Self {
Self {
max_turns,
turn_count: AtomicU32::new(0),
}
}
}
impl Hook for RateLimitHook {
async fn pre_turn(&self) -> Result<HookResult, anyhow::Error> {
let count = self.turn_count.fetch_add(1, Ordering::SeqCst);
if count >= self.max_turns {
Ok(HookResult {
allow: false,
message: format!(
"Rate limit exceeded: {} of {} turns used",
count, self.max_turns
),
})
} else {
Ok(HookResult { allow: true, message: String::new() })
}
}
}A post_tool_call hook that records all tool invocations for auditing:
use antigravity_sdk_rust::hooks::Hook;
use antigravity_sdk_rust::types::{HookResult, ToolCall, ToolResult};
use std::sync::Mutex;
struct AuditRecord {
tool_name: String,
success: bool,
timestamp: std::time::Instant,
}
struct AuditHook {
records: Mutex<Vec<AuditRecord>>,
}
impl AuditHook {
fn new() -> Self {
Self {
records: Mutex::new(Vec::new()),
}
}
}
impl Hook for AuditHook {
async fn pre_tool_call(&self, tool_call: &ToolCall) -> Result<HookResult, anyhow::Error> {
println!("π Audit: tool '{}' invoked", tool_call.name);
Ok(HookResult { allow: true, message: String::new() })
}
async fn post_tool_call(&self, result: &ToolResult) -> Result<(), anyhow::Error> {
let record = AuditRecord {
tool_name: result.name.clone(),
success: result.error.is_none(),
timestamp: std::time::Instant::now(),
};
if let Ok(mut records) = self.records.lock() {
records.push(record);
}
Ok(())
}
}An on_tool_error hook that provides fallback values when specific tools fail:
use antigravity_sdk_rust::hooks::Hook;
use antigravity_sdk_rust::types::HookResult;
struct ErrorRecoveryHook;
impl Hook for ErrorRecoveryHook {
async fn on_tool_error(
&self,
error: &anyhow::Error,
) -> Result<(HookResult, Option<serde_json::Value>), anyhow::Error> {
let msg = error.to_string();
// Provide a fallback value for network-related errors
if msg.contains("timeout") || msg.contains("connection refused") {
println!("β οΈ Network error detected, providing fallback");
Ok((
HookResult {
allow: true,
message: "Recovered from network error with fallback".to_string(),
},
Some(serde_json::json!({
"error": "network_unavailable",
"fallback": true,
"message": "Service temporarily unavailable, using cached data"
})),
))
} else {
// Let other hooks handle it, or propagate the error
Ok((
HookResult {
allow: false,
message: msg,
},
None,
))
}
}
}use antigravity_sdk_rust::agent::Agent;
use antigravity_sdk_rust::hooks::DynHook;
use std::sync::Arc;
// Assuming LoggingHook and RateLimitHook are defined as above
#[tokio::main]
async fn main() -> Result<(), anyhow::Error> {
let agent = Agent::builder()
// Register hooks via the builder
.hook(Arc::new(LoggingHook) as Arc<dyn DynHook>)
// .hook(Arc::new(RateLimitHook::new(100)) as Arc<dyn DynHook>)
.allow_all()
.build();
let agent = agent.start().await?;
let response = agent.chat("Hello!").await?;
println!("{}", response.text);
agent.stop().await?;
Ok(())
}
struct LoggingHook;
impl antigravity_sdk_rust::hooks::Hook for LoggingHook {
async fn on_session_start(&self) -> Result<(), anyhow::Error> {
println!("Session started");
Ok(())
}
}You can also register hooks after construction but before starting:
use antigravity_sdk_rust::agent::Agent;
use antigravity_sdk_rust::hooks::DynHook;
use std::sync::Arc;
struct MyHook;
impl antigravity_sdk_rust::hooks::Hook for MyHook {}
let mut agent = Agent::builder().allow_all().build();
agent.register_hook(Arc::new(MyHook) as Arc<dyn DynHook>);
// agent.start().await?;