Skip to content

Hooks & Guardrails

Extensible hook system for intercepting LLM calls, tool execution, and session lifecycle.

Hooks provide interception points throughout the PromptKit pipeline:

  • ProviderHook — intercept LLM calls (before/after), with optional streaming chunk interception
  • ToolHook — intercept tool execution (before/after)
  • SessionHook — track session lifecycle (start, update, end)
  • Built-in guardrails — content safety hooks (banned words, length, sentences, required fields)

Intercepts LLM provider calls. This is the primary hook for content validation and guardrails.

type ProviderHook interface {
Name() string
BeforeCall(ctx context.Context, req *ProviderRequest) Decision
AfterCall(ctx context.Context, req *ProviderRequest, resp *ProviderResponse) Decision
}

An opt-in streaming extension for ProviderHook. Hooks that also implement ChunkInterceptor can inspect each streaming chunk in real time:

type ChunkInterceptor interface {
OnChunk(ctx context.Context, chunk *providers.StreamChunk) Decision
}

Intercepts LLM-initiated tool calls:

type ToolHook interface {
Name() string
BeforeExecution(ctx context.Context, req ToolRequest) Decision
AfterExecution(ctx context.Context, req ToolRequest, resp ToolResponse) Decision
}

Tracks session lifecycle events:

type SessionHook interface {
Name() string
OnSessionStart(ctx context.Context, event SessionEvent) error
OnSessionUpdate(ctx context.Context, event SessionEvent) error
OnSessionEnd(ctx context.Context, event SessionEvent) error
}

All hook methods return a Decision:

type Decision struct {
Allow bool
Reason string
Metadata map[string]any
Enforced bool // Hook already applied enforcement
}

Helpers:

hooks.Allow // Zero-cost approval
hooks.Deny("reason") // Denial with reason — pipeline stops with HookDeniedError
hooks.DenyWithMetadata("reason", m) // Denial with reason + metadata
hooks.Enforced("reason", m) // Enforcement applied — pipeline continues with modified content

Built-in guardrail hooks return Enforced decisions instead of Deny. When a guardrail triggers:

  1. The hook modifies content in-place (truncation for length validators, replacement for content blockers)
  2. Returns hooks.Enforced() so the pipeline continues with the modified content
  3. The violation is recorded in message.Validations and emitted as a validation.failed event

This means guardrails are non-fatal — they fix the content and let the pipeline proceed, rather than returning an error to the caller. Custom hooks can choose either behavior.

type ProviderRequest struct {
ProviderID string
Model string
Messages []types.Message
SystemPrompt string
Round int
Metadata map[string]any
}
type ProviderResponse struct {
ProviderID string
Model string
Message types.Message
Round int
LatencyMs int64
}
type ToolRequest struct {
Name string
Args json.RawMessage
CallID string
}
type ToolResponse struct {
Name string
CallID string
Content string
Error string
LatencyMs int64
}
type SessionEvent struct {
SessionID string
ConversationID string
Messages []types.Message
TurnIndex int
Metadata map[string]any
}

When a hook returns Deny (not Enforced), the runtime wraps the denial in a HookDeniedError:

type HookDeniedError struct {
HookName string
HookType string // "provider_before", "provider_after", "chunk", "tool_before", "tool_after"
Reason string
Metadata map[string]any
}

Check for hook denials in your error handling:

var hookErr *hooks.HookDeniedError
if errors.As(err, &hookErr) {
log.Printf("Denied by %s: %s", hookErr.HookName, hookErr.Reason)
}

The Registry collects and executes hooks in order:

reg := hooks.NewRegistry(
hooks.WithProviderHook(myHook),
hooks.WithToolHook(myToolHook),
hooks.WithSessionHook(mySessionHook),
)

The registry automatically detects ProviderHook implementations that also satisfy ChunkInterceptor and routes streaming chunks to them.

Execution methods:

MethodDescription
RunBeforeProviderCallRun all provider hooks’ BeforeCall
RunAfterProviderCallRun all provider hooks’ AfterCall
RunOnChunkRun all chunk interceptors’ OnChunk
RunBeforeToolExecutionRun all tool hooks’ BeforeExecution
RunAfterToolExecutionRun all tool hooks’ AfterExecution
RunSessionStartRun all session hooks’ OnSessionStart
RunSessionUpdateRun all session hooks’ OnSessionUpdate
RunSessionEndRun all session hooks’ OnSessionEnd

Multiple hooks execute in registration order. The first Deny short-circuits — subsequent hooks are not called.

All guardrail hooks implement ProviderHook. Some also implement ChunkInterceptor for real-time streaming enforcement.

Rejects responses containing banned words. Case-insensitive with word-boundary matching.

Streaming: Yes (implements ChunkInterceptor)

import "github.com/AltairaLabs/PromptKit/runtime/hooks/guardrails"
hook := guardrails.NewBannedWordsHook([]string{
"guarantee", "promise", "definitely",
})

SDK usage:

conv, _ := sdk.Open("./app.pack.json", "chat",
sdk.WithProviderHook(guardrails.NewBannedWordsHook([]string{
"guarantee", "promise",
})),
)

Rejects responses exceeding character and/or token limits. Pass 0 to disable a limit.

Streaming: Yes (implements ChunkInterceptor)

hook := guardrails.NewLengthHook(1000, 250) // maxCharacters, maxTokens

Token estimation: uses chunk.TokenCount if available, otherwise approximates at 1 token ≈ 4 characters.

Rejects responses exceeding a sentence count. Splits on ., !, ?.

Streaming: No (requires complete response)

hook := guardrails.NewMaxSentencesHook(5)

Rejects responses missing any of the specified field strings (case-insensitive substring match).

Streaming: No (requires complete response)

hook := guardrails.NewRequiredFieldsHook([]string{
"order number", "tracking number", "estimated delivery",
})

The guardrails.NewGuardrailHook factory creates hooks from a type name and params map. This is used internally to convert pack YAML validators: sections to hooks:

hook, err := guardrails.NewGuardrailHook("banned_words", map[string]any{
"words": []string{"guarantee", "promise"},
})

Supported type names: banned_words, length, max_length, max_sentences, required_fields.

// Set a custom blocked message (replaces content when guardrail triggers)
hook, _ := guardrails.NewGuardrailHook("banned_words", params,
guardrails.WithMessage("This response has been blocked by our content policy."),
)
// Monitor-only mode: evaluate but don't modify content
hook, _ := guardrails.NewGuardrailHook("banned_words", params,
guardrails.WithMonitorOnly(),
)
OptionDescription
WithMessage(msg)Custom message shown when content is blocked (default: generic policy message)
WithMonitorOnly()Evaluate and record results without modifying content

Monitor-only guardrails evaluate content and emit events, but never modify the response. This is useful for:

  • Gradual rollout — observe guardrail behavior before enforcing
  • Analytics — track policy violations without impacting users
  • Shadow testing — compare guardrail results against production traffic

The guardrail still returns an Enforced decision (so the pipeline continues), and violations are recorded in message.Validations and emitted as validation.failed events with MonitorOnly: true.

type PIIHook struct{}
func (h *PIIHook) Name() string { return "pii_filter" }
func (h *PIIHook) BeforeCall(ctx context.Context, req *hooks.ProviderRequest) hooks.Decision {
return hooks.Allow // No input filtering in this example
}
func (h *PIIHook) AfterCall(ctx context.Context, req *hooks.ProviderRequest, resp *hooks.ProviderResponse) hooks.Decision {
content := resp.Message.Content()
if containsSSN(content) {
return hooks.Deny("response contains SSN")
}
return hooks.Allow
}
type StreamingPIIHook struct {
buffer strings.Builder
}
func (h *StreamingPIIHook) Name() string { return "streaming_pii" }
func (h *StreamingPIIHook) BeforeCall(ctx context.Context, req *hooks.ProviderRequest) hooks.Decision {
h.buffer.Reset()
return hooks.Allow
}
func (h *StreamingPIIHook) AfterCall(ctx context.Context, req *hooks.ProviderRequest, resp *hooks.ProviderResponse) hooks.Decision {
return hooks.Allow
}
// Implement ChunkInterceptor for streaming checks
func (h *StreamingPIIHook) OnChunk(ctx context.Context, chunk *providers.StreamChunk) hooks.Decision {
h.buffer.WriteString(chunk.Content)
if containsSSN(h.buffer.String()) {
return hooks.Deny("streaming content contains SSN")
}
return hooks.Allow
}
type AuditToolHook struct {
logger *slog.Logger
}
func (h *AuditToolHook) Name() string { return "audit_tools" }
func (h *AuditToolHook) BeforeExecution(ctx context.Context, req hooks.ToolRequest) hooks.Decision {
h.logger.Info("tool called", "name", req.Name, "callID", req.CallID)
return hooks.Allow
}
func (h *AuditToolHook) AfterExecution(ctx context.Context, req hooks.ToolRequest, resp hooks.ToolResponse) hooks.Decision {
if resp.Error != "" {
h.logger.Error("tool failed", "name", req.Name, "error", resp.Error)
}
return hooks.Allow
}
  1. BeforeCall hooks run before the LLM request (first deny aborts the call)
  2. OnChunk interceptors run on each streaming chunk (first deny aborts the stream)
  3. AfterCall hooks run after the LLM response (first deny rejects the response)
  4. BeforeExecution tool hooks run before each tool call
  5. AfterExecution tool hooks run after each tool call
  6. Session hooks run at session boundaries (start, after each turn, end)
conv, _ := sdk.Open("./app.pack.json", "chat",
sdk.WithProviderHook(guardrails.NewLengthHook(1000, 250)), // Fast O(1)
sdk.WithProviderHook(guardrails.NewBannedWordsHook(banned)), // O(n*w)
sdk.WithProviderHook(customExpensiveHook), // Slow
)

Hooks that implement ChunkInterceptor can abort a streaming response mid-flight, saving API costs:

// BannedWordsHook and LengthHook both support streaming
// MaxSentencesHook and RequiredFieldsHook require the full response
resp, err := conv.Send(ctx, "Hello")
if err != nil {
var hookErr *hooks.HookDeniedError
if errors.As(err, &hookErr) {
log.Printf("Policy violation: %s", hookErr.Reason)
// Return a safe fallback response to the user
}
}

Stateless hooks are safe for concurrent use. If your hook must maintain state (e.g., a streaming buffer), ensure it is scoped to a single conversation or protected by synchronization.

import (
"github.com/AltairaLabs/PromptKit/runtime/hooks"
"github.com/AltairaLabs/PromptKit/runtime/hooks/guardrails"
)

Hooks can be implemented as external subprocesses in any language using the exec protocol. Configure them in RuntimeConfig:

spec:
hooks:
pii_redactor:
command: ./hooks/pii-redactor
hook: provider
phases: [before_call, after_call]
mode: filter
timeout_ms: 3000
audit_logger:
command: ./hooks/audit-logger
hook: session
phases: [session_start, session_update, session_end]
mode: observe

Three adapters bridge external processes to the hook interfaces:

AdapterImplementsDescription
ExecProviderHookProviderHookExternal provider interception
ExecToolHookToolHookExternal tool interception
ExecSessionHookSessionHookExternal session tracking

Modes:

  • filter — Fail-closed. Process failure = deny. Can block the pipeline.
  • observe — Fire-and-forget. Process failure is swallowed. Pipeline always continues.

See Exec Hooks for the full how-to guide and Exec Protocol for the wire format.