Hooks & Guardrails
Extensible hook system for intercepting LLM calls, tool execution, and session lifecycle.
Overview
Section titled “Overview”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)
Core Interfaces
Section titled “Core Interfaces”ProviderHook
Section titled “ProviderHook”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}ChunkInterceptor
Section titled “ChunkInterceptor”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}ToolHook
Section titled “ToolHook”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}SessionHook
Section titled “SessionHook”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}Decision Type
Section titled “Decision Type”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 approvalhooks.Deny("reason") // Denial with reason — pipeline stops with HookDeniedErrorhooks.DenyWithMetadata("reason", m) // Denial with reason + metadatahooks.Enforced("reason", m) // Enforcement applied — pipeline continues with modified contentEnforcement vs Denial
Section titled “Enforcement vs Denial”Built-in guardrail hooks return Enforced decisions instead of Deny. When a guardrail triggers:
- The hook modifies content in-place (truncation for length validators, replacement for content blockers)
- Returns
hooks.Enforced()so the pipeline continues with the modified content - The violation is recorded in
message.Validationsand emitted as avalidation.failedevent
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.
Request & Response Types
Section titled “Request & Response Types”ProviderRequest
Section titled “ProviderRequest”type ProviderRequest struct { ProviderID string Model string Messages []types.Message SystemPrompt string Round int Metadata map[string]any}ProviderResponse
Section titled “ProviderResponse”type ProviderResponse struct { ProviderID string Model string Message types.Message Round int LatencyMs int64}ToolRequest / ToolResponse
Section titled “ToolRequest / ToolResponse”type ToolRequest struct { Name string Args json.RawMessage CallID string}
type ToolResponse struct { Name string CallID string Content string Error string LatencyMs int64}SessionEvent
Section titled “SessionEvent”type SessionEvent struct { SessionID string ConversationID string Messages []types.Message TurnIndex int Metadata map[string]any}HookDeniedError
Section titled “HookDeniedError”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.HookDeniedErrorif errors.As(err, &hookErr) { log.Printf("Denied by %s: %s", hookErr.HookName, hookErr.Reason)}Registry
Section titled “Registry”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:
| Method | Description |
|---|---|
RunBeforeProviderCall | Run all provider hooks’ BeforeCall |
RunAfterProviderCall | Run all provider hooks’ AfterCall |
RunOnChunk | Run all chunk interceptors’ OnChunk |
RunBeforeToolExecution | Run all tool hooks’ BeforeExecution |
RunAfterToolExecution | Run all tool hooks’ AfterExecution |
RunSessionStart | Run all session hooks’ OnSessionStart |
RunSessionUpdate | Run all session hooks’ OnSessionUpdate |
RunSessionEnd | Run all session hooks’ OnSessionEnd |
Multiple hooks execute in registration order. The first Deny short-circuits — subsequent hooks are not called.
Built-in Guardrail Hooks
Section titled “Built-in Guardrail Hooks”All guardrail hooks implement ProviderHook. Some also implement ChunkInterceptor for real-time streaming enforcement.
BannedWordsHook
Section titled “BannedWordsHook”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", })),)LengthHook
Section titled “LengthHook”Rejects responses exceeding character and/or token limits. Pass 0 to disable a limit.
Streaming: Yes (implements ChunkInterceptor)
hook := guardrails.NewLengthHook(1000, 250) // maxCharacters, maxTokensToken estimation: uses chunk.TokenCount if available, otherwise approximates at 1 token ≈ 4 characters.
MaxSentencesHook
Section titled “MaxSentencesHook”Rejects responses exceeding a sentence count. Splits on ., !, ?.
Streaming: No (requires complete response)
hook := guardrails.NewMaxSentencesHook(5)RequiredFieldsHook
Section titled “RequiredFieldsHook”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",})Factory
Section titled “Factory”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.
Factory Options
Section titled “Factory Options”// 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 contenthook, _ := guardrails.NewGuardrailHook("banned_words", params, guardrails.WithMonitorOnly(),)| Option | Description |
|---|---|
WithMessage(msg) | Custom message shown when content is blocked (default: generic policy message) |
WithMonitorOnly() | Evaluate and record results without modifying content |
Monitor-Only Mode
Section titled “Monitor-Only Mode”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.
Custom Hooks
Section titled “Custom Hooks”Custom ProviderHook
Section titled “Custom ProviderHook”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}Custom ProviderHook with ChunkInterceptor
Section titled “Custom ProviderHook with ChunkInterceptor”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 checksfunc (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}Custom ToolHook
Section titled “Custom ToolHook”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}Execution Order
Section titled “Execution Order”- BeforeCall hooks run before the LLM request (first deny aborts the call)
- OnChunk interceptors run on each streaming chunk (first deny aborts the stream)
- AfterCall hooks run after the LLM response (first deny rejects the response)
- BeforeExecution tool hooks run before each tool call
- AfterExecution tool hooks run after each tool call
- Session hooks run at session boundaries (start, after each turn, end)
Best Practices
Section titled “Best Practices”1. Put Fast Hooks First
Section titled “1. Put Fast Hooks First”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)2. Use Streaming Hooks for Early Abort
Section titled “2. Use Streaming Hooks for Early Abort”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 response3. Handle HookDeniedError
Section titled “3. Handle HookDeniedError”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 }}4. Keep Hooks Stateless When Possible
Section titled “4. Keep Hooks Stateless When Possible”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.
Package Import
Section titled “Package Import”import ( "github.com/AltairaLabs/PromptKit/runtime/hooks" "github.com/AltairaLabs/PromptKit/runtime/hooks/guardrails")External Exec Hooks
Section titled “External Exec Hooks”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: observeThree adapters bridge external processes to the hook interfaces:
| Adapter | Implements | Description |
|---|---|---|
ExecProviderHook | ProviderHook | External provider interception |
ExecToolHook | ToolHook | External tool interception |
ExecSessionHook | SessionHook | External 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.
See Also
Section titled “See Also”- Checks Reference — All check types, parameters, and extensibility details
- Unified Check Model — How guardrails, assertions, and evals relate
- Guardrails Reference — Guardrail configuration and behavior
- Pipeline Reference — Stage and pipeline interfaces
- Validation Tutorial — Step-by-step guide
- Exec Hooks — External hooks in any language
- Exec Protocol — Wire protocol reference