hooks

command
v1.5.11 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 20, 2026 License: Apache-2.0 Imports: 11 Imported by: 0

README

Hooks & Guardrails Example

Demonstrates how to use SDK hooks and guardrails to enforce policy on both sides of an LLM call — the user's input before it's sent, and the assistant's response after it comes back.

What You'll Learn

  • Registering input guardrails that gate the user's message before the LLM call (guardrails.Input, guardrails.InputFunc) — a hit spends zero tokens, no provider request is made
  • Registering output guardrails that gate the assistant's response after the LLM call (guardrails.Output, built-in banned_words/length)
  • Declaring a guardrail entirely in the pack (hooks.pack.json's validators block) — no Go code at all
  • The two ways a guardrail can respond, and how to tell them apart:
    • Enforced — a graceful block. Send() returns no error; the turn carries a canned message and a validation record instead.
    • Deny — a hard error. Send() returns an error you catch with errors.As.
  • Writing a custom ProviderHook with ChunkInterceptor for streaming support (the full-interface path, used here for a Deny example)
  • Streaming with chunk-level guardrail enforcement

Prerequisites

  • Go 1.26+
  • An API key for one of: OpenAI, Anthropic, or Google (the packs do not pin a provider)

Running the Example

export OPENAI_API_KEY=your-key
go run .

How It Works

Input guardrails — graceful blocking, before any LLM call

Hooks are registered as SDK options when opening a conversation. guardrails.Input gates the user's message; a hit means the provider is never called:

conv, err := sdk.Open("./hooks.pack.json", "chat",
    sdk.WithGuardrail(
        guardrails.Input("banned_words", map[string]any{
            "words": []any{"wire transfer"},
        }, guardrails.WithMessage("I can't help with transfers.")),
    ),
)

resp, err := conv.Send(ctx, "Can you help me set up a wire transfer?")
// err is nil — an Enforced guardrail is not an error.
// resp.Text() == "I can't help with transfers."

For a bespoke check with no interface to implement, use guardrails.InputFunc:

guardrails.InputFunc("no-ssn", func(ctx context.Context, in *hooks.InputRequest) hooks.Decision {
    if strings.Contains(strings.ToLower(in.UserInput), "social security number") {
        in.Replacement = "I can't help with that request."
        return hooks.Enforced("ssn requested", map[string]any{"validator_type": "no-ssn"})
    }
    return hooks.Allow
})

hooks.InputRequest gives you UserInput, the full Messages history, the current Round, and Replacement (the canned text to send back). There's a matching guardrails.OutputFunc for the response side, working against hooks.OutputRequest (Content, Message — mutate it in place to rewrite, Round).

Direction is the only thing that changes. A content check evaluates whichever side direction selects, so the same type works either way — guardrails.Input("banned_words", …) gates the user's message and guardrails.Output("banned_words", …) gates the reply. As an output guardrail a check judges that response alone; an earlier turn does not affect the verdict.

Pack-declared validators — the same gating, zero Go code

hooks.pack.json's chat prompt declares an input guardrail directly:

"validators": [
  {
    "type": "regex",
    "enabled": true,
    "params": {
      "pattern": "(?i)\\brouting number\\b",
      "expect_match": false,
      "direction": "input",
      "message": "I can't help with bank routing numbers."
    }
  }
]

The promptpack schema's Validator object has no top-level message or direction property (additionalProperties: false) — both live inside params. direction accepts "input", "output", or "both"; it defaults to "output" if omitted. Pack-declared validators are wired in before any WithGuardrail hooks registered in code, so they always get first look at a call.

Enforced vs. Deny

Every eval-backed and func-backed guardrail in this example (Input, InputFunc, Output, and the pack validator) answers with hooks.Enforced on a hit: Send() succeeds, the turn is replaced with a canned message, and it's marked with types.FinishReasonSafety plus a types.ValidationResult naming the guardrail. The pipeline is not aborted — downstream stages still run.

The custom PIIHook in this example is different: it implements the full hooks.ProviderHook interface and answers with hooks.Deny — a genuine hard error:

resp, err := conv.Send(ctx, "Make up a fake contact card...")
if err != nil {
    var denied *hooks.HookDeniedError
    var aborted *providers.ValidationAbortError
    if errors.As(err, &denied) {
        fmt.Printf("Blocked: %s\n", denied.Reason)
    } else if errors.As(err, &aborted) {
        fmt.Printf("Blocked during streaming: %s\n", aborted.Reason)
    }
}

Hooks execute in registration order; the first non-Allow decision (Deny or Enforced) short-circuits the rest for that call.

Detecting a graceful block without string-matching the text

Because an Enforced block returns no error, you can't use err != nil to notice one. This example's blockedByGuardrail helper checks resp.Validations() instead:

func blockedByGuardrail(resp *sdk.Response) (validatorType string, blocked bool) {
    for _, v := range resp.Validations() {
        if !v.Passed {
            return v.ValidatorType, true
        }
    }
    return "", false
}

The two signals, and when to use which. The pipeline marks a blocked turn's message with FinishReason == types.FinishReasonSafety, and that now reaches the caller on both paths — resp.Message().FinishReason after Send() (#1681) and after the terminal ChunkDone of Stream() (#1715). There is no dedicated Response.FinishReason() accessor; go through Message(). That's the cheapest check for whether a turn was blocked, and it also surfaces other terminal states such as max_output_tokens and refusal.

resp.Validations() is the only signal that names which guardrail fired, so the helper above stays the right tool when you want to log or branch on the specific policy. It is populated only when the firing guardrail's hooks.Enforced(...) call includes a "validator_type" key in its metadata — guardrails.Input/Output always do; a bespoke InputFunc/OutputFunc only does if you add it yourself, same as the no-ssn example above.

Next Steps

Documentation

Overview

Package main demonstrates hooks and guardrails with the PromptKit SDK.

This example shows:

  • Input guardrails that gate the user's message BEFORE the LLM call (guardrails.Input, guardrails.InputFunc) — a hit spends zero tokens
  • Output guardrails that gate the assistant's response AFTER the LLM call (guardrails.Output)
  • Pack-declared validators: the same input/output gating with zero Go code, via hooks.pack.json's "validators" block
  • The two ways a guardrail can respond, and how to tell them apart:
  • Enforced — a graceful block. Send returns no error; the turn carries a canned message and a validation record instead.
  • Deny — a hard error. Send returns an error you catch with errors.As.
  • Custom ProviderHook: a PII detection hook (the full-interface path, used here to demonstrate Deny)
  • Streaming with chunk-level guardrail enforcement

Run with:

export OPENAI_API_KEY=your-key
go run .

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL