The hands-on companion to the episode. By the end you will have provisioned a Kubernetes cluster with Cluster API (or GKE), deployed a full OpenTelemetry + Dynatrace + Istio ambient + otel-demo stack, wired kagent and the mesh to collectors in the
defaultnamespace, run an SRE agent that troubleshoots a broken gRPC route, and composed a BMAD crew that builds a real Todo app on GitHub — issues, milestone, code, PR and review — all watched live in two Dynatrace dashboards.
Portability first. This repo ships no environment-specific values in git —
no IPs, tokens, tenants or secrets. Everything variable lives in deploy/.env
(copied from deploy/.env.example) and is hydrated at
apply time. You can run the whole thing in your cluster.
Validated on the episode cluster (${K8S_CLUSTER_NAME}, kagent v0.9.11, models
self-hosted on the Mac Studio ${OLLAMA_HOST} over Ollama, qwen3.6:latest). Real
outputs are quoted in §8 Validation.
- Prerequisites
- Provision the cluster (Cluster API or GKE)
- Deploy the platform stack
- Collectors in the default namespace + wiring
- Step 1 — the brain: a ModelConfig for Ollama
- Step 2 — an SRE agent with MCP + a gRPC-route demo
- Step 3 — a BMAD crew that ships a Todo app on GitHub
- Observe it all — Dynatrace dashboards
- Validation — what we actually saw
- Cleanup
- Troubleshooting
-
Tools:
kubectl,helm3.x,git,clusterctl(for the Cluster API path), and optionally thekagentCLI andgcloud(for the GKE path). -
Accounts / endpoints:
- A Proxmox cluster you control or a GKE project.
- A Dynatrace tenant (SaaS) + two API tokens (ingest + data-ingest).
- A GitHub account and a fine-grained PAT scoped to one demo repo (§6).
- An Ollama endpoint hosting a tool-calling model (this episode: the Mac
Studio at
${OLLAMA_HOST},qwen3.6:latest). kagent runs on tool calls — a model that can't call tools will fail §5 and §6.
-
Config: copy the env template and fill in your own values — this is the only place your environment-specific data lives:
cp deploy/.env.example deploy/.env $EDITOR deploy/.env # OLLAMA_HOST, K8S_CLUSTER_NAME, DT_*, GITHUB_*, ... set -a && . deploy/.env && set +a
deploy/.envis git-ignored. Never commit real endpoints, tenants or tokens; a CI grep-gate fails the build on a stray IP /dt0c01./github_pat_.
Two documented on-ramps to a conformant cluster; everything downstream is identical once you have a kubeconfig. Model hosting stays external on the Mac Studio Ollama endpoint in both paths — the cluster never hosts the LLM.
You supply your own Proxmox details in deploy/.env (PROXMOX_URL, credentials
as a Secret, node/template IDs, IP ranges). No real IPs live in the repo — the
render templates carry placeholder ranges only.
# bootstrap the management cluster with the Proxmox + in-cluster IPAM providers
clusterctl init --infrastructure proxmox --ipam in-cluster
# render your workload cluster from values.env, then apply
deploy/01-provision-capi.sh # renders clusters/${K8S_CLUSTER_NAME}/*.yaml and appliesThe provisioning scripts under
deploy/are owned by ProxOps and follow the org's provenobservable-llm/Sympozium CAPI pattern. This tutorial documents the contract; the scripts hold the exact commands.
No Proxmox? Bring a managed cluster and skip straight to §2:
gcloud container clusters create ${K8S_CLUSTER_NAME} \
--project ${GKE_PROJECT} --region ${GKE_REGION} \
--num-nodes 3 --machine-type e2-standard-4
gcloud container clusters get-credentials ${K8S_CLUSTER_NAME} --region ${GKE_REGION}One ordered, idempotent deploy — every step is kubectl apply + an explicit
kubectl wait, so it is safe to re-run. Ordering is load-bearing because of CRD +
webhook readiness.
deploy/02-platform-stack.sh # runs the ordered install below| # | Component | Notes |
|---|---|---|
| 1 | cert-manager | webhooks/CA for the OTel Operator; wait Ready first |
| 2 | OpenTelemetry Operator | manages our collector CRs (§3); depends on cert-manager |
| 3 | Dynatrace Operator v1.9.1 |
PINNED, mode kubernetes-monitoring — k8s entity/enrichment + log/metric ingest, not full-stack OneAgent on the apps |
| 4 | Gateway API CRDs + GAMMA | standard channel Gateway API; GAMMA = mesh binding of Service→routes for Istio ambient |
| 5 | Istio (ambient profile) | ztunnel + istio-cni + istiod; ambient dataplane |
| 6 | Collectors in default ns |
DaemonSet + StatefulSet (§3) |
| 7 | kagent (Helm) | values overridden to export to the default-ns collector (§3) |
| 8 | otel-demo | native OTel SDK instrumentation, no Dynatrace auto-injection |
Why otel-demo stays OTel-native (D4). The story is "OTel-native app → our
collector → Dynatrace backend." We do not layer Dynatrace OneAgent on the demo
workloads — that would double-instrument and skew the picture. DynaKube stays in
kubernetes-monitoring mode so it enriches k8s entities without instrumenting the
demo. This is the honest, portable demo.
The architectural crux: two collectors live in the default
namespace, and every workload points at them — there are no per-namespace collectors.
node logs/traces │ kagent OTLP + Istio ambient
▼ │ OTLP
┌─────────────────────────┐ forward ┌─────────────────▼────────────┐
│ otel-agent (DaemonSet) │─────OTLP────▶ │ otel-gateway (StatefulSet) │──OTLP/HTTP──▶ Dynatrace
│ per-node: hostmetrics, │ │ cluster metrics, tail-sampling│ (+Api-Token)
│ filelog, k8sattributes │ │ SINGLE Dynatrace egress │
└─────────────────────────┘ │ (one credential surface) │
└──────────────────────────────┘
DynaKube ActiveGate (kubernetes-monitoring) ─────────────────────────────▶ Dynatrace (k8s metrics)
Topology note. kagent and Istio ambient export directly to the StatefulSet gateway (the sole Dynatrace egress). The DaemonSet handles node-local logs/traces + host/kubelet metrics and forwards them to the same gateway.
observability/21-otel-agent-daemonset.yaml— the per-node ingest tier.memory_limiterfirst,batchlast,k8sattributes(Kubernetes-only) for enrichment; forwards to the gateway (no DT egress here).observability/22-otel-gateway-statefulset.yaml— the cluster fan-in:k8s_clustermetrics, tail-based sampling (keep errors, slow, and all GenAI spans), and the one OTLP→Dynatrace exporter.
Wiring each producer to the default-ns collector:
-
kagent —
observability/kagent-otel-values.yamlpoints the kagent OTLP exporter atotel-agent-collector.default.svc.cluster.local:4317:helm upgrade kagent oci://ghcr.io/kagent-dev/kagent/helm/kagent \ -n kagent --reuse-values -f observability/kagent-otel-values.yaml
-
Istio ambient — set mesh telemetry (ztunnel/waypoint access logs + metrics) to export to the same default-ns collector via the Telemetry API / meshConfig
defaultProviders→ an OTLP provider named for the default-ns gateway. -
otel-demo — repoint its built-in OTLP endpoint env at the default-ns DaemonSet.
Rationale: one collector plane, one Dynatrace egress, one credential surface.
kagent emits GenAI spans (
gen_ai.*, ADKgcp.vertex.agent) and logs — there are no kagent OTLP metrics, so the agentic-efficiency dashboard is span-derived (seedashboards/EFFICIENCY-SIGNALS.md).
Every agent needs a model, declared as a ModelConfig. First we limit kagent to
just this — prove the brain works before anything else.
→ manifests/01-modelconfig-mac-studio.yaml
apiVersion: kagent.dev/v1alpha2
kind: ModelConfig
metadata:
name: mac-studio-ollama
namespace: kagent
spec:
model: qwen3.6:latest # MUST support function/tool calling
provider: Ollama
ollama:
host: http://${OLLAMA_HOST} # hydrated from deploy/.env — no IP in git
# apiKeySecret / apiKeySecretKey omitted — keyless local Ollamaenvsubst < manifests/01-modelconfig-mac-studio.yaml | kubectl apply -f -
kubectl get modelconfig mac-studio-ollama -n kagentAn agent references the model by name — the field is modelConfig (a plain
string), not modelConfigRef.
An agent with a model but no tools can only talk. Tools come from MCP servers.
We deploy a single SRE networking agent whose tools include the
henrikrexed/mcp-k8s-networking
MCP server (Gateway API / Istio route inspection) plus kagent's built-in k8s tools.
5.1 — deploy the MCP server + register it. The server runs in-cluster and is
registered as a RemoteMCPServer.
→ manifests/sre/10-mcp-k8s-networking-server.yaml
envsubst < manifests/sre/10-mcp-k8s-networking-server.yaml | kubectl apply -f -
kubectl -n kagent get remotemcpserver mcp-k8s-networking # ACCEPTED True, status.tools non-empty5.2 — deploy the SRE agent. toolNames are explicitly enumerated (small-model
discipline — qwen3.6 degrades with a large tool surface).
→ manifests/sre/11-sre-networking-agent.yaml
kubectl apply -f manifests/sre/11-sre-networking-agent.yaml
kubectl get agent sre-networking-agent -n kagent # READY True, ACCEPTED True5.3 — the troubleshooting demo (deterministic). Break a gRPC route in otel-demo,
then let the agent diagnose it.
→ manifests/demos/grpc-misroute/
# Break it: the checkout GRPCRoute backendRef.port is 8080 (frontend HTTP)
# instead of 5050 (checkout gRPC). gRPC/h2c to an HTTP/1 listener fails.
kubectl apply -f manifests/demos/grpc-misroute/broken.yamlAsk the SRE agent (kagent UI or A2A) — the prompts that drive the demo:
- "Traffic to the checkout service is failing — what is broken in the network path?"
- "Show me the gRPC routes for checkout and which backend + port they resolve to."
- "Compare the route's target port to the port the checkout service exposes."
- "What is the root cause and what is the fix?"
The agent reads the GRPCRoute via mcp-k8s-networking, resolves the backendRef to
checkout:8080, compares it to the checkout Service (gRPC 5050), and reports
backendRef.port 8080 should be 5050. Apply the fix and watch traffic recover in
the otel-demo traces in Dynatrace:
kubectl apply -f manifests/demos/grpc-misroute/fix.yamlWhy gRPC specifically: it rides HTTP/2 (h2c in-mesh), so pointing the route at an HTTP/1-only port is a silent, protocol-level failure. The agent has to reason about protocol + port, not just reachability.
The payoff — orchestration. In kagent, an agent can be a tool of another
agent: when one lists another as type: Agent, the orchestrator's LLM delegates
over the A2A protocol. There is no Team/Ensemble CRD — the crew is the
graph of references.
v2 makes the crew operate GitHub. Six BMAD specialists, each acting on the world through kagent's built-in GitHub MCP server, run the full software-delivery loop and build a Todo app — mirroring the Sympozium episode one-for-one.
| Agent | BMAD role | GitHub tools (least-privilege) |
|---|---|---|
bmad-analyst |
Mary — requirements → issues | issues RW, repo RO |
bmad-pm |
John — milestone + plan | issues RW, repo RO |
bmad-architect |
Winston — design → dev-task issues | issues RW, repo RO |
bmad-developer |
Amelia — code → branch → PR | repo RW, PRs RW, issues RO |
bmad-qa |
Quinn — review the PR | PRs RW, issues RW |
bmad-orchestrator |
BMad Master — conduct the chain | type: Agent → the five above, + issues RO |
No agent gets a merge_* tool — merge stays a human/CI step, so the crew can never
merge its own PR.
6.1 — bring your own GitHub PAT → a Kubernetes Secret. The crew authenticates to
GitHub with your own Personal Access Token, which you store as the Secret
github-mcp-token (key token) in the kagent namespace. The token is never
committed — the manifests reference the Secret; only you ever hold the value.
Create a fine-grained PAT (github.com → Settings → Developer settings → Fine-grained tokens), then scope it by which path you're running — least privilege:
| Path | Repository access | Permissions |
|---|---|---|
| Read-only issue demo (analyst lists/reads issues) | only ${GITHUB_DEMO_REPO} |
Issues: Read (+ Metadata: Read, auto) |
| Full write demo (crew opens issues/branches/PRs) | only ${GITHUB_DEMO_REPO} |
Issues: RW · Contents: RW · Pull requests: RW |
# paste YOUR token — it stays only in this Secret, never in a manifest or git
export GITHUB_TOKEN='<your-fine-grained-PAT>' # from GitHub → Fine-grained tokens
kubectl -n kagent create secret generic github-mcp-token \
--from-literal=token="$GITHUB_TOKEN" \
--dry-run=client -o yaml | kubectl apply -f -
unset GITHUB_TOKENThe demo repo is public, so the read-only path authenticates even with a token that has no extra permissions — keep it minimal. Rotate the PAT after the demo, and if a token is ever pasted into a chat/ticket, treat it as compromised and rotate. To rotate: recreate the Secret (same command, new token) and re-apply §6.2.
6.2 — the GitHub MCP server. All six agents reference ONE self-hosted server —
github-mcp (GitHub's official ghcr.io/github/github-mcp-server) registered as the
github-mcp-remote RemoteMCPServer in 07-github-mcp.yaml,
which the crew's kustomization applies in §6.3. It reads the PAT from the github-mcp-token
Secret (§6.1), runs GITHUB_TOOLSETS=issues,pull_requests,repos + GITHUB_READ_ONLY=0,
and least privilege is enforced per agent by the toolNames each Agent lists — no
agent is granted merge_pull_request (merge stays human/CI).
# 07-github-mcp.yaml keeps GITHUB_PERSONAL_ACCESS_TOKEN empty in git; inject at apply:
GITHUB_PAT="$(kubectl -n kagent get secret github-mcp-token -o jsonpath='{.data.token}' | base64 -d)" \
envsubst < manifests/bmad-crew/07-github-mcp.yaml | kubectl apply -f -
# VALIDATION GATE — confirm the server registered the write tool surface
kubectl -n kagent get remotemcpserver github-mcp-remote \
-o jsonpath='{.status.conditions[?(@.type=="Accepted")].status}{" "}{.status.discoveredTools[*].name}'
# → Accepted=True ... list_issues issue_read issue_write create_pull_request create_branch pull_request_read ...This github-mcp-server build uses consolidated tool names (
issue_read/issue_write,pull_request_read/pull_request_review_write) — verify 01–06toolNamesagainst the livestatus.discoveredTools.values-github-mcp.yamldocuments a hosted alternative (token only in the Secret viaheadersFrom) that needs a kagent GitHub-MCP Helm chart not yet published, so the self-hosted server above is what deploys today. One token, one repo = the blast radius.
6.3 — deploy the crew.
kubectl apply -k manifests/bmad-crew
kubectl get agents -n kagent -l app.kubernetes.io/part-of=bmad-crew
# all six: READY True, ACCEPTED True6.4 — run the Todo-app demo. One kickoff to the orchestrator runs the whole chain:
kubectl run a2a --rm -i --restart=Never --image=curlimages/curl:8.10.1 -n kagent \
--command -- curl -s -m 300 -X POST -H 'Content-Type: application/json' \
--data '{"jsonrpc":"2.0","id":"1","method":"message/send","params":{"message":
{"role":"user","messageId":"m1","kind":"message",
"parts":[{"kind":"text","text":"Build a simple Todo app in the '"$GITHUB_DEMO_REPO"' repository. Run the full BMAD workflow: analyst scopes requirements as GitHub issues, PM creates a milestone and plan, architect creates dev-task issues, developer opens a PR with working code, QA reviews the PR. Coordinate the whole chain and give me a summary with links to every issue and PR you created."}]}}}' \
http://kagent-controller.kagent:8083/api/a2a/kagent/bmad-orchestratorExpected artifacts on ${GITHUB_DEMO_REPO}: requirement issues → a "Todo app v1"
milestone → dev-task issues → a feat/todo-app branch + PR → a QA review.
| Dimension | Sympozium BMAD Ensemble |
kagent BMAD crew |
|---|---|---|
| Roster definition | One Ensemble CRD lists agentConfigs[]; controller generates one Agent per entry |
No parent resource — each Agent authored independently |
| Composition style | One-to-many generative (1 manifest → N members) | Peer-to-peer / reference-based graph |
| Transport between agents | Sympozium controller / IPC bridge (delegate_to_persona) |
A2A protocol (JSON-RPC/HTTP) + agent-as-tool |
| Acting on the world | SkillsPack tools | GitHub MCP server (issues, PRs, repo) |
Ensemble = declarative roster; kagent = compositional graph. Same crew, same Todo-app workflow, opposite construction. Keep the crew in one namespace — cross-namespace agent refs still have rough edges (kagent-dev/kagent#841).
Every hop in the crew — invoke_agent, execute_tool (including the GitHub MCP
calls), generate_content — is a span. Two Dynatrace dashboards
(dashboards/) tell the story on live telemetry:
- kagent Health — engine up, runs succeeding vs. erroring, pod/workload resource health (DynaKube k8s metrics).
- kagent Agentic Efficiency
— token usage (
gen_ai.usage.*), tool calls, delegation hops across the A2A graph, latency per turn — all span-derived (dashboards/EFFICIENCY-SIGNALS.md).
Both are templated on ${DT_TENANT} / ${K8S_CLUSTER_NAME} — no tenant baked in.
Deploy them per dashboards/README.md. A watch-out from
building these: gen_ai.usage.* lands on both call_llm and generate_content
spans — sum on one to avoid a 2× token double-count.
kagent exposes each agent as an A2A JSON-RPC endpoint on the controller
(:8083). Invoke with message/send, or use the kagent UI at kagent-ui:8080.
Single agent + MCP. Prompt: "Inspect the cluster with your tools and name three namespaces you observed." → completed, 1323 tokens:
"I observed the
default,kube-system, andistio-systemnamespaces, along with several others includingkagent,kgateway-system,metallb-system…"
The agent read real cluster state through its read-only k8s tools. The model + MCP loop works.
Crew delegation over A2A. The orchestrator log shows it calling qwen3.6, resolving the analyst's A2A card, and delegating over A2A:
POST http://${OLLAMA_HOST}/api/chat 200 (orchestrator decides)
GET http://bmad-analyst.kagent:8080/.well-known/agent-card.json 200
a2a.client.card_resolver - Successfully fetched agent card ... 'name': 'bmad_analyst'
POST http://kagent-controller.kagent:8083/api/tasks 201 (task handed to analyst)
Real type: Agent delegation firing over A2A with a self-hosted model.
📝 Episode note. In the Sympozium episode the local model's orchestrator frequently never emitted a delegate call. Here kagent's agent-as-tool
- A2A delegation does fire with qwen3.6 — a nice on-screen contrast. Multi-hop crews are still slow on a local model, so keep demo tasks small and timeouts generous (200–300s). Live GitHub artifact counts / token totals are captured at record time on the dashboards.
kubectl delete -k manifests/bmad-crew
helm uninstall github-mcp -n kagent
kubectl -n kagent delete secret github-mcp-token
kubectl delete -f manifests/sre/11-sre-networking-agent.yaml
kubectl delete -f manifests/sre/10-mcp-k8s-networking-server.yaml
kubectl delete -f manifests/demos/grpc-misroute/fix.yaml
kubectl delete modelconfig mac-studio-ollama -n kagent
# platform stack + cluster: tear down with deploy/99-teardown.sh (ProxOps)| Symptom | Likely cause / fix |
|---|---|
ACCEPTED=False on an Agent |
Schema error — check field names: modelConfig (string, not modelConfigRef); RemoteMCPServer vs MCPServer fields don't mix. kubectl describe agent <name>. |
ToolServer has empty status.tools |
GitHub PAT missing/expired or wrong scope; the MCP endpoint didn't resolve. Recreate github-mcp-token with a repo-scoped fine-grained PAT. |
| Agent references a tool that never fires | The referenced ToolServer isn't Accepted, or toolNames names a tool not in status.tools. Trim to what actually resolves. |
Developer create_branch/push_files 403s |
PAT scope too narrow — needs Contents: RW, not just Issues. |
| Orchestrator answers itself instead of delegating | Local models sometimes skip delegation. Strengthen the systemMessage, keep the task small, give it time. |
| Crew / SRE call is very slow | Multi-hop delegation × a local model. Expected. Keep prompts short; client timeout 200–300s. |
| gRPC route "looks up" but checkout fails | Protocol mismatch — a health check on the wrong port can pass while gRPC/h2c fails. Compare route port to the Service's grpc/h2c port. |
| Path | Kind | Purpose |
|---|---|---|
deploy/.env.example |
— | the one place environment-specific values live |
deploy/*.sh |
— | ordered, idempotent provisioning + stack deploy (ProxOps) |
manifests/01-modelconfig-mac-studio.yaml |
ModelConfig |
Mac Studio Ollama model (the brain) |
manifests/sre/10-mcp-k8s-networking-server.yaml |
Deployment + RemoteMCPServer |
mcp-k8s-networking tool backend |
manifests/sre/11-sre-networking-agent.yaml |
Agent |
SRE networking troubleshooting agent |
manifests/demos/grpc-misroute/{broken,fix}.yaml |
GRPCRoute |
deterministic break/fix for the SRE demo |
manifests/bmad-crew/values-github-mcp.yaml |
Helm values | GitHub MCP server → github-mcp-server-* ToolServers |
manifests/bmad-crew/01..06-*.yaml |
Agent |
the six BMAD v2 agents (GitHub-operating) |
manifests/bmad-crew/kustomization.yaml |
— | kubectl apply -k the whole crew |
observability/21-otel-agent-daemonset.yaml |
OpenTelemetryCollector |
default-ns DaemonSet (node ingest) |
observability/22-otel-gateway-statefulset.yaml |
OpenTelemetryCollector |
default-ns StatefulSet (Dynatrace egress) |
observability/kagent-otel-values.yaml |
Helm values | point kagent OTLP at the default-ns collector |
observability/10-dynakube.yaml |
DynaKube |
Dynatrace k8s-monitoring |
dashboards/*.json |
Dynatrace | health + agentic-efficiency dashboards |
Research consolidated in doc kagent-research. Primary docs:
https://kagent.dev/docs/kagent/concepts. GitHub MCP server:
https://github.com/kagent-dev/kagent (contrib/tools/github-mcp-server).
Networking MCP: https://github.com/henrikrexed/mcp-k8s-networking.