Overview
Air-gapped is the self-hosted deployment that runs every xpander component in your environment. Nothing connects to xpander cloud at run time. That scope covers the web UI, the control plane services, a self-hosted Supabase stack (auth, storage, edge functions), and the data tier (PostgreSQL, Redis Stack, MongoDB). It is one Helm release, in one namespace. Nothing is sent to xpander: no heartbeats, no catalog sync, no telemetry. Every other connection is optional and yours: your LLM endpoint, the cloud services you choose (Bedrock, S3, SES), and the registry you mirror from at install time. The install-time / run-time / never split is the egress table on Air-Gapped Security Architecture. Entitlement is a sealed, signed Ed25519 license verified in-cluster. As in every self-hosted edition, agents’ skill calls, including skills served over MCP, run from your cluster, and agent workspaces (the agents’ computers) run there too. MCP clients such as Claude, ChatGPT or Cursor can connect to Omni, the built-in agent, once the install sets its MCP origin,global.mcp.publicUrl. Until then, with global.mcp.publicUrl unset and ui.env.VITE_OMNI_MCP_URL empty, Settings > Channels lists MCP as not configured. It installs in a cloud VPC in private mode (no internet or NAT gateways), or in any disconnected compute environment.
Air-Gapped Security Architecture
- The license is the identity. Two sealed files (
install.jsonandactivation.json, thexpander-license/v2format) plus the organization id you pass with--org: there are no console IDs and no API key. Installs issued with the older singlelicense.json(v1) carry the org id inside the file. The installer generates the environment id, and the first install’s seed pipeline creates everything else in-cluster, including your organization. Single-tenant: the license binds one organization. - Images come from your own registry mirror. xpander delivers the artifact set through the gated
xpanderaihubDocker Hub org with a per-customer read token; the installer mirrors it into a registry your cluster can reach, or you run the mirroring yourself on a sealed network. - Image tags are pinned per release. The chart refuses to render against unpinned images, and upgrades require mirroring the new bundle first.
--edition airgap. The flag selects the air-gap flow: a pre-install checklist, license validation, registry mirroring when needed, and a live watch over the chart’s install pipeline.
What Talks to What
Outbound (from your cluster). Nothing to xpander is required at run time. The rows below are the connections you choose; the complete install-time / run-time / never table is on Air-Gapped Security Architecture.Prerequisites
prjquota. See Sizing and requirements. To run it without privileged containers, see the unprivileged mode.
The fleet (the pool of executor pods where agent turns run) is always rendered and starts warm with one executor. It has one storage prerequisite: a StorageClass that mounts XFS with prjquota for the 500 GiB per-executor volume. Bring that class under the name in global.fleet.storage.className (fleet-xfs; createClass: false is the Air-Gapped default). Or set global.fleet.storage.createClass: true on EKS to let the chart render the class for the EBS CSI driver (ebs.csi.aws.com, gp3, encrypted). On EKS Auto Mode also set global.fleet.storage.provisioner: ebs.csi.eks.amazonaws.com. The classic driver object exists there but has no controller behind it, so a class on ebs.csi.aws.com never binds. If that class is missing on a live cluster, or the driver named in provisioner is not installed, the chart renders the executor idle at replicas: 0, annotated xpander.ai/fleet-idle-reason. An --atomic install or upgrade then never waits on a volume that cannot bind. Create the class and the next upgrade brings the executor up.
Sizing tiers, the storage breakdown and how each service scales with usage are on Sizing and requirements.
Install in Three Steps
Receive the license files and pull token by email
install.json (identity: org, edition, no expiry) and activation.json (entitlement: term, caps, features), your organization id for --org, the license public key, and a registry pull token for the gated xpanderaihub Docker Hub org (read-only, scoped to your account, individually revocable; it gates delivery only, the license is the enforcement). What each file gates and how renewal works: Two-File Licensing.Run the one-liner
license.json (xpander-license/v1) passes --license-file ./license.json instead of the three flags above.The command downloads the installer from charts.xpander.ai and runs an interactive wizard: preflight checks, configuration, Helm install, and a seeded admin login to sign in with when it finishes. Run it from a terminal with kubectl access to the target cluster.The wizard prompts for the rest:- Registry
- Chart source (OCI pull or local path)
- Data tier
- Admin email
- Organization display name
- StorageClass
- Whether to enable agent code execution (
--code-execution/--no-code-execution). The runner embeds dockerd, so its pod must be privileged, and a restricted PodSecurity policy refuses it - A base domain for the public hosts
- LLM provider keys, for any hosted providers your egress policy allows
Sign in as the seeded admin
bash install.sh --edition airgap --manual prints the whole install as a personalized, copy-pasteable runbook and executes nothing: see Advanced: review-first / manual install.
What Happens When You Run the Installer
The full anatomy of an install, in order. Steps 1–6 take a few minutes altogether; step 7, the cluster actually coming up, is the long one, typically 10–20 minutes. Each phase is announced as a stable[N/8] name: ... progress line (the shape scripts grep, see Automation & CI).
FIX: line (the repair) and a RERUN: line (your personalized command to resume). Nothing needs diagnosing from scratch.Resolve: load the latest release
--release-manifest ./release-manifest.json. A missing or invalid manifest stops the operation. How release selection works.Checklist: preconditions verified before anything changes
/v2/ (a 401 counts as up), representative images (agent-controller, deployment-manager) exist in the mirror, each probed at its own pinned tag, a usable RWO StorageClass exists, the image pins are in place (N images pinned, each at its own version), the org seed has its inputs (--org-name + --admin-email), and a chart source exists. Any failed item stops the run here, with nothing to clean up.License: validated locally, applied as a Secret
xpander-license/v2 envelopes, one kind: install and one kind: activation, bound to the org id from --org; a legacy v1 file is checked for its envelope, expiry and the org id it carries), then applied idempotently as the xpander-license Secret. The chart wires it to every license-consuming service and verifies the Ed25519 signature against xpander’s issuer public key: in the preflight hook, and again at deployment-manager boot.Chart: pulled over OCI
xpander-airgap chart is pulled from the gated OCI registry with your read token, or from your own mirror if you mirrored the chart next to the images, or from a hand-carried .tgz via --chart-path.Values: secrets never touch the command line
Install: helm, without --atomic on purpose
helm upgrade --install runs in the background (its log path is printed). The first install deliberately runs without --atomic: --atomic implies --wait, which would hold the post-install seed hooks until every pod is Ready, while the api and agent-controller pods are themselves waiting on that seed. The smoke hook (step 7) is the real readiness gate. Upgrades always run with --atomic: a failed upgrade rolls back automatically and the previous revision keeps serving.Pipeline: the cluster comes up, hook by hook (10–20 min)
Summary: URLs, password, logs
https://chat.<domain>, the API at https://api.<domain>, Supabase at https://supabase.<domain>), and the bootstrap admin password itself, plus the command to read it again later:~/.xpander/logs/: latest.log always points at the most recent run, ready to review and share with support if you need help.Releases
Helm remains the deployment mechanism. The release manifest supplies the xpander image versions and digests to Helm; it does not install workloads itself. The installer and mirror script default to the release manifest in the latest release. Each manifest identifies one complete set of 28 xpander images: 19 xpander services (among thembilling and openapi-spec-generator), 3 agent runtime images, and 6 artifacts (the UI, migrations, edge functions, skills, catalog specs, and icons). A newer tag in a registry does not change this set.
- Use the latest release: omit
--release-manifestwhen running the installer or mirror script on a connected machine. - Repeat one release: retain its JSON manifest and pass the same
--release-manifest FILEto both scripts. This also prevents a new release between mirroring and installation from changing the selected images. - Stop on an invalid release: a missing, incomplete, or invalid manifest stops the operation. It does not fall back to
latest, guess individual tags, or silently omit missing artifact images. - Keep the release consistent: explicit image tag flags must match the manifest. The installer applies the release image pins over conflicting image values. Customer configuration such as registry addresses, infrastructure settings, and secrets remains separate.
agent-harness and openapi-spec-generator, plus the third-party data-tier and Supabase images and charts/xpander-airgap. image_sync.sh creates missing ECR repositories itself.
Following a development stream instead of a release is for a staging environment that tracks pre-release builds. Set XPANDER_IMAGE_STREAM=dev in the installer’s environment, and pass --stream dev to image_sync.sh. Without the variable, an air-gapped install pins every image to the approved release and refuses development overrides.
Per-artifact tag flags:
--manifest-out <file> makes the mirror script write the tags it copied as a values file, to pass to the installer with --values last. --release-candidate selects a candidate release. --registry-username xpanderaihub --registry-token <token> and --xpander-registry-token <token> carry the pull credentials.
The icon pack has no installer flag: pin it with --set ui.iconPack.image.repository=... --set ui.iconPack.image.tag=..., or icons fall back to monograms.
Publishing a release does not upgrade customer clusters. Your Helm invocation, GitOps reconciliation policy, or upgrade schedule determines when it is installed. Updating a Helm repository index only refreshes the available chart list.
Existing Helm and GitOps Pipelines
When the Mirror Is Empty, the Wizard Offers to Fill It
The wizard can mirror the selected release from a connected machine. Choose Mirror them now, provide the xpander registry pull credentials and any destination credentials, and let the copy finish before installation. ECR repositories are created automatically when the configured AWS credentials allow it. For a sealed network, use the archive flow below. Non-interactively,--mirror now|guide|skip chooses the mirroring behavior; --mirror now needs the registry credentials. Skipping mirroring does not make missing release images available.
Sealed / Manual Mirroring
Useimage_sync.sh to mirror the release before installing it. Mirroring requires skopeo and Python 3; the Docker pull/tag/push fallback does not preserve the release digests.
On a connected workstation, download the manifest once and use that exact file for the copy and upgrade:
--dry-run prints the copy plan. Keep the release JSON manifest; a single --tag or a legacy service-only manifest does not describe the complete release.
Fully sealed network? Archive on the connected side, transfer the entire directory, and import it inside:
install.sh, image_sync.sh, and their matching release_manifest.py alongside the bundle. Obtain the validator from the immutable release_manifest-<sha256>.py URL embedded in those scripts and save it as release_manifest.py beside them. The scripts verify its checksum before executing it. Connected runs download the matching validator automatically.
The archive includes a release manifest and an image index. Import validates the complete xpander image set and digests before copying. An incomplete archive must be completed on the connected side. Legacy archives require the previous importer and do not establish a release.
The chart itself is an OCI artifact. Either mirror it next to the images (--chart-version <X.Y.Z> --chart-source registry-1.docker.io/xpanderaihub/xpander-airgap, it lands at oci://<your-registry>/xpander/charts/xpander-airgap), or pull and hand-carry the .tgz:
Using Nexus, Artifactory, or Harbor
Using Nexus, Artifactory, or Harbor
--registry target. Create a hosted Docker repository and fill it with the wizard’s mirror-now step, or with image_sync.sh directly; --dest-user / --dest-token cover repositories behind Basic auth:--registry nexus.corp.example:5000/xpander.Pull-through proxy, no mirror step. Create a Docker proxy repository with Docker Hub (https://registry-1.docker.io) as the upstream, and set the xpander-issued read token as the proxy’s upstream credentials. Install with the org path kept inside the registry value:xpanderaihub path segment must remain: the chart composes every image reference as <registry>/<name>, so the cluster asks the proxy for <proxy-repo>/xpanderaihub/agent-controller, which maps to xpanderaihub/agent-controller upstream. The installer’s tag resolution works against the proxy the same way (Docker Registry v2 API, Basic or bearer auth).A fully sealed network can’t use a proxy: it needs upstream reachability. Keep the two-hop archive flow above, landing in a hosted repository.Data Tier: In-Cluster or External
The data tier is the one choice that is expensive to change later: the chart-managed StatefulSets own their PVCs, and moving to a managed service afterwards is a migration. The wizard asks per store; the default is both in-cluster.- In-cluster (default)
- External (RDS / MemoryDB)
- PostgreSQL 16: durable history/memory tier and per-agent database provisioning
- Redis Stack (
redis-stack-server): queues, cache, and knowledge-base vectors. Deliberately not plain Redis: knowledge bases are RediSearch (FT.*) indexes - MongoDB: catalog and monitoring stores
- The Supabase stack ships its own pinned PostgreSQL 15 (it requires extensions managed services don’t offer)
--storage-class <name> if you don’t want the cluster default.Local LLM Inference
An air-gapped install needs an LLM endpoint reachable in-network. xpander speaks the OpenAI-compatible API, so any local runtime that exposes it plugs in directly:global.llm.gateway.{provider,model,apiBase} in values). LLM defaults follow values-as-truth with snapshot convergence. Every install and upgrade converges the organization’s LLM defaults to the chart’s declaration, but only when the declaration itself changed. Defaults an admin has edited in the UI survive upgrades until you deliberately change the declared value.
global.llm.bedrock.bearerTokenSecret empty and set global.llm.bedrock.region. The role bound to the pods needs the Bedrock permissions itself (bedrock:InvokeModel, bedrock:InvokeModelWithResponseStream, bedrock:Converse, bedrock:ConverseStream on *, because the global.* inference profiles route across regions), and it must be bound to both ServiceAccounts the LLM services run as in the release namespace: default and agent-controller. The chat and task paths use those credentials directly and do not assume a second role. The account must be opted in to Bedrock’s data retention mode for the models in every region a global. profile spans, and a sealed network needs VPC endpoints for bedrock-runtime, sts and eks-auth. A Bedrock API key (bearer token) in bearerTokenSecret is the alternative. The “IAM role” method under Settings, LLM settings, Providers is not the path for chat and tasks: it applies to the Claude Code leg only (see below). If a Bedrock entry with an IAM role exists there and chat fails, remove it, then restart the agent-controller, agent-worker, agents, actions and functions deployments so the old value is dropped.bearerTokenSecret and assumeRoleArn unset. Put the Bedrock permissions on the role the pods already run as (the install’s xpander-sa-role in that customer’s case), bind it to the default and agent-controller service accounts, and upgrade the release. A separate Bedrock access role is not needed.
Claude Code on Bedrock takes its own path: it reaches Bedrock through the Agent Controller’s LLM proxy, which resolves AWS auth on the controller itself. This is the only place the assume-role option applies. There too a bearer token is optional: global.llm.bedrock.assumeRoleArn (plus assumeRoleExternalId) takes the STS assume-role path, and with both the token and the role empty the controller signs with the pod’s own credential chain (EKS Pod Identity or IRSA). global.llm.bedrock.baseUrl points that leg at your VPC endpoint on a sealed install. Fable- and Mythos-class Anthropic models additionally need the account’s Bedrock data-retention opt-in, per region; the exact call is on AI vendors.
The summaries xpander writes around agent work (thread titles, context compaction, evaluation judges) follow the organization’s LLM settings: the org default model leads, and the install’s other configured providers back it up. Nothing in that chain dials a public endpoint on a sealed install.
A worked vLLM setup on EKS Auto Mode
One in-cluster endpoint, checked end to end on an Air-Gapped install: vLLM serving a small instruct model on a T4, registered in xpander as a custom provider. The cluster side is yours; xpander only needs the address. GPU capacity. On EKS Auto Mode aNodePool restricted to the g4dn family with a nvidia.com/gpu taint provisions a T4 node in about two minutes. Auto Mode exposes nvidia.com/gpu: 1 on it with no device-plugin install. The pool needs a nodeClassRef to the built-in class: group: eks.amazonaws.com, kind: NodeClass, name: default. Weights go on a PersistentVolumeClaim mounted as HF_HOME, so they survive a pod restart; on a sealed network they have to be on that volume already.
Five things that stop vLLM before it serves an agent.
- Kubernetes writes every Service in the namespace into a pod’s environment as
<NAME>_PORT(service links). A Service namedvllmtherefore injectsVLLM_PORT=tcp://..., and vLLM refuses to start withVLLM_PORT ... appears to be a URI. SetenableServiceLinks: falseon the pod, or name the Service something else. - A T4 GPU needs
--dtype=half. vLLM 0.11 crash-loops on a T4 because its newer engine needs a newer GPU;vllm/vllm-openai:v0.9.2runs there. - Pass the key as the
VLLM_API_KEYenvironment variable, not the--api-keyflag. The flag prints the key in the startup log. - Start with
--enable-auto-tool-choice --tool-call-parser=hermes, the two vLLM flags that let a Qwen model return function calls. xpander’s Add provider dialog probes whether the model accepts function calls; without these flags the discovered model is marked as unable to run agents. - Give the model a context window well above the composed prompt. With
--max-model-len=8192the first turn failed with vLLM’s400 This model's maximum context length is 8192 tokens. However, you requested 8298 tokens. The conversation shows Unknown model error. The controller logsNon-retryable model provider error: Unknown model errornext to a[prompt-budget] system_message_tok=...line, which sizes the prompt it composed.--max-model-len=32768ran. For a custom model the controller’s context optimizer also warnsunknown model.id ... defaulting to 128,000 window.
Register the endpoint in xpander
- Open Settings > LLM settings and click Add custom provider. The dialog is titled Add provider.
- Name is free text. Slug is derived from it, lowercase with underscores, and stays stable. Protocol is OpenAI-compatible. Base URL is the address the pods reach, for example
http://vllm.<namespace>.svc.cluster.local:8000/v1. - API key: No key (local or unauthenticated server), Use an existing key, or Create a new key with a Key name and the API key (the value in
VLLM_API_KEY). - Under Models, click Test connection. A reachable endpoint answers Reachable with the round-trip time and lists the models it serves; a model that accepts function calls carries a
toolsbadge. Click Add on the model, and give it a Display name if you want one. - Click Add provider. The provider row reads OpenAI-compatible, how many of its models are in the catalog, and Reachable.
- Open the provider’s model list and choose Set model cost from the model row’s menu. The dialog says what it is for: “Agents can use the model once it has a cost.” Enter the Price per 1M tokens (USD): Input and Output, and optionally Cache read and Cache write. These prices measure credit burn for the model; they are never charged. Who can use it is Everyone in the workspace or Specific groups and people. Click Save cost.

Add provider, filled in: name and slug, OpenAI-compatible, the in-cluster base URL, a new key, the discovered model with a display name, and the Reachable result of Test connection. Shown with sample data.

Settings > LLM settings after Add provider: the custom provider's row shows OpenAI-compatible, its models in the catalog and Reachable. Shown with sample data.

Set model cost: provider, model id, price per million tokens for input, output and the cache rates, and who can use the model. Shown with sample data.
POST /v1/agents accepts only the built-in providers in model_provider; a custom slug is refused with 422. Agents reach a custom provider through OpenCode. In the composer’s Harness and model popover, the OpenCode pane lists every provider, custom ones included; the model becomes selectable once it has a cost, and the provider’s row then counts it as enabled. The conversation header names what runs, for example OpenCode · Qwen2.5 1.5B (lab T4), and the controller’s gateway turn start line carries model=<host>:<port>/<model id>. vLLM logs the call as POST /v1/chat/completions 200.
The executor fleet on Air-Gap
Every agent runs on Claude Code, Codex or OpenCode. Their turns run on the executor fleet the chart always renders; sizing and node prerequisites are on Sizing and requirements. What an Air-Gapped operator configures beyond that:AI Skill Generation
Air-gapped installs include the skill-generation pipeline: upload an OpenAPI spec in the UI (Connectors → Create connector) and the openapi-spec-generator service enriches it into a full skill (operation grouping, naming, descriptions, summaries, and validation) through a multi-step LLM pipeline. Runs execute in the background; generated skills can be deleted, and a new spec version can be uploaded over an existing skill. The air-gap license carries the entitlement for this (Custom connectors in the license panel), and the generator’s image is part of the standard mirrored set.
The generator runs on exactly one LLM provider, decided at install time: Bedrock when the install has Bedrock configured, otherwise OpenAI when an OpenAI key was provided. That one provider powers every pipeline step and the embeddings, and the service refuses runs when its provider’s credentials are absent rather than degrading quietly. With neither provider, the generator is not installed at all and skill creation in the UI is spec-only: the spec is ingested as-is, with no AI enrichment (the UI flag VITE_CONNECTOR_AI_ENRICHMENT is set for you).
The generator writes the specs it works on to an object store bucket, on both the catalog and the openapi-spec-generator services; without it the upload step fails on a missing bucket name. The bucket must have versioning enabled: the generator lists object versions to find the latest spec and fails on an unversioned bucket.
Custom Actions and Code Execution
Custom actions and code run in short-lived sandbox jobs started by theactions service. On a multi-tenant cloud those jobs resolve public DNS only; on a single-tenant installation the point of a custom action is often to reach an internal system, so hand the jobs the cluster’s resolver:
api.env.SANDBOX_IMAGE overrides it when you track a development stream.
The License
install.json + activation.json, the xpander-license/v2 format), which enables renewal without a reinstall and enforces seat/agent caps. See Two-File Licensing. The single-file flow below (xpander-license/v1) still works for installs issued that way.kind: install and one kind: activation, the org id from --org) and creates the xpander-license Secret with both files as keys, idempotently:
global.license.publicKey, and deployment-manager re-verifies at boot: it refuses to start on a bad signature.
- Your own
--valuesfile: the installer generates theglobal.licenseblock (secretName,installKey,activationKey,publicKey) into its own values. When you also pass--valuesof your own, pass--set global.license.installKey=install.json --set global.license.activationKey=activation.json --set global.license.publicKey=<license public key>as well, or your file displaces the installer’s license block. - Renewing: renewal re-issues only
activation.json. Replace that one key in the Secret (leaveinstall.jsonas it is) and redeploy: roll the license consumers, runinstall.sh upgradeby hand, or let your pipeline re-run its install. License consumers carry a checksum annotation over the Secret, so a redeploy rolls them onto the new file. No reinstall, no re-mirror. Steps: Renewing: re-activate. - Expired activation: the installer warns at the license phase and the chart’s preflight refuses a fresh install. A running install degrades in stages instead of stopping:
GET /v1/license/statuson the API (with a signed-in user’s bearer token) returns the current state,valid,expiring,graceorexpired, plus the deployment and granted features; alert on anything pastexpiring. - Single-tenant: the license binds one organization; the org seed creates exactly that org.
- Legacy single file (
xpander-license/v1):--license-file ./license.jsoncreates the Secret with onelicense.jsonkey, wired throughglobal.license.key. Renewal on that path is a newlicense.jsonin the Secret plus the same redeploy.
Domains, Ingress & DNS
One--domain names every public host. Give the installer a base domain, xpander.acme.internal, and the chart derives the whole browser-facing set from it:
app.for the UIauth.for Supabaseapi.chat.for agent-controllerhooks.for the webhook servicelinks.for short links (thefunctionsService)actions.andclient-auth.dm.internal.for deployment-manager, on a second, internal ingress class (deployment-manager.ingress.className), because remote runtimes and the edge functions reach it over a backchannel rather than the user-facing path
<name>.ingress.host (the UI also with --ui-host). The stack mints and validates URLs against these origins, so move the matching knobs in the same change:
<name>.ingress.enabled: false keeps one private; global.ingress.enabled: false suppresses all of them, for clusters that manage their own ingress objects. The base domain also derives the UI’s runtime configuration: Xpander Chat needs to know its Supabase, API, and chat-backend origins at boot. Without a domain, or explicit ui.env overrides, a browser on a sealed network would be pointed at origins it cannot reach.
Overrides and knobs:
--ui-host <host>replaces the derivedchat.<domain>only; any other host is overridable per service via<name>.ingress.hostin values (the table above lists the URL knobs each rename drags along).--ingress-scheme https|http(defaulthttps) sets how the browser reaches those hosts: it feeds every derived URL. Usehttponly for rigs without certificates.- No ingress controller is assumed. Set the class and annotations per component (
<name>.ingress.className/annotations) or once for all of them viaglobal.ingress.className/global.ingress.annotations.
tls: block, passed through verbatim, per component (<name>.ingress.tls) or once via global.ingress.tls. Terminate at your controller with certificates that cover every host you publish (a wildcard *.xpander.acme.internal covers the derived set; an internal host such as dm.internal.<domain> sits one level deeper and needs its own wildcard):
*.xpander.acme.example does not cover *.staging.xpander.acme.example. The Ingress objects carry the controller’s annotations and no tls: block at all. On EKS Auto Mode, put the group name in an IngressClassParams object rather than in the alb.ingress.kubernetes.io/group.name annotation, which Auto Mode ignores. Otherwise every Ingress gets its own load balancer. The load balancer exists only after the first Ingress is created, so DNS records that alias it are a second pass after the install.
Making the Hosts Resolve
- Internal DNS zone
- VPN + internal load balancer
- Local test: port-forward + /etc/hosts
Signing In
The install ends with the summary box printing the derived URLs and the bootstrap admin password; the release NOTES (helm get notes xpander -n xpander) repeat the commands. With no external dependency configured, sign-in still works:
-
Bootstrap password (default): open the UI (
https://chat.<domain>) at/login-passwordwith your--admin-emailand the password from the summary; read it again any time: -
Email OTP without SMTP: the login page’s email flow works with no mailer. The code is generated and stored, just not sent. Print it with the chart’s
scripts/get_login_code.sh <email> <namespace>. With SMTP configured (global.smtp.*in values), the same codes arrive by email. On installs with inbound agent mailboxes, agents’ email replies deliver through the same relay.INBOUND_EMAIL_REPLY_DOMAINcontrols the From domain, and the SMTP identity must be allowed to send as the agents’ mailbox subdomain. -
SSO with your identity provider (OIDC): the SSO button is driven by the OIDC configuration an administrator enters in Xpander Chat (Settings, OIDC), served by the
client-authcomponent, not by GoTrue’s external providers. Setup, IdP client settings, the Keycloakpreferred_usernamenote and troubleshooting: SSO with your identity provider. Google and GitHub social buttons remain GoTrue providers (supabase.auth.extraEnv).
supabase.db.jwtExpiry defaults to 86400: a signed-in user’s access token stays valid for a day and the session renews through the rotating refresh token. One value feeds GoTrue, PostgREST and the database setting, so an override moves all three together. Tokens minted before an upgrade keep their old expiry until they refresh, and the migrations hook re-applies the database side on every upgrade, so an install born with the older one-hour default converges too.
Upgrading an Air-Gapped Install
Slack handle management compatibility
For releases that include administrator-authorized Slack agent handles, mirror the matching Supabase migrations image and bots image before upgrading the chart. The migrations image must include migrations20260910231402 and 20260911192635. The corresponding chart’s post-migration check requires get_slack_handle_authorization, save_slack_handle_authorization, and complete_slack_handle_authorization; an older migrations image fails this compatibility check.
Do not upgrade the chart alone before those images are published and available in your mirror. Use the matching release artifacts together. For your installation’s Slack app, include usergroups:write in its User Token Scopes, then complete administrator consent in Settings → Channels → Slack. The consent token is encrypted in the installation’s local Supabase Vault; no new static token or environment variable is needed.
Slack requires explicitly allowed connectivity and cannot operate in a fully disconnected installation. This upgrade does not enable Slack connectivity or agent handles automatically.
Upgrade procedure
Check the latest release for updates. An air-gap upgrade is two steps, in this order:Mirror the new bundle FIRST
ImagePullBackOff. Re-run the mirroring for the target release (curl -fsSLO https://charts.xpander.ai/image_sync.sh and run it against your registry as in Sealed / manual mirroring), and hand-carry the new chart .tgz if your install host can’t pull it over OCI.Run the upgrade
helm get values). It loads the release pins over the previous image values. Pass the same manifest used for mirroring; if the manifest cannot be loaded or validated, the upgrade stops. Upgrades run with --atomic, which rolls back workload changes on failure; database migrations remain forward-only. This is the hand-driven path; a pipeline should use the declarative re-install below instead.Declarative re-install for CI
A pipeline should inherit nothing from the release it is upgrading. Run install semantics on every deploy:bash install.sh --edition airgap ... with the full set of values files, the release manifest and mirror pins, the license flags and --org, exactly as on day one. Against an existing deployed release the installer adds --atomic by itself, so a failed run still rolls back. The reason to avoid install.sh upgrade in CI is what it inherits: the reference customer pipeline once let a stored digest ride along and ran a stale image on staging for four days before anyone noticed. Two settings go with that path:
-
--helm-timeout 20m(the default is30m). Upgrades run--atomic, and the rollback waits with the same timeout, so budget twice the value plus a few minutes for hooks inside your job’s credential lifetime. A one-hour OIDC session cannot host a 30-minute timeout with its rollback. -
Heal before you run. The installer refuses to install over a release in
pending-install,pending-upgradeorfailed. Runhelm history xpander -n xpander, thenhelm rollback xpander <last deployed revision> -n xpander, orhelm uninstall xpander -n xpanderwhen no deployed revision exists (thexpander-generated-keysSecret survives an uninstall). Delete Failed-phase pods and failed hook Jobs first so the new run starts clean. -
Pin the environment id on every re-install. The first install creates the organization’s default location, the runtime environment its agents run in, and a later install cannot point it at a new id. A pipeline that re-runs the install must therefore pass
--env <existing default environment id>after the first run. Read the id from xpander’s database before installing: -
Wait for Helm’s own verdict. The installer’s stage watcher exits on the first failed hook pod, and a pod evicted while the cluster rebalances counts even though its Job retries and succeeds. When
install.shexits non-zero while itshelm upgrade --install xpanderprocess is still running, do not kill that process: killing Helm mid-upgrade strands the release inpending-upgrade. Wait for Helm to finish and read the release status; a retried hook pod under a Job that completed is not a failure. -
global.upgradeStrategy: replaceon clusters that provision nodes on demand. A surge upgrade of every Deployment can cascade into node-provisioning waves that outlive Helm’s timeout and roll back a healthy upgrade;replacerecreates pods in place instead.
--atomic. --atomic waits for the executor fleet like any other workload. This has two consequences.
A running harness turn holds the roll: on SIGTERM an executor drains for up to global.fleet.executor.drainSeconds (3600 s) before the replacement pod appears. A roll during a long turn takes as long as that turn. The installer’s timeout covers it. On a hand-driven helm upgrade, give --timeout at least the drain window, or upgrade in a quiet period. A turn still running at the deadline is stopped with “The executor is restarting; send the message again to continue where it left off.” It resumes on the new pod when the user sends again.
An idle executor does not stall an upgrade. On a live cluster without the fleet’s StorageClass, the chart renders the executor at zero replicas and records why on the xpander.ai/fleet-idle-reason annotation. The installer skips zero-replica workloads when it verifies the release.
A first install runs without --wait/--atomic on purpose: the post-install smoke hook is its readiness gate.
What survives what:
- Generated keys survive every upgrade: the keygen hook is lookup-preserve, the
xpander-generated-keysSecret is Job-created (never Helm-rendered), and rollback never rewinds it. helm rollback xpander <N>restores any prior revision: everything the chart owns is release-versioned.- Except the database schema. Migrations run as a versioned Job per revision (kept as the audit trail) and are forward-only: a schema rollback means restore-from-backup, never an automatic down-migration.
- Settings that converge on every upgrade: the one-day access-token lifetime (
supabase.db.jwtExpiry) is re-applied on the database side by the migrations hook, so an install born before that default cannot drift back to one hour.
Automation & CI
The installer is built to be driven by scripts, CI pipelines, and coding agents:- Every prompt has a flag. With
--non-interactivenothing is read from the terminal, even when one exists: missing required values exit 2 with the usage and a completed rerun line rather than blocking. It implies--yesand--on-probe-failure abort. - Stable progress output. Progress is one line per transition in a grep-friendly shape,
[3/8] license: ok, and--quietreduces output to phase lines and the outcome only. Color is emitted only when stdout is a terminal. statusis the poll target. A read-only health report: edition, chart, release status, hook jobs, workload readiness, and URLs.--jsonemits one JSON object.
--mirror now --xpander-registry-token <token>. Without a --mirror answer, a non-interactive run against an empty mirror exits 2 with the options spelled out.
Poll until healthy:
Reference values file
The keys a complete production values file carries, with the reason each exists. Hosts, bucket names and endpoints are placeholders a pipeline renders from its infrastructure outputs; passwords never appear here (pass them with--set), and image pins come from the mirror manifest passed last.
orgSeed, generated passwords, image pins) in this file: extra values files merge last and would silently override what the installer computes.
CI/CD & GitOps
Jenkins (or any CI): watch the releases feed, mirror, upgrade
Jenkins (or any CI): watch the releases feed, mirror, upgrade
status. Run it on an agent with cluster access, Helm 3.13 or later, skopeo, and Python 3.1 means the upgrade failed; a run against a deployed release adds --atomic, so Helm has already rolled it back. Exit code 2 is a usage error in the command line. Exit code 4 means a preflight check failed.The status command fails on two codes. Exit code 1 means the install is degraded, and the output lists the reasons. Exit code 3 means nothing is installed.status --json emits one JSON object if you’d rather parse than gate on the exit code.ArgoCD: the chart as Helm OCI from your registry
ArgoCD: the chart as Helm OCI from your registry
--release-manifest ./release-manifest.json --values-out xpander/values.yaml. It exports xpander/values.yaml and xpander/values.yaml.release.json. The second file contains Helm values, not the original release metadata. Keep the original manifest for your release record and pass both generated values files, with release values last:- Values: regenerate the release values for each release and review them together with the chart version. Do not add image overrides after the release values; direct Helm/GitOps does not enforce the installer’s manifest checks. The generated file can carry secrets (LLM keys, external DSNs): strip those into your secret manager before committing.
- Bootstrap secrets: the
xpander-licenseSecret (and the registry pull secret, when one is needed) are created once out-of-band (kubectl create secret ...as shown in The License), or managed via SealedSecrets / External Secrets Operator. - Hooks: Argo CD maps Helm hooks (the chart’s preflight → keygen → migrations → org-seed → smoke pipeline) onto its own sync phases, and hook weights onto sync waves. This mapping is supported by Argo, but we have not certified the chart under Argo CD: verify the hook ordering on your Argo version before relying on it for upgrades;
install.shremains the certified path.
Troubleshooting
Every installer failure prints two lines:FIX:, the action that repairs the condition, and RERUN:, your personalized non-interactive one-liner (secrets typed at prompts are shown as placeholders). The installer is idempotent: fix the condition, paste the rerun line, and it picks up where the failure left off. The air-gap checklist verifies preconditions before anything changes, so most problems stop the run with nothing to clean up. For scripted runs, the exit code names the failure class (Automation & CI), and bash install.sh status gives a read-only health verdict.
Mirroring and Registries
Mirror push fails: name unknown, 'The repository ... does not exist' (ECR)
Mirror push fails: name unknown, 'The repository ... does not exist' (ECR)
image_sync.sh creates each ECR repository automatically before pushing (it needs AWS CLI credentials on the machine running it). Refresh your copy:Mirror push fails: authentication required at the destination
Mirror push fails: authentication required at the destination
authentication required writing to your registry, even though docker login to it succeeds.Cause: skopeo cannot read logins kept in Docker Desktop’s credential store, so the docker login is invisible to the copy.Fix: the current image_sync.sh logs in itself for ECR destinations (aws ecr get-login-password | skopeo login --username AWS --password-stdin). For other registries, pass the credentials explicitly:401 from Docker Hub when resolving or mirroring
401 from Docker Hub when resolving or mirroring
401 from auth.docker.io (incorrect username or password). The installer surfaces the registry’s own reason.Cause: the pull credential is the organization username xpanderaihub plus the read token from your delivery email. A personal Docker Hub username returns 401, and so does a token missing its read scopes.Fix: re-run with the exact pair from the email (--xpander-registry-user xpanderaihub --xpander-registry-token <token>). If it still refuses, ask xpander to re-issue the token: it needs repository-read and image-pull access to the gated repos.Pods can't pull: manifest unknown at your pinned tag
Pods can't pull: manifest unknown at your pinned tag
manifest unknown, or the installer’s checklist reports:image_sync.sh, safe to re-run), then verify the exact coordinate before retrying:Cluster and Network
Nodes time out pulling from docker.io (dial tcp ... i/o timeout)
Nodes time out pulling from docker.io (dial tcp ... i/o timeout)
docker.io instead of your mirror.Fix: install with --registry <your-mirror>/<path> so every xpander image resolves in-network. Make sure the mirror itself is reachable from the nodes. On an ECR-only sealed VPC, that means the ecr.api + ecr.dkr interface endpoints, the S3 gateway endpoint, and ECR read on the node role. Find the offending reference with:Preflight: no node with a supported architecture (Graviton/ARM)
Preflight: no node with a supported architecture (Graviton/ARM)
exec format error.)Cause: xpander images are amd64-only; ARM/Graviton nodes (t4g, m7g, c7g, …) cannot run them. The installer’s preflight and the chart’s preflight hook both check before anything deploys.Fix: add an amd64 node group (x86 instance types) and re-run. Only if you have genuinely arm64 images should you override the chart’s check: --set 'preflight.allowedArchitectures[0]=arm64'.LoadBalancer Service stuck <pending>: could not find any suitable subnets
LoadBalancer Service stuck <pending>: could not find any suitable subnets
kubectl -n xpander port-forward svc/ui 8080:80.External data-tier probe fails: namespaces 'xpander' not found
External data-tier probe fails: namespaces 'xpander' not found
curl -fsSL https://charts.xpander.ai/install.sh), or create the namespace and re-run:Pods stuck in Pending
Pods stuck in Pending
Pending; kubectl -n xpander describe pvc shows unbound claims.Cause: no usable RWO StorageClass (or the wrong one), or the cluster is under capacity for the footprint.Fix: kubectl get storageclass, pass --storage-class <name> if the default isn’t right; check kubectl -n xpander get events --sort-by=.lastTimestamp for FailedScheduling and add nodes if capacity is the constraint.UI loads blank: Missing or invalid environment variables
UI loads blank: Missing or invalid environment variables
bash install.sh upgrade (chart ≥ 0.6.0 injects the config from --domain). Staying on an older chart, set the origins yourself via ui.env (VITE_SUPABASE_URL, VITE_CHAT_BACKEND_URL, …) in your values.Only the UI host resolves; other subdomains 404 or NXDOMAIN
Only the UI host resolves; other subdomains 404 or NXDOMAIN
chat.<domain> opens, but sign-in or API calls fail, supabase.<domain> / api.<domain> don’t resolve, or land on a default-backend 404.Cause: the install publishes one host per service (seven with the derived names), and only one got a DNS record (or an /etc/hosts entry).Fix: create a wildcard record (*.<domain>) pointing at the ingress controller, or one record per host: chat, api, agent-controller, supabase, webhook, client-auth, actions with the derived names, or whatever you set in <name>.ingress.host. Verify what the cluster actually publishes:Install and Upgrade
Release stuck in pending-install / pending-upgrade / failed
Release stuck in pending-install / pending-upgrade / failed
install.sh upgrade requires deployed: operating on a stuck release would strand it. Recover first:RERUN: line. pending-install usually means an interrupted or timed-out first install: helm uninstall is the right recovery. The xpander-generated-keys Secret survives the uninstall (Job-created, not Helm-rendered), so a reinstall keeps the same keys.'images are unpinned' / chart refuses to render
'images are unpinned' / chart refuses to render
License expired or rejected
License expired or rejected
xpander-license/v2 files, license.json and activation.json, and confirms they name the organization passed with --org. On a legacy install it reads the single v1 file, its expiry and its organization id. The chart’s preflight hook repeats the check in the cluster, including the Ed25519 signature, and the deployment-manager service repeats it at boot. To see where a running install stands, call GET /v1/license/status on the API. It answers valid, expiring, grace or expired.Expired: the installer warns License expired on <date> - preflight will refuse the install. Get a renewed activation.json from the xpander.ai team (delivered over email), replace only that key in the Secret, and redeploy:install.sh upgrade by hand or your pipeline’s re-install: license consumers carry a checksum annotation over the Secret, so the redeploy rolls them onto the new file. If you pass your own --values, keep the --set global.license.installKey/activationKey/publicKey flags on that command (The License). A legacy v1 install swaps its single license.json key the same way.An agent answers 500 Failed to build agent details
An agent answers 500 Failed to build agent details
agents service could not assemble the agent, most often because the agent holds a connection to an API connector that never finished its pipeline (status faulty, no spec). Confirm in the agents pod log: a pydantic error on ConnectedConnector.open_api_spec_id. Recovery, from the Supabase database inside the cluster:A pipeline stage failed (preflight / keygen / migrations / org-seed / smoke)
A pipeline stage failed (preflight / keygen / migrations / org-seed / smoke)
--org-name and --admin-email whenever the Supabase stack is on: the checklist catches this before install; a hand-driven helm upgrade that drops them fails at render time.Services fail auth after an upgrade with a custom values file
Services fail auth after an upgrade with a custom values file
password: "" beside it overrides the generated password with the empty string. Helm merges the explicit empty value over the keygen Secret’s. The installer warns when it sees this shape; the fix is to drop the empty password: key from your values file entirely (a key that is absent is preserved, a key that is "" wins).Creating a skill from an OpenAPI spec stops at "Generating the enriched spec", but the skill appears anyway
Creating a skill from an OpenAPI spec stops at "Generating the enriched spec", but the skill appears anyway
Pods in ImagePullBackOff after an upgrade
Pods in ImagePullBackOff after an upgrade
manifest unknown). Re-run the mirroring for the target release, then re-run the upgrade: it runs with --atomic, so the failed attempt already rolled back and the previous revision kept serving.Edge-runtime crash-looping after upgrading with --reuse-values
Edge-runtime crash-looping after upgrading with --reuse-values
helm upgrade --reuse-values replays the previous release’s computed values, which can resurrect a stale edge-runtime placeholder image pin that newer chart versions removed. Upgrade with your real values files instead (what install.sh upgrade does), or clear the stale pin once:--edge-functions-tag).Release already runs the other deployment's chart
Release already runs the other deployment's chart
--release / --namespace), or remove the existing release first (helm uninstall xpander -n xpander).Piped run exits with 'these required flags are missing'
Piped run exits with 'these required flags are missing'
bash leaves no stdin to prompt on, so the run is non-interactive: Air-Gapped requires --org, --install-license-file and --activation-license-file (or --license-file on a legacy v1 install). Missing flags exit with code 2 and a completed rerun line. Download the script and run bash install.sh --edition airgap from a terminal for the interactive wizard.Next Steps
- Air-Gapped Security Architecture: zero egress to xpander Cloud, signed Ed25519 license, local sign-in
- Advanced: review-first / manual install: read the installer before running it, plus
--manual,--values-out, and--dry-run - The chart’s own README (inside the
.tgz) covers advanced topics: bring-your-own ingress for the UI/API/Supabase gateway hosts, private CA and corporate proxy support, SMTP, internet posture and skill offering, builtin-skills seeding, and Bedrock region pinning

