Skip to content
Docs

Run Cursor Cloud Agents on Vercel Sandbox

Learn how to run Cursor Cloud Agents on Vercel Sandbox with BYOM worker pools, durable workflows, isolated microVMs, and scale-to-zero compute.

Allen ZhouMember of Technical Staff

Cursor Cloud Agents can now run in Vercel Sandbox instead of Cursor's hosted machines. This guide uses Cursor's Self-Hosted Machines APIs and Vercel 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.

Copy link to headingHow it works

The integration has two planes:

  • Control plane (Vercel Functions + Vercel 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 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.

Cursor's team pool 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.

Copy link to headingPrerequisites

Cursor members can create user API keys, but only team admins can create the Enterprise service account this controller requires.

Copy link to headingSet up the project

Create a Next.js app and install the dependencies:

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:

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:

vercel link
vercel env pull .env.local

On Vercel, the Sandbox SDK 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:

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.

Copy link to headingBuild 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, install the CLI once, and save the result as a Sandbox snapshot.

Create scripts/build-snapshot.ts:

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:

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.

Copy link to headingRegister 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:

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 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.

Copy link to headingBuild the control plane

The walkthrough creates the following files:

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

Copy link to headingConnect 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:

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. Add the API helper:

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:

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 documents POST /v1/sub-tokens as returning a one-hour token. Add token minting and status lookup:

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 explicitly defines 404 as no live claim because it was already released, expired, or adopted, so cleanup should not retry that response:

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}`);
}
}

Copy link to headingProvision a worker Sandbox

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

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:

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:

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:

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 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:

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.

Copy link to headingClaim 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:

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:

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.

Copy link to headingOrchestrate each worker

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

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:

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.

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:

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);
}
}

Copy link to headingRun the discovery workflow

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

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:

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:

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.

Copy link to headingExpose the controller endpoint

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

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:

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.

Copy link to headingStart 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:

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.

Copy link to headingProduction considerations

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

Copy link to headingReliability and lifecycle

Use Cursor's list-then-watch SSE flow 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.

Copy link to headingCredentials 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.

Copy link to headingCapacity 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.

Copy link to headingWhat 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 and the Vercel Sandbox documentation.

Related documentation

More Vercel Sandbox guides