This template runs Cursor Self-Hosted Machines (self-hosted pool) workers in
your cluster. Cursor hosts the agent loop. An in-cluster
agent worker controller kubectl-creates one worker Pod per spawn; each
worker runs tool calls inside your cluster network.
For the published worker-set-controller / WorkerDeployment path, see
Deploying with Kubernetes.
Use this sample for controller --spawn Pods (claim-then-spawn or
--warm-idle).
- You start a cloud agent at cursor.com/agents against a self-hosted pool. The request stays pending until a worker claims it (or a warm idle worker is already connected).
- Helm installs a single-replica Deployment of
agent worker controller --spawn /hooks/spawn-pod.sh --pool <name>. - On each
--spawn, the hook runskubectl create -f -for a Pod withrestartPolicy: Never. That Pod startsagent worker --pool <name> --worker-dir <dir> start. - The worker opens an outbound bridge to Cursor and executes tool calls in
your cluster. When
--idle-release-timeoutelapses after the session, the worker exits 0 and the Pod becomes Succeeded.
| Property | Description |
|---|---|
| Controller + spawn | One controller process; workers are kubectl-created Pods |
| One-shot Pods | restartPolicy: Never — idle exit completes the Pod; next spawn creates a new one |
| Claim or warm | controller.warmIdle=0 claim-then-spawn, or >0 for --warm-idle N |
| Service account key | Long-lived CURSOR_API_KEY from a Secret |
| Additive | Safe to run alongside worker-set-controller / WorkerDeployment |
| Hibernation | Off by default. Opt in with hibernation.enabled=true plus a pool reconnect window to keep a per-worker workspace volume across idle exits. See Hibernation |
Product semantics:
Self-hosted pools
(Any repo,
pool names,
multiple repo roots).
repo and pool labels are reserved; the worker derives repo= from a git
remote when one exists.
This template’s chart default is pool: default. That is the unnamed pool
the CLI joins when --pool has no name. A repo-backed default pool shows
under that repository. An any-repo fleet uses a name of its own (k8s-workers
in the examples below) so it appears under Any repo. Register that name
with POST /v0/private-workers/pools (omit repo fields) so the picker lists it
before a worker connects. Keep Helm pool and the dashboard pool name the same.
Build the worker image so workerDir is either a clone with a remote
(repo-bound) or a directory with no git remote (any-repo).
Dashboard: Any repo. Docs:
Any repo pools.
Routing is by pool name. Give the fleet a name other than default, then
register it:
curl --request POST \
--url "https://api.cursor.com/v0/private-workers/pools" \
-u "$CURSOR_API_KEY:" \
--header 'Content-Type: application/json' \
--data '{"scope":"team","poolName":"k8s-workers"}'Users specify that name when starting an agent: the dashboard Any repo
group, pool=k8s-workers on Slack/GitHub/Linear, or the API with
env.type: "pool" and env.name, omitting repos. Those starts leave off
repo= labels.
Controller (what Helm runs):
agent worker controller --spawn /hooks/spawn-pod.sh --pool <name>One chart install per pool is the usual pattern. Repeat --pool only when a
single controller should serve several pools.
Worker Pod (spawned by the hook):
agent worker --pool <name> --worker-dir /workspace start/workspace should have no git remote so the worker leaves off repo=
labels. --pool on the guest must match the controller pool name.
Optional: if a pending request carries a clone URL and your image/entrypoint
clones it into --worker-dir, that session becomes repo-bound. To stay
any-repo, keep --worker-dir as a directory without a git remote and let the
agent or your scripts clone into it.
Routing is by git remote. Bake (or init-container clone) a repository
into workerDir with a configured remote. The worker derives repo=owner/name
from that remote (leave repo= labels unset; the worker sets them).
Users pick that repository in the dashboard (the pool appears under that
repo). Replace any public sample remote with your real repository before you
run real work. Private remotes need git auth (HTTPS token or SSH) in the
worker image or via extraEnv / mounted credentials.
Optional controller filter: set controller.repository so the controller
only handles pending requests for that repository identity.
One named any-repo pool covers a large GitHub Enterprise fleet. Routing is the pool name, not a baked remote, so thousands of repos share the same image and controller.
- Register the pool up front (the curl above) so it stays in the picker with zero connected workers.
- Leave
workerDiras an empty directory. A git remote in that directory makes the worker repo-bound to that one remote. - Put
git, your GHE credentials, and the build tools a session needs in the image. Clone the claimed repo in the session, or from asessionStarthook inworkerDir, with those credentials. A shallow clone of the one repo the agent is starting is the working set; the image stays the same for every repo. --clone-git-reposclones with a minted GitHub token. That path needs the GitHub App to reach the host, and it applies to a named pool other thandefault. A private GHE server is cloned by the image with its own credentials.- Claim-mode spawn env includes
CURSOR_REPO_URLwhen the request targets a repository. An Any repo start omitsrepos, so that variable is unset and the session does the clone. controller.warmIdlecovers process startup. Time to first edit is then the clone. RaiseidleReleaseTimeoutso a follow-up reuses that checkout. Start with a small idle count; one warm controller per pool.
Independent of any-repo vs repo-bound.
The controller watches pending pool requests, claims each one, and runs
--spawn once per claim. Worker Pods appear when there is demand. Closest to
the Cloudflare / Lambda “spawn on claim” templates.
Passed through as agent worker controller --warm-idle <count>. The
controller keeps <count> idle workers connected in pool by running the
spawn hook once per missing warm worker (new Pods each time, rather than
patching a Deployment or HPA).
When a session finishes and the worker exits, that Pod completes. The next warm reconcile sees idle below target and spawns again.
Run one warm controller per pool. Two concurrent warm controllers can
transiently over-spawn. This chart uses strategy: Recreate on the
controller Deployment so a rollout keeps a single controller.
- A Kubernetes cluster (v1.24+) and
kubectlcontext - Helm v3
- A Cursor Enterprise team with Self-Hosted Machines / self-hosted pools enabled
- A service account API key for pool workers (personal API keys are rejected)
- A worker container image that includes:
- the
agent/cursor-agentCLI. Warm reconcile parsesGET /v0/private-workers/pools, includingworkerReadyTimeoutSeconds.2026.09.03-a76a283accepts that field. If the image does not bake the binary, download the cursor.com/install build in the containercommandbefore exec. The chart uses the samecommandfor the controller and for spawned workers. gitonPATH(required for git remotes /--clone-git-repos)- a workspace directory at
workerDir(default/workspace) /bin/shif you turn on hibernation (the entrypoint wrapper runs before the CLI)
- the
- The controller container also needs
kubectlonPATHand a POSIX shell (sh,sed,tr,cut,date; any Debian, Ubuntu, Alpine or busybox base has them). Overridecontroller.imageif your worker image hasagentbut notkubectl. A base image that has neither can install both in that samecommand.
-
Clone this repository.
git clone https://github.com/anysphere/k8s-workers.git cd k8s-workers -
Create a namespace and store the service account API key.
kubectl create namespace cursord kubectl create secret generic cursor-workers-api-key \ --from-literal=api-key='YOUR_SERVICE_ACCOUNT_API_KEY' \ -n cursord -
Install the chart (claim-then-spawn, named any-repo pool).
helm upgrade --install my-workers ./chart \ --namespace cursord --create-namespace \ --set image.repository=YOUR_REGISTRY/YOUR_WORKER_IMAGE \ --set image.tag=YOUR_TAG \ --set pool=k8s-workers \ --set controller.warmIdle=0 \ --set auth.existingSecret=cursor-workers-api-key
For a warm pool of three idle workers instead:
helm upgrade --install my-workers ./chart \ --namespace cursord \ --set image.repository=YOUR_REGISTRY/YOUR_WORKER_IMAGE \ --set image.tag=YOUR_TAG \ --set pool=k8s-workers \ --set controller.warmIdle=3 \ --set auth.existingSecret=cursor-workers-api-key
Chart-managed Secret (prefer
--setor a gitignored values overlay over committing the key):helm upgrade --install my-workers ./chart \ --namespace cursord --create-namespace \ --set image.repository=YOUR_REGISTRY/YOUR_WORKER_IMAGE \ --set image.tag=YOUR_TAG \ --set pool=k8s-workers \ --set auth.apiKey='YOUR_SERVICE_ACCOUNT_API_KEY' -
Confirm the controller is up.
kubectl -n cursord get deploy,pods -l app.kubernetes.io/instance=my-workers kubectl -n cursord logs -l app.kubernetes.io/component=controller -f
-
Start an agent from cursor.com/agents (see Run a cloud agent below).
Render without installing:
helm template my-workers ./chart \
--set image.repository=example.local/cursor-worker \
--set image.tag=test \
--set auth.existingSecret=cursor-workers-api-keyLocal lint / kubeconform (optional):
./scripts/helm-validate.shFull values reference: chart/README.md.
- Open cursor.com/agents.
- Start an agent, pick the Any repo group, and choose the pool name
(
k8s-workersunless you overrodepool). - From Slack/GitHub/Linear use
pool=<name>. From the API useenv.type: "pool"andenv.name, and omitrepos. - With
controller.warmIdle=0, the controller claims and spawns a Pod. WithwarmIdle>0, an idle worker should already be connected.
- Open cursor.com/agents.
- Start an agent, pick the repository that matches the git remote baked into your worker image, and choose Self-hosted with the same pool name.
- The worker advertises
repo=from that remote. Private remotes need credentials in the image or via chartextraEnv/ volumes.
For a first walkthrough without private git auth, bake
https://github.com/octocat/Hello-World into the image workspace, then
replace it with your real repository before production work.
By default a worker Pod is one-shot: it exits after idleReleaseTimeout,
and a follow-up that arrives later lands on a fresh Pod with a fresh
workspace. Hibernation keeps the workspace. Each worker id gets a
PersistentVolumeClaim ws-<worker-id> mounted at workerDir; the Pod still
exits when idle, but the volume stays, and when a follow-up arrives Cursor
asks the controller to bring the same worker id back. The spawn hook mounts
the same claim into a new Pod and the agent resumes on its files.
Nothing about this is on unless you turn it on. Two independent gates must both be set, and both default to off:
| Gate | Where | Default | What it controls |
|---|---|---|---|
hibernation.enabled |
this chart | false |
Whether the hook creates and mounts ws-<worker-id> claims, renders the reaper CronJob, and grants the controller PVC rights |
workerReadyTimeoutSeconds |
the pool in Cursor | 0 |
How long Cursor waits for the claimed worker to come back before handing the follow-up to any worker |
| Pool window | hibernation.enabled |
What you get |
|---|---|---|
0 |
false |
Today's behavior. |
0 |
true |
Claims are created and never reused; the reaper deletes them after hibernation.pvcTtl. helm install NOTES remind you to set the window. |
> 0 |
false |
Cursor asks for a wake; the hook spawns a fresh Pod with the claimed id and no volume. Same as expiry, only sooner. |
> 0 |
true |
Hibernation. |
k8s-workers is one release per pool, so a dev-loop pool can hibernate
while a ci pool in the same cluster stays one-shot.
Disk only. Files under workerDir (and under hibernation.homeDir if you
set hibernation.mountHome=true) survive. Processes, dev servers, tmux
sessions, and anything installed outside those paths are gone on wake.
Cursor-managed hibernation snapshots memory; this does not.
-
Build the worker image so it can populate an empty workspace. The claim is mounted over
workerDir, so anything the image baked there is hidden. Pick one:- Bake the clone somewhere else (say
/opt/workspace-seed) and sethibernation.seed.fromPath=/opt/workspace-seed; the entrypoint copies it into the empty volume on first use. - Set
hibernation.seed.cloneUrland provide git credentials throughextraEnvor mounted volumes; the entrypoint clones on first use. - Any-repo pools: leave both empty. The agent clones into the empty workspace as it does today.
- Bake the clone somewhere else (say
-
Install or upgrade with the feature on.
hibernation.storageClassNameshould be a class withvolumeBindingMode: WaitForFirstConsumerand encryption at rest. Empty uses the cluster's default StorageClass, and only when one is marked default. A cluster that has classes but no default leaves the claimPendingwithno storage class is set.helm upgrade --install my-workers ./chart \ --namespace cursord --create-namespace \ --set image.repository=YOUR_REGISTRY/YOUR_WORKER_IMAGE \ --set image.tag=YOUR_TAG \ --set pool=default \ --set auth.existingSecret=cursor-workers-api-key \ --set idleReleaseTimeout=300 \ --set hibernation.enabled=true \ --set hibernation.size=20Gi \ --set hibernation.storageClassName=YOUR_STORAGE_CLASS \ --set hibernation.seed.fromPath=/opt/workspace-seed
-
Give the pool a reconnect window in Cursor. The chart cannot do this for you yet;
helm installprints the exact call in its NOTES. Use the pool's team-scoped service account key (a repo-scoped key cannot register the window):curl --request POST \ --url "https://api.cursor.com/v0/private-workers/pools" \ -u "$CURSOR_API_KEY:" \ --header 'Content-Type: application/json' \ --data '{"scope":"team","poolName":"default","workerReadyTimeoutSeconds":900}'
For a repo-backed pool include
repoOwner,repoName, andrepoUrlin the body. OmittingworkerReadyTimeoutSecondson a later call never resets an existing window. Set it to0to turn wakes off again. -
Check it. Start an agent against the pool, have it create an uncommitted file, and wait for the Pod to go
Succeeded:kubectl -n cursord get pods,pvc -l app.kubernetes.io/component=worker kubectl -n cursord get pvc -l app.kubernetes.io/component=workspace
The claim is
Boundwith no running consumer. Send a follow-up asking forgit status: a new Pod named<worker-id>-<suffix>appears with the sameCURSOR_AGENT_WORKER_ID, its log starts withentrypoint: workspace /workspace already populated; resuming, and the agent still sees the file.
helm upgrade ... --set hibernation.enabled=false returns to one-shot Pods
on the next spawn. Existing ws-* claims are not deleted by the chart; the
reaper is gone with the feature, so delete them yourself:
kubectl -n cursord delete pvc -l app.kubernetes.io/component=workspaceSet the pool window back to 0 with the same API call, or leave it: with the
feature off, a wake is just a fresh Pod.
A wake has to beat two timers. workerReadyTimeoutSeconds runs from the
follow-up's arrival in Cursor; the worker's own --idle-release-timeout
(idleReleaseTimeout) also runs from that arrival, and a worker that
connects after it elapsed is released at once. Both must exceed the
worst-case wake: schedule, image pull, volume attach, connect, plus node
scale-up when the cluster is at zero. A short idle timeout with a slow wake
produces a release loop. hibernation.wakeWindowSeconds (default 900) is
only what NOTES print for the API call; the chart does not set it on the
pool.
Cursor never deletes Kubernetes objects. A CronJob
(<release>-k8s-workers-reaper, every 15 minutes by default) deletes:
- workspace claims whose
cursor.com/last-used-epochis older thanhibernation.pvcTtl(default7d) and that no running Pod uses; Succeeded/Failedworker Pods older thanhibernation.podTtl(default1h).
A finished Pod holds a kubernetes.io/pvc-protection finalizer on its claim,
so the reaper deletes a worker's finished Pods before its claim. Run it by
hand with kubectl -n cursord create job --from=cronjob/<release>-k8s-workers-reaper reap-now.
Disable it with hibernation.reaper.enabled=false and delete claims yourself.
The reaper container runs /hooks/reaper.sh. It does not use command, so a
start script that installs kubectl for the controller does not put it on
the reaper's PATH. hibernation.reaper.image defaults to the controller
image; that image must already contain kubectl, or set
hibernation.reaper.image to one that does.
- Zone pinning. A
ReadWriteOnceblock volume is bound to the zone (or node, for local storage) where it was provisioned. A wake Pod that cannot schedule there staysPendingwithvolume node affinity conflictuntil the window lapses, and the workspace is lost anyway. Use per-zone node groups so the autoscaler can grow the right zone, a regional disk, orReadWriteManynetwork storage (slowergitandnode_modulesI/O). - Attach race. The previous Pod may still be
Terminatingwhen the wake Pod starts; Kubernetes clears the transient Multi-Attach error itself. Budget a few seconds. - Security. The claim holds a checkout and possibly credential residue
from
--mint-github-tokenor--sync-dashboard-secrets. Use an encrypted storage class. Claims are keyed strictly by worker id, so a volume never mounts for a different agent.hibernation.mountHomeis off by default for this reason. - Volume ownership. A newly provisioned volume is
root:rootand mode755. The agent runs as the image user. If that user is not root, setpodSecurityContext.fsGroupto its gid so the kubelet makes the mount group-writable before the container starts. The container user cannotchownthe mount fromcommand. Do not set a defaultfsGroup; images that run as root do not need one. - Quotas. Every worker id that ever ran leaves a claim until the TTL. A team can run up to 1000 workers; size the storage quota, and note that EBS caps attachments per node near 25.
- Session hooks.
sessionStartfires on claim. Whether it fires again when a wake adopts the claim is not confirmed; do not rely on it to rebuild state on wake yet.
Useful while iterating on the spawn hook or image.
-
Install the CLI from cursor.com/install.
-
Ensure
kubectlpoints at the cluster and can create Pods in the target namespace. -
Export
CURSOR_API_KEYand run:export CURSOR_API_KEY='YOUR_SERVICE_ACCOUNT_API_KEY' agent worker controller --spawn ./path/to/spawn-pod.sh --pool k8s-workers # warm idle: # agent worker controller --spawn ./path/to/spawn-pod.sh --pool k8s-workers --warm-idle 2
Point --spawn at a copy of the chart's spawn ConfigMap. The hook reads its
Pod (and, with hibernation, PVC and entrypoint) manifests from the directory
it lives in, so dump every key of the ConfigMap into one directory:
mkdir -p hooks
kubectl -n cursord get cm my-workers-k8s-workers-spawn -o json > spawn-cm.json
for key in $(jq -r '.data | keys[]' spawn-cm.json); do
jq -r --arg k "$key" '.data[$k]' spawn-cm.json > "hooks/$key"
done
chmod +x hooks/spawn-pod.sh
agent worker controller --spawn ./hooks/spawn-pod.sh --pool defaultThe in-cluster install remains the supported long-running path.
# Controller
kubectl -n cursord logs -l app.kubernetes.io/component=controller -f
# Worker Pods created by --spawn
kubectl -n cursord get pods -l app.kubernetes.io/component=worker
# Succeeded/Failed one-shot Pods stay until you delete them (the hibernation
# reaper does this for you when that feature is on)
kubectl -n cursord delete pod -l app.kubernetes.io/component=worker \
--field-selector=status.phase=Succeeded
# Hibernation only: workspace claims and their last use
kubectl -n cursord get pvc -l app.kubernetes.io/component=workspace \
-o custom-columns='NAME:.metadata.name,STATUS:.status.phase,LAST_USED:.metadata.annotations.cursor\.com/last-used-at'
kubectl -n cursord get cronjob,jobs -l app.kubernetes.io/component=reaperWorker Pods are named <worker-id>-<suffix> and carry the worker id in the
cursor.com/worker-id label, so all episodes of one worker are
kubectl -n cursord get pods -l cursor.com/worker-id=<worker-id>.
Worker health (same contract as the public Kubernetes guide):
| Endpoint | 200 | 503 |
|---|---|---|
/healthz |
Process is running | — |
/readyz |
Connected and idle | Starting, or running a session |
Only worker Pods serve these endpoints.
| Symptom | What to check |
|---|---|
| Nothing is ever claimed | Controller logs; CURSOR_API_KEY Secret; pool matches the dashboard; Self-Hosted enabled for the team |
HTTP 401 / invalid API key |
Use a service account key with agent scope, not a personal key |
| Pods spawn then exit immediately | Image has agent + git; workerDir exists; check worker logs |
| Agent cannot find the pool under a repo | You started any-repo (no repo= labels). Pick Any repo, or bake a git remote for repo-bound |
| Pool missing from the web UI | Register a named pool (POST /v0/private-workers/pools, no repo fields) and set Helm pool to that name. default is the unnamed pool; a repo-backed default pool shows under its repository |
unrecognized_keys / workerReadyTimeoutSeconds |
Controller CLI is older than the pools response. Use a build that accepts that field (2026.09.03-a76a283 does) |
| Warm idle overshoots | Only one controller per pool; chart uses Recreate — avoid a second Helm release on the same pool with warmIdle>0 |
Controller CrashLoop, exec: "agent": executable file not found |
The image does not contain the CLI. Download the cursor.com/install build in command before exec. The same command runs in worker Pods. |
| Controller CrashLoop | Controller image missing kubectl or agent; RBAC Role cannot create Pods; Secret key name ≠ auth.secretKey |
| Follow-up lands on a fresh workspace with hibernation on | Pool window still 0 (run the NOTES API call with a team-scoped key); idleReleaseTimeout or the window shorter than the wake; claim reaped (pvcTtl) or deleted; hook logged exit 3 because ws-<worker-id> was gone |
Permission denied writing under workerDir |
The new volume is root:root 755. Set podSecurityContext.fsGroup to the image user's gid (often 1000). See Caveats. |
ws-* stays Pending, storage class blank, no storage class is set |
The cluster has no default StorageClass. Set hibernation.storageClassName. |
ws-* stays Pending, Waiting for a volume to be created by ebs.csi.aws.com |
The class (often in-tree gp2, provisioner kubernetes.io/aws-ebs) is translated to the EBS CSI driver, and that driver is not installed. Install the aws-ebs-csi-driver add-on, or name a class whose provisioner is running. |
Wake Pod stays Pending, volume node affinity conflict |
The claim's zone/node has no capacity. Per-zone node groups, regional disk, or ReadWriteMany storage. See Caveats |
Reaper Job Error, kubectl: not found |
The reaper does not run command. hibernation.reaper.image (the controller image when empty) must have kubectl on PATH, or set hibernation.reaper.enabled=false. |
ws-* claim stuck Terminating |
A Succeeded/Failed Pod still references it (kubernetes.io/pvc-protection). Delete those Pods; the reaper does this before deleting a claim |
| Worker Pod fails at start with hibernation on | Worker image lacks /bin/sh; seed.fromPath missing in the image; seed.cloneUrl needs git and credentials |
Operator (WorkerDeployment) |
This chart |
|---|---|
readyReplicas = idle workers; busy-safe rolling updates |
--warm-idle or claim-then-spawn; one-shot Pods |
Operator token exchange + --auth-token-file |
Long-lived CURSOR_API_KEY Secret |
WorkerDeployment + worker-set-controller |
Vanilla Pods via --spawn |
| Optional demand autoscaling / scale-to-zero | Claim-then-spawn (warmIdle=0) or fixed idle via --warm-idle |
- Self-Hosted Pool (Any repo, pool names, multiple repo roots)
- Deploying with Kubernetes (operator path)
- Service accounts
- This repo:
chart/,scripts/helm-validate.sh
First-party code in this repository is licensed under the Apache License, Version 2.0 — see LICENSE.
This license does not grant permission to use the trade names, trademarks, service marks, or product names of SpaceXAI, Anysphere, Cursor, or Grok, except as required for reasonable and customary use in describing the origin of the Work.
Kubernetes is a registered trademark of The Linux Foundation. All other trademarks are the property of their respective owners.
Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.