Install on Kubernetes#
Lens Agents installs into your own Kubernetes cluster from a Helm chart published to GHCR as an OCI artifact. There is no hosted control plane to send workloads to: the platform server, the sandboxes it runs, the database, and the audit trail all stay inside your cluster.
helm install lens-agents oci://ghcr.io/lensapp/lens-agents \
--set encryption.key="$(openssl rand -hex 32)" \
--set config.publicUrl="https://agents.example.com" \
--set sandboxIngress.host="sandboxes.example.com" \
--set inference.openrouter.token="<openrouter-api-key>" \
--wait --timeout 10m
That is a complete install. Everything below explains what those values do and what to change for production. The provider on the last line is the shortest one to start with; four others can replace it or join it.
Want to try it first?
Try Lens Agents locally walks the same install on a single-node local cluster, with a first agent at the end. Come back here when you install on a real cluster.
Prerequisites#
| Requirement | Detail |
|---|---|
| Kubernetes | A cluster you can install into, with a default storage class for the bundled database and sandbox volumes. |
| Helm | v3.14 or later, for OCI charts and --reset-then-reuse-values. |
Node kernel with nf_tables |
Sandboxes lock their network down with nftables. A node whose kernel lacks nf_tables runs the platform but fails every sandbox. |
| Outbound network access | To pull images from GHCR and to complete activation against Lens Cloud. |
| A Lens ID | The account that activates the install becomes its owner. Sign up at app.k8slens.dev. |
| An inference provider credential | AWS Bedrock, Microsoft Foundry, OpenAI, an OpenRouter API key, or your own LiteLLM proxy. See Inference providers. |
Managed cloud Kubernetes (EKS, AKS, GKE) and self-managed distributions carry nf_tables in their node kernels. Local clusters that run the node as a container on your host kernel — kind, Docker Desktop Kubernetes, minikube --driver=docker — also work. minikube's VM drivers ship an ISO kernel built without nf_tables and cannot run sandboxes.
Required values#
Three values are required, and one more decides whether agents can answer at all. The rest have workable defaults.
encryption.key#
A 64-character hex key used to encrypt secrets at rest — credentials, connector tokens, AWS keys. Generate one with openssl rand -hex 32.
Losing this key makes every stored secret unreadable. Store it the way you store any other root secret, and prefer handing the platform a Kubernetes Secret over passing the key on the command line:
kubectl create secret generic lens-agents-encryption \
--from-literal=encryptionKey="$(openssl rand -hex 32)"
helm install lens-agents oci://ghcr.io/lensapp/lens-agents \
--set encryption.existingSecret=lens-agents-encryption \
--set config.publicUrl="https://agents.example.com"
encryption.existingSecretKey names the key inside that Secret and defaults to encryptionKey.
config.publicUrl#
The URL the platform answers on. It is not cosmetic — the platform builds OAuth redirect URLs, sandbox ingress URLs, and activation callbacks from it. Set it to the address users and agents will actually reach, including the scheme.
sandboxIngress.host#
The wildcard DNS parent for sandbox exposed ports. A sandbox that publishes a port becomes reachable at <slug>.<sandboxIngress.host>, so *.sandboxes.example.com must resolve to your ingress.
Leave it empty and sandboxes still run, but exposed ports resolve to no URL.
An inference provider#
Agents reach their model through the platform's managed inference proxy, which is what makes spending limits, PII masking, and per-call audit possible. An install with no provider configured comes up healthy and runs sandboxes, but no agent in it answers.
Configure at least one. The quickest route is an install-level token, which every organization can use unless inference.deploymentKeyFallback is false:
| Provider | Minimum values | What it serves |
|---|---|---|
| OpenRouter | inference.openrouter.token |
Roughly 400 models from around 60 vendors, on either wire format. No cloud account, IAM role, or region to decide first. |
| AWS Bedrock | inference.bedrock.token, or nothing at all where the platform's service account assumes an IAM role |
Claude. A token also exposes Bedrock Mantle, which serves Claude and GPT off the same key. |
| Microsoft Foundry | inference.azure.baseUrl and inference.azure.token |
Claude and GPT from one resource and key. |
| OpenAI | inference.openai.token |
GPT. inference.openai.baseUrl points the backend at any OpenAI-compatible endpoint instead. |
| LiteLLM | inference.litellm.baseUrl and inference.litellm.token |
The models your own LiteLLM proxy routes, on either wire format. |
Every provider also accepts its token from a Kubernetes Secret, which is what to do outside a first install:
kubectl create secret generic lens-agents-openrouter \
--from-literal=NEXUS_OPENROUTER_TOKEN="<openrouter-api-key>"
helm upgrade lens-agents oci://ghcr.io/lensapp/lens-agents \
--reset-then-reuse-values \
--set inference.openrouter.existingSecret=lens-agents-openrouter
--reset-then-reuse-values is what keeps the values you already set, and it also takes the chart default for any value your release does not have yet, such as a value a newer chart adds. Without it, Helm resets every value this command does not name back to the chart default, encryption.key included. Do not use --reuse-values: it skips the new defaults, so an upgrade to a chart that adds a value can fail.
Providers are independent of each other. Set the values for any combination in one install, or add a provider later with a helm upgrade --reset-then-reuse-values that names its values, as above. The platform reads them at startup, so the upgrade restarts its pod.
Install-level tokens are optional. Helm owns the endpoints: Bedrock, Bedrock Mantle, OpenAI, and OpenRouter are always served, and Microsoft Foundry and LiteLLM once their baseUrl is set. A served backend with no install-level token can be used only by an organization whose org admin has added its own key on the Inference Keys page. Native Bedrock is the exception: with no token, it uses the platform's AWS credentials, unless inference.deploymentKeyFallback is false. See Per-organization keys.
Configuring a provider makes it available. A policy has to enable managed inference and name that provider before any sandbox can use it. Inference providers covers all six backends, which variables each one sets in a sandbox, and why a sandbox allowed to dial a provider host directly is metered by nothing.
config.ownerEmails#
Not required, and worth setting before anyone signs in.
| Value | Default | Purpose |
|---|---|---|
config.ownerEmails |
[] |
The people who run this installation, by email. Empty names no owners, which leaves organization creation open to anyone who can sign in. |
--set 'config.ownerEmails={ops@acme.com,admin@acme.com}'
Name owners and only they may create organizations; everyone else joins one by invitation, and a signed-in person with no organization, no invitation, and no right to create one is refused rather than given an account. Behind a corporate identity provider that is the difference between your platform team having accounts and your whole company having them. See Installation owners for the admission rules and the IdP claim it depends on.
Database#
The chart bundles PostgreSQL 17 and enables it by default. That is fine for evaluation and small installs, but the bundled instance ships with a default password and a single 8Gi volume.
For production, point the platform at a database you operate:
kubectl create secret generic lens-agents-db \
--from-literal=DATABASE_URL="postgres://user:pass@db.example.com:5432/lens_agents"
helm upgrade --install lens-agents oci://ghcr.io/lensapp/lens-agents \
--set postgresql.enabled=false \
--set externalDatabase.existingSecret=lens-agents-db \
...
To keep the bundled database but harden it, set postgresql.auth.password, size postgresql.persistence.size, and name a postgresql.persistence.storageClass that your cluster backs up.
Ingress#
The platform listens on port 3002 behind a ClusterIP service. Expose it with the chart's ingress, a Gateway API HTTPRoute, or your own resource in front of the service.
--set ingress.enabled=true \
--set ingress.className=nginx \
--set ingress.hosts[0].host=agents.example.com \
--set ingress.tls[0].secretName=lens-agents-tls \
--set ingress.tls[0].hosts[0]=agents.example.com
Set httpRoute.enabled=true with httpRoute.parentRefs instead if you run Gateway API.
Sandbox exposed ports need a second, wildcard route for *.<sandboxIngress.host> pointing at the same service. The platform resolves the sandbox from the subdomain and enforces the port's auth mode before proxying.
| Value | Default | Purpose |
|---|---|---|
config.maxHeaderBytes |
32768 |
The largest request header block the platform accepts, from 16384 to 1048576 bytes. A value outside that range stops the server from starting. |
A browser WebSocket carries its access token in Sec-WebSocket-Protocol, so headers outgrow Node's default of 16384 bytes. Raise the matching limit on an ingress controller in front to the same size, such as large-client-header-buffers on ingress-nginx.
The platform holds long-lived WebSocket connections
Sandbox tunnels, exec sessions, and the admin UI all use WebSockets. Configure your ingress with generous read and send timeouts, or exec sessions drop mid-command.
Client API#
The chart can also install the Lens Agents client API, the public API that end-user clients such as the iOS app call. It calls the platform as each person, with a token the identity provider exchanges for theirs, so the platform itself can stay on your private network. It is off by default. Its resources are named <release>-bff, on a public host of its own:
kubectl create secret generic lens-agents-client-api \
--from-literal=client-secret="<client-secret>"
helm upgrade lens-agents oci://ghcr.io/lensapp/lens-agents \
--reset-then-reuse-values \
--set bff.enabled=true \
--set bff.publicHosts[0]=api.agents.example.com \
--set bff.ingress.enabled=true \
--set bff.tokenExchange.clientId=lens-agents-client-api \
--set bff.tokenExchange.existingSecret=lens-agents-client-api
Set bff.httpRoute.enabled=true with bff.httpRoute.parentRefs instead of the ingress if you run Gateway API; enable one, not both.
The identity provider must support RFC 8693 token exchange for access tokens, such as Keycloak 26.2 or later with standard token exchange enabled on the exchange client. bff.tokenExchange.tokenEndpoint is that provider's token endpoint, exactly as its discovery document names it; the default is Lens ID's.
The install fails, rather than deploying a client API that cannot work, when:
bff.tokenExchange.clientIdorbff.tokenExchange.existingSecretis empty. The Secret holds the client's secret underclient-secret, or the key inbff.tokenExchange.secretKey.bff.oidc.issuerandbff.oidc.jwksUridiffer fromoidc.issuerandoidc.jwksUri. The client API cannot read the platform's values, so set both pairs when you use another identity provider.oidc.directAccessRoleis set andbff.tokenExchange.clientIdis not inoidc.delegatedClients. See SSO.bff.config.publicUrlis set andconfig.upstreamOAuthAdditionalRedirectUrisdoes not list<bff.config.publicUrl>/oauth/mcp/callback.- A non-default
service.port,fullnameOverride, ornameOverridewould point the client API at the wrong service. The error names thebffvalue to set.
To send the platform each person's own token instead of exchanging it, set bff.nexusAuth=forward and leave bff.tokenExchange.clientId and bff.tokenExchange.existingSecret empty. Use it only where everyone the client API acts for may call the platform directly. The install fails when oidc.directAccessRole is also set, because the platform refuses the client apps' own tokens while that gate is on.
Client MCP#
The client API can also serve the Lens Agents Client MCP at /mcp/client: tools an agent calls to ask the person using a Lens Agents client to act. Its request_credential tool asks the person for a header secret, such as an API key, and stores it as the agent's own credential, which the platform injects into the agent's requests without the agent seeing it. find_connection searches the Lens Agents marketplace, and request_connection asks the person to add an entry it found.
Create a Secret with the signing keys, as kid=<at least 32 bytes as base64url>, comma-separated, newest first, and point bff.clientMcp.url at the client API's in-cluster Service:
kubectl create secret generic lens-agents-client-mcp \
--from-literal=keys="k1=$(openssl rand 32 | basenc --base64url)"
helm upgrade lens-agents oci://ghcr.io/lensapp/lens-agents \
--reset-then-reuse-values \
--set bff.clientMcp.url=http://lens-agents-bff.<namespace>.svc/mcp/client \
--set bff.clientMcp.existingSecret=lens-agents-client-mcp
The route answers only requests addressed to the host in bff.clientMcp.url, so an in-cluster URL keeps it off the internet. The URL must end in /mcp/client and be https, or http to loopback or an in-cluster service name. If you restrict bff.networkPolicy.ingress.publicFrom, admit the platform's pods.
Sandbox runtime#
sandbox.provisioner and sandbox.k8s control how sandboxes are scheduled.
| Value | Default | Purpose |
|---|---|---|
sandbox.provisioner |
kubernetes |
What each sandbox runs as: a pod, or an agent-sandbox Sandbox object. See Running sandboxes as agent-sandbox objects. |
sandbox.k8s.cpu |
500m |
Ceiling for a sandbox's CPU request. A sandbox spec asking for more is rejected. |
sandbox.k8s.memory |
2Gi |
Ceiling for a sandbox's memory. |
sandbox.k8s.runtimeClassName |
(empty) | Runtime class for sandbox pods. Set it to a Kata Containers or gVisor class for per-sandbox microVM or user-space kernel isolation. |
sandbox.k8s.agentVolumeStorageClass |
(cluster default) | Storage class for persistent agent volumes. |
sandboxNamespace.name |
<release>-sandbox |
Namespace sandboxes are created in. |
Raise sandbox.k8s.cpu and sandbox.k8s.memory before running agents that build software or hold large working sets — the ceilings apply to every sandbox in the install.
sandbox.k8s.runtimeClassName is the single highest-value hardening step. Without it, sandboxes get privilege dropping, kernel-level network lockdown, and proxy-mediated egress, but share the node kernel. With a Kata runtime class, each sandbox gets its own kernel. See Sandbox isolation.
Running sandboxes as agent-sandbox objects#
sandbox.provisioner chooses what the platform creates for each sandbox.
| Value | Each sandbox is |
|---|---|
kubernetes (default) |
One pod, which the platform creates and deletes itself. |
kubernetes-agent-sandbox |
One kubernetes-sigs/agent-sandbox Sandbox object (agents.x-k8s.io/v1beta1). The agent-sandbox controller owns the pod. |
The chart does not install the controller. Install it before you set the value, at the version this target was verified against:
kubectl apply -f https://github.com/kubernetes-sigs/agent-sandbox/releases/download/v1.0.3/sandbox.yaml
helm upgrade lens-agents oci://ghcr.io/lensapp/lens-agents \
--reset-then-reuse-values \
--set sandbox.provisioner=kubernetes-agent-sandbox
The chart adds the RBAC rule the platform needs on sandboxes in agents.x-k8s.io. A stopped sandbox stays as a suspended Sandbox object you can inspect:
kubectl get sandboxes -n <sandbox-namespace>
Storage is unchanged on either target: the platform keeps its own volume claim per sandbox, so a stop, a restart, or a new revision keeps the data.
Stop every running sandbox before you change sandbox.provisioner
Both targets name the pod after the sandbox, so an object left by the other target blocks the new one. Moving back from kubernetes-agent-sandbox to kubernetes, also delete the suspended objects, because the controller keeps ownership of any pod with that name:
kubectl delete sandboxes -n <sandbox-namespace> --all
Verify the install#
kubectl rollout status deploy/lens-agents --timeout=5m
kubectl get pods
curl -fsS https://agents.example.com/health && echo OK
The platform serves three surfaces once it is up:
| Surface | Path | Used by |
|---|---|---|
| Admin UI | / |
Platform administrators |
| REST API | /v1 |
nexusctl, scripts, CI |
| MCP endpoint | /mcp |
Desktop AI tools and external agents |
| CLI installer | /install.sh |
Anyone installing nexusctl |
The OpenAPI document for the running version is served at /v1/openapi.json.
The installer and the binaries it downloads are unauthenticated by necessity — you hold no credentials until the CLI that obtains them exists. They serve a build matching this platform's version, so an air-gapped install needs no package registry.
A fresh install boots unactivated and shows an activation screen instead of a login. Continue to Activation.
Upgrade#
helm upgrade lens-agents oci://ghcr.io/lensapp/lens-agents --reset-then-reuse-values --wait
Database migrations run at startup. Sandboxes keep running across a platform upgrade; they reconnect to the new server process when it is ready.
Related#
- Activation — register the install and create the first administrator
- Inference providers — every supported backend, and how to wire each one up so agents can reach a model
- Install the CLI — install
nexusctlfrom your own server in one command - Sandbox isolation — what the runtime class buys you
Sizing a production cluster, air-gapped installs, and hardened runtime classes are worth a conversation. Talk to us.