Callbacks and Plugins are the most powerful extension points in the ADK Go framework. If Tools extend the Agent’s “capability boundary” and Skills extend its “knowledge depth”, then Callbacks and Plugins extend the Agent’s “control surface”—they allow developers to insert custom logic at every key lifecycle point of Agent execution, enabling logging, security auditing, dynamic routing, result post-processing, and other cross-cutting concerns.
Architectural Perspective: Why Lifecycle Hooks Are Needed
In a production-grade Agent system, pure business logic (reasoning, Tool calls, output generation) often accounts for only 30%-40% of the overall code. The rest is consumed by observability, security, compliance, performance optimization, and other infrastructure needs. If these needs are tightly coupled with business logic, the result is:
- Code bloat: Every Agent must repeatedly implement logging, monitoring, rate limiting, etc.
- Difficult maintenance: Changes to infrastructure logic require modifying every Agent.
- Complex testing: Business-logic tests become polluted by infrastructure dependencies.
- Low reuse: Generic cross-cutting logic cannot be shared across Agents.
Callbacks and Plugins solve these problems through aspect-oriented programming (AOP) thinking. They decouple infrastructure logic from business logic and “weave” it into the Agent lifecycle declaratively.
Callbacks: Fine-Grained Lifecycle Hooks
Callbacks are ADK Go’s lightest-weight extension mechanism. They are registered as functions on specific Agent lifecycle events and are suitable for simple, stateless cross-cutting logic.
Complete Lifecycle Event Map
ADK Go defines the following lifecycle events covering the full trajectory of Agent execution:
| Event | Trigger | Typical Use |
|---|---|---|
BeforeRun | Before the Agent starts processing user input | Input validation, sensitive-word filtering, permission checks |
BeforeModelCall | Before sending a request to the LLM | Prompt modification, context injection, cost estimation |
AfterModelCall | After receiving the LLM response | Response parsing, content moderation, result caching |
BeforeToolCall | Before executing a Tool | Tool-parameter validation, permission checks, logging |
AfterToolCall | After a Tool finishes executing | Result processing, error recovery, metrics reporting |
OnError | When an error occurs at any stage | Error classification, alerting, degradation handling |
AfterRun | After the Agent has completed all processing | Result formatting, session persistence, follow-up actions |
OnStreamChunk | When each streaming chunk is received | Real-time rendering, sensitive-content interception, typewriter effect |
Production-Grade Callback Configuration Example
Below is a Callback configuration that includes full observability, security, and resilience design:
package main
import (
"context"
"fmt"
"log"
"os"
"strings"
"time"
"google.golang.org/adk/agent"
"google.golang.org/adk/callback"
"google.golang.org/adk/llm"
)
func main() {
ctx := context.Background()
model, err := llm.NewGeminiModel(ctx, llm.GeminiConfig{
APIKey: os.Get...Y"),
Model: "gemini-2.0-flash",
})
if err != nil {
log.Fatalf("Failed to initialize model: %v", err)
}
agent, err := agent.New(agent.Config{
Name: "production-agent",
Model: model,
Instruction: "You are a helpful assistant.",
Callbacks: []callback.Callback{
// 1. Input security audit
{
Type: callback.BeforeRun,
Handler: func(ctx context.Context, input *callback.RunInput) error {
// Log user input (note: PII must be masked in production)
log.Printf("[AUDIT] user=%s session=%s input_len=%d",
input.UserID, input.SessionID, len(input.Text))
// Sensitive-word filtering
blockedWords := []string{"password", "secret", "token", "key"}
for _, word := range blockedWords {
if strings.Contains(strings.ToLower(input.Text), word) {
// Do not block, but mark as high risk
input.Metadata["risk_level"] = "high"
input.Metadata["sensitive_words"] = append(
input.Metadata["sensitive_words"].([]string), word)
}
}
// Input length limit (prevent Prompt Injection via overly long input consuming tokens)
if len(input.Text) > 10000 {
return fmt.Errorf("input too long: %d characters, maximum allowed 10000", len(input.Text))
}
return nil
},
},
// 2. Prompt enhancement and cost estimation
{
Type: callback.BeforeModelCall,
Handler: func(ctx context.Context, call *callback.ModelCallInput) error {
// Inject current time (mitigate model knowledge-cutoff issue)
call.Prompt += fmt.Sprintf("
[System hint] Current time: %s", time.Now().Format(time.RFC3339))
// Rough token-consumption estimate
estimatedTokens := len(call.Prompt) * 3 / 2 // conservative for CJK characters
if estimatedTokens > 8000 {
log.Printf("[WARN] Large context request: %d tokens", estimatedTokens)
}
// Record model-call metrics
metrics.IncrementCounter("model_calls", metrics.Tag{"model", call.ModelName})
return nil
},
},
// 3. Response post-processing and content moderation
{
Type: callback.AfterModelCall,
Handler: func(ctx context.Context, call *callback.ModelCallOutput) error {
// Latency logging
metrics.RecordHistogram("model_latency_ms", float64(call.Duration.Milliseconds()))
// Output PII detection
if containsPII(call.Response.Text) {
log.Printf("[ALERT] Model output contains PII; it has been masked")
call.Response.Text = maskPII(call.Response.Text)
}
// Cache high-frequency responses
if call.Response.Metadata["cacheable"] == true {
cache.Set(call.CacheKey, call.Response, 5*time.Minute)
}
return nil
},
},
// 4. Tool-call monitoring and circuit breaker
{
Type: callback.BeforeToolCall,
Handler: func(ctx context.Context, call *callback.ToolCallInput) error {
// Check Tool circuit-breaker state
if circuitBreaker.IsOpen(call.ToolName) {
return fmt.Errorf("Tool %s is currently unavailable (circuit breaker open)", call.ToolName)
}
// Log Tool call
log.Printf("[TOOL] name=%s args=%v", call.ToolName, call.Arguments)
// Parameter security check
if err := validateToolArgs(call.ToolName, call.Arguments); err != nil {
return fmt.Errorf("Tool parameter validation failed: %w", err)
}
return nil
},
},
{
Type: callback.AfterToolCall,
Handler: func(ctx context.Context, call *callback.ToolCallOutput) error {
duration := time.Since(call.StartTime)
metrics.RecordHistogram("tool_latency_ms", float64(duration.Milliseconds()),
metrics.Tag{"tool", call.ToolName})
if call.Error != nil {
metrics.IncrementCounter("tool_errors", metrics.Tag{"tool", call.ToolName})
// Record error for circuit-breaker decision
circuitBreaker.RecordFailure(call.ToolName)
} else {
circuitBreaker.RecordSuccess(call.ToolName)
}
return nil
},
},
// 5. Error handling and degradation
{
Type: callback.OnError,
Handler: func(ctx context.Context, err *callback.ErrorEvent) error {
log.Printf("[ERROR] phase=%s error=%v", err.Phase, err.Error)
// Classify error and trigger different handling strategies
switch classifyError(err.Error) {
case ErrorTypeRateLimit:
// Rate limit: trigger exponential backoff retry
err.RetryAfter = calculateBackoff(err.Attempt)
case ErrorTypeModelUnavailable:
// Model unavailable: switch to fallback model
err.FallbackModel = "gemini-1.5-flash"
case ErrorTypeToolFailure:
// Tool failure: try cached result or return friendly message
if cached, ok := cache.Get(err.ToolName); ok {
err.RecoveryData = cached
}
}
// Send alert for severe errors
if err.Severity == callback.SeverityCritical {
alert.Send(fmt.Sprintf("Agent critical error: %v", err.Error))
}
return nil
},
},
// 6. Streaming output processing
{
Type: callback.OnStreamChunk,
Handler: func(ctx context.Context, chunk *callback.StreamChunk) error {
// Real-time sensitive-word detection (especially important in streaming)
if containsSensitiveContent(chunk.Text) {
chunk.Text = maskSensitiveContent(chunk.Text)
}
// Record streaming metrics
metrics.IncrementCounter("stream_chunks")
return nil
},
},
},
})
if err != nil {
log.Fatalf("Failed to create Agent: %v", err)
}
// Use the Agent...
}
Plugins: Full-Featured Extension Modules
If Callbacks are “function-level hooks”, then Plugins are “module-level extensions”. Plugins can:
- Register new Callbacks
- Add new Tools
- Modify Agent configuration
- Access and modify internal state
- Implement cross-request state sharing
Plugin Interface
type Plugin interface {
Name() string
Initialize(ctx context.Context, config PluginConfig) error
RegisterCallbacks(registry *callback.Registry) error
RegisterTools(registry *tool.Registry) error
Shutdown(ctx context.Context) error
}
Production-Grade Plugin Example: Distributed Tracing
Below is a Plugin that implements OpenTelemetry distributed tracing, demonstrating how a Plugin can deeply integrate into every corner of the Agent:
package tracing
import (
"context"
"fmt"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
"google.golang.org/adk/agent"
"google.golang.org/adk/callback"
"google.golang.org/adk/plugin"
"google.golang.org/adk/tool"
)
// TracingPlugin provides OpenTelemetry distributed tracing for the Agent
type TracingPlugin struct {
tracer trace.Tracer
config TracingConfig
}
type TracingConfig struct {
ServiceName string
ExporterEndpoint string
SamplingRate float64
}
func (p *TracingPlugin) Name() string {
return "opentelemetry-tracing"
}
func (p *TracingPlugin) Initialize(ctx context.Context, config plugin.PluginConfig) error {
p.config = config.(TracingConfig)
// Initialize OTel TracerProvider
tp, err := initTracerProvider(p.config)
if err != nil {
return fmt.Errorf("failed to initialize TracerProvider: %w", err)
}
otel.SetTracerProvider(tp)
p.tracer = tp.Tracer(p.config.ServiceName)
return nil
}
func (p *TracingPlugin) RegisterCallbacks(registry *callback.Registry) error {
// Create a Trace Span for each Agent Run
registry.Register(callback.BeforeRun, func(ctx context.Context, input *callback.RunInput) error {
ctx, span := p.tracer.Start(ctx, "agent.run",
trace.WithAttributes(
attribute.String("agent.name", input.AgentName),
attribute.String("session.id", input.SessionID),
attribute.String("user.id", input.UserID),
attribute.Int("input.length", len(input.Text)),
),
)
// Store span in context for later callbacks
input.Context = trace.ContextWithSpan(ctx, span)
return nil
})
registry.Register(callback.AfterRun, func(ctx context.Context, output *callback.RunOutput) error {
span := trace.SpanFromContext(ctx)
span.SetAttributes(
attribute.Int("output.length", len(output.Text)),
attribute.Int("tool.calls", output.ToolCallCount),
attribute.Int("model.calls", output.ModelCallCount),
)
if output.Error != nil {
span.SetStatus(codes.Error, output.Error.Error())
span.RecordError(output.Error)
}
span.End()
return nil
})
// Create a child Span for each model call
registry.Register(callback.BeforeModelCall, func(ctx context.Context, call *callback.ModelCallInput) error {
ctx, span := p.tracer.Start(ctx, "llm.call",
trace.WithAttributes(
attribute.String("model.name", call.ModelName),
attribute.Int("prompt.tokens", call.PromptTokens),
),
)
call.Context = ctx
return nil
})
registry.Register(callback.AfterModelCall, func(ctx context.Context, call *callback.ModelCallOutput) error {
span := trace.SpanFromContext(ctx)
span.SetAttributes(
attribute.Int("response.tokens", call.ResponseTokens),
attribute.Float64("duration.ms", float64(call.Duration.Milliseconds())),
)
span.End()
return nil
})
// Create a child Span for each Tool call
registry.Register(callback.BeforeToolCall, func(ctx context.Context, call *callback.ToolCallInput) error {
ctx, span := p.tracer.Start(ctx, fmt.Sprintf("tool.%s", call.ToolName),
trace.WithAttributes(
attribute.String("tool.name", call.ToolName),
),
)
call.Context = ctx
return nil
})
registry.Register(callback.AfterToolCall, func(ctx context.Context, call *callback.ToolCallOutput) error {
span := trace.SpanFromContext(ctx)
span.SetAttributes(
attribute.Bool("tool.success", call.Error == nil),
attribute.Float64("duration.ms", float64(call.Duration.Milliseconds())),
)
if call.Error != nil {
span.RecordError(call.Error)
}
span.End()
return nil
})
return nil
}
func (p *TracingPlugin) RegisterTools(registry *tool.Registry) error {
// This Plugin does not register new Tools
return nil
}
func (p *TracingPlugin) Shutdown(ctx context.Context) error {
// Graceful shutdown to ensure all spans are exported
if tp, ok := otel.GetTracerProvider().(*sdktrace.TracerProvider); ok {
return tp.Shutdown(ctx)
}
return nil
}
Using a Plugin
func main() {
tracingPlugin := &tracing.TracingPlugin{}
agent, err := agent.New(agent.Config{
Name: "traced-agent",
Plugins: []plugin.Plugin{
tracingPlugin,
&logging.Plugin{},
&metrics.Plugin{},
},
})
// Ensure Plugins shut down gracefully on application exit
defer func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
for _, p := range agent.Plugins() {
if err := p.Shutdown(ctx); err != nil {
log.Printf("Plugin %s shutdown failed: %v", p.Name(), err)
}
}
}()
}
Callbacks vs Plugins: How to Choose
| Scenario | Recommended Mechanism | Reason |
|---|---|---|
| Simple logging | Callback | Lightweight, declarative, no extra dependencies |
| Input/output filtering | Callback | Simple logic, stateless |
| Cross-request state sharing | Plugin | Can maintain internal state |
| Adding new Tools | Plugin | Needs access to Tool Registry |
| Complex observability integration | Plugin | Needs to initialize external clients (OTel, Prometheus, etc.) |
| Dynamic configuration updates | Plugin | Can implement hot-reload logic |
| A/B testing framework | Plugin | Needs to maintain experiment state and traffic splitting |
Advanced Pattern: Plugin Chains and Priority
When multiple Plugins are registered, their execution order and interactions must be carefully designed. ADK Go supports Plugin priority and chain execution:
agent, err := agent.New(agent.Config{
Plugins: []plugin.Plugin{
// High priority: security-related Plugins run first
&security.Plugin{Priority: 100},
// Medium priority: observability Plugins
&tracing.Plugin{Priority: 50},
&metrics.Plugin{Priority: 50},
// Low priority: business-enhancement Plugins
&enhancement.Plugin{Priority: 10},
},
})
Plugin chains follow the Onion Model:
Request enters
↓
[Security Plugin] BeforeRun
↓
[Tracing Plugin] BeforeRun
↓
[Metrics Plugin] BeforeRun
↓
[Enhancement Plugin] BeforeRun
↓
[Core business logic]
↓
[Enhancement Plugin] AfterRun
↓
[Metrics Plugin] AfterRun
↓
[Tracing Plugin] AfterRun
↓
[Security Plugin] AfterRun
↓
Response returned
This design ensures that security policies are always on the outermost layer (first Before, last After), while business-enhancement logic is on the innermost layer.
Production Pitfalls and Debugging Tips
Pitfall 1: Blocking Operations in Callbacks
Callbacks run on the Agent’s main execution path. If a Callback performs blocking I/O (such as a database query or HTTP request), it directly increases Agent response latency.
Solution: Make non-critical operations asynchronous
{
Type: callback.AfterRun,
Handler: func(ctx context.Context, output *callback.RunOutput) error {
// Log asynchronously without blocking the main flow
go func() {
auditLog.Write(output)
}()
return nil
},
}
Pitfall 2: Error Propagation in Callback Chains
A Callback returning an error may cause the entire Agent execution to fail. You need a clear error-handling strategy:
{
Type: callback.BeforeRun,
Handler: func(ctx context.Context, input *callback.RunInput) error {
err := doSomething()
if err != nil {
// Strategy A: block execution
// return err
// Strategy B: log error but continue
log.Printf("Non-fatal error: %v", err)
return nil
// Strategy C: degrade gracefully
input.Metadata["degraded"] = true
return nil
}
return nil
},
}
Pitfall 3: Plugin Initialization Failure Blocks Agent Startup
In production, the failure of an observability Plugin (such as Tracing) should not block core business. Implement graceful degradation:
func (p *TracingPlugin) Initialize(ctx context.Context, config plugin.PluginConfig) error {
err := p.doInit(config)
if err != nil {
// Log error but mark the Plugin as noop
log.Printf("Tracing initialization failed, degraded: %v", err)
p.noop = true
}
return nil // do not return error to avoid blocking Agent startup
}
Debugging Tip: Callback Execution Tracing
When Agent behavior is abnormal, locating which Callback caused the problem can be difficult. Implement Callback execution tracing:
func tracedCallback(cb callback.Callback) callback.Callback {
return callback.Callback{
Type: cb.Type,
Handler: func(ctx context.Context, event interface{}) error {
start := time.Now()
err := cb.Handler(ctx, event)
duration := time.Since(start)
log.Printf("[CALLBACK] type=%s duration=%v error=%v",
cb.Type, duration, err)
return err
},
}
}
Summary
Module 9 is complete. We have deeply explored four advanced topics:
- Grounding: Search-enhanced generation that anchors Agent answers to the real world, solving LLM knowledge-cutoff and hallucination problems.
- Artifacts: A structured content-generation mechanism that upgrades Agent output from free text to typed, machine-parseable data units.
- Skills: Preset professional capability modules that give Agents systematic thinking frameworks and best practices in specific domains.
- Callbacks and Plugins: Lifecycle hooks and extension mechanisms that enable elegant separation and deep customization of cross-cutting concerns.
Together, these mechanisms form the “infrastructure layer” of a production-grade Agent system—they do not directly participate in business reasoning, but they determine the system’s reliability, maintainability, and scalability.
Next we enter the final module: Real-World Comprehensive—end-to-end project practice.
← Skills for Agents | End-to-End Project →
Want to learn more Go ADK hands-on? Follow the “Full-Stack Peak — Mengshou Programming” WeChat account for weekly Go / AI programming practice updates.
