MastertheMesh
Solo · agentgateway · install · Keycloak · routing · MCP · code mode · authz · LLM · observability · cost · guardrails · kind
Getting Started · Runs on kind or any cluster

agentgateway quickstart: your first POC

TO
Tom O'Rourke
Solo.io

The lab to hand a customer on day one. From an empty cluster to a working, secured, observable Solo Enterprise for agentgateway, one step at a time. Install the CRDs, control plane and UI by Helm, stand up an identity provider, configure a Gateway and an HTTPRoute, expose an MCP tool server, turn on observability, and track LLM cost by dimension. Every step links to the official docs. It runs on kind here, but the same commands work on any Kubernetes cluster.

Solo Enterprise for agentgateway v2026.7.0 Keycloak (OIDC) MCP Observability Cost Management kind / any k8s

The goal. Get a customer from nothing to a real, governed AI gateway they can build a POC on, without hunting through six doc pages to work out the order. Follow the steps below, top to bottom. Each section is short: what it is, the official doc, the exact commands, and how to check it worked.

What you need: kubectl, helm, and an AGENTGATEWAY_LICENSE_KEY for the Enterprise pieces (UI, MCP, cost). Ask your Solo account team for a trial key.

Step by step

  1. Install: Gateway API CRDs, agentgateway CRDs, control plane, and the UI, all by Helm. Helm install docs
  2. IdP: Keycloak, your OIDC identity provider. JWT auth docs
  3. Configure: a Gateway and an HTTPRoute to a sample app. Traffic management docs
  4. MCP: expose an MCP tool server through the gateway. Static MCP docs
  5. Code Mode: collapse many tools into one run_code tool to cut tokens. MCP docs
  6. MCP AuthZ: require a JWT, then lock alice down to a single tool. JWT auth docs
  7. Connect to LLMs: front OpenAI and Anthropic, keep the key at the gateway. LLM docs
  8. Observability: metrics, traces and access logs in the UI. Observability docs
  9. Cost management: automatic cost from the built-in catalog, plus a custom dimension. Cost tracking docs
  10. Guardrails: block and mask PII in prompts and responses. Guardrails docs

0. Prerequisites

Download the labs and change into this one's folder, then point at your cluster and load the license. On kind, create a throwaway one-node cluster; on an existing cluster, skip that and just set your context.

Run every cell from this lab's directory. The yaml/ paths are relative, so running them from another lab's folder deploys that lab's manifests instead. This lab runs on any Kubernetes cluster as-is — all access is via port-forward and Keycloak is only used to mint tokens (in-cluster issuer), so the only kind-specific line is kind create cluster; skip it on an existing cluster.
bash get the lab
git clone https://github.com/tjorourke/solo-labs.git
cd solo-labs/agentgateway-quickstart-kind
bash cluster + license
# kind (skip on an existing cluster)
kind create cluster --name agw-quickstart   # kind makes it your current context
# on an existing cluster instead:  kubectl config use-context <your-context>

# the Enterprise pieces need a license
export AGENTGATEWAY_LICENSE_KEY=<your-trial-key>

1. Install

Helm install   Install the UI

Four steps: the upstream Gateway API CRDs, the agentgateway CRDs, the control plane, and the UI. The UI (management plane) also brings the OpenTelemetry collector and ClickHouse used later for observability and cost.

Both the control plane and the UI need the license. The two enterprise-agentgateway and management installs each take licensing.licenseKey, so make sure you ran export AGENTGATEWAY_LICENSE_KEY back in step 0. The CRDs do not need it.
bash Gateway API CRDs + agentgateway CRDs + control plane
# Kubernetes Gateway API CRDs (prerequisite)
kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.5.0/standard-install.yaml

# agentgateway CRDs
helm upgrade -i --create-namespace --namespace agentgateway-system --version v2026.7.0 \
  enterprise-agentgateway-crds \
  oci://us-docker.pkg.dev/solo-public/enterprise-agentgateway/charts/enterprise-agentgateway-crds

# control plane (needs the license)
helm upgrade -i -n agentgateway-system enterprise-agentgateway \
  oci://us-docker.pkg.dev/solo-public/enterprise-agentgateway/charts/enterprise-agentgateway \
  --version v2026.7.0 \
  --set-string licensing.licenseKey=${AGENTGATEWAY_LICENSE_KEY}

# wait for the controller, then for the GatewayClass it creates a moment later
kubectl -n agentgateway-system rollout status deploy/enterprise-agentgateway --timeout=120s
until kubectl get gatewayclass enterprise-agentgateway >/dev/null 2>&1; do sleep 2; done
kubectl wait --for=condition=Accepted gatewayclass/enterprise-agentgateway --timeout=60s
bash the UI / management plane (OTel collector + ClickHouse)
helm upgrade -i management \
  oci://us-docker.pkg.dev/solo-public/solo-enterprise-helm/charts/management \
  --namespace agentgateway-system --create-namespace --version 0.5.2 \
  --set cluster="mgmt-cluster" \
  --set products.agentgateway.enabled=true \
  --set "products.agentgateway.features.cost-management=true" \
  --set-string licensing.licenseKey=${AGENTGATEWAY_LICENSE_KEY}

# the UI pod has 4 containers, so the first pull takes a minute or two; wait for Ready
kubectl -n agentgateway-system rollout status deploy/solo-enterprise-ui --timeout=300s
bash open the UI + Cost Management dashboard
kubectl -n agentgateway-system port-forward svc/solo-enterprise-ui 4000:80
# -> http://localhost:4000/age/                  (UI: traffic, latency, traces)
# -> http://localhost:4000/age/cost-management    (Cost Management dashboard)

2. IdP: identity provider

JWT auth   Set up JWT auth

The gateway authenticates requests against an OIDC provider. For a POC, run the bundled Keycloak. For a real deployment, point it at your own IdP. Either way the gateway needs the same thing: an issuer and a JWKS endpoint to validate tokens.

Run upstream Keycloak (quay.io/keycloak/keycloak, dev mode, in-memory) and import a ready-made realm: solo, users alice/bob/carol, public client kagent.

Why not a Helm chart for Keycloak? The common one is Bitnami, and Bitnami is now Broadcom: its images moved to a paywalled/legacy registry, so a Helm-for-Keycloak path lands on ImagePullBackOff. Every Solo lab uses this same upstream-image pattern instead. The manifest (yaml/keycloak/) ships with this lab.
bash deploy Keycloak + import the realm
kubectl create namespace keycloak

# realm imported from a ConfigMap (Keycloak reads /opt/keycloak/data/import on first boot)
kubectl -n keycloak create configmap keycloak-realm-import \
  --from-file=realm.json=yaml/keycloak/realm.json --dry-run=client -o yaml | kubectl apply -f -

kubectl -n keycloak apply -f yaml/keycloak/keycloak.yaml
kubectl -n keycloak rollout status statefulset/keycloak --timeout=300s
bash prove it: OIDC discovery + a token for alice
kubectl -n keycloak port-forward svc/keycloak 8081:80 &
for _ in $(seq 1 30); do curl -sf -o /dev/null localhost:8081/realms/solo 2>/dev/null && break; sleep 1; done  # wait for the tunnel

curl -s localhost:8081/realms/solo/.well-known/openid-configuration | jq .issuer
# -> "http://keycloak.keycloak.svc.cluster.local/realms/solo"

curl -s -X POST localhost:8081/realms/solo/protocol/openid-connect/token \
  -d grant_type=password -d client_id=kagent -d username=alice -d password=alice | jq -r .access_token
# -> a JWT. Enforce it on the gateway with the same policy shown in the BYO tab.

In production, skip Keycloak and point the gateway at your existing IdP. agentgateway validates JWTs against any OIDC provider; it needs three things:

  • Issuer: the OIDC issuer URL, matching the token's iss claim.
  • JWKS: the public-keys endpoint (the jwks_uri from the issuer's /.well-known/openid-configuration); the gateway fetches keys here to verify signatures.
  • Audience: the client/audience the tokens are minted for (the aud claim).

Create an OIDC app (client) in your IdP, note those three, then apply a JWT policy:

yaml EnterpriseAgentgatewayPolicy — validate tokens from your IdP
# A backend for the IdP host (external HTTPS provider), so the gateway can fetch JWKS.
apiVersion: enterpriseagentgateway.solo.io/v1alpha1
kind: EnterpriseAgentgatewayBackend
metadata: { name: idp, namespace: agentgateway-system }
spec:
  static:
    host: your-tenant.okta.com     # your IdP host
    port: 443
---
apiVersion: enterpriseagentgateway.solo.io/v1alpha1
kind: EnterpriseAgentgatewayPolicy
metadata: { name: jwt-auth, namespace: agentgateway-system }
spec:
  targetRefs:
    - group: gateway.networking.k8s.io
      kind: Gateway
      name: agw                      # the Gateway from step 3
  traffic:
    jwtAuthentication:
      mode: Strict                   # Strict = require a valid token; Permissive = allow anonymous
      providers:
        - issuer: "https://your-tenant.okta.com/oauth2/default"
          audiences: ["<your-client-id>"]
          jwks:
            remote:
              jwksPath: "/oauth2/default/v1/keys"   # the jwks_uri path from discovery
              cacheDuration: "5m"
              backendRef:
                group: enterpriseagentgateway.solo.io
                kind: EnterpriseAgentgatewayBackend
                name: idp
                port: 443

Where to find the issuer and JWKS per provider (always confirm from the issuer's /.well-known/openid-configuration):

ProviderIssuerJWKS path
Oktahttps://<tenant>.okta.com/oauth2/<server>/oauth2/<server>/v1/keys
Auth0https://<tenant>.auth0.com//.well-known/jwks.json
Entra ID (Azure AD)https://login.microsoftonline.com/<tenant-id>/v2.0/<tenant-id>/discovery/v2.0/keys
Googlehttps://accounts.google.com/oauth2/v3/certs
Keycloak (prod)https://<host>/realms/<realm>/realms/<realm>/protocol/openid-connect/certs
The backendRef under jwks.remote is where the gateway fetches the keys. For an external HTTPS IdP use an EnterpriseAgentgatewayBackend (as above); for an in-cluster IdP point it at the Service instead. Full walkthrough: JWT auth setup.

3. Configure: Gateway + HTTPRoute

Traffic management

A Gateway (class enterprise-agentgateway) is your listener; an HTTPRoute sends traffic to a backend. We deploy a tiny httpbin app and route to it. This is standard upstream Gateway API, portable to any conformant gateway.

One manifest: the demo namespace, a tiny httpbin, the Gateway, and an HTTPRoute. Ships as yaml/gateway/httpbin-route.yaml.

yaml namespace + httpbin + Gateway + HTTPRoute
apiVersion: v1
kind: Namespace
metadata: { name: demo }
---
apiVersion: apps/v1
kind: Deployment
metadata: { name: httpbin, namespace: demo, labels: { app: httpbin } }
spec:
  replicas: 1
  selector: { matchLabels: { app: httpbin } }
  template:
    metadata: { labels: { app: httpbin } }
    spec:
      containers:
        - name: httpbin
          image: mccutchen/go-httpbin:v2.15.0
          ports: [{ containerPort: 8080 }]
---
apiVersion: v1
kind: Service
metadata: { name: httpbin, namespace: demo }
spec:
  selector: { app: httpbin }
  ports: [{ port: 8000, targetPort: 8080 }]
---
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata: { name: agw, namespace: demo }
spec:
  gatewayClassName: enterprise-agentgateway
  listeners:
    - name: http
      protocol: HTTP
      port: 80
      allowedRoutes: { namespaces: { from: Same } }
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata: { name: httpbin, namespace: demo }
spec:
  parentRefs: [{ name: agw }]
  rules:
    - backendRefs: [{ name: httpbin, port: 8000 }]

One command creates the namespace and deploys everything (the Namespace is in the manifest).

bash deploy in one apply
kubectl apply -f - <<'EOF'
apiVersion: v1
kind: Namespace
metadata: { name: demo }
---
apiVersion: apps/v1
kind: Deployment
metadata: { name: httpbin, namespace: demo, labels: { app: httpbin } }
spec:
  replicas: 1
  selector: { matchLabels: { app: httpbin } }
  template:
    metadata: { labels: { app: httpbin } }
    spec:
      containers:
        - name: httpbin
          image: mccutchen/go-httpbin:v2.15.0
          ports: [{ containerPort: 8080 }]
---
apiVersion: v1
kind: Service
metadata: { name: httpbin, namespace: demo }
spec:
  selector: { app: httpbin }
  ports: [{ port: 8000, targetPort: 8080 }]
---
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata: { name: agw, namespace: demo }
spec:
  gatewayClassName: enterprise-agentgateway
  listeners:
    - name: http
      protocol: HTTP
      port: 80
      allowedRoutes: { namespaces: { from: Same } }
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata: { name: httpbin, namespace: demo }
spec:
  parentRefs: [{ name: agw }]
  rules:
    - backendRefs: [{ name: httpbin, port: 8000 }]
EOF

# the Gateway reports Programmed before its proxy pod is Ready, so wait on the proxy too
kubectl -n demo wait gateway/agw --for=condition=Programmed --timeout=120s
kubectl -n demo rollout status deploy/agw --timeout=120s
bash verify: curl through the gateway
kubectl -n demo port-forward svc/agw 8080:80 &
for _ in $(seq 1 30); do curl -sf -o /dev/null localhost:8080/get 2>/dev/null && break; sleep 1; done  # wait for the tunnel
curl -s -o /dev/null -w '%{http_code}\n' localhost:8080/get         # -> 200
curl -s -o /dev/null -w '%{http_code}\n' localhost:8080/status/418  # -> 418

4. MCP: expose a tool server

Static MCP backend

agentgateway can front MCP (Model Context Protocol) tool servers, so agents reach their tools through the same governed gateway. Point an EnterpriseAgentgatewayBackend of type mcp at a tool-server Service, then route to it. Here we expose the official everything MCP reference server at /mcp: a demo server with a batch of tools (echo, get-sum, get-tiny-image, and more).

The tool server, an EnterpriseAgentgatewayBackend of type mcp, and a route at /mcp, all in demo next to the Gateway. Ships as yaml/mcp/mcp.yaml.

yaml MCP tool server + backend + route
apiVersion: apps/v1
kind: Deployment
metadata: { name: mcp-everything, namespace: demo, labels: { app: mcp-everything } }
spec:
  replicas: 1
  selector: { matchLabels: { app: mcp-everything } }
  template:
    metadata: { labels: { app: mcp-everything } }
    spec:
      containers:
        - name: everything
          image: node:22-alpine
          command: ["npx"]
          args: ["-y", "@modelcontextprotocol/server-everything", "streamableHttp"]
          ports: [{ containerPort: 3001 }]
---
apiVersion: v1
kind: Service
metadata: { name: mcp-everything, namespace: demo }
spec:
  selector: { app: mcp-everything }
  ports: [{ port: 80, targetPort: 3001 }]
---
apiVersion: enterpriseagentgateway.solo.io/v1alpha1
kind: EnterpriseAgentgatewayBackend
metadata: { name: mcp-backend, namespace: demo }
spec:
  mcp:
    targets:
      - name: everything
        static:
          backendRef: { name: mcp-everything }
          port: 80
          protocol: StreamableHTTP
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata: { name: mcp, namespace: demo }
spec:
  parentRefs: [{ name: agw }]
  rules:
    - matches: [{ path: { type: PathPrefix, value: /mcp } }]
      backendRefs:
        - name: mcp-backend
          group: enterpriseagentgateway.solo.io
          kind: EnterpriseAgentgatewayBackend

One apply deploys the tool server, backend and route into demo.

bash deploy the MCP server + backend + route
kubectl apply -f - <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata: { name: mcp-everything, namespace: demo, labels: { app: mcp-everything } }
spec:
  replicas: 1
  selector: { matchLabels: { app: mcp-everything } }
  template:
    metadata: { labels: { app: mcp-everything } }
    spec:
      containers:
        - name: everything
          image: node:22-alpine
          command: ["npx"]
          args: ["-y", "@modelcontextprotocol/server-everything", "streamableHttp"]
          ports: [{ containerPort: 3001 }]
---
apiVersion: v1
kind: Service
metadata: { name: mcp-everything, namespace: demo }
spec:
  selector: { app: mcp-everything }
  ports: [{ port: 80, targetPort: 3001 }]
---
apiVersion: enterpriseagentgateway.solo.io/v1alpha1
kind: EnterpriseAgentgatewayBackend
metadata: { name: mcp-backend, namespace: demo }
spec:
  mcp:
    targets:
      - name: everything
        static:
          backendRef: { name: mcp-everything }
          port: 80
          protocol: StreamableHTTP
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata: { name: mcp, namespace: demo }
spec:
  parentRefs: [{ name: agw }]
  rules:
    - matches: [{ path: { type: PathPrefix, value: /mcp } }]
      backendRefs:
        - name: mcp-backend
          group: enterpriseagentgateway.solo.io
          kind: EnterpriseAgentgatewayBackend
EOF

# first boot pulls the npm package, so allow a little longer
kubectl -n demo rollout status deploy/mcp-everything --timeout=240s

Validate it by asking the server what tools it exposes:

bash validate: list the tools
kubectl -n demo port-forward svc/agw 8080:80 &
for _ in $(seq 1 30); do curl -sf -o /dev/null localhost:8080/get 2>/dev/null && break; sleep 1; done  # wait for the tunnel

# ask the MCP server what tools it has, print just the names (the Inspector CLI does the MCP handshake)
npx -y @modelcontextprotocol/inspector@0.21.2 --cli http://localhost:8080/mcp --method tools/list \
  | yq -p json '.tools[].name'
# -> echo
#    get-sum
#    get-tiny-image
#    ... (13 tools in total)

Then call one of them (reusing the port-forward from the list cell):

bash validate: call a tool
npx -y @modelcontextprotocol/inspector@0.21.2 --cli http://localhost:8080/mcp \
  --method tools/call --tool-name echo --tool-arg message="hello via agentgateway"
# -> { "content": [ { "type": "text", "text": "Echo: hello via agentgateway" } ] }
A bare curl to /mcp returns session header is required: MCP needs an initialize handshake before tools/list. The Inspector does that for you, in the CLI above or as a browser UI (npx @modelcontextprotocol/inspector@0.21.2, Transport Streamable HTTP, URL http://localhost:8080/mcp).

5. Code Mode: fewer tokens per MCP call

Static MCP backend

When an agent faces a wall of MCP tools, every call ships all those tool schemas in the prompt, and each step is a separate round trip. Code Mode collapses the whole tool set into a single run_code tool: the gateway hands the model a generated JavaScript API and runs the code it sends back, so one call does the work of many. Fewer schemas in the prompt and fewer round trips means a lower token bill.

Code Mode changes the tool surface, so it is a different posture from the per-tool RBAC in the next step (which needs the individual tools). We front the same everything server on a new route /mcp-code, leaving the standard /mcp from step 4 in place, so both exist side by side. It uses entMcp (the Enterprise MCP spec) with toolMode: Code and stateful sessions.
bash deploy a Code Mode backend + route
kubectl apply -f - <<'EOF'
apiVersion: enterpriseagentgateway.solo.io/v1alpha1
kind: EnterpriseAgentgatewayBackend
metadata: { name: mcp-code, namespace: demo }
spec:
  entMcp:
    toolMode: Code            # collapse every tool into one run_code tool
    sessionRouting: Stateful
    targets:
      - name: everything
        static: { host: mcp-everything.demo.svc.cluster.local, port: 80, protocol: StreamableHTTP, path: /mcp }
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata: { name: mcp-code, namespace: demo }
spec:
  parentRefs: [{ name: agw }]
  rules:
    - matches: [{ path: { type: PathPrefix, value: /mcp-code } }]
      backendRefs:
        - name: mcp-code
          group: enterpriseagentgateway.solo.io
          kind: EnterpriseAgentgatewayBackend
EOF

The 13 tools are now a single run_code tool:

bash validate: one tool instead of many
kubectl -n demo port-forward svc/agw 8080:80 &
for _ in $(seq 1 30); do curl -sf -o /dev/null localhost:8080/get 2>/dev/null && break; sleep 1; done  # wait for the tunnel

# Code Mode uses Streamable HTTP with stateful sessions, so force the http transport
npx -y @modelcontextprotocol/inspector@0.21.2 --cli http://localhost:8080/mcp-code \
  --transport http --method tools/list | yq -p json '.tools[].name'
# -> run_code        (all 13 tools collapsed into one)

Call run_code with a small script: the model calls the tools it needs from the generated API (here get_sum), and only the answer comes back:

bash validate: run one script that uses the tools
npx -y @modelcontextprotocol/inspector@0.21.2 --cli http://localhost:8080/mcp-code \
  --transport http --method tools/call --tool-name run_code \
  --tool-arg 'code=const s = await get_sum({ a: 2, b: 3 }); s'
# -> { "structuredContent": { "success": "The sum of 2 and 3 is 5." } }

6. MCP AuthZ: JWT + per-tool RBAC

JWT auth   Set up JWT auth

Right now the MCP server is open. Lock it down with two policies: one requires a valid Keycloak JWT on the /mcp route, the other does per-tool authorization by identity. We restrict alice to the single get-sum tool; everyone else keeps full access. The gateway filters tools/list and blocks tools/call, so alice never even sees the tools she cannot use.

This builds on the Keycloak from step 2 and the MCP server from step 4. The CEL rules read jwt.<claim> from the token (here jwt.email) and mcp.tool.name from the MCP call. Rules are OR-ed: a tool is allowed only if at least one rule matches.

Two policies: JWT auth on the /mcp route, and per-tool authorization on the MCP backend. Ships as yaml/mcp-authz/policies.yaml.

yaml JWT auth + per-tool authorization
# 1. Require a valid Keycloak JWT on the /mcp route (no token -> 401)
apiVersion: enterpriseagentgateway.solo.io/v1alpha1
kind: EnterpriseAgentgatewayPolicy
metadata: { name: mcp-jwt, namespace: demo }
spec:
  targetRefs:
    - group: gateway.networking.k8s.io
      kind: HTTPRoute
      name: mcp
  traffic:
    jwtAuthentication:
      mode: Strict
      providers:
        - issuer: "http://keycloak.keycloak.svc.cluster.local/realms/solo"
          jwks:
            remote:
              jwksPath: "/realms/solo/protocol/openid-connect/certs"
              backendRef: { kind: Service, name: keycloak, namespace: keycloak, port: 80 }
---
# 2. Per-tool authorization on the MCP backend: alice may only call get-sum
apiVersion: enterpriseagentgateway.solo.io/v1alpha1
kind: EnterpriseAgentgatewayPolicy
metadata: { name: mcp-authz, namespace: demo }
spec:
  targetRefs:
    - group: enterpriseagentgateway.solo.io
      kind: EnterpriseAgentgatewayBackend
      name: mcp-backend
  backend:
    mcp:
      authorization:
        action: Allow          # a tool matching no rule is hidden + denied
        policy:
          matchExpressions:
            - 'jwt.email == "alice@example.com" && mcp.tool.name == "get-sum"'
            - 'jwt.email != "alice@example.com"'     # everyone else keeps full access

One apply installs both policies.

bash apply: require a JWT + per-tool authorization
kubectl apply -f - <<'EOF'
# 1. Require a valid Keycloak JWT on the /mcp route (no token -> 401)
apiVersion: enterpriseagentgateway.solo.io/v1alpha1
kind: EnterpriseAgentgatewayPolicy
metadata: { name: mcp-jwt, namespace: demo }
spec:
  targetRefs:
    - group: gateway.networking.k8s.io
      kind: HTTPRoute
      name: mcp
  traffic:
    jwtAuthentication:
      mode: Strict
      providers:
        - issuer: "http://keycloak.keycloak.svc.cluster.local/realms/solo"
          jwks:
            remote:
              jwksPath: "/realms/solo/protocol/openid-connect/certs"
              backendRef: { kind: Service, name: keycloak, namespace: keycloak, port: 80 }
---
# 2. Per-tool authorization on the MCP backend: alice may only call get-sum
apiVersion: enterpriseagentgateway.solo.io/v1alpha1
kind: EnterpriseAgentgatewayPolicy
metadata: { name: mcp-authz, namespace: demo }
spec:
  targetRefs:
    - group: enterpriseagentgateway.solo.io
      kind: EnterpriseAgentgatewayBackend
      name: mcp-backend
  backend:
    mcp:
      authorization:
        action: Allow          # a tool matching no rule is hidden + denied
        policy:
          matchExpressions:
            - 'jwt.email == "alice@example.com" && mcp.tool.name == "get-sum"'
            - 'jwt.email != "alice@example.com"'     # everyone else keeps full access
EOF

Get a token for alice from Keycloak:

bash validate: mint a token for alice
kubectl -n keycloak port-forward svc/keycloak 8081:80 &
for _ in $(seq 1 30); do curl -sf -o /dev/null localhost:8081/realms/solo 2>/dev/null && break; sleep 1; done  # wait for the tunnel

TOKEN=$(curl -s -X POST localhost:8081/realms/solo/protocol/openid-connect/token \
  -d grant_type=password -d client_id=kagent -d username=alice -d password=alice | jq -r .access_token)

First, without a token the route now rejects the request:

bash validate: no token is rejected
kubectl -n demo port-forward svc/agw 8080:80 &
for _ in $(seq 1 30); do curl -sf -o /dev/null localhost:8080/get 2>/dev/null && break; sleep 1; done  # wait for the tunnel

npx -y @modelcontextprotocol/inspector@0.21.2 --cli http://localhost:8080/mcp --method tools/list
# -> Failed to connect: authentication failure: no bearer token found

Now list the tools as alice: the same server, filtered to just the one tool she is allowed:

bash validate: list tools as alice (now reduced)
npx -y @modelcontextprotocol/inspector@0.21.2 --cli http://localhost:8080/mcp \
  --header "Authorization: Bearer $TOKEN" --method tools/list \
  | yq -p json '.tools[].name'
# -> get-sum        (all 13 before, 1 now)

Prove the enforcement both ways: the allowed tool works, a denied tool is refused:

bash validate: allowed vs denied tool call
# allowed: get-sum
npx -y @modelcontextprotocol/inspector@0.21.2 --cli http://localhost:8080/mcp \
  --header "Authorization: Bearer $TOKEN" --method tools/call --tool-name get-sum --tool-arg a=2 --tool-arg b=3
# -> { "content": [ { "type": "text", "text": "The sum of 2 and 3 is 5." } ] }

# denied: echo is not in alice's allow-list
npx -y @modelcontextprotocol/inspector@0.21.2 --cli http://localhost:8080/mcp \
  --header "Authorization: Bearer $TOKEN" --method tools/call --tool-name echo --tool-arg message=hi
# -> error -32602 "Unknown tool: echo"

7. Connect to LLMs

LLM providers   Supported providers

agentgateway fronts LLM providers the same way it fronts any backend. Your apps send an OpenAI-format request to the gateway; agentgateway translates it to the provider's native API and translates the response back. The API key lives in a Secret at the gateway, so your apps never hold it, and every call is measured for the observability and cost steps that follow.

You need a real provider API key to test this step. Export the one for the tab you are using: export OPENAI_API_KEY=sk-... or export ANTHROPIC_API_KEY=sk-ant-....

The key goes in a Secret; the backend and route point at OpenAI. Requests hit /openai.

bash deploy the OpenAI backend + route
kubectl -n demo create secret generic openai-secret \
  --from-literal=Authorization="$OPENAI_API_KEY"

kubectl apply -f - <<'EOF'
apiVersion: enterpriseagentgateway.solo.io/v1alpha1
kind: EnterpriseAgentgatewayBackend
metadata: { name: openai, namespace: demo }
spec:
  ai:
    groups:
      - providers:
          - name: openai
            openai:
              model: gpt-4o-mini
            policies:
              auth:
                secretRef: { name: openai-secret }
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata: { name: openai, namespace: demo }
spec:
  parentRefs: [{ name: agw }]
  rules:
    - matches: [{ path: { type: PathPrefix, value: /openai } }]
      backendRefs:
        - name: openai
          group: enterpriseagentgateway.solo.io
          kind: EnterpriseAgentgatewayBackend
EOF
bash validate: query OpenAI through the gateway
kubectl -n demo port-forward svc/agw 8080:80 &
for _ in $(seq 1 30); do curl -sf -o /dev/null localhost:8080/get 2>/dev/null && break; sleep 1; done  # wait for the tunnel

# OpenAI-format request; model:"" uses the backend's configured model
curl -s localhost:8080/openai -H 'content-type: application/json' \
  -d '{"model":"","messages":[{"role":"user","content":"Write a haiku about AI gateways."}]}' \
  | jq -r '.choices[0].message.content'
# -> a three-line haiku about AI gateways

Same shape, Anthropic provider. You still send an OpenAI-format request to /anthropic; the gateway translates to Anthropic's Messages API and back.

bash deploy the Anthropic backend + route
kubectl -n demo create secret generic anthropic-secret \
  --from-literal=Authorization="$ANTHROPIC_API_KEY"

kubectl apply -f - <<'EOF'
apiVersion: enterpriseagentgateway.solo.io/v1alpha1
kind: EnterpriseAgentgatewayBackend
metadata: { name: anthropic, namespace: demo }
spec:
  ai:
    groups:
      - providers:
          - name: anthropic
            anthropic:
              model: claude-haiku-4-5-20251001
            policies:
              auth:
                secretRef: { name: anthropic-secret }
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata: { name: anthropic, namespace: demo }
spec:
  parentRefs: [{ name: agw }]
  rules:
    - matches: [{ path: { type: PathPrefix, value: /anthropic } }]
      backendRefs:
        - name: anthropic
          group: enterpriseagentgateway.solo.io
          kind: EnterpriseAgentgatewayBackend
EOF
bash validate: query Anthropic through the gateway
kubectl -n demo port-forward svc/agw 8080:80 &
for _ in $(seq 1 30); do curl -sf -o /dev/null localhost:8080/get 2>/dev/null && break; sleep 1; done  # wait for the tunnel

# still OpenAI-format in and out; the gateway translates to Anthropic and back
curl -s localhost:8080/anthropic -H 'content-type: application/json' \
  -d '{"model":"","messages":[{"role":"user","content":"Write a haiku about AI gateways."}]}' \
  | jq -r '.choices[0].message.content'
# -> a three-line haiku about AI gateways

8. Observability

Observability   Tracing

The management plane from step 1 runs the OpenTelemetry collector and ClickHouse that back the UI dashboard, but the gateway does not export to them by default. Attach a tracing policy that points the gateway at the collector, then send traffic and watch the dashboard fill in.

randomSampling is the fraction of requests traced, a value from 0 to 1. We use "true" here to trace every request so nothing is missed on a quiet demo cluster. In production you drop it to something like "0.1" (10%) to keep overhead and storage down, set clientSampling: "true" so a sampling decision already made upstream is honoured (the trace stays intact end to end), and use filter (a CEL expression) to always capture the requests you care about, such as errors.

Send the gateway's traces to the management collector. Ships as yaml/observability/tracing.yaml.

yaml export gateway traces to the collector
apiVersion: enterpriseagentgateway.solo.io/v1alpha1
kind: EnterpriseAgentgatewayPolicy
metadata: { name: tracing, namespace: demo }
spec:
  targetRefs:
    - group: gateway.networking.k8s.io
      kind: Gateway
      name: agw
  frontend:
    tracing:
      backendRef:
        name: solo-enterprise-telemetry-collector
        namespace: agentgateway-system
        port: 4317
      protocol: GRPC
      randomSampling: "true"      # sample every request (fine for a demo)
bash apply: export gateway traces to the collector
kubectl apply -f - <<'EOF'
apiVersion: enterpriseagentgateway.solo.io/v1alpha1
kind: EnterpriseAgentgatewayPolicy
metadata: { name: tracing, namespace: demo }
spec:
  targetRefs:
    - group: gateway.networking.k8s.io
      kind: Gateway
      name: agw
  frontend:
    tracing:
      backendRef:
        name: solo-enterprise-telemetry-collector
        namespace: agentgateway-system
        port: 4317
      protocol: GRPC
      randomSampling: "true"      # sample every request (fine for a demo)
EOF
bash generate some traffic
kubectl -n demo port-forward svc/agw 8080:80 &
for _ in $(seq 1 30); do curl -sf -o /dev/null localhost:8080/get 2>/dev/null && break; sleep 1; done  # wait for the tunnel
for i in $(seq 1 20); do curl -s -o /dev/null localhost:8080/get; curl -s -o /dev/null localhost:8080/status/500; done
bash view it in the UI
kubectl -n agentgateway-system port-forward svc/solo-enterprise-ui 4000:80
# -> http://localhost:4000/age/   (traffic, latency, traces)
The request, latency and error panels are built from these traces. Token Usage by Model stays empty until you send LLM traffic (step 7) with a cost catalog loaded (step 9), since only LLM calls carry token and cost data.

9. Cost management (with dimensions)

Cost tracking   Model costs   Cost dashboard

agentgateway prices every LLM request from a built-in model cost catalog that ships with the gateway, so there is nothing to load: send LLM traffic and spend shows up. Spend is sliced by dimensions. model and provider are built in; Group, User and Virtual Key ship as defaults; you add your own (here a Team) to attribute spend the way you report it.

The Cost Management dashboard is part of the UI from step 1 (turned on with products.agentgateway.features.cost-management=true, already set in the install). The default catalog covers current OpenAI, Anthropic and Gemini models. To price a model it does not know, or to override a rate, point an EnterpriseAgentgatewayParameters at your own catalog ConfigMap (optional, not needed here).

Add a custom Team dimension, derived from an x-team request header. Dimensions are a helm value on the control plane, so this re-runs that chart in place. (You can also add one in the UI: Dimensions page → Add scopeSave Changes.)

The budgetDimensions values: keep the defaults and add Team. Ships as yaml/cost/dimensions-values.yaml.

yaml budgetDimensions values
budgetDimensions:
  config:
    hierarchy:                 # ordered scopes
      - { id: group, expression: 'coalesce(jwt.group, apiKey.group)', displayName: Group }
      - { id: user,  expression: 'coalesce(apiKey.user, apiKey.name, apiKey.owner, jwt.sub, jwt.email, basicAuth.username, source.identity.namespace + "/" + source.identity.serviceAccount, source.subjectCn)', displayName: User }
      - { id: team,  expression: 'request.headers["x-team"]', displayName: Team }
    attributes:                # flat attributes
      - { id: virtualKey, expression: apiKey.id, displayName: Virtual Key }

Apply it with a helm upgrade (--reuse-values keeps your license and other settings), then restart the gateway so it picks up the new dimension.

bash add the Team dimension
helm upgrade enterprise-agentgateway \
  oci://us-docker.pkg.dev/solo-public/enterprise-agentgateway/charts/enterprise-agentgateway \
  --namespace agentgateway-system --version v2026.7.0 --reuse-values -f - <<'EOF'
budgetDimensions:
  config:
    hierarchy:
      - { id: group, expression: 'coalesce(jwt.group, apiKey.group)', displayName: Group }
      - { id: user,  expression: 'coalesce(apiKey.user, apiKey.name, apiKey.owner, jwt.sub, jwt.email, basicAuth.username, source.identity.namespace + "/" + source.identity.serviceAccount, source.subjectCn)', displayName: User }
      - { id: team,  expression: 'request.headers["x-team"]', displayName: Team }
    attributes:
      - { id: virtualKey, expression: apiKey.id, displayName: Virtual Key }
EOF

kubectl -n demo rollout restart deploy/agw
kubectl -n demo rollout status deploy/agw --timeout=90s

Generate some LLM spend, tagging each request with a team (reuses the /openai route from step 7):

bash generate LLM spend, tagged by team
kubectl -n demo port-forward svc/agw 8080:80 &
for _ in $(seq 1 30); do curl -sf -o /dev/null localhost:8080/get 2>/dev/null && break; sleep 1; done  # wait for the tunnel

for team in platform platform research; do
  curl -s localhost:8080/openai -H 'content-type: application/json' -H "x-team: $team" \
    -d '{"model":"","messages":[{"role":"user","content":"Write a haiku about AI gateways."}]}' -o /dev/null
done

Open the dashboard: Cost Management shows spend by model and provider, and now by Team; the Dimensions page lists them all.

bash view Cost Management
kubectl -n agentgateway-system port-forward svc/solo-enterprise-ui 4000:80
# -> http://localhost:4000/age/cost-management   (spend by model, provider, Team)
# -> http://localhost:4000/age/dimensions        (the dimensions you can slice by)

10. Guardrails: block and mask PII

LLM guardrails

The gateway can inspect prompts and responses and act on sensitive data before it reaches the model or the user. A prompt guard attached to an LLM route can reject a request that carries PII (or a banned term) and mask PII in the model's reply. Built-in detectors cover CreditCard, Ssn, PhoneNumber, Email and CaSin; you can add your own regex under matches.

Do step 7 first. One prompt guard protects every LLM route you list in targetRefs — here both /openai and /anthropic. Keep only the routes you actually created; a targetRef to a route that does not exist just does not attach (the others still work).

One policy, both routes: reject PII requests, mask PII responses. Ships as yaml/guardrails/prompt-guard.yaml.

yaml prompt guard on the LLM routes
apiVersion: enterpriseagentgateway.solo.io/v1alpha1
kind: EnterpriseAgentgatewayPolicy
metadata: { name: prompt-guard, namespace: demo }
spec:
  targetRefs:                    # one policy, every LLM route you made in step 7
    - { group: gateway.networking.k8s.io, kind: HTTPRoute, name: openai }
    - { group: gateway.networking.k8s.io, kind: HTTPRoute, name: anthropic }
  backend:
    ai:
      promptGuard:
        request:
          - regex:
              action: Reject               # Reject or Mask
              builtins: ["Ssn", "CreditCard"]
              # matches: ["(?i)api[_-]?key"]   # add your own regex too
            response:
              statusCode: 403
              message: "Blocked: the request contained sensitive data (PII)."
        response:
          - regex:
              action: Mask                  # mask PII the model returns
              builtins: ["Ssn", "CreditCard", "Email"]
bash apply the prompt guard
kubectl apply -f - <<'EOF'
apiVersion: enterpriseagentgateway.solo.io/v1alpha1
kind: EnterpriseAgentgatewayPolicy
metadata: { name: prompt-guard, namespace: demo }
spec:
  targetRefs:                    # one policy, every LLM route you made in step 7
    - { group: gateway.networking.k8s.io, kind: HTTPRoute, name: openai }
    - { group: gateway.networking.k8s.io, kind: HTTPRoute, name: anthropic }
  backend:
    ai:
      promptGuard:
        request:
          - regex:
              action: Reject               # Reject or Mask
              builtins: ["Ssn", "CreditCard"]
              # matches: ["(?i)api[_-]?key"]   # add your own regex too
            response:
              statusCode: 403
              message: "Blocked: the request contained sensitive data (PII)."
        response:
          - regex:
              action: Mask                  # mask PII the model returns
              builtins: ["Ssn", "CreditCard", "Email"]
EOF

Clean prompts still go through on both routes:

bash validate: a normal prompt is allowed
kubectl -n demo port-forward svc/agw 8080:80 &
for _ in $(seq 1 30); do curl -sf -o /dev/null localhost:8080/get 2>/dev/null && break; sleep 1; done  # wait for the tunnel

for r in openai anthropic; do
  echo -n "/$r  "; curl -s -o /dev/null -w '%{http_code}\n' localhost:8080/$r \
    -H 'content-type: application/json' -d '{"model":"","messages":[{"role":"user","content":"Say hi in one word."}]}'
done
# -> /openai     200
#    /anthropic  200

A prompt carrying an SSN is blocked on either route before it reaches the model:

bash validate: a prompt with PII is rejected
for r in openai anthropic; do
  echo -n "/$r  "; curl -s -o /dev/null -w '%{http_code}\n' localhost:8080/$r \
    -H 'content-type: application/json' -d '{"model":"","messages":[{"role":"user","content":"My SSN is 123-45-6789, remember it."}]}'
done
# -> /openai     403
#    /anthropic  403

Done. What to explore next

You now have agentgateway installed by Helm, with an IdP, routing, an MCP tool server, Code Mode, per-tool RBAC, LLM providers, observability, cost dimensions and PII guardrails. Each of these has a lab that goes deeper than this walkthrough:

Tear down (kind): kind delete cluster --name agw-quickstart

See also: rvennam's agentgateway POC demo (gist).

Versions

Built and verified on:

Enterprise
Solo Enterprise for agentgatewayv2026.7.0
management (UI) chart0.5.2
Gateway APIv1.5.0
Keycloak26.3

Versions

Built and verified on:

Enterprise
Gateway APIv1.5.0
Keycloak26.3
Solo Enterprise for agentgatewayv2026.7.0
management (UI) chart0.5.2