This template runs Cursor cloud agents inside Cloudflare Containers that you control. Cursor hosts the agent loop. Each claimed request gets an isolated container: its own filesystem, process space, and outbound bridge.
The Worker itself is the controller. A cron trigger fires every five minutes; each run lists pending requests for your pool, holds the pending-requests SSE stream open until the next run is due, claims each request, and starts one guest container per claim. There is no controller binary, no long-running controller container, and no state outside Cursor's claim API.
Product routing is documented in the self-hosted pool guide (repo-less / any-repo, pool names, multiple repo roots). This template supports both repo-bound and any-repo starts.
- You start an agent at cursor.com/agents and choose Self-hosted. Cursor records a pending private-worker request.
- Every five minutes the cron runs
src/controller.ts:GET /v0/private-workers/pending-requests?pool=<CURSOR_POOL>— claims anything already waiting and returns astreamCursor.GET /v0/private-workers/pending-requests/stream?pool=…&cursor=…— stays open for ~4m50s (CONTROLLER_RUN_BUDGET_MS) and claims eachcreatedevent as it arrives, so coverage is continuous across runs. A410(expired cursor) re-lists; a closed stream reconnects from the last event id.
POST /v0/private-workers/claimwith a freshcf-<uuid>worker id is the only mutex.409means another controller won;404means the request is gone. Overlapping cron runs are harmless.- On a successful claim the Worker starts a guest
CursorPoolWorkercontainer namedspawn/<workerId>with theCURSOR_*env for that request. If the claim has a repo,CURSOR_REPO_URLis set and the guest clones (or restores an R2 snapshot). If not, the guest starts from an empty workspace with no git remote. - The guest runs
agent worker --worker-dir <dir> --pool <name> startas a long-lived outbound bridge. Cursor keeps driving the agent loop. - When the worker is idle for
WORKER_IDLE_RELEASE_TIMEOUT_SECONDS(default 300), the guest exits and the container stops.
| Property | Description |
|---|---|
| Worker is the controller | One TypeScript module in the Worker lists, streams, and claims. Nothing else runs between claims. |
| Containers isolation | Each claim is one Cloudflare Container. Checkouts and processes are not shared with other runs. |
| Durable Object | CursorPoolWorker owns a single guest container and enforces MAX_RUN_LIFETIME_SECONDS. |
| R2 snapshots | Optional post-clone cache for repo-bound boots, keyed by the clone URL. A miss is a cold git clone, not a failure. Unused in any-repo mode. |
| Outbound-only | The guest opens an outbound connection to Cursor. No inbound ports on the container; the Worker only serves /health and the snapshot routes. |
- A Cloudflare account with Workers, Containers, and R2 (Workers Paid).
- Node 20 or later. Docker if you build the container image locally; Wrangler builds it on
npx wrangler deploy. - A Cursor service-account API key with agent scope. Pool workers reject personal API keys.
The image installs the current prod CLI with curl -fsSL https://cursor.com/install | bash; the guest only needs agent worker start.
-
Clone this template and install dependencies.
git clone https://github.com/anysphere/cloudflare-workers.git cd cloudflare-workers npm install -
Create the R2 bucket named in
wrangler.jsonc.npx wrangler r2 bucket create cursor-pool-worker-snapshots
-
Put secrets.
npx wrangler secret put CURSOR_API_KEY # team service-account key (required) npx wrangler secret put GIT_USERNAME # optional: e.g. x-access-token npx wrangler secret put GIT_TOKEN # optional: PAT for private repos npx wrangler secret put SNAPSHOT_AUTH_TOKEN # optional: enables the R2 snapshot cache
-
In
wrangler.jsonc, setvars.CURSOR_POOLto the pool name you will select in the dashboard (defaultdefault). For the snapshot cache also setvars.WORKER_PUBLIC_URLto this Worker's URL (for examplehttps://cursor-pool-workers.<account>.workers.dev). -
Deploy.
npx wrangler deploy
Keep containers[].max_instances at least the number of concurrent claimed runs you expect. Watch containers with npx wrangler containers list and the controller with npx wrangler tail.
To run one controller pass locally: npx wrangler dev --test-scheduled and then curl "http://localhost:8787/__scheduled?cron=*/5+*+*+*+*". To change the interval, edit triggers.crons in wrangler.jsonc and CONTROLLER_RUN_BUDGET_MS in src/config.ts together.
Routing is by git remote. Users pick the repo in the dashboard (the pool appears under that repo). Pool name is extra routing, not a substitute for the clone. Docs: register multiple repo roots.
-
Open cursor.com/agents.
-
Start an agent, pick the repo, and choose Self-hosted with the
CURSOR_POOLname. -
The pending request includes a clone URL. After claiming, the Worker sets
CURSOR_REPO_URL(andCURSOR_REPO_OWNER/CURSOR_REPO_NAME) on the guest. -
The guest restores or clones that URL into
$HOME/workspaces/repo-0(scheme-less identities such asgithub.com/owner/repogethttps://prepended first), then starts:agent worker --worker-dir "$HOME/workspaces/repo-0" --pool "$CURSOR_POOL" start --verbose
The worker derives repo=owner/name from the git remote. Do not set repo= labels by hand.
Snapshots (src/snapshots.ts) are an optional R2 cache of that post-clone tree, enabled when both WORKER_PUBLIC_URL and SNAPSHOT_AUTH_TOKEN are set. Skip them if a cold clone every boot is fine. The public CLI accepts repeated --worker-dir (up to 20); this container starts a single root.
Routing is by pool name, not by git remote. Docs: repo-less pools.
-
Open cursor.com/agents.
-
Start an agent, pick the Any repo group, and choose the
CURSOR_POOLname. From Slack/GitHub/Linear usepool=<name>. From the API useenv.type: "pool"andenv.name, and omitrepos. -
The pending request has no repo, so the Worker does not set
CURSOR_REPO_URL. -
The guest creates
$HOME/workspaces/repo-0with no git remote and starts:agent worker --worker-dir "$HOME/workspaces/repo-0" --pool "$CURSOR_POOL" start --verbose
The worker omits repo= labels. The pool name on the guest comes from the request's pool label (falling back to default), the same rule the official CLI controller uses.
If a later claim includes CURSOR_REPO_URL and the entrypoint clones it into --worker-dir, that session becomes repo-bound. To stay any-repo, keep --worker-dir as a directory with no git remote and let the agent or a hook clone into it.
| Symptom | Cause | Fix |
|---|---|---|
| Nothing is ever claimed | Cron not firing, CURSOR_API_KEY unset, or CURSOR_POOL does not match the dashboard pool |
npx wrangler tail; look for controller[<pool>]: run done every five minutes and any HTTP 401. |
HTTP 401 in the controller log |
Personal key, or a service-account key without agent scope | Put a team service-account key with agent scope as CURSOR_API_KEY. |
| Agent cannot find the pool under a repo | You started any-repo (no repo= labels) |
Pick the Any repo group, or start repo-bound so the guest clones and advertises repo=. |
| Claimed but the agent never starts | Guest container failed to boot (max_instances reached, clone failed, bad GIT_TOKEN) |
npx wrangler containers list; check container stopped lines in wrangler tail. The run errors out on Cursor's side after its claim wait. |
| Snapshot miss / slow first boot | No R2 object yet, stale snapshot, or cache not enabled | Expected for repo-bound. Set WORKER_PUBLIC_URL + SNAPSHOT_AUTH_TOKEN to enable. Unused in any-repo mode. |
- Cloudflare Containers
- Cursor self-hosted pools (repo-less / any-repo, pool names, multiple repo roots)
src/controller.ts— list, SSE watch, claim, spawnwrangler.jsonc— Worker, cron, Container, Durable Object, and R2 bindings
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, Cursor, or Grok, except as required for reasonable and customary use in describing the origin of the Work.
Cloudflare and related marks are trademarks of Cloudflare, Inc. 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.