By the end you have the whole Solo Enterprise agentic platform running on one kind
cluster: Keycloak as the single OIDC issuer, Solo Enterprise for kagent, the OTel
stack and the Enterprise UI, in-cluster AgentRegistry, and Istio ambient with
enterprise agentgateway as the waypoint data plane, all reachable through one ingress
at http://*.localtest.me with no port-forwards. You install it product by
product, in dependency order, and for each one you get the real Helm command or YAML,
why each flag is there, and the configuration notes that make it click into place.
Before you begin
Prerequisites
docker,kind,kubectl,helm,jq,gcloudarctlv2026.6.1, the enterprise CLIgcloud auth loginplushelm registry loginto the Solo public registry (the charts are public, the OCI pull still needs a token)- Solo Enterprise license keys for kagent, Solo Istio, and enterprise agentgateway
ANTHROPIC_API_KEY(the kagent chart wires it as the default model provider)
What this installs, in order
- kind cluster, a host OCI registry, and the Gateway API CRDs
- Keycloak, the single OIDC issuer (realm, clients, personas)
- Solo Enterprise for kagent
- OTel telemetry and the Enterprise UI (Solo Enterprise management chart)
- AgentRegistry, in-cluster
- Istio ambient (Gloo Operator and a ServiceMeshController) and enterprise agentgateway
- An ingress Gateway and the consoles at
*.localtest.me
What the platform is
Seven products, each in its own namespace, wired together by one OIDC issuer and one
ingress. Keycloak issues the tokens. Solo Enterprise for kagent is the agent runtime.
The Solo Enterprise management chart brings the OTel collectors, ClickHouse, and the
Enterprise UI (Dashboard, Agents, Tracing, Access Policies). AgentRegistry is the
in-cluster catalog and control plane. Istio ambient plus enterprise agentgateway is
the data plane that makes a kagent AccessPolicy enforceable at a waypoint. And the
enterprise-agentgateway ingress Gateway puts the consoles on *.localtest.me
with no port-forwards.
| Namespace | What lives there |
|---|---|
keycloak | Keycloak (dev mode), the single OIDC issuer, realm agentregistry |
kagent | Solo Enterprise for kagent: the controller, bundled tool server, the OBO signing key |
solo-enterprise | Solo Enterprise management chart: ClickHouse, OTel collectors, the Enterprise UI |
agentregistry-system | AgentRegistry server plus its bundled Postgres, ClickHouse, and telemetry collector |
agentgateway-system | enterprise agentgateway controller and the ingress Gateway |
gloo-system | Gloo Operator and the AgentgatewayParameters for the waypoint GatewayClass |
istio-system | Solo Istio in ambient mode: istiod, ztunnel, the CNI |
Prerequisites
Tooling first: docker, kind, kubectl,
helm, jq, openssl, plus gcloud
for the Solo public Helm charts and arctl for the registry CLI. The
Solo charts live in a public Google Artifact Registry, but a Helm OCI pull still
needs a token, so authenticate gcloud once and log Helm in to the
registry. arctl is pinned to v2026.6.1, the in-cluster
registry release (the old local Docker daemon is gone).
# gcloud + helm OCI login for the Solo public charts
gcloud auth login
gcloud auth print-access-token \
| helm registry login -u oauth2accesstoken --password-stdin us-docker.pkg.dev
# arctl pinned to the in-cluster release
curl -sSL https://storage.googleapis.com/agentregistry-enterprise/install.sh \
| ARCTL_VERSION=v2026.6.1 sh
export PATH="$HOME/.arctl/bin:$PATH"
Three licenses are needed, all enterprise: Solo Enterprise for kagent, Solo Istio, and enterprise agentgateway. In this lab they can all be one Solo license key. You also need an Anthropic API key for the kagent default model provider. AgentRegistry itself needs no license.
us-docker.pkg.dev/solo-public
are public, but Helm's OCI client still presents a bearer token on pull, so the
helm registry login step is not optional. The Solo Istio container
images are a separate problem handled in step 6: kind nodes have no
registry credentials, so those images are pre-pulled on the host and loaded into the
cluster.
Running it
Every step below is automated by the scripts that ship with
the AgentCore lab. ./scripts/setup.sh
runs the whole chain in order, or run each step on its own:
| Step | Script |
|---|---|
| Prerequisites | ./scripts/00-prereqs.sh |
| Step 1 · cluster + registry + Gateway API | ./scripts/01-cluster.sh |
| Step 2 · Keycloak | ./scripts/02-keycloak.sh |
| Step 3 · Solo Enterprise for kagent | ./scripts/03-kagent.sh |
| Step 4 · OTel + Enterprise UI | ./scripts/03b-telemetry.sh |
| Step 5 · AgentRegistry | ./scripts/04-agentregistry.sh |
| Step 6 · Istio ambient + agentgateway | ./scripts/05-waypoint.sh |
| Step 7 · ingress Gateway | ./scripts/06-gateway.sh |
| Connect arctl | ./scripts/connect.sh |
Step 1: kind cluster, host OCI registry, Gateway API CRDs
Run: ./scripts/00-prereqs.sh then ./scripts/01-cluster.sh
The kind config does two things that matter for the whole platform. It maps host
ports 80 and 443 onto NodePorts 30080 and 30443, so once the ingress Gateway is up
the consoles answer at http://*.localtest.me with nothing forwarded. And
it points containerd at /etc/containerd/certs.d so the nodes can pull
localhost:5001/... images from the host-side registry.
yamlkind/cluster.yaml
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
name: agentcore-demo
nodes:
- role: control-plane
# Map host :80/:443 to the agentgateway ingress (a NodePort Service on
# 30080/30443), so the consoles are reachable at http://*.localtest.me with
# no kubectl port-forward. *.localtest.me resolves to 127.0.0.1 via public DNS.
extraPortMappings:
- containerPort: 30080
hostPort: 80
protocol: TCP
- containerPort: 30443
hostPort: 443
protocol: TCP
- role: worker
# Read per-registry config from /etc/containerd/certs.d so the nodes can pull
# localhost:5001/... from the host-side kind-registry container.
containerdConfigPatches:
- |-
[plugins."io.containerd.grpc.v1.cri".registry]
config_path = "/etc/containerd/certs.d"
networking:
apiServerAddress: "127.0.0.1"
The host registry follows the canonical kind local-registry recipe: a
registry:2 container on 127.0.0.1:5001, a per-node
hosts.toml that resolves localhost:5001 to
kind-registry:5000, a local-registry-hosting ConfigMap that
advertises it to the cluster, and joining the registry to the kind
docker network so the nodes can reach it by name. Then the standard Gateway API CRDs.
bashhost registry + Gateway API CRDs (from 01-cluster.sh)
# 1. registry container on the host
docker run -d --restart=always -p 127.0.0.1:5001:5000 --name kind-registry registry:2
# 2. per-node hosts.toml: localhost:5001 -> kind-registry:5000
for node in $(kind get nodes --name agentcore-demo); do
docker exec "$node" mkdir -p /etc/containerd/certs.d/localhost:5001
cat <<EOF | docker exec -i "$node" cp /dev/stdin /etc/containerd/certs.d/localhost:5001/hosts.toml
[host."http://kind-registry:5000"]
EOF
done
# 3. join the registry to the kind docker network
docker network connect kind kind-registry
# 4. advertise it to the cluster
kubectl apply -f - <<EOF
apiVersion: v1
kind: ConfigMap
metadata: { name: local-registry-hosting, namespace: kube-public }
data:
localRegistryHosting.v1: |
host: "localhost:5001"
help: "https://kind.sigs.k8s.io/docs/user/local-registry/"
EOF
# 5. Gateway API standard CRDs
kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.4.0/standard-install.yaml
Step 2: Keycloak, the single OIDC issuer
Run: ./scripts/02-keycloak.sh
Keycloak is the one issuer for the whole platform, both AgentRegistry and kagent
validate against it. It runs in dev mode as a StatefulSet with
--import-realm, with bootstrap admin admin/admin. The key
setting is KC_HOSTNAME=http://keycloak.localtest.me: that stamps the
token iss as the gateway hostname so the same issuer string works in the
browser, on the host, and (via the hostAlias in step 5) inside the cluster.
yamlyaml/keycloak/keycloak.yaml (the env that matters)
containers:
- name: keycloak
image: quay.io/keycloak/keycloak:26.3
args: [start-dev, --import-realm]
env:
- { name: KC_BOOTSTRAP_ADMIN_USERNAME, value: admin }
- { name: KC_BOOTSTRAP_ADMIN_PASSWORD, value: admin }
- { name: KC_HTTP_RELATIVE_PATH, value: / }
# Issuer = http://keycloak.localtest.me (port 80). The SAME iss works in the
# browser (via the gateway) and in-cluster (via a hostAlias to this ClusterIP).
- { name: KC_HOSTNAME, value: "http://keycloak.localtest.me" }
- { name: KC_HOSTNAME_STRICT, value: "false" }
- { name: KC_HOSTNAME_BACKCHANNEL_DYNAMIC, value: "true" }
- { name: KC_PROXY_HEADERS, value: "xforwarded" }
- { name: KC_HEALTH_ENABLED, value: "true" }
volumeMounts:
- { name: realm-import, mountPath: /opt/keycloak/data/import, readOnly: true }
The realm is agentregistry. It carries the clients both products need,
one group, a group-membership mapper, and the per-client audience mappers. The clients:
| Client | Role |
|---|---|
ar-backend | AgentRegistry confidential validator (no flows enabled, validation only) |
ar-ui | AgentRegistry UI, public, authcode + PKCE |
ar-cli-password | scripted arctl login, public, password grant (audience ar-backend) |
kagent-backend | kagent confidential validator, service accounts enabled (audience kagent-backend) |
kagent-ui | Enterprise UI frontend, public, authcode + PKCE (audience kagent-backend) |
Two realm details run through everything that follows. First, every client carries a
group-membership mapper that emits the user's groups as a claim named
Groups, capital G. Every role-mapper in the platform
reads claims.Groups, so the realm emits the claim with a capital G to match. Second,
the audience mappers are split on purpose: the ar-* clients stamp
aud: ar-backend, while kagent-ui and
kagent-cli-password stamp aud: kagent-backend, so each product
validates tokens against its own audience.
jsonthe two mapper shapes on each client (from agentregistry-realm.json)
{
"name": "groups",
"protocol": "openid-connect",
"protocolMapper": "oidc-group-membership-mapper",
"config": {
"claim.name": "Groups", // capital G, every role mapper reads claims.Groups
"full.path": "false",
"id.token.claim": "true",
"access.token.claim": "true",
"userinfo.token.claim": "true"
}
},
{
"name": "kagent-backend-audience", // ar-* clients use included.client.audience: ar-backend
"protocol": "openid-connect",
"protocolMapper": "oidc-audience-mapper",
"config": {
"included.client.audience": "kagent-backend",
"id.token.claim": "false",
"access.token.claim": "true"
}
}
Personas. The realm ships one built-in superuser:
admin-user / password, in group admins. Group
admins maps to the kagent global.Admin role and to the
AgentRegistry superuser. To model real teams you add more realm users, say
alice and bob, and place them in groups like
readers and writers; the role-mapper then turns the
Groups claim into product roles per user. The
governance lab does exactly this, with
per-team catalog lanes built on these same groups.
Keycloak generates the two confidential client secrets (ar-backend and
kagent-backend) at realm import. The next charts consume them, so scrape
them from the Keycloak admin API once Keycloak is up.
bashscrape a confidential client secret (from lib.sh)
# admin token on the master realm, then the client secret on agentregistry
kubectl -n keycloak port-forward svc/keycloak 18099:8080 &
ADM=$(curl -s -X POST http://localhost:18099/realms/master/protocol/openid-connect/token \
-d 'grant_type=password&client_id=admin-cli&username=admin&password=admin' | jq -r .access_token)
CID=$(curl -s -H "Authorization: Bearer $ADM" \
"http://localhost:18099/admin/realms/agentregistry/clients?clientId=kagent-backend" | jq -r '.[0].id')
curl -s -H "Authorization: Bearer $ADM" \
"http://localhost:18099/admin/realms/agentregistry/clients/$CID/client-secret" | jq -r .value
The two DNS facts
Two name-resolution facts recur in every step, so it is worth stating them once.
1. *.localtest.me resolves to 127.0.0.1 via public
DNS. No /etc/hosts entry is needed. A browser request to
http://keycloak.localtest.me hits host port 80, the kind extraPortMapping
forwards it to NodePort 30080, the ingress Gateway picks it up, an HTTPRoute matches
the hostname, and it lands on the backing Service.
2. In-cluster pods cannot resolve keycloak.localtest.me.
Public DNS sends it to 127.0.0.1, which inside a pod is the pod itself.
Every pod that does OIDC discovery (the AgentRegistry server, the kagent controller,
the Enterprise UI) therefore gets a hostAlias that maps
keycloak.localtest.me to Keycloak's ClusterIP. The
bridge_keycloak_hostalias helper patches it onto each deployment, then
the pod rolls and OIDC discovery resolves.
bashthe hostAlias bridge (bridge_keycloak_hostalias in lib.sh)
# map keycloak.localtest.me -> Keycloak's ClusterIP on a deployment's pods
ip=$(kubectl -n keycloak get svc keycloak -o jsonpath='{.spec.clusterIP}')
kubectl -n "$ns" patch deploy "$dep" --type=json -p \
"[{\"op\":\"replace\",\"path\":\"/spec/template/spec/hostAliases\",\
\"value\":[{\"ip\":\"$ip\",\"hostnames\":[\"keycloak.localtest.me\"]}]}]"
Step 3: Solo Enterprise for kagent
Run: ./scripts/03-kagent.sh
kagent is installed as two charts, CRDs then controller, both at
0.4.3 from oci://us-docker.pkg.dev/solo-public/kagent-enterprise-helm/charts.
The provider is Anthropic. The controller runs an OIDC access-token interceptor and
refuses to start without a discoverable issuer, so oidc.issuer points at
the Keycloak issuer, oidc.clientId is kagent-backend, and
its secret comes from a Secret reference. oidc.skipOBO=false turns on
on-behalf-of token exchange, which needs an RSA signing key in a Secret named
jwt in the kagent namespace.
bashOBO signing key + OIDC secret (from 03-kagent.sh)
# RSA signing key the controller uses to mint OBO tokens; Secret MUST be named jwt
openssl genpkey -algorithm RSA -out jwt.pem -pkeyopt rsa_keygen_bits:2048
kubectl -n kagent create secret generic jwt --from-file=jwt=jwt.pem
# the kagent-backend confidential client secret scraped in step 2
kubectl -n kagent create secret generic kagent-enterprise-oidc-secret \
--from-literal=clientSecret="$KAGENT_BACKEND_SECRET"
bashinstall the controller (from 03-kagent.sh)
helm upgrade --install kagent-crds \
oci://us-docker.pkg.dev/solo-public/kagent-enterprise-helm/charts/kagent-enterprise-crds \
--namespace kagent --create-namespace --version 0.4.3 --wait
helm upgrade --install kagent \
oci://us-docker.pkg.dev/solo-public/kagent-enterprise-helm/charts/kagent-enterprise \
--namespace kagent --version 0.4.3 \
--set global.licensing.licenseKey="$SOLO_LICENSE_KEY" \
--set providers.default=anthropic \
--set providers.anthropic.apiKey="$ANTHROPIC_API_KEY" \
--set oidc.issuer="http://keycloak.localtest.me/realms/agentregistry" \
--set oidc.clientId=kagent-backend \
--set oidc.secretRef=kagent-enterprise-oidc-secret \
--set oidc.secretKey=clientSecret \
--set oidc.skipOBO=false \
--set-json 'controller.envFrom=[{"configMapRef":{"name":"kagent-enterprise-config"}}]' \
--set kagent-tools.enabled=true \
--set ui.enabled=false \
--set otel.tracing.enabled=true \
--set otel.tracing.exporter.otlp.endpoint="http://solo-enterprise-telemetry-collector.solo-enterprise.svc.cluster.local:4317" \
--set-json 'rbac.roleMapping={"roleMapper":"claims.Groups.transformList(i, v, v in rolesMap, rolesMap[v])","roleMappings":{"admins":"global.Admin","readers":"global.Reader","writers":"global.Writer"}}'
The role-mapper CEL reads claims.Groups and maps group
admins to global.Admin. The install does not pass
--wait: the controller does OIDC discovery at startup against
keycloak.localtest.me, which a pod cannot resolve until the hostAlias is
added, so it would never go Ready under --wait. The script applies the
chart, adds the hostAlias to the controller, then waits. The OTel tracing settings
also make the controller stamp the collector endpoint into every agent it later
deploys, so spans export once an agent runs.
Step 4: OTel telemetry and the Enterprise UI
Run: ./scripts/03b-telemetry.sh
The Solo Enterprise management chart (0.4.3 from
oci://us-docker.pkg.dev/solo-public/solo-enterprise-helm/charts/management)
bundles ClickHouse, the OTel collectors, and the Enterprise UI served at
kagent.localtest.me with its Dashboard, Agents, Tracing, and Access
Policies views. The trace path is: an agent exports OTLP to the telemetry collector,
the collector writes to ClickHouse, and the Tracing tab reads it back.
bashinstall the management chart (from 03b-telemetry.sh)
# the Enterprise UI backend validates with the kagent-backend client secret
kubectl -n solo-enterprise create secret generic ui-backend-oidc-secret \
--from-literal=clientSecret="$KAGENT_BACKEND_SECRET"
helm upgrade --install solo-mgmt \
oci://us-docker.pkg.dev/solo-public/solo-enterprise-helm/charts/management \
--namespace solo-enterprise --create-namespace --version 0.4.3 \
--set cluster=agentcore-demo \
--set products.kagent.enabled=true \
--set products.kagent.namespace=kagent \
--set products.agentgateway.namespace=agentgateway-system \
--set licensing.licenseKey="$SOLO_LICENSE_KEY" \
--set clickhouse.persistentVolume.enabled=false \
--set oidc.issuer="http://keycloak.localtest.me/realms/agentregistry" \
--set ui.frontend.oidc.clientId=kagent-ui \
--set ui.backend.oidc.clientId=kagent-backend \
--set-json 'rbac.roleMapping={"roleMapper":"claims.Groups.transformList(i, v, v in rolesMap, rolesMap[v])","roleMappings":{"admins":"global.Admin","readers":"global.Reader","writers":"global.Writer"}}'
The two flags that make the UI manage the local cluster are
products.kagent.enabled=true and
products.kagent.namespace=kagent; without the namespace pointing at the
local kagent install the UI has nothing to manage.
ui.frontend.oidc.clientId=kagent-ui is the public PKCE client for the
browser; ui.backend.oidc.clientId=kagent-backend is the confidential
client whose secret is the Secret above. ClickHouse persistence is off for the demo.
As with kagent, there is no --wait: the UI backend does OIDC discovery,
so the script installs, adds the hostAlias to solo-enterprise-ui, then
waits for readiness.
Step 5: AgentRegistry, in-cluster
Run: ./scripts/04-agentregistry.sh
AgentRegistry at 2026.6.1 from
oci://us-docker.pkg.dev/solo-public/agentregistry-enterprise/helm/agentregistry-enterprise
is the in-cluster server with a bundled Postgres, ClickHouse, and telemetry
collector. No license is required. OIDC is wired to the same realm:
oidc.clientId=ar-backend with its scraped secret as the validator,
oidc.publicClientId=ar-ui for the browser, oidc.roleClaim=Groups,
and oidc.superuserRole=admins. Postgres is bundled. The server Service
listens on :12121 and stays ClusterIP; the ingress in step 7 exposes it.
bashinstall AgentRegistry (from 04-agentregistry.sh)
helm upgrade --install agentregistry \
oci://us-docker.pkg.dev/solo-public/agentregistry-enterprise/helm/agentregistry-enterprise \
--namespace agentregistry-system --create-namespace --version 2026.6.1 \
--set oidc.issuer="http://keycloak.localtest.me/realms/agentregistry" \
--set oidc.clientId=ar-backend \
--set oidc.clientSecret="$AR_BACKEND_SECRET" \
--set oidc.publicClientId=ar-ui \
--set oidc.roleClaim=Groups \
--set oidc.superuserRole=admins \
--set kagent.outboundAuth.oidc.clientId=kagent-backend \
--set kagent.outboundAuth.oidc.clientSecret="$KAGENT_BACKEND_SECRET" \
--set database.postgres.type=bundled
kagent.outboundAuth at kagent-backend.
When the registry later deploys an agent to the kagent runtime it mints a
client-credentials token to call the kagent controller, and that token needs the kagent
audience. Set kagent.outboundAuth.oidc to kagent-backend, the
service-account client whose token carries aud: kagent-backend and
Groups: [admins], so the cross-product call is authorized end to end. The server
gets the same hostAlias treatment, then the script waits for it to come up.
Step 6: Istio ambient and enterprise agentgateway, the waypoint data plane
Run: ./scripts/05-waypoint.sh
A kagent AccessPolicy is inert on its own. Enforcement happens at an agentgateway
waypoint sitting in front of the workload, and a waypoint needs Istio in ambient
mode. This step stands up that data plane: ambient Istio via the Gloo Operator and a
ServiceMeshController, then enterprise agentgateway, which provides the
enterprise-agentgateway and enterprise-agentgateway-waypoint
GatewayClasses. There is no workload here; this is the data plane the
AgentCore lab later enforces policy on.
First the Gateway API experimental CRDs, which ambient waypoints
need. The standard install from step 1 (Gateway API at 1.5 and up) ships a
safe-upgrades.gateway.networking.k8s.io ValidatingAdmissionPolicy and
Binding that forbid layering the experimental channel on top, so delete both first,
then apply the experimental CRDs. The deletion is eventually consistent, so retry the
apply with backoff, re-dropping the policy on each denial.
bashexperimental Gateway API CRDs (from 05-waypoint.sh)
# drop the safe-upgrades policy that blocks the experimental channel
kubectl delete validatingadmissionpolicybinding safe-upgrades.gateway.networking.k8s.io --ignore-not-found
kubectl delete validatingadmissionpolicy safe-upgrades.gateway.networking.k8s.io --ignore-not-found
# then apply experimental (retry with backoff; re-drop on each denial)
kubectl apply --server-side --force-conflicts \
-f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.4.0/experimental-install.yaml
Then the Gloo Operator (0.5.2 from
oci://us-docker.pkg.dev/solo-public/gloo-operator-helm/gloo-operator) and
a ServiceMeshController in ambient mode. The SMC version
field is the stripped form 1.29.2-patch0; the operator appends
-solo for the Standard distribution. Cluster and network are set so the
mesh identity is consistent.
yamlServiceMeshController (ambient), from 05-waypoint.sh
apiVersion: operator.gloo.solo.io/v1
kind: ServiceMeshController
metadata:
name: managed-istio
spec:
cluster: agentcore-demo
network: agentcore-demo
trustDomain: cluster.local
version: "1.29.2-patch0" # operator appends -solo for distribution: Standard
dataplaneMode: Ambient
distribution: Standard
installNamespace: istio-system
scalingProfile: Demo
trafficCaptureMode: Auto
onConflict: Force
image:
registry: us-docker.pkg.dev
repository: soloio-img/istio
kind load them into the cluster before the ServiceMeshController reconciles. The
operator deploys the -solo distribution tags
(pilot:1.29.2-patch0-solo), so pull those exact tags.
bashpre-pull + kind load the Solo Istio images (from 05-waypoint.sh)
gcloud auth configure-docker us-docker.pkg.dev --quiet
for img in pilot proxyv2 install-cni ztunnel; do
docker pull "us-docker.pkg.dev/soloio-img/istio/$img:1.29.2-patch0-solo"
kind load docker-image "us-docker.pkg.dev/soloio-img/istio/$img:1.29.2-patch0-solo" --name agentcore-demo
done
Then enterprise agentgateway at v2026.5.1 (CRDs then controller, from
oci://us-docker.pkg.dev/solo-public/enterprise-agentgateway/charts),
which registers the two GatewayClasses. The last piece is the waypoint identity.
bashenterprise agentgateway (from 05-waypoint.sh)
helm upgrade --install agentgateway-crds \
oci://us-docker.pkg.dev/solo-public/enterprise-agentgateway/charts/enterprise-agentgateway-crds \
--namespace agentgateway-system --create-namespace --version v2026.5.1 --wait
helm upgrade --install agentgateway \
oci://us-docker.pkg.dev/solo-public/enterprise-agentgateway/charts/enterprise-agentgateway \
--namespace agentgateway-system --version v2026.5.1 \
--set licensing.licenseKey="$AGENTGATEWAY_LICENSE_KEY" --wait
CLUSTER_ID and NETWORK on the
waypoint GatewayClass. Every waypoint the kagent translator later creates takes its
identity from CLUSTER_ID and NETWORK, matched to istiod, so it fetches
its cert and ztunnel can classify the service. Set them once via an
AgentgatewayParameters referenced by the waypoint GatewayClass, and every
waypoint inherits them, no per-deployment patching.
yamlwaypoint identity params + GatewayClass wiring (from 05-waypoint.sh)
apiVersion: agentgateway.dev/v1alpha1
kind: AgentgatewayParameters
metadata:
name: waypoint-params
namespace: gloo-system
spec:
env:
- { name: CLUSTER_ID, value: "agentcore-demo" }
- { name: NETWORK, value: "agentcore-demo" }
---
# then point the waypoint GatewayClass at it
kubectl patch gatewayclass enterprise-agentgateway-waypoint --type=merge -p \
'{"spec":{"parametersRef":{"group":"agentgateway.dev","kind":"AgentgatewayParameters","name":"waypoint-params","namespace":"gloo-system"}}}'
Finally, enroll the kagent namespace in the ambient mesh so its workloads can sit behind a waypoint:
kubectl label ns kagent istio.io/dataplane-mode=ambient --overwrite
kubectl label ns kagent topology.istio.io/network=agentcore-demo --overwrite
Step 7: the ingress Gateway and the *.localtest.me consoles
Run: ./scripts/06-gateway.sh
The last install is the ingress that ties the host port mapping from step 1 to the
services. A Gateway on the enterprise-agentgateway
GatewayClass with an HTTP :80 listener that accepts routes from all namespaces, plus
three HTTPRoutes that map each console hostname to its Service: Keycloak,
AgentRegistry on :12121, and the Enterprise UI.
yamlthe ingress Gateway + three HTTPRoutes (from 06-gateway.sh)
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: agentgateway-proxy
namespace: agentgateway-system
spec:
gatewayClassName: enterprise-agentgateway
listeners:
- name: http
protocol: HTTP
port: 80
allowedRoutes:
namespaces: { from: All }
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata: { name: keycloak, namespace: keycloak }
spec:
parentRefs: [{ name: agentgateway-proxy, namespace: agentgateway-system }]
hostnames: ["keycloak.localtest.me"]
rules:
- matches: [{ path: { type: PathPrefix, value: / } }]
backendRefs: [{ name: keycloak, port: 80 }]
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata: { name: agentregistry, namespace: agentregistry-system }
spec:
parentRefs: [{ name: agentgateway-proxy, namespace: agentgateway-system }]
hostnames: ["agentregistry.localtest.me"]
rules:
- matches: [{ path: { type: PathPrefix, value: / } }]
backendRefs: [{ name: agentregistry-enterprise-server, port: 12121 }]
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata: { name: kagent-ui, namespace: solo-enterprise }
spec:
parentRefs: [{ name: agentgateway-proxy, namespace: agentgateway-system }]
hostnames: ["kagent.localtest.me"]
rules:
- matches: [{ path: { type: PathPrefix, value: / } }]
backendRefs: [{ name: solo-enterprise-ui, port: 80 }]
The gateway controller provisions a Service for the Gateway. Patch it to
type: NodePort with nodePort: 30080 so kind's host :80
mapping reaches it, then wait for the Gateway to report Programmed.
bashpin the gateway Service to NodePort 30080 (from 06-gateway.sh)
GW_SVC=$(kubectl -n agentgateway-system get svc \
-l gateway.networking.k8s.io/gateway-name=agentgateway-proxy -o name | head -1)
kubectl -n agentgateway-system patch "$GW_SVC" --type=json -p "[
{\"op\":\"replace\",\"path\":\"/spec/type\",\"value\":\"NodePort\"},
{\"op\":\"replace\",\"path\":\"/spec/ports/0/nodePort\",\"value\":30080}
]"
kubectl -n agentgateway-system wait --for=condition=Programmed gateway/agentgateway-proxy --timeout=120s
With the gateway Programmed the platform is reachable at three URLs, no port-forwards:
http://keycloak.localtest.me, http://agentregistry.localtest.me,
and http://kagent.localtest.me (log in as admin-user /
password).
Connect arctl
Run: ./scripts/connect.sh
The platform is up, so the registry CLI can log in. arctl user login uses
the password-credentials flow against the ar-cli-password client, against
the keycloak.localtest.me issuer. This needs the gateway up first, since
that is what makes the issuer resolve from the host. The token lands in the OS
keychain, so subsequent arctl commands need no per-call token.
arctl user login \
--oidc-flow password-credentials \
--oidc-issuer-url http://keycloak.localtest.me/realms/agentregistry \
--oidc-client-id ar-cli-password \
--oidc-username admin-user --oidc-password password
The configuration notes that matter
arctl can log in,
because that is what makes the issuer resolve from the host. Run them out of order and
the symptom is rarely the real cause.
- One namespace per product keeps the blast radius and the RBAC
legible:
keycloak,kagent,solo-enterprise,agentregistry-system,agentgateway-system,gloo-system,istio-system. - The
Groupsclaim case. The realm emits a capital-GGroupsclaim and every role-mapper CEL readsclaims.Groups; keep the two aligned and group membership maps cleanly to roles. - The hostAlias issuer bridge goes on every pod that does OIDC
discovery (the registry server, the kagent controller, the Enterprise UI), so each
resolves
keycloak.localtest.meto Keycloak's ClusterIP in-cluster. With the registry running inside the cluster, this single alias is all the issuer bridging the platform needs. - AgentRegistry
kagent.outboundAuthpoints at thekagent-backendservice-account client, so the deploy-to-kagent call carries the kagent audience. - The kagent controller
envFrompulls thekagent-enterprise-configConfigMap, so the controller picks up its OIDC settings and validates forwarded tokens. CLUSTER_IDandNETWORKon the waypoint GatewayClass (via AgentgatewayParameters) give each waypoint its identity, so it fetches its cert and ztunnel classifies it.- Load the
-soloIstio images into kind withkind loadso the nodes have them locally; the operator uses the-solodistribution tags. - Layer the experimental Gateway API CRDs that ambient waypoints use; the standard install's safe-upgrades policy is removed first so the experimental channel applies cleanly.
- The
istiodalias Service points the well-known CA addressistiod.istio-system.svc:15012at the managedistiod-gloo, so the waypoint resolves the Istio CA.
Where to take it next
- The AgentCore lab runs on this exact
platform: scaffold an MCP server, a skill, and an agent with
arctl, publish them, and deploy the same agent to Solo Enterprise for kagent and AWS Bedrock AgentCore, with tool-level authorization enforced at an agentgateway waypoint. - The governance lab puts registry identity, AccessPolicies, and per-team catalog visibility on top, using the same Keycloak realm and groups.
- AgentRegistry end to end, part 1 walks
the whole
arctllifecycle frominitto a hosted, OIDC-protected agent.
Versions
Built and verified on:
v2026.6.10.4.32026.6.10.4.30.5.21.29.2-patch0v2026.5.1v1.4.026.3latest