Install, configure, and run the agentctl control plane in production. agentctl is a
Kubernetes control plane for fleets of conformant AI agents: it provisions, scales,
secures, and exposes agents through Custom Resources. This runbook covers installation,
chart configuration, high availability, observability, and day-2 operations. For the
architecture and the security model, see architecture.md and
security.md.
All commands assume the Helm release is agentctl in namespace agentctl-system; adjust
-n <namespace> if you install elsewhere.
The control plane is eight Deployments (eight container images). All expose Prometheus
/metrics; the port and scheme differ by component.
| Component | Role | Service port | /metrics scheme |
Default |
|---|---|---|---|---|
agentctl-operator |
Reconciles Agent/AgentFleet into workloads; leader-elected |
8080 (http) |
http | always on |
agentctl-apiserver |
Aggregated API for management verbs (drain, lame-duck, cancel, pause, resume) | 443 → 6443 (https) |
https (mTLS-gated) | apiserver.enabled: true |
agentctl-admission |
Validating + mutating webhooks | 443 → 8443 (https) |
https | admission.enabled: true |
agentctl-gateway |
A2A surface (Agent Cards, message/send, message/stream, tasks) |
80 → 8080 (http) |
http | gateway.enabled: true |
agentctl-modelgateway |
Secret-free inference broker (intelligence plane) | 80 → 8080 (http) |
http | modelgateway.enabled: true |
agentctl-mcpgateway |
Secret-free MCP tool broker (tools plane) | 80 → 8080 (http) |
http | mcpgateway.enabled: true |
agentctl-coordination |
Work fabric (work.*); claim-fleet backlog |
80 → 8080 (http) |
http | coordination.enabled: false |
agentctl-scaler |
KEDA external scaler; reads the coordination backlog | 80 → 8080 (http) |
http | scaler.enabled: false |
The reference agent (agentd) and any conformant agent run as ordinary pods on the pod
network, serving mTLS HTTPS on :8443. They are not part of the control plane and are not
installed by the chart.
| Prerequisite | Required? | Why |
|---|---|---|
| cert-manager (>= 1.13) | Required | Issues every control-plane serving/mTLS certificate and injects the caBundle into the aggregated APIService and the validating webhook. |
| KEDA | Optional | Claim-fleet autoscaling only. Needed when scaler.enabled=true; the operator renders a keda.sh ScaledObject per claim fleet. |
| NetworkPolicy-capable CNI (Calico, Cilium, …) | Optional | Tenant isolation. networkPolicies.enabled=true ships policies, but a policy-enforcing CNI must apply them — kindnet ignores NetworkPolicies. |
| Postgres | Optional | Durable coordination/task/usage state. Bundled single-pod Postgres is the default; in-memory is the single-replica fallback. |
Install cert-manager first:
helm repo add jetstack https://charts.jetstack.io
helm install cert-manager jetstack/cert-manager -n cert-manager \
--create-namespace --set crds.enabled=trueEvery non-core feature is gated and defaults to off, so a stock install ships no KEDA-, Prometheus-Operator-, or Grafana-coupled objects.
The chart lives at charts/agentctl and is also published as an OCI
chart. Helm does not reliably own the namespace it installs into, so either pass
--create-namespace or pre-create the namespace:
# From the published OCI chart
helm install agentctl oci://ghcr.io/agentctl-dev/charts/agentctl \
-n agentctl-system --create-namespace
# Or from a local checkout
helm install agentctl ./charts/agentctl -n agentctl-system --create-namespaceVerify the rollout and the aggregated API:
kubectl -n agentctl-system get pods
kubectl -n agentctl-system rollout status deploy/agentctl-operator
kubectl get apiservice v1alpha1.management.agentctl.dev # AVAILABLE should read TrueThe CLI wraps Helm with two preflight steps Helm cannot do itself: it fails fast if
helm is missing or cert-manager is absent, and it creates and labels the install
namespace. It then shells out to helm upgrade --install.
agentctl install -n agentctl-system \
--registry ghcr.io/agentctl-dev --tag <version> \
--set gateway.replicas=2
agentctl install --dry-run # render + validate, no cluster mutation
agentctl uninstall -n agentctl-system--registry/--tag map to image.registry/image.tag; --set KEY=VALUE is passed to
Helm verbatim (repeatable). The default chart reference is
oci://ghcr.io/agentctl-dev/charts/agentctl; pass --chart ./charts/agentctl for a local
path.
The deploy/ tree holds raw per-component manifests and kustomize overlays,
plus the generated CRDs. Use it for development, overlays, or when you need to understand
each object. cert-manager is still a hard prerequisite. The Helm chart is the supported
production path — the raw manifests do not wire TLS, caBundle injection, or Postgres for
you the way the chart does.
The bundle/ directory is an Operator Lifecycle Manager bundle (alpha /
preview) that installs the operator and the CRDs through OLM tooling. OLM's deployment
install strategy cannot carry the aggregated APIService, the webhook registration, or
the cert-manager Certificate/Issuer objects, so those must be applied separately.
Prefer the Helm chart for a complete, wired install; use the bundle only to evaluate the
operator through OperatorHub tooling.
The chart installs the four CRDs in charts/agentctl/crds/ on
first install — agents, agentfleets, modelpools, and mcpserversets, all in group
agentctl.dev, version v1alpha1. Helm intentionally never updates crds/ on
helm upgrade; see Upgrades & CRDs for the upgrade procedure.
Configure the chart through values. The table below lists the load-bearing knobs; see
charts/agentctl/values.yaml for the full set and the
per-component scheduling/resource/env passthroughs (nodeSelector, affinity,
tolerations, topologySpreadConstraints, priorityClassName, podAnnotations,
podLabels, extraEnv, envFrom, serviceAccount.annotations).
| Value | Default | Effect / gate |
|---|---|---|
image.registry |
"" |
Empty pulls local (kind-loaded) agentctl/<comp> names; set ghcr.io/agentctl-dev for the published images. |
image.tag |
dev |
Image tag for every component. Pin by digest via image.digests for reproducible installs. |
certManager.enabled |
true |
Required. Creates a self-signed bootstrap Issuer → CA → CA Issuer and issues every serving/mTLS cert. |
certManager.caIssuerRef |
"" |
Use an existing cluster CA ClusterIssuer instead of the self-signed bootstrap. |
postgres.mode |
bundled |
bundled (single-pod Postgres) or external (managed DSN Secret). |
postgres.bundled.storage |
emptyDir |
emptyDir (eval — data lost on pod restart) or pvc (durable, pvcSize default 5Gi). |
postgres.bundled.tls.enabled |
false |
Encrypt the in-cluster Postgres hop (sslmode=require). Requires cert-manager. |
postgres.bundled.tls.verifyFull |
false |
CA-pin + hostname-verify the hop (sslmode=verify-full). Requires tls.enabled. |
postgres.external.dsnSecretName |
"" |
mode=external: pre-created Secret holding DATABASE_URL. |
coordination.enabled |
false |
The work fabric (work.*). Enable for claim-mode fleets. |
coordination.store |
memory |
memory (single-replica, in-process) or postgres (durable + shared, HA-capable). |
coordination.attestIdentity |
true |
Bind each claim to the caller's source-IP-attested pod identity (blocks cross-tenant ack/release). |
coordination.mtls.enabled |
false |
Mutually authenticate the scaler → coordination backlog hop. Requires cert-manager. |
scaler.enabled |
false |
KEDA external scaler for claim-depth autoscaling. Also flips the operator's SCALER_ENABLED on. Requires KEDA. |
modelgateway.attestIdentity |
true |
Derive the agent namespace from the caller's source IP (not the spoofable X-Agent-Namespace header). Set false only for a trusted single-tenant install. |
modelgateway.secretsNamespaces |
[] |
Namespaces the ModelGateway may read provider-credential Secrets in. Empty = cluster-wide read (dev). Scope in production. |
mcpgateway.secretsNamespaces |
[] |
Namespaces the MCPGateway may read tool-credential Secrets in. Empty = cluster-wide read (dev). The MCPGateway is always source-IP-attested. |
apiToken.enabled |
false |
In-cluster bearer gate (AGENTCTL_API_TOKEN) on the coordination server, ModelGateway, A2A gateway, and scaler. |
trustedProxy.enabled |
false |
Second mTLS listener on the gateway (:8443) that trusts a fronting proxy's asserted identity headers. |
networkPolicies.enabled |
false |
Ship default-deny + sanctioned-flow policies. networkPolicies.agentNamespaces lists tenant namespaces. Needs a policy CNI. |
metrics.serviceMonitor.enabled |
false |
Render one Prometheus-Operator ServiceMonitor per component. |
observability.dashboards.enabled |
false |
Render the Grafana dashboard ConfigMap. |
observability.alerts.enabled |
false |
Render the PrometheusRule. Needs the Prometheus-Operator CRDs. |
operator.replicas |
1 |
Leader-elected; raise for warm-standby HA (see High availability). |
<comp>.autoscaling.enabled |
false |
HPA for apiserver, gateway, modelgateway. When on, the HPA owns replicas and <comp>.replicas is ignored. |
<comp>.pdb.enabled |
false |
PodDisruptionBudget for operator, apiserver, gateway, modelgateway, admission, coordination, scaler. |
namespace.create |
false |
Whether the chart renders the namespace object. |
admission.allowedRegistries |
agentd:,mock-agent,agentctl/,gcr.io/,registry.k8s.io/,ghcr.io/ |
CSV image-registry prefix allow-list the webhook enforces (empty = allow all). |
Two identity mechanisms are enforced cryptographically and are on by default:
-
Outbound (agent → gateway). The ModelGateway (
modelgateway.attestIdentity, defaulttrue), MCPGateway (always on), and coordination server (coordination.attestIdentity, defaulttrue) resolve the caller's source pod IP to the owning pod via the Kubernetes API and derive its namespace/identity from that — never from a self-asserted request header. Confined tenant pods dropCAP_NET_RAWand so cannot spoof their source IP, so one tenant cannot bill another's ModelPool budget or ack/release another's claim. These paths require the cluster-widepods get/listgrant the chart renders for them. -
Inbound (control plane → agent). The aggregated apiserver and A2A gateway dial the agent pod directly over mTLS, presenting the control-plane client certificate that authenticates them as the Management origin — the only origin the agent accepts for management/A2A. The trust anchor is the cluster CA, not DNS.
Turn attestation off (modelgateway.attestIdentity=false, coordination.attestIdentity=false)
only for a deliberately trusted single-tenant install; the header/self-asserted fallbacks are
spoofable by any in-cluster pod.
The gateway enforces inbound auth by one of three mechanisms. Two are chart-level:
-
apiToken.enabled— a single coarse in-cluster bearer token (AGENTCTL_API_TOKEN). The chart mints akeep-policy Secretagentctl-api-tokenand wires it into the coordination server, ModelGateway, A2A gateway, and scaler; those services then requireAuthorization: Bearer <token>. The operator injects it into agent pods only in the control-plane namespace (asecretKeyRefcannot cross namespaces) — replicate the Secret into other tenant namespaces to gate agents there. Read the token with:kubectl -n agentctl-system get secret agentctl-api-token \ -o jsonpath='{.data.AGENTCTL_API_TOKEN}' | base64 -d
-
trustedProxy.enabled— a fronting API gateway (e.g. APISIX) terminates edge auth and asserts the verified identity as<prefix>-subject/-email/-groupsheaders (prefixtrustedProxy.headerPrefix, defaultx-agentctl). The gateway honors those headers only over the mTLS listener on:8443, verifying the proxy's client cert against the agentctl CA andtrustedProxy.allowedNames; on any other path it strips the headers so they cannot be self-asserted.
Per-agent OIDC inbound policy is not a chart value — it is configured on each Agent
via spec.access.oidc (issuer, audience, requiredClaims) and validated by the admission
webhook. See security.md for the full trust model.
The gateway and modelgateway connect with a rustls client; the DSN's sslmode selects the
transport:
| Mode | Behavior |
|---|---|
sslmode=disable |
Plaintext; relies on in-cluster NetworkPolicy scope. Default for the bundled store. |
sslmode=require |
Encrypted, server cert not verified. Set by postgres.bundled.tls.enabled=true. |
sslmode=verify-full |
Encrypted, server cert CA-pinned and hostname-verified. Set by postgres.bundled.tls.verifyFull=true (reads the CA from DB_CA_FILE/PGSSLROOTCERT). |
For a managed Postgres (mode=external), put the desired sslmode in the DSN Secret and
supply the CA per your provider's process.
The operator is a level-triggered singleton: two reconcile loops at once would race
server-side applies. Every replica contends for a coordination.k8s.io Lease named
agentctl-operator in the control-plane namespace; only the holder runs the controllers.
Raise operator.replicas above 1 for warm standbys — this adds failover speed, not
reconcile throughput.
- Lease timings: lease duration 15s, renew every 10s, retry every 2s. A standby takes
over once
renewTime + 15shas passed, so worst-case reconcile handoff is bounded at roughly the lease duration. - Probes: liveness (
/healthz) is 200 on every replica, leader or standby, so the kubelet never kills a healthy standby. Readiness (/readyz) is 200 once the manager is up and participating — not gated on holding leadership — so aRollingUpdatedoes not deadlock and standbys stayReady. - Metrics reachability: the operator Service sets
publishNotReadyAddresses: trueso/metricsis scrapeable on every replica. Exactly one replica reportsagentctl_operator_leader 1. - Loss of leadership: if the leader cannot renew within the lease duration (apiserver unreachable) or a peer takes over, the process exits and Kubernetes restarts it to rejoin the election — guaranteeing two reconcile loops never run at once.
Enable a PodDisruptionBudget so node drains keep a replica up:
helm upgrade agentctl ./charts/agentctl -n agentctl-system --reuse-values \
--set operator.replicas=2 --set operator.pdb.enabled=trueapiserver, gateway, modelgateway, and mcpgateway are stateless (state lives in
Postgres). Raise <comp>.replicas, or enable a CPU HPA for apiserver, gateway, and
modelgateway:
helm upgrade agentctl ./charts/agentctl -n agentctl-system --reuse-values \
--set gateway.autoscaling.enabled=true \
--set gateway.autoscaling.minReplicas=2 \
--set gateway.autoscaling.maxReplicas=10When the HPA is enabled it owns the replica count and <comp>.replicas is ignored. The
admission, coordination, and scaler components have no CPU HPA — scale them with
<comp>.replicas.
For HA, back shared state with Postgres and replicate the durable components:
-
A2A tasks, push configs, token usage persist to Postgres. The gateway and modelgateway are stateless, so they scale horizontally against a shared DB. For HA/DR use an external managed Postgres (
postgres.mode=external) — the bundled Postgres is a single pod with no replication or failover. -
Coordination. The default
coordination.store=memorykeeps the claim queue in process — keepcoordination.replicas=1. Setcoordination.store=postgresto make the queue durable and shared, then raisecoordination.replicasfor HA:helm upgrade agentctl ./charts/agentctl -n agentctl-system --reuse-values \ --set coordination.enabled=true \ --set coordination.store=postgres \ --set coordination.replicas=2
The bundled Postgres Secret and PVC, and the gateway signing Secret
(agentctl-gateway-signing), carry helm.sh/resource-policy: keep — they survive
helm uninstall and are reused (via lookup) across upgrades, so DB auth and Agent-Card
JWS signatures stay stable. Back up the signing Secret and the cert-manager CA
(agentctl-ca-key) as part of DR.
Every component serves hand-rolled Prometheus text at /metrics. Scrape targets and metric
families:
| Component | Endpoint | Key metric families |
|---|---|---|
| operator | :8080 http |
agentctl_operator_leader, agentctl_operator_reconcile_total, agentctl_operator_reconcile_errors_total, agentctl_operator_reconcile_duration_seconds |
| apiserver | :6443 https (behind the front-proxy mTLS gate) |
agentctl_apiserver_verb_forwarded_total, agentctl_apiserver_verb_denied_total |
| gateway | :8080 http |
agentctl_gateway_rpc_requests_total, _stream_requests_total, _card_requests_total, _tasks_total, _upstream_errors_total |
| modelgateway | :8080 http |
agentctl_modelgateway_infer_requests_total, _infer_errors_total, _tokens_total, _budget_rejections_total |
| admission | :8443 https |
agentctl_admission_admit_total, _deny_total, _reviews_total, _mutations_total, _mutations_patched_total |
| mcpgateway / coordination / scaler | :8080 http |
component /metrics |
The apiserver serves /metrics on the same :6443 mTLS surface as the API — only a
CA-signed client cert can scrape it (the ServiceMonitor scrapes scheme: https with
insecureSkipVerify).
The chart ships three opt-in observability objects. All require the corresponding cluster add-on:
helm upgrade agentctl ./charts/agentctl -n agentctl-system --reuse-values \
--set metrics.serviceMonitor.enabled=true \
--set observability.dashboards.enabled=true \
--set observability.alerts.enabled=true \
--set observability.alerts.labels.release=prometheusmetrics.serviceMonitor.enabledrenders oneServiceMonitorper enabled component (scrape jobagentctl-<comp>, interval30s). Requires the Prometheus-Operator CRDs. Usemetrics.serviceMonitor.labelsto match your PrometheusserviceMonitorSelector.observability.dashboards.enabledrenders the Grafana dashboard as a ConfigMap labeledgrafana_dashboard: "1"for the Grafana sidecar's auto-discovery (no Grafana CRDs needed). The dashboard covers the operator (reconcile/error rate, latency, leader), the A2A gateway (per-surface request + upstream-error rates), the ModelGateway (inference, tokens, budget rejections), and the admission webhook (admit/deny, reviews, mutations).observability.alerts.enabledrenders thePrometheusRule. Requires the Prometheus-Operator CRDs. Setobservability.alerts.labelsto whatever your PrometheusruleSelectormatches (kube-prometheus-stack defaults torelease: <name>).
Shipped alert rules:
| Alert | Severity | Expression |
|---|---|---|
AgentctlOperatorNoLeader |
critical (5m) | sum(agentctl_operator_leader) == 0 |
AgentctlReconcileErrors |
warning (10m) | sum(rate(agentctl_operator_reconcile_errors_total[5m])) > 0.1 |
AgentctlComponentDown |
critical (5m) | (up{job=~"agentctl-.*"} == 0) or (absent(up{job="agentctl-operator"}) == 1) |
AgentctlAdmissionDenySpike |
warning (10m) | sum(rate(agentctl_admission_deny_total[5m])) > 1 |
AgentctlBudgetRejections |
warning (15m) | sum(rate(agentctl_modelgateway_budget_rejections_total[5m])) > 0 |
Tracing is off unless OTEL_EXPORTER_OTLP_ENDPOINT is set (the default is fmt-only logging
with byte-identical output). Enable the OTLP/gRPC exporter and W3C traceparent propagation
per component via extraEnv:
apiserver:
extraEnv:
- name: OTEL_EXPORTER_OTLP_ENDPOINT
value: http://otel-collector.observability.svc:4317The apiserver injects traceparent into its management calls to the agent pod, so an agent
run joins the operator's trace when tracing is on.
The aggregated apiserver serves five lifecycle verbs under management.agentctl.dev for
both agents and agentfleets:
| Verb | Effect |
|---|---|
drain |
Stop accepting new work and finish in-flight tasks. |
lame-duck |
Mark the agent unhealthy for load-balancing without stopping it. |
cancel |
Cancel in-flight work. |
pause |
Suspend processing. |
resume |
Resume a paused agent. |
Each request is a create on the <resource>/<verb> connect subresource. The apiserver
authorizes it via a SubjectAccessReview, then dials the target pod(s) directly over mTLS
as the Management origin. For an AgentFleet the verb fans out to every Running replica
and returns a partial-success Status (207 when some replicas failed).
kubectl ──create──▶ kube-apiserver ──aggregates──▶ agentctl-apiserver
│ 1. verify front-proxy client cert
│ 2. SubjectAccessReview(user, verb)
│ 3. dial pod(s) over mTLS ──▶ agent :8443 /mcp
Invoke a verb with kubectl create --raw against the subresource path:
# Drain a single Agent
kubectl create --raw \
/apis/management.agentctl.dev/v1alpha1/namespaces/default/agents/my-agent/drain \
-f /dev/null
# Pause an entire AgentFleet (fans out to all replicas)
kubectl create --raw \
/apis/management.agentctl.dev/v1alpha1/namespaces/default/agentfleets/workers/pause \
-f /dev/nullAccess is RBAC-gated. Grant a role the verb by allowing create on the subresource in the
management.agentctl.dev group:
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: agent-operator
rules:
- apiGroups: ["management.agentctl.dev"]
resources:
- agents/drain
- agents/lame-duck
- agents/cancel
- agents/pause
- agents/resume
- agentfleets/drain
- agentfleets/lame-duck
- agentfleets/cancel
- agentfleets/pause
- agentfleets/resume
verbs: ["create"]A subject without a matching binding is denied by the SubjectAccessReview (403).
AgentFleet exposes the Kubernetes scale subresource mapped to .spec.replicas, so any
workload autoscaler or kubectl scale can drive it:
kubectl scale agentfleet/workers --replicas=3-
Claim mode renders a Deployment. With the scaler off,
.spec.replicassets the count. Withcoordination.enabledandscaler.enabled, KEDA's HPA owns the count and drives it from the coordination backlog — including scale-from-zero:helm upgrade agentctl ./charts/agentctl -n agentctl-system --reuse-values \ --set coordination.enabled=true --set scaler.enabled=true
The operator renders a
keda.shScaledObjectper claim fleet, applied best-effort: if the KEDA CRDs are absent it logs the failure and sets aScaledObject=Falsecondition on the fleet without failing the workload — the fleet still runs, just without claim-depth autoscaling. -
Shard mode renders a StatefulSet of
scaling.shardsfixed hash partitions. Changing the shard count is a guarded, stop-the-world rebalance, not an elastic scale.
The coordination server is reachable in-cluster at http://agentctl-coordination.<ns>.svc/
(or the mTLS listener on :8443 when coordination.mtls.enabled), and its backlog is the
scale-from-zero signal.
Restart a Deployment to pick up a rotated Secret or recover after a Postgres restore:
kubectl -n agentctl-system rollout restart \
deploy/agentctl-gateway deploy/agentctl-modelgatewayhelm upgrade agentctl ./charts/agentctl -n agentctl-system \
--reuse-values --set image.tag=<new>Upgrades are idempotent: the bundled Postgres password and the gateway signing seed are read
from their existing Secrets via lookup and reused, not regenerated, so DB auth and JWS
signatures survive every upgrade. keep-policy Secrets and the Postgres PVC are never
deleted by helm uninstall.
Helm installs the CRDs in charts/agentctl/crds/ on first install only and never
updates them on helm upgrade. After a chart upgrade that changes a CRD schema, printer
column, or subresource, apply the CRDs by hand:
kubectl apply -f charts/agentctl/crds/The CRDs are single-version v1alpha1, so kubectl apply of the new schema is sufficient —
there is no multi-version conversion. Never kubectl delete a CRD to refresh it: deleting a
CRD cascade-deletes every Agent/AgentFleet/ModelPool/MCPServerSet instance.
helm history agentctl -n agentctl-system
helm rollback agentctl <REVISION> -n agentctl-systemhelm rollback reverts the templated objects but, mirroring upgrade, does not touch CRDs or
keep-policy Secrets/PVC. The gateway migrates the DB schema forward on startup, so rolling
back to an older binary across a schema migration is not guaranteed safe — prefer rolling
forward, and restore Postgres from a pre-upgrade dump if you must go back.
architecture.md— components, planes, and the Agent Control Contract.security.md— identity, attestation, tenant isolation, and the trust model.charts/agentctl/values.yaml— the full value reference.