Skip to main content
Reference for the parts of a self-hosted install that run agents. How much cluster to plan is on Sizing and requirements.

Agent workspaces

Each agent whose harness is xpander and that uses a workspace gets its own pod, created on demand and reclaimed when idle. Size for concurrently active workspaces, not total agents. Idle workspaces use ~50 Mi. An active one uses ~0.5-1 vCPU and up to 1 GiB, bursting to its limits. The volume persists after the pod is reclaimed, so plan 5 GiB per workspace-using agent, not per pod.
The workspace image is ~1 GiB and is pulled on the first start on each node. Budget ~2 GiB of node disk for the image cache.

The executor fleet

The fleet is a fleet-executor StatefulSet. Each replica is one PodSecurity baseline pod over one XFS volume: no privileged container, no host namespaces, no user namespaces and no node preparation. The pod holds the executor container, which claims turns, plus one slot-<i> container per turn slot (fleet.turnsMax). Each turn runs in its own slot container, as the agent’s own uid. It has its own processes, /proc, /tmp and memory and CPU limit, behind a seccomp profile, no_new_privs and Landlock. An agent’s directory lives on exactly one executor’s volume, and its turns always run there.
The fleet is always rendered by both charts (there is no enable flag). It starts warm with one executor (fleet.replicas: 1 on Hybrid, global.fleet.replicas: 1 on Air-Gapped). Harness agents (Claude Code, Codex, OpenCode) run only on the executors, never as a pod per agent. So the chart always keeps at least one: replicas: 0 (or a blank value) renders one executor. The one case that renders zero is a live cluster whose executor volume can never bind (see Idle guard). Agent turns then wait in the queue until it is fixed.

Per executor

Defaults per executor pod: 2 / 8 vCPU and 8 / 28 GiB (request / limit), 12 slot containers, a 500 GiB XFS volume.
Size the executor’s memory against turnsMax. Each slot container is limited to the turn ceiling (8 GiB by default), and the node must back them. If the turns together use more memory than the node has, the kernel OOM-kills one. The chart default (12 slots, executor limit 28 GiB) is a conservative starting footprint. It assumes typical turns stay well below their ceiling. For a production fleet, either use memory-rich nodes sized toward turnsMax x 8 GiB or lower turnsMax.

Per turn

Ceilings per turn (fleet.turn). An agent can lower its own, never raise it.

Concurrency caps

There is no per-organization cap. fleet.orgMaxTurns is removed, and a license’s agent cap does not limit concurrent turns. A values file that still sets it upgrades cleanly, and the key renders nothing.

Reading the runtime logs

One turn shows in two logs, the executor and the Agent Controller:
Executor (fleet-executor-<n>, container executor), in order:
Agent Controller (deploy/agent-controller). The same turn shows as:
  • direct turn conversation=<id> task=<id> status=... received_to_enqueued_ms=... enqueued_to_claimed_ms=...: the message became a task and was picked up by the fleet. enqueued_to_claimed_ms is where a cold executor shows. The turn runs on the model the conversation picked. The agent record’s model is only the default for new conversations.
  • event=fleet.claimed executor=fleet-executor-0: which executor took it.
  • event=compute_usage.emitted cli=claude-code cpu_seconds=...: the compute record written when the turn ended.
LLM Gateway (llm-gw, kubectl -n xpander logs -f -l app=llm-gw). Each model call the turn made is one event=llm_gw.call llm_route=gateway_direct status=... org=<id> agent=<id> task=<id> client=... provider=... request_id=<id> line. To see the model a call used, paste its request_id into Request or trace ID and press Enter in Platform > AI governance > Logs, on your install’s own address.

Node prerequisites

All required: a cluster that fails one runs no agent turns. install.sh and the Air-Gapped preflight check each and name the fix. There is no Kubernetes 1.33, kernel 6.3, containerd 2.0 or user.max_user_namespaces minimum, and no node configuration.

Landlock on the executor nodes

Landlock limits each turn to the agent’s own files. It is on by default (fleet.landlock.enabled: true, or global.fleet.landlock.enabled on Air-Gapped). On a node without it, the executor takes no turn: /health answers 503, and its refusal field and the log say the kernel has no Landlock.
On GKE, keep the cluster on Container-Optimized OS and add one Ubuntu node pool for the executors:
The taint is optional. If you cannot add a node pool, set fleet.landlock.enabled: false, and Landlock is off on every node.
/health then shows landlock_enabled: false.

Fleet egress

The executors’ NetworkPolicy (fleet.networkPolicy.enabled, default true) gives agents the same reachability as any other pod, private ranges and in-cluster services included, minus link-local. fleet.networkPolicy.exceptCidrs defaults to ["169.254.0.0/16"] and exceptCidrsV6 to ["fe80::/10"]. Setting either replaces the whole list, so keep the link-local range in it. To close more ranges, use extraExceptCidrs. extraEgress appends raw egress rules. fleet.egress.allowHosts is still accepted for older agent-harness images and is no longer needed.

Autoscaling

An agent runs only on the executor that holds its home, so scaling in would strand agents. With fleet.keda.enabled: true the chart renders a KEDA ScaledObject that keeps every executor running: One executor per node, so grow the node group and maxReplicas together. Without KEDA (the default), set fleet.replicas. On a sealed network, a fixed count sized for peak is recommended.

Safe rollout, drain, and upgrades

A roll never kills a running turn

A rolling update, a KEDA scale-in and a pod delete all send SIGTERM, which the executor treats as a drain, not a kill:
  1. The executor stops claiming work, and its heartbeat says draining. The Agent Controller routes around it: nothing new lands on it, and whatever was queued for it goes back to the shared queue. It still counts as fleet presence, so new turns wait for the replacement instead of starting anywhere else.
  2. The turns in flight keep running for up to fleet.executor.drainSeconds (3600 s by default). A turn still running when that window ends is stopped the way a user stop is. The CLI is interrupted, its session is kept, and the task ends stopped with “The executor is restarting; send the message again to continue where it left off.” Sending the message again resumes the session on the new pod. No task is left hanging as executing.
  3. Each slot container holds its turn until the executor’s health endpoint reports draining with no turn in flight. The pod grace (fleet.executor.terminationGraceSeconds, 7500 s: the CLI run budget plus five minutes) is a ceiling, never a wait. The pod leaves as soon as its containers exit.
A roll replaces one pod at a time, so a roll during a long turn takes as long as that turn, up to the drain window. So:
  • If your turns run long, raise drainSeconds toward the run budget. It must stay at least 120 s under the pod grace. The Air-Gapped chart refuses to render otherwise.
  • Never shorten a termination with --grace-period=0 or --force on a pod that still has turns. That is the one way to kill a turn.
  • Plan image rolls for quiet hours when you can. They are safe at any time, only slower under load.
  • The drain needs an agent-harness image that carries it. An older executor ignores the setting and exits as before.

Idle guard when the StorageClass is missing

The executor is Ready only when its volume binds on an XFS + prjquota StorageClass. So an install never waits on one that cannot bind, both charts check the live cluster:
helm template and dry-runs always render the configured replicas (at least one). fleet.idleGuard: "off" removes the lookup for Argo CD, Flux and policy gates, and the executor stays Pending until its class exists. Create the class (or point provisioner at your CSI driver) and the next upgrade brings it up. Meanwhile turns queue.

--wait, --atomic and the installer

helm upgrade --wait (and --atomic) waits for the executor StatefulSet:
  • A running turn holds the roll. With --wait, an upgrade during a long turn can take up to the drain window plus pod start. Give --timeout at least the drain window (3600 s by default) or upgrade in a quiet period.
  • An idle executor never stalls an upgrade. With the idle guard above, a cluster without the storage prerequisites renders the executor at zero replicas and the upgrade completes.
install.sh handles both for you:

Image and registry

The executor and every slot use this image. Budget the ~1.5 GB pull per executor node. On Air-Gapped, mirror it at the pinned tag before the first executor starts.

Network egress

Executors hold no LLM provider key. A turn’s model calls go straight to the in-cluster LLM Gateway, which is always on, with the run’s own token. The gateway is the component that egresses to the model provider.
The executor refuses to register while the instance metadata endpoint (169.254.169.254) answers from the pod, so a turn can never read node credentials.

Model providers

Keys are saved in the organization (Platform > AI governance > Models) or on the install (secrets.static), and the LLM Gateway adds them to each call. Per-provider keys, Bedrock authentication and default harnesses: AI vendors.

Next steps

Size your cluster

Footprint, calculator and capacity planning.

Set up an EKS cluster

Nodes and StorageClasses.