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
- Install: Gateway API CRDs, agentgateway CRDs, control plane, and the UI, all by Helm. Helm install docs
- IdP: Keycloak, your OIDC identity provider. JWT auth docs
- Configure: a
Gatewayand anHTTPRouteto a sample app. Traffic management docs - MCP: expose an MCP tool server through the gateway. Static MCP docs
- Code Mode: collapse many tools into one
run_codetool to cut tokens. MCP docs - MCP AuthZ: require a JWT, then lock alice down to a single tool. JWT auth docs
- Connect to LLMs: front OpenAI and Anthropic, keep the key at the gateway. LLM docs
- Observability: metrics, traces and access logs in the UI. Observability docs
- Cost management: automatic cost from the built-in catalog, plus a custom dimension. Cost tracking docs
- 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.
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 UIFour 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.
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 authThe 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.
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
issclaim. - JWKS: the public-keys endpoint (the
jwks_urifrom 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
audclaim).
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):
| Provider | Issuer | JWKS path |
|---|---|---|
| Okta | https://<tenant>.okta.com/oauth2/<server> | /oauth2/<server>/v1/keys |
| Auth0 | https://<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 |
https://accounts.google.com | /oauth2/v3/certs | |
| Keycloak (prod) | https://<host>/realms/<realm> | /realms/<realm>/protocol/openid-connect/certs |
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" } ] }
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.
/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.
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 providersagentgateway 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.
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 TracingThe 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)
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.
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 scope → Save 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.
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:
- Put a ceiling on spend. Budgets and virtual keys turn the dimensions from step 9 into enforcement: a monthly cap per team, a per-key budget, requests blocked when it is spent.
- Go further on MCP governance. You did per-tool RBAC in step 6; tool curation adds an approved manifest, risk tiers and chain rules. The umbrella view: eight enterprise controls for MCP.
- Stronger guardrails. Step 10 used the built-in regex guard; go deeper with external guardrail services and cloud detectors (Bedrock Guardrails, model armor) in PII / DLP guardrails.
- Keep it up under load. Add multi-provider model failover (an
ai.groupswith more than one provider) and rate limiting per team or token, so a busy or degraded provider does not take the gateway down or blow the budget.
Tear down (kind): kind delete cluster --name agw-quickstart
See also: rvennam's agentgateway POC demo (gist).
Versions
Built and verified on:
v2026.7.00.5.2v1.5.026.3Versions
Built and verified on:
v1.5.026.3v2026.7.00.5.2