---
title: Run Cursor Cloud Agents on Vercel Sandbox
description: Learn how to run Cursor Cloud Agents on Vercel Sandbox with BYOM worker pools, durable workflows, isolated microVMs, and scale-to-zero compute.
url: /kb/guide/cursor-vercel-sandbox
canonical_url: "https://vercel.com/kb/guide/cursor-vercel-sandbox"
published: 2026-09-02
last_updated: 2026-09-02
authors: Allen Zhou
related:
  - /docs/sandbox
  - /docs/functions
  - /docs/vercel-sandbox/concepts/snapshots
  - /docs/plans
  - /docs/cli
  - /docs/oidc
  - /docs/vercel-sandbox/sdk-reference
  - /docs/sandbox/concepts/runtimes
install_vercel_plugin: npx plugins add vercel/vercel-plugin
---

Cursor Cloud Agents can now run in Vercel Sandbox instead of Cursor's hosted machines. This guide uses Cursor's [Self-Hosted Machines APIs](https://cursor.com/docs/cloud-agent/bring-your-own-machine) and [Vercel Sandbox](https://vercel.com/docs/sandbox) to create one isolated Linux microVM for each queued agent.

Cursor hosts the agent harness and inference loop. Vercel Sandbox supplies the computer where the agent clones code, runs commands, edits files, and executes tests.

Follow the walkthrough below to build it yourself.

## How it works

The integration has two planes:

- **Control plane (**[**Vercel Functions**](https://vercel.com/docs/functions) **+** [**Vercel Workflow**](https://vercel.com/workflow)**):** a Function exposes the controller endpoint, while a durable discovery workflow checks the Cursor team pool with adaptive backoff (longer waits after consecutive empty checks). Cursor's claim endpoint assigns each pending request to one worker ID. Each claim starts an independent child workflow that manages one worker's provisioning, monitoring, and cleanup.
  
- **Compute plane (Vercel Sandbox):** boots from a [snapshot](https://vercel.com/docs/vercel-sandbox/concepts/snapshots) with the Cursor Agent CLI preinstalled and starts a request-scoped Cursor worker. The worker exits after an idle grace period, and the Sandbox has a fixed upper-bound timeout.
  

```mermaid
graph LR
  subgraph cursor [Cursor]
    request([Cloud Agent request]) --> pool[Team pool]
  end

  subgraph control [Vercel control plane: Functions + Workflow]
    discovery[Discovery workflow] -->|claims| worker[Worker workflow]
  end

  subgraph compute [Compute plane]
    sandbox[Vercel Sandbox]
  end

  pool -->|pending work| discovery
  worker -->|provisions| sandbox
```

Cursor's [team pool](https://cursor.com/docs/cloud-agent/bring-your-own-machine/pools) remains registered when it has zero workers. That makes scale-to-zero possible: users can select the pool while no compute is running, and the controller creates capacity only when a request arrives.

Each request is claimed before its child workflow starts. Cursor's claim endpoint atomically assigns one pending request to one worker ID, so competing controllers cannot claim the same request. That guarantee covers request assignment only. Deterministic Workflow hooks deduplicate the pool controller and child workflow, while deterministic Sandbox names make provisioning idempotent across retries. Together, these safeguards prevent retries from creating competing workers or duplicate Sandboxes for the same Cursor request.

## Prerequisites

- A [Vercel account](https://vercel.com/docs/plans) with Vercel Sandbox access
  
- A [Cursor Enterprise account](https://cursor.com/docs/enterprise) with [Self-Hosted Machines](https://cursor.com/docs/cloud-agent/bring-your-own-machine) enabled
  
- A Cursor [agent-scoped team service account API key](https://cursor.com/docs/account/enterprise/service-accounts#creating-a-service-account), created by a Cursor team admin
  
- Node.js 22 or later
  
- The [Vercel CLI](https://vercel.com/docs/cli)
  

Cursor members can create user API keys, but only team admins can create the [Enterprise service account](https://cursor.com/docs/account/enterprise/service-accounts#creating-a-service-account) this controller requires.

## Set up the project

Create a Next.js app and install the dependencies:

```bash
pnpm create next-app cursor-sandbox-workers
cd cursor-sandbox-workers
pnpm add @vercel/sandbox workflow
pnpm add -D tsx
mkdir -p lib scripts workflows app/api/cursor-workers/controller
```

Enable Workflow's Next.js directives in `next.config.ts`:

```typescript
import type { NextConfig } from "next";
import { withWorkflow } from "workflow/next";

const nextConfig: NextConfig = {};

export default withWorkflow(nextConfig);
```

Link the app to Vercel and pull a development [OIDC token](https://vercel.com/docs/oidc):

```bash
vercel link
vercel env pull .env.local
```

On Vercel, the [Sandbox SDK](https://vercel.com/docs/vercel-sandbox/sdk-reference) authenticates automatically with OIDC. The pulled `VERCEL_OIDC_TOKEN` provides the same behavior during local development.

Add your Cursor service account key to `.env.local`:

```bash
CURSOR_SERVICE_ACCOUNT_API_KEY=your-agent-scoped-service-account-key
CURSOR_POOL_NAME=vercel-sandbox
```

The service account key stays in the Vercel Functions and Workflow control plane. It is used to inspect and claim work and to mint one-hour worker tokens; it is never copied into an agent's microVM.

## Build the worker snapshot

Installing the Cursor Agent CLI for every request would add setup time before an agent can begin. Instead, start from a [Vercel Managed Image](https://vercel.com/docs/sandbox/concepts/runtimes), install the CLI once, and save the result as a [Sandbox snapshot](https://vercel.com/docs/vercel-sandbox/concepts/snapshots).

Create `scripts/build-snapshot.ts`:

```typescript
import { Sandbox } from "@vercel/sandbox";

async function main() {
  const sandbox = await Sandbox.create({
    image: "vercel/sandbox/universal:latest",
    timeout: 10 * 60 * 1000,
  });

  const install = await sandbox.runCommand({
    cmd: "bash",
    args: [
      "-lc",
      "curl https://cursor.com/install -fsS | bash && mkdir -p /vercel/sandbox/workspace",
    ],
  });
  if (install.exitCode !== 0) throw new Error("Cursor install failed");

  const snapshot = await sandbox.snapshot({ expiration: 0 });
  console.log(`CURSOR_WORKER_SNAPSHOT_ID=${snapshot.snapshotId}`);
}

main();
```

Create the snapshot and save the printed ID:

```bash
pnpm tsx scripts/build-snapshot.ts
vercel env add CURSOR_WORKER_SNAPSHOT_ID
```

Snapshotting stops the source Sandbox automatically. Rebuild the snapshot whenever you want to update the Cursor Agent CLI.

## Register a scale-to-zero team pool

The pool must exist before a user or API request can target it. Register an any-repository team pool with no workers attached:

```bash
curl --request POST \
  --url https://api.cursor.com/v0/private-workers/pools \
  --header "Authorization: Bearer $CURSOR_SERVICE_ACCOUNT_API_KEY" \
  --header "Content-Type: application/json" \
  --data '{
    "scope": "team",
    "poolName": "vercel-sandbox",
    "workerReadyTimeoutSeconds": 0
  }'
```

Cursor's [pool registration reference](https://cursor.com/docs/cloud-agent/api/endpoints#register-a-pool) defines `workerReadyTimeoutSeconds` as the time a claimed request waits for its offline worker to reconnect. Setting it to `0` makes a follow-up reacquire from the pool immediately, where the controller can claim it and create a fresh Sandbox.

For a pool limited to one repository, also pass `repoOwner`, `repoName`, and `repoUrl`. Repository-scoped service account keys must include the same repository filter when listing pending requests.

## Build the control plane

The walkthrough creates the following files:

```text
lib/cursor-workers.ts
workflows/cursor-worker.ts
workflows/cursor-pool-controller.ts
app/api/cursor-workers/controller/route.ts
scripts/build-snapshot.ts
```

### Connect to Cursor's API

Each code block continues the named file from the preceding block. Create `lib/cursor-workers.ts` with its imports, configuration, and shared types:

```typescript
import { Sandbox } from "@vercel/sandbox";

const CURSOR_API = "https://api.cursor.com";
const API_KEY = process.env.CURSOR_SERVICE_ACCOUNT_API_KEY!;
const SNAPSHOT_ID = process.env.CURSOR_WORKER_SNAPSHOT_ID!;
const POOL_NAME = process.env.CURSOR_POOL_NAME ?? "vercel-sandbox";
const MAX_WORKERS_PER_TICK = 5;
const WORKER_TOKEN_PATH = "/tmp/cursor-worker-token";

type PendingRequest = {
  id: string;
  userId: number;
  claimedWorkerId?: string;
};

export type ClaimedRequest = {
  requestId: string;
  userId: number;
  workerId: string;
};
```

Cursor accepts [Basic or Bearer authentication](https://cursor.com/docs/cloud-agent/api/endpoints#workers-and-pools). Add the API helper:

```typescript
async function cursorFetch<T>(path: string, init?: RequestInit) {
  const response = await fetch(`${CURSOR_API}${path}`, {
    ...init,
    headers: {
      Authorization: `Bearer ${API_KEY}`,
      "Content-Type": "application/json",
    },
  });
  if (!response.ok) throw new Error(`Cursor request failed: ${response.status}`);
  return response.json() as Promise<T>;
}
```

The module needs five Cursor operations: list pending work, claim it for one worker, mint a user-scoped token, read the agent status, and release the claim. Start with discovery and claiming:

```typescript
const listPendingRequests = () =>
  cursorFetch<{ requests: PendingRequest[] }>(
    `/v0/private-workers/pending-requests?pool=${encodeURIComponent(POOL_NAME)}`,
  );

const claim = (id: string, workerId: string) =>
  cursorFetch("/v0/private-workers/claim", {
    method: "POST",
    body: JSON.stringify({ id, workerId }),
  });
```

Cursor's [worker token reference](https://cursor.com/docs/cloud-agent/api/endpoints#create-a-user-scoped-worker-token) documents `POST /v1/sub-tokens` as returning a one-hour token. Add token minting and status lookup:

```typescript
const createWorkerToken = (forUserId: number) =>
  cursorFetch<{ accessToken: string }>("/v1/sub-tokens", {
    method: "POST",
    body: JSON.stringify({ forUserId }),
  });

export const getAgentStatus = (id: string) =>
  cursorFetch<{ status: "ACTIVE" | "IDLE" | "ARCHIVED" }>(
    `/v1/agents/${encodeURIComponent(id)}`,
  ).then((agent) => agent.status);
```

Release the claim during cleanup. Cursor's [claim release reference](https://cursor.com/docs/cloud-agent/api/endpoints#release-a-claim) explicitly defines `404` as no live claim because it was already released, expired, or adopted, so cleanup should not retry that response:

```typescript
export async function release(id: string) {
  const response = await fetch(
    `${CURSOR_API}/v0/private-workers/claims/${encodeURIComponent(id)}/release`,
    {
      method: "POST",
      headers: { Authorization: `Bearer ${API_KEY}` },
    },
  );
  if (!response.ok && response.status !== 404) {
    throw new Error(`Cursor claim release failed: ${response.status}`);
  }
}
```

### Provision a worker Sandbox

Use deterministic names so a retry resolves to the same claim and microVM:

```typescript
const normalize = (id: string) =>
  id.toLowerCase().replace(/[^a-z0-9-]/g, "-");

const workerIdFor = (id: string) => `pw_${normalize(id)}`.slice(0, 63);
export const sandboxNameFor = (id: string) =>
  `cursor-${normalize(id)}`.slice(0, 63);
```

Write the minted worker token to a file readable only by the Sandbox user:

```typescript
async function writeWorkerToken(sandbox: Sandbox, accessToken: string) {
  await sandbox.writeFiles([
    {
      path: WORKER_TOKEN_PATH,
      content: accessToken,
      mode: 0o600,
    },
  ]);
}
```

Provisioning retrieves or creates the named Sandbox, then refreshes its token file on every attempt:

```typescript
async function createWorkerSandbox(job: ClaimedRequest) {
  const { accessToken } = await createWorkerToken(job.userId);
  const sandbox = await Sandbox.getOrCreate({
    name: sandboxNameFor(job.requestId),
    source: { type: "snapshot", snapshotId: SNAPSHOT_ID },
    persistent: false,
    timeout: 45 * 60 * 1000,
    env: { CURSOR_AGENT_WORKER_ID: job.workerId },
  });
  await writeWorkerToken(sandbox, accessToken);
  return sandbox;
}
```

Then start a detached worker. `--auth-token-file` supplies the pre-minted token without placing it in the process arguments. The non-blocking `flock` prevents a retry from starting a second process in the same Sandbox:

```typescript
async function startWorker(sandbox: Sandbox) {
  await sandbox.runCommand({
    cmd: "bash",
    args: [
      "-lc",
      "exec flock -n /tmp/cursor-worker.lock agent worker " +
        "--auth-token-file $CURSOR_AUTH_TOKEN_FILE --pool $CURSOR_POOL " +
        "--worker-dir /vercel/sandbox/workspace start",
    ],
    env: {
      CURSOR_AUTH_TOKEN_FILE: WORKER_TOKEN_PATH,
      CURSOR_POOL: POOL_NAME,
      CURSOR_WORKER_IDLE_RELEASE_TIMEOUT: "600",
    },
    detached: true,
  });
}
```

Cursor's [claim release reference](https://cursor.com/docs/cloud-agent/api/endpoints#release-a-claim) documents `CURSOR_WORKER_IDLE_RELEASE_TIMEOUT` as the environment-variable form of `--idle-release-timeout`. This guide uses `600` for a ten-minute grace period.

Expose one provisioning function for the worker workflow:

```typescript
export async function provisionWorker(job: ClaimedRequest) {
  const sandbox = await createWorkerSandbox(job);
  await startWorker(sandbox);
  return { sandboxId: sandbox.name, workerId: job.workerId };
}
```

The service account key remains in the Vercel Functions and Workflow control plane. Only the one-hour token for the requesting Cursor user enters the microVM.

### Claim pending requests

Claim one request at a time. Redispatch a claimed request only when its worker ID matches the deterministic ID owned by this controller:

```typescript
async function claimOne(request: PendingRequest) {
  const workerId = workerIdFor(request.id);
  const job = { requestId: request.id, userId: request.userId, workerId };

  if (request.claimedWorkerId) {
    return request.claimedWorkerId === workerId ? job : null;
  }
  try {
    await claim(request.id, workerId);
    return job;
  } catch {
    return null; // Another controller won the claim.
  }
}
```

Apply the concurrency cap and return the claimed jobs:

```typescript
export async function discoverAndClaim() {
  const { requests } = await listPendingRequests();
  const jobs: ClaimedRequest[] = [];
  for (const request of requests.slice(0, MAX_WORKERS_PER_TICK)) {
    const job = await claimOne(request);
    if (job) jobs.push(job);
  }
  return jobs;
}
```

The cap is deliberate. It prevents one queue spike from creating unbounded compute and gives you a simple concurrency control to tune for your team.

### Orchestrate each worker

Create `workflows/cursor-worker.ts`. Begin with the dependencies used by the worker workflow:

```typescript
import { APIError, Sandbox } from "@vercel/sandbox";
import { createHook, sleep } from "workflow";
import {
  getAgentStatus,
  provisionWorker,
  release,
  sandboxNameFor,
  type ClaimedRequest,
} from "@/lib/cursor-workers";
```

Each claimed request gets its own workflow. Keep external operations in steps so Workflow can retry and record them independently:

```typescript
async function provisionStep(job: ClaimedRequest) {
  "use step";
  return provisionWorker(job);
}

async function statusStep(job: ClaimedRequest) {
  "use step";
  return getAgentStatus(job.requestId).catch(() => "UNKNOWN" as const);
}
```

Cleanup is also a step. It stops the named Sandbox and then releases the Cursor claim. Treat a missing Sandbox as already cleaned up.

```typescript
async function cleanupStep(job: ClaimedRequest) {
  "use step";
  try {
    const sandbox = await Sandbox.get({ name: sandboxNameFor(job.requestId) });
    await sandbox.stop();
  } catch (error) {
    if (!(error instanceof APIError && error.response.status === 404)) {
      throw error;
    }
  } finally {
    await release(job.requestId);
  }
}
```

The workflow lease deduplicates each request. After provisioning, the child checks the agent every 30 seconds and cleans up when it becomes idle or reaches the 45-minute limit:

```typescript
export async function cursorWorkerWorkflow(job: ClaimedRequest) {
  "use workflow";
  using lease = createHook({ token: `cursor-worker:${job.requestId}` });
  if (await lease.getConflict()) return { status: "already-running" };

  try {
    const worker = await provisionStep(job);
    for (let check = 0; check < 90; check += 1) {
      const status = await statusStep(job);
      if (status === "IDLE" || status === "ARCHIVED") {
        return { status: "completed", ...worker };
      }
      await sleep("30s");
    }
    return { status: "timed-out", ...worker };
  } finally {
    await cleanupStep(job);
  }
}
```

### Run the discovery workflow

Now create `workflows/cursor-pool-controller.ts` with its imports:

```typescript
import { createHook, sleep } from "workflow";
import { start } from "workflow/api";
import {
  discoverAndClaim,
  type ClaimedRequest,
} from "@/lib/cursor-workers";
import { cursorWorkerWorkflow } from "./cursor-worker";
```

The parent only discovers work and starts children. Starting a child inside a step gives every request its own Workflow run and event log:

```typescript
async function discoverStep() {
  "use step";
  try {
    return await discoverAndClaim();
  } catch (error) {
    console.error("Cursor pool discovery failed", error);
    return [];
  }
}

async function dispatchStep(job: ClaimedRequest) {
  "use step";
  return start(cursorWorkerWorkflow, [job]);
}
```

Use a pool-level lease and adaptive polling in the parent:

```typescript
export async function cursorPoolController(poolName: string) {
  "use workflow";
  using lease = createHook({ token: `cursor-pool:${poolName}` });
  if (await lease.getConflict()) return { status: "already-running" };

  let emptyChecks = 0;
  while (true) {
    const jobs = await discoverStep();
    await Promise.all(jobs.map(dispatchStep));

    emptyChecks = jobs.length ? 0 : emptyChecks + 1;
    const interval = jobs.length ? "5s" : emptyChecks >= 5 ? "5m" : "1m";
    await sleep(interval);
  }
}
```

The controller checks again quickly while draining a backlog, polls once per minute when recently idle, and backs off to five minutes after repeated empty checks. Hook leases deduplicate both the controller and per-request workers.

### Expose the controller endpoint

Create `app/api/cursor-workers/controller/route.ts` to start the controller:

```typescript
import { start } from "workflow/api";
import { cursorPoolController } from "@/workflows/cursor-pool-controller";

const POOL_NAME = process.env.CURSOR_POOL_NAME ?? "vercel-sandbox";

export async function POST(request: Request) {
  const secret = process.env.CONTROLLER_SECRET;
  if (!secret || request.headers.get("authorization") !== `Bearer ${secret}`) {
    return new Response("Unauthorized", { status: 401 });
  }

  const run = await start(cursorPoolController, [POOL_NAME]);
  return Response.json({ runId: run.runId, status: "starting" });
}
```

Set the controller secret, deploy, and start the workflow once:

```bash
vercel env add CONTROLLER_SECRET
vercel deploy --prod

curl --request POST https://your-project.vercel.app/api/cursor-workers/controller \
  --header "Authorization: Bearer $CONTROLLER_SECRET"
```

The controller reconciles immediately and then uses adaptive polling. Durable sleeps are not bound by Vercel Function duration and do not consume compute while suspended, so this design works on Hobby as well as Pro and Enterprise. The 45-minute Sandbox timeout is the Hobby maximum; Pro and Enterprise support longer sessions. For lower pickup latency, a persistent service can consume Cursor's pending-request SSE stream and keep the same claim-and-dispatch path.

## Start an agent on the team pool

Users can select `vercel-sandbox` from Cursor's Cloud Agents interface. You can also target the pool through the Cloud Agents API:

```bash
curl --request POST \
  --url https://api.cursor.com/v1/agents \
  --header "Authorization: Bearer $CURSOR_API_KEY" \
  --header "Content-Type: application/json" \
  --data '{
    "prompt": { "text": "Add tests for the checkout flow and open a PR" },
    "env": { "type": "pool", "name": "vercel-sandbox" },
    "repos": [
      {
        "url": "https://github.com/acme/storefront",
        "startingRef": "main"
      }
    ],
    "autoCreatePR": true
  }'
```

The request first appears in the durable pool. On the next discovery pass, the parent Workflow claims it and dispatches a child Workflow. The child retrieves or creates the request's named Sandbox, starts the worker, monitors the agent, and cleans up when the run becomes idle.

No inbound port, load balancer, or publicly reachable worker service is required.

## Production considerations

This implementation is intentionally small. Before using it for a large fleet, account for reliability, security, and operating cost.

### Reliability and lifecycle

Use Cursor's [list-then-watch SSE flow](https://cursor.com/docs/cloud-agent/api/endpoints#watch-pending-pool-requests) when you need lower pickup latency than adaptive polling provides. Workflow runs stay pinned to the deployment that started them; because the controller starts children without overriding `deploymentId`, its children use the same deployment. After an incompatible change, cancel the old controller and start it again from the new production deployment. The hook lease prevents two controllers from remaining active for the same pool.

Workflow may retry an interrupted step. Cursor's atomic claim, deterministic worker IDs, per-request Workflow leases, and deterministically named `Sandbox.getOrCreate()` calls make those retries safe. Each child Workflow stops its Sandbox and releases its claim when the agent becomes idle, with the 45-minute Sandbox timeout as a final safety net. User-scoped worker tokens expire after one hour and cannot refresh themselves, so longer sessions require a secure refresh path or a different scoped credential strategy.

### Credentials and network access

Configure Cursor's Git provider integration or supply short-lived Git credentials through your organization's approved secret flow. Do not bake credentials into a snapshot. Restrict outbound traffic to the destinations required by the worker and its build; Vercel Sandbox supports egress allowlists and credential brokering at the microVM firewall.

### Capacity and maintenance

Tune `MAX_WORKERS_PER_TICK`, Sandbox vCPUs, and pool quotas for your Vercel plan and expected workload. Rebuild snapshots periodically to keep the Cursor Agent CLI and system packages current. Record request ID, worker ID, Sandbox ID, creation time, and termination reason in your telemetry, but never log worker tokens.

## What you built

The completed integration uses Vercel Functions and Workflow to discover, claim, and coordinate Cursor requests. Each request runs in a dedicated Vercel Sandbox created from a prebuilt snapshot, then shuts down automatically when the agent becomes idle.

This provides a scale-to-zero Cursor Self-Hosted Machines team pool without maintaining long-lived worker infrastructure. For implementation details, see Cursor's [team pool APIs](https://cursor.com/docs/cloud-agent/bring-your-own-machine/pools) and the [Vercel Sandbox documentation](https://vercel.com/docs/sandbox).