Skip to main content
Guides

This guide walks through embedding Keep’s policy engine in a Go application. By the end, you will load rules, evaluate calls, handle decisions, and wire it into an HTTP middleware.

Prerequisites

  • Go 1.25 or later
  • A directory of Keep rule files (see Writing rules)

Install

go get github.com/majorcontext/keep

Load rules

keep.Load reads YAML rule files from a directory, compiles CEL expressions and redact patterns, and returns a ready-to-use engine:

engine, err := keep.Load("./rules")
if err != nil {
    log.Fatalf("load rules: %v", err)
}
defer engine.Close()

Pass options to configure additional directories or override mode:

engine, err := keep.Load("./rules",
    keep.WithProfilesDir("./profiles"),
    keep.WithPacksDir("./packs"),
    keep.WithForceEnforce(),
)
OptionEffect
WithProfilesDir(dir)Load profile YAML files that define field aliases
WithPacksDir(dir)Load starter pack YAML files with reusable rules
WithForceEnforce()Override every scope’s mode to enforce
WithJudge(fn)Register an LLM-as-judge function for rules with action: judge

Evaluate calls

Build a keep.Call and pass it to Evaluate with a scope name:

result, err := engine.Evaluate(keep.Call{
    Operation: "create_issue",
    Params:    map[string]any{"priority": 1, "title": "Fix login bug"},
    Context:   keep.CallContext{AgentID: "my-agent"},
}, "linear-tools")
if err != nil {
    log.Fatalf("evaluate: %v", err)
}

A Call has three fields:

  • Operation — the action being performed (e.g. "create_issue", "llm.tool_result")
  • Params — arbitrary key-value parameters the rules inspect
  • Context — metadata like AgentID, UserID, Timestamp, and Labels

The second argument to Evaluate is the scope name declared in your rule files. If the scope does not exist, Evaluate returns an error listing available scopes.

Convenience constructors

For common gatekeeper shapes, helper constructors build the Call for you. They set Operation and Params but leave Context.Scope unset — assign it from your deployment convention.

HelperUse
NewHTTPCall(method, host, path)An outbound HTTP request. Exposes params.method, params.host, params.path.
NewHTTPCallWithBody(method, host, path, body)Same, plus the decoded request body under params.body.
NewMCPCall(tool, params)An MCP tool call. Operation is the tool name; params is passed through.

The built-in keep-llm-gateway does not use these — it decomposes provider requests into semantic calls (params.model, params.system, params.text, …). The HTTP helpers are for embedding Keep in your own HTTP proxy or middleware.

Inspecting the request body

NewHTTPCallWithBody exposes the decoded body so rules can match on it:

- name: block-gpt4
  match:
    when: "params.body.model == 'gpt-4'"
  action: deny

body is typed any, so it accepts a JSON object (map[string]any), an array ([]any), or a scalar.

Buffering and parsing a request body is not free, so only do it when a rule in the scope actually reads it. Engine.RequiresBody(scope) answers that from a compile-time scan of the scope’s rules:

func middleware(engine *keep.Engine, scope string) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        var body any // any JSON shape: object, array, or scalar
        if engine.RequiresBody(scope) {
            raw, err := io.ReadAll(r.Body)
            if err != nil {
                http.Error(w, "cannot read request body", http.StatusBadRequest)
                return
            }
            r.Body = io.NopCloser(bytes.NewReader(raw)) // restore for the proxy
            if len(raw) > 0 {
                if err := json.Unmarshal(raw, &body); err != nil {
                    http.Error(w, "invalid JSON body", http.StatusBadRequest)
                    return
                }
            }
        }
        call := keep.NewHTTPCallWithBody(r.Method, r.Host, r.URL.Path, body)
        call.Context.Scope = scope
        // ... evaluate and act on the decision
    }
}

RequiresBody fails safe: it detects every idiomatic body reference (params.body.x, params["body"], has(params.body), comprehensions, the in operator), and for any unrecognized use of the params map — or an unknown scope name — it returns true so the body is buffered rather than silently skipped.

Handle decisions

result.Decision is one of three values:

switch result.Decision {
case keep.Allow:
    // Proceed with the call.

case keep.Deny:
    // Block the call. result.Rule and result.Message explain why.
    log.Printf("denied by rule %q: %s", result.Rule, result.Message)

case keep.Redact:
    // Allow the call but apply mutations first.
    params = keep.ApplyMutations(params, result.Mutations)
}

ApplyMutations returns a new map with redacted values. The original map is not modified.

Every evaluation populates result.Audit with the timestamp, scope, operation, rules evaluated, and decision — useful for structured logging regardless of outcome.

Lifecycle

Close

Close stops the rate counter garbage collection goroutine. Call it when the engine is no longer needed to prevent goroutine leaks:

defer engine.Close()

Reload

Reload re-reads all rule files from disk and recompiles evaluators. The rate counter store is preserved across reloads, so rate-limiting state is not lost:

if err := engine.Reload(); err != nil {
    log.Printf("reload failed: %v", err)
}

This lets you pick up rule changes without restarting the process — useful with file watchers or a config reload signal handler.

Listing scopes

Scopes returns the sorted list of loaded scope names:

fmt.Println(engine.Scopes()) // [anthropic-gateway linear-tools]

Thread safety

The engine is safe for concurrent use. Multiple goroutines can call Evaluate simultaneously. Reload acquires a write lock internally, so concurrent evaluations block briefly during a reload and resume with the new rules.

LLM-as-judge

To use rules with action: judge, register a judge function when loading the engine. The judge package provides a cache wrapper and ready-made providers for Anthropic and OpenAI:

import (
    "github.com/majorcontext/keep/judge"
    anthropicjudge "github.com/majorcontext/keep/judge/anthropic"
)

provider := anthropicjudge.New(os.Getenv("ANTHROPIC_API_KEY"))
cached := judge.NewCache(provider)

engine, err := keep.Load("./rules",
    keep.WithJudge(cached.Judge),
)

judge.NewCache wraps any judge provider with an in-memory verdict cache. Identical content evaluated against the same prompt and model returns a cached result without calling the provider. The cache holds up to 10,000 entries by default, configurable with judge.WithMaxSize(n).

Without WithJudge, rules with action: judge are skipped during evaluation.

Complete example

This HTTP middleware evaluates every request against a policy scope before forwarding it:

package main

import (
    "encoding/json"
    "log"
    "net/http"

    "github.com/majorcontext/keep"
)

func main() {
    engine, err := keep.Load("./rules")
    if err != nil {
        log.Fatal(err)
    }
    defer engine.Close()

    handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.Write([]byte("OK"))
    })

    http.Handle("/", policyMiddleware(engine, "my-scope", handler))
    log.Fatal(http.ListenAndServe(":8080", nil))
}

func policyMiddleware(eng *keep.Engine, scope string, next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        call := keep.Call{
            Operation: r.Method + " " + r.URL.Path,
            Params:    map[string]any{"method": r.Method, "path": r.URL.Path},
            Context:   keep.CallContext{AgentID: r.Header.Get("X-Agent-ID")},
        }

        result, err := eng.Evaluate(call, scope)
        if err != nil {
            http.Error(w, "policy error", http.StatusInternalServerError)
            return
        }

        switch result.Decision {
        case keep.Deny:
            w.Header().Set("Content-Type", "application/json")
            w.WriteHeader(http.StatusForbidden)
            json.NewEncoder(w).Encode(map[string]string{
                "error": result.Message,
                "rule":  result.Rule,
            })
            return
        case keep.Redact:
            // For HTTP middleware, redaction typically applies to response bodies.
            // Handle based on your application's needs.
        }

        next.ServeHTTP(w, r)
    })
}