Hooks
Hooks let you observe, modify, or block agent behavior at key points in the execution pipeline. They cover tool calls, model interactions, error recovery, agent lifecycle, input validation, and cross-cutting event observation.
Setup
myAgent := agent.New(llmClient,
agent.WithHooks(agent.Hooks{
PreToolUse: func(ctx context.Context, tc agent.ToolUseContext) (agent.PreToolUseResult, error) {
log.Printf("Tool call: %s (branch: %s)", tc.ToolName, tc.Branch)
return agent.PreToolUseResult{Action: agent.HookAllow}, nil
},
}),
)
Hook Types
| Hook | Fires | Can |
|---|---|---|
PreToolUse |
Before a tool executes | Allow, Deny, or Modify input |
PostToolUse |
After a tool executes | Allow or Modify output |
PreModelCall |
Before an LLM request | Allow or Modify messages/tools |
PostModelCall |
After an LLM response | Allow or Modify response |
OnSubagentStart |
When a background sub-agent launches | Observe only |
OnSubagentStop |
When a background sub-agent finishes | Observe only |
OnToolError |
When a tool returns an error | Allow (re-raise) or Modify (recover) |
OnModelError |
When an LLM call fails | Allow (re-raise) or Modify (recover) |
BeforeAgent |
Before an agent starts its run | Allow, Deny, or Modify (short-circuit) |
AfterAgent |
After an agent completes its run | Allow or Modify response |
BeforeRun |
At the start of Chat/ChatStream | Observe only |
AfterRun |
At the end of Chat/ChatStream | Observe only |
OnUserMessage |
When a user message arrives | Allow, Deny, or Modify message |
OnEvent |
On every hook event emitted | Observe only |
HookAction
Every hook returns a HookAction that controls what happens next:
| Action | Behavior |
|---|---|
HookAllow |
Continue normally (default) |
HookDeny |
Block execution (PreToolUse, BeforeAgent, OnUserMessage) |
HookModify |
Replace input, output, messages, response, or recover from errors |
Denying a Tool Call
Return HookDeny from PreToolUse to block a tool before it runs:
agent.Hooks{
PreToolUse: func(_ context.Context, tc agent.ToolUseContext) (agent.PreToolUseResult, error) {
if tc.ToolName == "dangerous_tool" {
return agent.PreToolUseResult{
Action: agent.HookDeny,
DenyReason: "this tool is not allowed",
}, nil
}
return agent.PreToolUseResult{Action: agent.HookAllow}, nil
},
}
The agent receives a tool error result with the deny reason.
Modifying Tool Input
Return HookModify from PreToolUse to rewrite the input before execution:
agent.Hooks{
PreToolUse: func(_ context.Context, tc agent.ToolUseContext) (agent.PreToolUseResult, error) {
modified := strings.ReplaceAll(tc.Input, "SECRET", "[REDACTED]")
return agent.PreToolUseResult{
Action: agent.HookModify,
Input: modified,
}, nil
},
}
Modifying Model Messages
Return HookModify from PreModelCall to inject or filter messages before they reach the LLM:
agent.Hooks{
PreModelCall: func(_ context.Context, mc agent.ModelCallContext) (agent.ModelCallResult, error) {
extra := message.NewUserMessage("Remember: always respond in JSON.")
return agent.ModelCallResult{
Action: agent.HookModify,
Messages: append(mc.Messages, extra),
Tools: mc.Tools,
}, nil
},
}
Error Recovery
Tool Error Recovery
OnToolError fires when a tool returns an error, before the error reaches PostToolUse. Return HookModify with replacement output to recover:
agent.Hooks{
OnToolError: func(_ context.Context, tc agent.ToolErrorContext) (agent.ToolErrorResult, error) {
if tc.ToolName == "flaky_api" {
return agent.ToolErrorResult{
Action: agent.HookModify,
Output: "API temporarily unavailable, using cached data",
}, nil
}
return agent.ToolErrorResult{Action: agent.HookAllow}, nil
},
}
When recovery succeeds, the error flag is cleared and PostToolUse sees a non-error result. Multiple error callbacks chain — the first recovery wins.
Model Error Recovery
OnModelError fires when an LLM call fails. Return HookModify with a replacement response to recover:
agent.Hooks{
OnModelError: func(_ context.Context, mc agent.ModelErrorContext) (agent.ModelErrorResult, error) {
return agent.ModelErrorResult{
Action: agent.HookModify,
Response: &llm.Response{
Content: "Service temporarily unavailable. Please try again.",
},
}, nil
},
}
This works in both Chat() and ChatStream() paths.
Agent Lifecycle
Short-Circuiting with BeforeAgent
BeforeAgent fires before an agent starts its run. Return HookModify with a response to skip the agent entirely:
agent.Hooks{
BeforeAgent: func(_ context.Context, ac agent.LifecycleContext) (agent.LifecycleResult, error) {
if cached, ok := cache.Get(ac.Input); ok {
return agent.LifecycleResult{
Action: agent.HookModify,
Response: &agent.ChatResponse{Content: cached},
}, nil
}
return agent.LifecycleResult{Action: agent.HookAllow}, nil
},
}
Return HookDeny to block the agent run with a nil response.
Modifying with AfterAgent
AfterAgent fires after an agent completes. Modify the response before it reaches the caller:
agent.Hooks{
AfterAgent: func(_ context.Context, ac agent.LifecycleContext) (agent.LifecycleResult, error) {
modified := *ac.Response
modified.Content = sanitize(modified.Content)
return agent.LifecycleResult{
Action: agent.HookModify,
Response: &modified,
}, nil
},
}
Run Lifecycle
BeforeRun and AfterRun are observation-only hooks that fire at the very start and end of Chat()/ChatStream():
agent.Hooks{
BeforeRun: func(_ context.Context, rc agent.RunContext) {
metrics.StartTimer(rc.AgentName)
},
AfterRun: func(_ context.Context, rc agent.RunContext) {
metrics.RecordDuration(rc.AgentName, rc.Duration)
if rc.Error != nil {
metrics.RecordError(rc.AgentName, rc.Error)
}
},
}
AfterRun receives the final response, any error, and the total duration.
Input Validation
OnUserMessage fires when a user message arrives, before it reaches any agent logic. Use it to preprocess, validate, or reject messages:
agent.Hooks{
OnUserMessage: func(_ context.Context, uc agent.UserMessageContext) (agent.UserMessageResult, error) {
if containsPII(uc.Message) {
return agent.UserMessageResult{
Action: agent.HookDeny,
DenyReason: "message contains PII",
}, nil
}
return agent.UserMessageResult{
Action: agent.HookModify,
Message: sanitizeInput(uc.Message),
}, nil
},
}
OnUserMessage does not fire for Continue()/ContinueStream() since those resume with tool results, not user messages.
Cross-Cutting Event Observation
OnEvent fires on every hook event emitted during execution. Use it for logging, analytics, or event transformation:
agent.Hooks{
OnEvent: func(_ context.Context, evt agent.HookEvent) {
log.Printf("[%s] agent=%s tool=%s", evt.Type, evt.AgentName, evt.ToolName)
},
}
OnEvent fires once per hook-point invocation (after all hooks in the chain have run), not once per registered hook. It covers all event types except itself.
Chaining Multiple Hooks
Pass multiple Hooks to WithHooks, or call WithHooks multiple times. Hooks run in registration order.
Chain rules:
- Deny wins immediately — if any hook returns
HookDeny, later hooks are skipped - Last Modify wins — if multiple hooks return
HookModify, the last one's value is used - First recovery wins — for error callbacks (
OnToolError,OnModelError), the firstHookModifyresponse is used - nil fields are skipped — you only need to set the hooks you care about
Observation with NewObservingHooks
For pure observation (logging, metrics, streaming to a UI), use the NewObservingHooks helper. It wires all hooks to emit structured HookEvent values to a single callback:
myAgent := agent.New(llmClient,
agent.WithHooks(agent.NewObservingHooks(func(evt agent.HookEvent) {
log.Printf("[%s] agent=%s branch=%s tool=%s",
evt.Type, evt.AgentName, evt.Branch, evt.ToolName)
})),
)
All observing hooks return HookAllow — they never block or modify execution. OnEvent is left nil in observing hooks to avoid double-emission.
HookEvent
| Field | Type | Description |
|---|---|---|
Type |
HookEventType |
Event type (see below) |
Timestamp |
time.Time |
When the event fired |
AgentName |
string |
Name of the agent |
TaskID |
string |
Background task ID (if applicable) |
Branch |
string |
Agent hierarchy path (e.g. "orchestrator/researcher") |
ToolCallID |
string |
Tool call ID (tool events only) |
ToolName |
string |
Tool name (tool events only) |
Input |
string |
Tool input, sub-agent task, or user message |
Output |
string |
Tool output or sub-agent result |
IsError |
bool |
Whether an error occurred |
Duration |
time.Duration |
Execution duration (post-events only) |
Usage |
llm.TokenUsage |
Token usage (post model call only) |
Error |
string |
Error message (if IsError is true) |
Event Types
| Constant | Value | When |
|---|---|---|
HookEventPreToolUse |
"pre_tool_use" |
Before tool execution |
HookEventPostToolUse |
"post_tool_use" |
After tool execution |
HookEventPreModelCall |
"pre_model_call" |
Before LLM request |
HookEventPostModelCall |
"post_model_call" |
After LLM response |
HookEventSubagentStart |
"subagent_start" |
Background sub-agent launched |
HookEventSubagentStop |
"subagent_stop" |
Background sub-agent finished |
HookEventToolError |
"tool_error" |
Tool returned an error |
HookEventModelError |
"model_error" |
LLM call failed |
HookEventBeforeAgent |
"before_agent" |
Before agent starts |
HookEventAfterAgent |
"after_agent" |
After agent completes |
HookEventBeforeRun |
"before_run" |
Start of Chat/ChatStream |
HookEventAfterRun |
"after_run" |
End of Chat/ChatStream |
HookEventUserMessage |
"user_message" |
User message received |
Branch
The Branch field on all hook contexts gives you the agent hierarchy as a /-separated path. For a nested setup where an orchestrator delegates to a researcher which delegates to a scraper:
This lets you immediately see which agent in the hierarchy produced an event, without cross-referencing task IDs.
Hook Propagation
Hooks set on a parent agent automatically propagate to sub-agents that don't have their own hooks:
orchestrator := agent.New(llmClient,
agent.WithHooks(myHooks),
agent.WithSubAgents(
agent.SubAgentConfig{Name: "worker", Agent: worker},
),
)
// worker inherits myHooks since it has none of its own
If a sub-agent already has hooks configured, the parent's hooks are not applied.
Context Structs
ToolUseContext
Passed to PreToolUse and embedded in PostToolUseContext and ToolErrorContext:
type ToolUseContext struct {
ToolCallID string
ToolName string
Input string
AgentName string
TaskID string
Branch string
}
PostToolUseContext
Passed to PostToolUse:
type PostToolUseContext struct {
ToolUseContext // Embeds all fields from ToolUseContext
Output string
IsError bool
Duration time.Duration
}
ToolErrorContext
Passed to OnToolError:
type ToolErrorContext struct {
ToolUseContext // Embeds all fields from ToolUseContext
Error error
Output string
Duration time.Duration
}
ModelCallContext
Passed to PreModelCall:
type ModelCallContext struct {
Messages []message.Message
Tools []tool.BaseTool
AgentName string
TaskID string
Branch string
}
ModelResponseContext
Passed to PostModelCall:
type ModelResponseContext struct {
Response *llm.Response
Duration time.Duration
AgentName string
TaskID string
Branch string
Error error
}
ModelErrorContext
Passed to OnModelError:
type ModelErrorContext struct {
Messages []message.Message
Tools []tool.BaseTool
Error error
AgentName string
TaskID string
Branch string
}
SubagentEventContext
Passed to OnSubagentStart and OnSubagentStop:
type SubagentEventContext struct {
TaskID string
AgentName string
Task string
Branch string
Result string
Error error
Duration time.Duration
}
LifecycleContext
Passed to BeforeAgent and AfterAgent:
type LifecycleContext struct {
AgentName string
TaskID string
Branch string
Input string
Response *ChatResponse // nil for BeforeAgent, set for AfterAgent
}
RunContext
Passed to BeforeRun and AfterRun:
type RunContext struct {
AgentName string
TaskID string
Branch string
Input string
Response *ChatResponse // nil for BeforeRun, set for AfterRun
Error error // nil for BeforeRun, set for AfterRun if failed
Duration time.Duration // zero for BeforeRun
}
UserMessageContext
Passed to OnUserMessage:
Streaming to a UI
A common use case is forwarding hook events to a frontend over WebSocket or SSE:
agent.NewObservingHooks(func(evt agent.HookEvent) {
data, _ := json.Marshal(evt)
websocket.Send(data)
})
This gives the UI real-time visibility into tool calls, model interactions, error recovery, and agent lifecycle — including nested agent hierarchies via Branch.