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

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.

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.

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)
    })
}