6.3. Platform Agents
In one glance
- You will: Read the one file that declares the agent as a Kubernetes workload, then watch the controller rebuild the pod after you delete it.
- You need: The Skaffold loop from 6.2. Platform Install still running.
- Time: about 18 minutes, reference.
Why declare a BYO Agent instead of a declarative kagent Agent?
This course uses spec.type: BYO. You ship the container image, and kagent only schedules it and fronts it on the cluster network. That choice decides who owns the agent's behavior, which makes it the most important decision on this page.
kagent supports two shapes of Agent. A declarative Agent hands kagent a model, a system prompt, and a tool list, and kagent composes and runs the loop for you inside its own runtime and container. A BYO ("bring your own") Agent inverts that: you ship the container image and own the agent contract, and kagent's job narrows to scheduling the workload and fronting it on the cluster network.
This course spent chapters 2 through 5 building a complete Google ADK application:
- a persistent session/task server (Chapter 2.4),
- guarded write actions (Chapter 3.1),
- PII and injection guardrails (Chapter 4.5),
- audit transactions,
- OpenTelemetry spans (Chapter 7).
A declarative Agent would discard that and re-express a thinner agent in kagent's vocabulary. spec.type: BYO keeps the validated application intact — the exact image that ran on the host in Chapter 5 runs unchanged in the cluster, still serving its own A2A card, still driving its own ADK Runner. That is why Chapter 6.0 states plainly that kagent does not replace ADK.
The trade-off is explicit:
- Choose BYO when you already have a working agent and want to preserve its runtime, framework, protocol surface, and lifecycle exactly — as this course does. You accept that kagent cannot introspect your prompt or tools.
- Choose a declarative Agent when you want kagent to own composition and are starting from a model and a tool list, not a container. You gain declarative convenience and lose runtime control.
What does the BYO Agent declare?
infra/kagent/agent.yaml is one custom resource — an object type kagent added to the Kubernetes API — that carries the full workload shape:
apiVersion: kagent.dev/v1alpha2
kind: Agent
metadata:
name: agentops-agent
namespace: agentops
spec:
type: BYO
byo:
deployment:
image: agentops-agent:dev
imagePullPolicy: IfNotPresent
replicas: 1
serviceAccountName: agentops-agent
Under spec.byo.deployment the same manifest also declares:
- the container
env— the data-plane and A2A addressing contracts. resources— the compute envelope.podSecurityContextandsecurityContext— the hardening posture.volumeMounts/volumes— the state PVC and a small writable/tmp.
The sections below take each of those in turn. The logical image: agentops-agent:dev is a placeholder that Skaffold rewrites at deploy time — see the next section.
How does the custom resource become a running Deployment and Service?
A custom resource is declared intent, not a running process; a controller reconciles it into built-in Kubernetes objects.
kagent watches Agent resources. For type: BYO it turns spec.byo.deployment into a Deployment plus a Service on the A2A port 8080. That Deployment carries one replica, serviceAccountName: agentops-agent, imagePullPolicy: IfNotPresent, the hardened security contexts, the declared env, and the volume mounts.
You never author those objects. You author the Agent, and kagent keeps the derived objects converged to it.
Two rewrites happen before and during that reconcile:
- Skaffold rewrites
.spec.byo.deployment.image. Plain kustomize does not know that a custom resource contains an image field, soinfra/skaffold.yamldeclares aresourceSelectorthat points Skaffold at that exact path:groupKind: Agent.kagent.dev,image: [.spec.byo.deployment.image]. Skaffold then replacesagentops-agent:devwith the pushed registry reference tagged by the abbreviated Git commit (tagPolicy.gitCommit.variant: AbbrevCommitSha). Chapter 6.1 covers why a commit tag improves provenance but is still a mutable reference. - The overlay patches env values. The main
local-geminioverlay selectsgemini-3.5-flashby the namedAGENT_MODELfield; the optionallocaloverlay selectsqwen3:4b-instruct. GKE keeps its qualified Gemini pin. Model backend is a data-plane change (Chapter 6.5).
sequenceDiagram
participant S as Skaffold
participant K as kagent controller
participant D as Deployment + Service
participant R as Kubernetes workload controllers
participant P as Agent pod
S->>K: apply Agent CR (image → registry:commit)
K->>D: create Deployment (replicas 1, SA agentops-agent) + Service :8080
D->>R: declare pod template and replica count
R->>P: create replacement pod; scheduler selects its node
P-->>P: serve A2A card at the advertised svc FQDN
Note over P: kubectl delete pod
R->>P: replace deleted pod with the same state PVC
Diagram in words: Skaffold supplies the image in the Agent resource, kagent creates its Deployment and Service, and Kubernetes controllers maintain the pod count. A deleted pod is replaced with the same PVC; the scheduler chooses a compatible node.
That last loop is the checkpoint at the end of this page: delete the pod, and Kubernetes replaces it against the same claim. kagent owns the derived Deployment, so change the Agent resource instead of hand-editing that generated object.
Which environment variables preserve the data plane?
Moving to Kubernetes must not change what the agent does; it only repoints the unchanged app at cluster services instead of host processes. The env block does that repointing:
- name: AGENT_MODEL_PROVIDER
value: openai-compatible
- name: AGENT_MCP_URL
value: http://agentgateway:3000/mcp
- name: OPENAI_BASE_URL
value: http://agentgateway:4000/v1
- name: OPENAI_API_KEY
value: agentgateway
- name: OTEL_EXPORTER_OTLP_ENDPOINT
value: http://otel-collector:4318
- name: AGENT_STATE_DIR
value: /app/state
The decisive property is what is missing from that block: no upstream provider credential ever enters the agent pod. The gateway holds real upstream auth.
| Variable | Points at | Why it matters |
|---|---|---|
AGENT_MODEL_PROVIDER |
the same provider the app selected in Chapter 2.2 | the application code is unchanged, regardless of which model sits behind the gateway |
AGENT_MCP_URL |
agentgateway :3000 |
root_agent registers one remote McpToolset instead of six local read functions (Chapter 6.4) |
OPENAI_BASE_URL |
agentgateway :4000 |
model traffic goes to the gateway, not straight to a provider |
OPENAI_API_KEY |
the gateway's own auth check | agentgateway is the non-secret demo marker the gateway enforces (Chapter 5.5 / 6.5), not a real credential |
OTEL_EXPORTER_OTLP_ENDPOINT |
the collector :4318 |
spans leave the pod for the in-cluster collector |
AGENT_STATE_DIR |
the mounted state PVC | the one writable persistent path, because the pod's root filesystem is read-only |
The main local-gemini overlay uses Gemini API behind that endpoint; local is the optional Ollama profile, while GKE uses Vertex AI. Their provider credentials and network policies differ.
Owned by 6.4. Platform Tools for the MCP route and 6.5. Platform Gateway for the gateway and the model backend.
Why does the agent advertise a different A2A host than it binds?
Every network server has two distinct addresses: where it listens (the bind address) and where clients should call it (the advertised address). Conflating them silently breaks discovery, because a listen address like 0.0.0.0 is not a routable endpoint. The A2A block encodes both, plus the port kagent fronts:
- name: AGENT_A2A_HOST
value: agentops-agent.agentops.svc.cluster.local
- name: AGENT_A2A_BIND_HOST
value: 0.0.0.0
- name: AGENT_A2A_PORT
value: "8080"
AGENT_A2A_BIND_HOST=0.0.0.0 is the listen address: Uvicorn binds all interfaces so the kubelet and the gateway can reach the pod. It is also baked into the image (Dockerfile sets ENV AGENT_A2A_BIND_HOST=0.0.0.0 and EXPOSE 8080), while the host default is loopback-only. AGENT_A2A_HOST=agentops-agent.agentops.svc.cluster.local is the advertised host: server.py builds the agent card url from it and hands it to callers.
url=f"{settings.a2a_protocol}://{settings.a2a_host}:{settings.a2a_port}/",
The split is deliberate, and config.py records why:
# Never advertise 0.0.0.0: it is a listener, not a callable endpoint.
a2a_bind_host: str = Field(default="127.0.0.1", min_length=1)
a2a_host: str = Field(default="localhost", min_length=1)
A third address completes the picture — external clients never call 8080 directly. They port-forward agentgateway :3001 (Chapter 6.5), which fronts A2A; direct pod port 8080 is reserved for diagnosis.
flowchart TD
Client[Local client] -->|port-forward| Gateway["agentgateway :3001 — external hop"]
Gateway -->|A2A| Bind["AGENT_A2A_BIND_HOST 0.0.0.0:8080 — listen"]
Bind --> Card["agent card url = AGENT_A2A_HOST svc FQDN :8080 — advertised"]
The pitfall: set AGENT_A2A_HOST to 0.0.0.0 or leave it at the loopback default in-cluster, and the card resolves to an uncallable address — clients fail card resolution even though the pod is healthy and serving.
What is the ModelConfig for?
The BYO pod reads its environment, not the live ModelConfig resource.
ModelConfig declares the gateway contract for declarative kagent consumers. During rendering, this repository's Kustomize replacements also copy its model name into the BYO AGENT_MODEL environment variable. Apply the rendered workload to change that environment; editing only the live ModelConfig does not reconfigure the running BYO app.
Deeper: what the ModelConfig declares
ModelConfig/agentgateway in infra/kagent/modelconfig.yaml is kagent's declarative model contract — the object a declarative Agent or any other kagent-managed consumer would read to reach a model:
spec:
provider: OpenAI
model: gemini-3.5-flash
apiKeySecret: agentgateway-client
apiKeySecretKey: OPENAI_API_KEY
openAI:
baseUrl: http://agentgateway.agentops.svc.cluster.local:4000/v1
timeout: 120
Because the BYO agent brings its own env (the OPENAI_BASE_URL/OPENAI_API_KEY above), the ModelConfig does not inject the model into the BYO pod. It documents and enables the platform's OpenAI-compatible endpoint at agentgateway :4000 so the cluster has one declared model contract instead of an implicit one. apiKeySecret references the agentgateway-client Secret, whose value is the non-secret SDK marker (Chapter 6.5). The local overlay patches spec.model to qwen3:4b-instruct; the GKE overlay keeps gemini-3.5-flash while preserving the same gateway baseUrl.
How is the pod hardened?
Suppose an attacker got code execution inside this pod. They would not be root. They could write only to /app/state and a 128 MiB /tmp, would hold no Kubernetes API token, and could reach nothing on the network except the gateway and the collector.
The BYO deployment declares a defense-in-depth posture and a bounded compute envelope so one workload cannot escalate privileges or starve neighbors:
- UID/GID/fsGroup 10001 and
runAsNonRoot.fsGroupis what gives that non-root process write access to the mounted volume. - RuntimeDefault seccomp — the container runtime's default filter on which system calls the process may make.
- no privilege escalation and all capabilities dropped.
- read-only root filesystem, a writable 1 Gi RWO state PVC at
/app/state, and a 128 MiB/tmpemptyDir. - CPU/memory requests and limits.
- service-account token automount disabled — the
agentops-agentServiceAccount inserviceaccounts.yamlsetsautomountServiceAccountToken: false, because the agent never calls the Kubernetes API.
The compute envelope is concrete, not decorative:
resources:
requests:
cpu: 250m
memory: 512Mi
limits:
cpu: "1"
memory: 1536Mi
These numbers, plus the 128 MiB /tmp and the 1 Gi PVC, are what the namespace ResourceQuota and LimitRange are sized against. A ResourceQuota caps what a whole namespace may request; a LimitRange supplies defaults for pods that declare none.
Chapter 6.5 owns that math, including one surge pod per rolling deploy so Skaffold rollouts never deadlock. Do not re-derive it here; cross-link it.
A read-only root with an explicit writable state mount also constrains what a compromised process can persist. The default-deny egress rules (Chapters 6.5 and 4.6) constrain where it can send data.
The optional GKE gateway and MLflow identities obtain ambient cloud credentials through the metadata server, so Workload Identity Federation — mapping a workload identity to cloud IAM without a static key — does not require an automounted Kubernetes API token.
How does Kubernetes know the processes are actually ready?
Kubernetes calls an HTTP endpoint inside a container on a schedule. That probe is run by the kubelet — the Kubernetes agent on each node.
The agent image exposes two application-level endpoints on both network servers:
/livezproves the event loop can answer a trivial request.- On MCP,
/healthzopens the agent-published runtime database read-only and verifies integrity, required tables, the current audit version, and the exact non-partial unique idempotency index. - On A2A, application startup owns first-boot initialization and additive runtime migration.
/healthzthen runs the same read-only database probe, verifies the writable state directory, and checks the persistent session/task store.
Health polling never creates or migrates state. On a fresh volume the A2A startup publishes the database atomically, then prepares it before serving; MCP readiness remains false for missing, legacy, or failed-migration state.
The asymmetry is worth making explicit: both workloads publish the same endpoints, but only one has them wired as kubelet probes.
| Workload | Exposes /livez, /healthz? |
Wired as k8s probe? | What restarts it on failure |
|---|---|---|---|
agentops-agent (A2A) |
Yes | No — BYO v1alpha2 exposes no probe fields |
Kubelet restarts an exited container; ReplicaSet replaces a deleted pod; a hung process has no liveness probe |
agentops-mcp |
Yes | Yes — startupProbe, readinessProbe, livenessProbe |
The kubelet, via the wired probes (restart / drop from endpoints) |
The pinned kagent v1alpha2 BYO deployment schema does not expose container probe or pod termination-grace fields. The course therefore does not pretend the controller-created A2A pod has probes it cannot declare. Open a direct checkpoint in one terminal:
kubectl -n agentops port-forward svc/agentops-agent 8080:8080
Leave the forward running. From another terminal, call both endpoints:
curl -fsS http://localhost:8080/livez
curl -fsS http://localhost:8080/healthz
Do not patch the generated Deployment behind the controller: reconciliation can overwrite that drift.
Deeper: which workload wires the probes, and what the infra check asserts
The static MCP Deployment — not this BYO Agent — is where those endpoints are actually wired as startupProbe, readinessProbe, and livenessProbe; Chapter 6.4 owns that wiring and the shared-PVC read coherence, and scripts/check-infra.sh asserts the exact rendered paths and that the 15-second pod grace period exceeds AGENT_DRAIN_TIMEOUT_S=10. Both MCP HTTP transports and A2A run under Uvicorn with that bounded graceful-shutdown timeout, so SIGTERM stops new work and gives in-flight requests time to finish before Kubernetes may send SIGKILL.
When kagent adds those BYO fields, wire the same endpoints through the Agent resource and remove this limitation.
How do you verify the resource?
Inspect the live resource while Skaffold is running:
kubectl -n agentops get agents.kagent.dev
kubectl -n agentops get pods,pvc,svc
kubectl -n agentops describe agent.kagent.dev/agentops-agent
Expected: one ready agent pod, bound agentops-agent-state, and ClusterIP agentops-agent on 8080.
What proves this page worked?
Delete only the agent pod and watch Kubernetes replace it. Confirm the replacement mounts the same PVC and the agent card is available through gateway port 3001. This tests pod replacement, not zone/PV disaster recovery or recovery of a hung process.
You are done when:
kubectl -n agentops get agents.kagent.devlistsagentops-agentinstead of returning nothing.kubectl -n agentops get pods,pvc,svcshows one ready agent pod, a boundagentops-agent-stateclaim, and ClusterIPagentops-agenton 8080.- Deleting only the agent pod brings a replacement back, mounted on that same
agentops-agent-stateclaim. - The agent card still resolves through gateway port
3001once the replacement pod is ready. - You can say which address the pod binds (
0.0.0.0) and which address its card advertises (agentops-agent.agentops.svc.cluster.local), and why they must differ.
Continue to 6.4. Platform Tools when the pod you deleted has come back on the same state claim.