Hooks
Lifecycle hooks for customizing agent behavior
Hooks
Hooks let you inject custom logic at key points in the agent lifecycle — before a run starts, after a tool call, when a handoff occurs, or when the run completes. They're the extension point for cross-cutting concerns like logging, analytics, rate limiting, and custom business logic.
Agent Hooks
const agent = new Agent({
name: 'tracked',
model: claude('claude-sonnet-4-6'),
instructions: 'You are a helpful assistant.',
hooks: {
/** Called before the agent starts processing */
onStart: async (context) => {
console.log(`Run ${context.runId} started`)
await analytics.track('agent_run_started', { agent: context.agentName })
},
/** Called before each tool execution */
onToolCall: async (toolCall, context) => {
console.log(`Tool: ${toolCall.name}(${JSON.stringify(toolCall.args)})`)
// Return modified args, or throw to block the call
},
/** Called after each tool execution */
onToolResult: async (toolCall, result, context) => {
await logger.log({
tool: toolCall.name,
args: toolCall.args,
result,
duration: context.lastToolDuration,
})
},
/** Called when the agent hands off to another agent */
onHandoff: async (from, to, context) => {
console.log(`Handoff: ${from} → ${to}`)
await analytics.track('agent_handoff', { from, to })
},
/** Called when the run completes (success or failure) */
onEnd: async (result, context) => {
console.log(`Run ${context.runId} ended: ${result.status}`)
await analytics.track('agent_run_completed', {
agent: context.agentName,
status: result.status,
tokens: result.usage.totalTokens,
duration: context.durationMs,
})
},
/** Called when max turns is reached */
onMaxTurns: async (context) => {
console.log(`Agent hit max turns limit`)
// Optionally return a custom response
return {
output: 'I was unable to complete the task within the allowed steps. Please try again with a simpler request.',
}
},
/** Called when an error occurs */
onError: async (error, context) => {
await errorTracker.capture(error, { runId: context.runId })
// Optionally return a fallback response
return {
output: 'I encountered an error. Please try again.',
}
},
},
})Runner Hooks
Runner-level hooks apply to all agents in a run, including sub-agents and handoff targets:
const result = await Runner.run(agent, {
messages,
hooks: {
/** Called for every agent in the run (including sub-agents) */
onAgentStart: async (agentName, context) => {
console.log(`Agent "${agentName}" activated`)
},
onAgentEnd: async (agentName, result, context) => {
console.log(`Agent "${agentName}" finished: ${result.status}`)
},
/** Called for every tool call across all agents */
onToolCall: async (toolCall, agentName, context) => {
// Global tool call logging
},
/** Called for every guardrail trigger across all agents */
onGuardrailTriggered: async (guardrail, phase, result, context) => {
await securityLog.write({
guardrail: guardrail.name,
phase,
allow: result.allow,
reason: result.reason,
})
},
},
})Hook Context
Every hook receives a context object with metadata about the current run:
interface HookContext {
/** Unique run identifier */
runId: string
/** Name of the current agent */
agentName: string
/** Current turn number */
turn: number
/** Total tokens used so far */
tokensUsed: number
/** Run duration so far in milliseconds */
durationMs: number
/** Duration of the last tool call */
lastToolDuration?: number
/** Custom metadata attached to the run */
metadata: Record<string, unknown>
}Common Hook Patterns
Cost Tracking
hooks: {
onEnd: async (result, context) => {
const cost = calculateCost(result.usage, context.agentName)
await billing.record(context.userId, cost)
if (cost > 1.0) {
await alerts.send(`High-cost run: $${cost.toFixed(2)} for ${context.agentName}`)
}
},
}Retry on Failure
hooks: {
onError: async (error, context) => {
if (context.retryCount < 3 && isTransient(error)) {
// Signal the runner to retry
return { retry: true, delay: 1000 * Math.pow(2, context.retryCount) }
}
// Give up and return a fallback
return { output: 'Service temporarily unavailable.' }
},
}Tool Call Modification
hooks: {
onToolCall: async (toolCall, context) => {
if (toolCall.name === 'web_search') {
// Add a safety suffix to all searches
return {
...toolCall,
args: {
...toolCall.args,
query: `${toolCall.args.query} site:trusted-sources.com`,
},
}
}
},
}Performance Monitoring
hooks: {
onToolCall: async (toolCall, context) => {
context.metadata[`tool_start_${toolCall.id}`] = Date.now()
},
onToolResult: async (toolCall, result, context) => {
const startTime = context.metadata[`tool_start_${toolCall.id}`]
const duration = Date.now() - startTime
if (duration > 5000) {
console.warn(`Slow tool: ${toolCall.name} took ${duration}ms`)
}
await metrics.histogram('tool_duration', duration, {
tool: toolCall.name,
agent: context.agentName,
})
},
}Hook Execution Order
onStart
│
├─ onToolCall (before tool executes)
│ └─ onToolResult (after tool returns)
│
├─ onToolCall ...
│ └─ onToolResult ...
│
├─ onGuardrailTriggered (if triggered)
│
├─ onHandoff (if handoff occurs)
│ └─ New agent's onStart
│ └─ ... (new agent's hooks run)
│
└─ onEnd (always runs, even on error)
or
onError → onEnd
or
onMaxTurns → onEndBest Practices
-
Keep hooks fast — Hooks run sequentially in the agent loop. Slow hooks add latency to every interaction. Use fire-and-forget for analytics.
-
Don't modify state in hooks — Hooks should observe and react, not mutate agent configuration. Use
onToolCallreturn values for safe modifications. -
Use runner hooks for cross-cutting concerns — Logging, analytics, and security should use runner-level hooks to cover all agents uniformly.
-
Handle errors in hooks — A crashing hook shouldn't crash the agent. Wrap hook logic in try/catch.
-
Use hooks for observability, not business logic — If your hook is implementing core business logic, it should probably be a guardrail or a tool instead.