Skip to main content

Hooks & Observability

Hooks are middleware that intercept model calls, tool calls, and session events. They provide a composable way to add caching, retry logic, cost tracking, rate limiting, metrics, and logging without modifying agent code. Blocking and streaming agent model calls emit the same model_call.before / model_call.after hook lifecycle.

The snippets on this page assume these imports:

import (
"context"
"fmt"
"log"
"strings"
"time"

"github.com/spawn08/chronos/engine/hooks"
"github.com/spawn08/chronos/sdk/agent"
)

Hook Interface

Every hook implements the Hook interface:

type Hook interface {
Before(ctx context.Context, evt *Event) error
After(ctx context.Context, evt *Event) error
}

Hooks are composed into a Chain and fire in order: all Before handlers run before the operation, all After handlers run after.

Event Types

EventModelCallBefore // Before an LLM API call
EventModelCallAfter // After an LLM API call
EventToolCallBefore // Before a tool execution
EventToolCallAfter // After a tool execution
EventSessionStart // When a session begins
EventSessionEnd // When a session ends

Built-in Hooks

MetricsHook

Tracks latency, token usage, and error rates for all model and tool calls.

metrics := hooks.NewMetricsHook()

// After running the agent...
summary := metrics.GetSummary()
fmt.Printf("Model calls: %d, Avg latency: %v\n", summary.TotalModelCalls, summary.AvgModelLatency)

// Per-call breakdown
for _, m := range metrics.GetMetrics() {
fmt.Printf(" %s: %v (error=%v)\n", m.Name, m.Duration, m.Error)
}

// Reset for a new measurement window
metrics.Reset()

CostTracker

Estimates API costs using configurable per-token pricing. Supports budget limits.

tracker := hooks.NewCostTracker(map[string]hooks.ModelPrice{
"gpt-5.5": {PromptPricePerToken: 0.000005, CompletionPricePerToken: 0.00003},
"claude-opus-4-8": {PromptPricePerToken: 0.000005, CompletionPricePerToken: 0.000025},
})
tracker.Budget = 1.00 // $1.00 max spend — blocks calls when exceeded

// After running...
report := tracker.GetGlobalCost()
fmt.Printf("Total: $%.4f (%d tokens)\n", report.TotalCost, report.PromptTokens+report.CompletionTokens)

// Per-session tracking
sessionReport := tracker.GetSessionCost("session-123")

Pass nil for the price table to use built-in defaults covering GPT-4o, Claude, Gemini, Mistral, and o-series models.

CacheHook

Caches identical LLM requests to avoid duplicate API calls. Supports TTL and max entries.

cache := hooks.NewCacheHook(5 * time.Minute)
cache.MaxEntries = 1000

// After running...
hits, misses := cache.Stats()
fmt.Printf("Cache: %d hits, %d misses\n", hits, misses)

// Manual clear
cache.Clear()

Streaming requests and tool-call responses are automatically excluded from caching.

RetryHook

Retries failed model calls with exponential backoff and jitter. When the agent provides the model provider and request in event metadata, the hook performs actual retry calls.

retry := hooks.NewRetryHook(3) // max 3 retry attempts
retry.BaseDelay = 500 * time.Millisecond
retry.MaxDelay = 10 * time.Second
retry.OnRetry = func(attempt int, delay time.Duration) {
log.Printf("Retry attempt %d after %v", attempt, delay)
}

// Optional: classify which errors are retryable
retry.RetryableError = func(err error) bool {
return strings.Contains(err.Error(), "rate limit") ||
strings.Contains(err.Error(), "timeout")
}

RateLimitHook

Token-bucket rate limiter for model API calls.

limiter := hooks.NewRateLimitHook(
60, // 60 requests per minute
0, // no token-per-minute limit
)
limiter.WaitOnLimit = true // block until capacity is available (vs. return error)

LoggingHook

Simple structured logging for all events.

logger := &hooks.LoggingHook{}

Composing Hooks

Hooks are added to an agent via the builder and execute in registration order:

a, _ := agent.New("observed-agent", "Agent").
WithModel(provider).
AddHook(logger). // fires first
AddHook(metrics). // fires second
AddHook(tracker). // fires third
AddHook(limiter). // fires fourth
AddHook(cache). // fires fifth
AddHook(retry). // fires last
Build()

Order matters:

  • Put LoggingHook first for comprehensive logging
  • Put RateLimitHook before CacheHook so cached responses bypass the limiter
  • Put RetryHook last so it catches errors from all earlier hooks

Custom Hooks

Implement the Hook interface:

type AuditHook struct {
entries []AuditEntry
}

func (h *AuditHook) Before(_ context.Context, evt *hooks.Event) error {
if evt.Type == hooks.EventModelCallBefore {
h.entries = append(h.entries, AuditEntry{
Time: time.Now(),
Model: evt.Name,
Type: "request",
})
}
return nil
}

func (h *AuditHook) After(_ context.Context, evt *hooks.Event) error {
if evt.Type == hooks.EventModelCallAfter {
h.entries = append(h.entries, AuditEntry{
Time: time.Now(),
Model: evt.Name,
Type: "response",
Error: evt.Error != nil,
})
}
return nil
}

See the hooks_observability example for a complete runnable demonstration.

CLI debug logs and persisted traces

YAML-built CLI agents can enable diagnostics without custom Go wiring:

agents:
- id: observed
name: Observed Agent
debug: true
tracing: true
storage:
backend: sqlite
dsn: chronos.db

Or enable them for one process:

chronos --debug --trace run --stream "investigate the failure"

debug writes execution details to stderr. tracing attaches the storage-backed collector; model, tool, and graph spans are persisted through the configured storage.Storage. Trace exporter failures remain best-effort and do not replace the agent's primary result.

See CLI Runtime Controls for flags and environment variables.