Skip to content

Process execution

Last updated View as MarkdownAgent setup

In the 1.0 preview, treat the sandbox as a computer you drive with explicit programs.

Each exec() starts a new supervised process from argv. The call resolves when launch succeeds (you receive a process handle with id and pid properties), not when the process exits. Each launch is independent: pass cwd and env when the process needs them, or put multi-step shell syntax in one explicit shell argv. For an interactive PTY, use the terminal API.

Long-running work often spans many short Worker requests. A process ID is enough only while the same container still has that process. Across idle stop, failure, or replace, store the full launch (command, options, and any app checkpoint) so you can start again. Refer to Continue work across requests.

Sandbox ID, container, and process

Most applications use one sandbox per user or task:

const sandbox = getSandbox(env.Sandbox, "user-123");
const sandbox = getSandbox(env.Sandbox, "user-123");

Three different things are in play:

Term Meaning
Sandbox ID The stable string your app uses to find that sandbox again (for example "user-123").
Container The Containers instance currently running work for that sandbox. Sandboxes run on containers. The sandbox ID is stable. The container instance behind it is not always the same one.
Process A program you start with exec() inside the current container. The handle and process.id mean “this program in this container,” not “this sandbox ID forever.”

Same sandbox ID does not mean the same container. Processes live only in the container that started them. After a new container serves that ID, start new processes — you do not resume the old ones. Full sandbox model: Sandbox lifecycle.

Command model

The command model changes in the 1.0 preview compared with the current stable package:

Current stable package 1.0 preview
exec(string) resolves when the command finishes exec(argv) resolves when the process starts
Default session can preserve cd / export Each launch is independent
startProcess / execStream for other shapes One process handle covers short and long-running work

Use argv for a single binary:

const process = await sandbox.exec(["node", "--version"]);
const output = await process.output({ encoding: "utf8" });
const process = await sandbox.exec(["node", "--version"]);
const output = await process.output({ encoding: "utf8" });

Use an explicit shell when you need shell syntax:

const process = await sandbox.exec([
	"/bin/bash",
	"-lc",
	"cd /workspace/app && npm test",
]);
const process = await sandbox.exec([
	"/bin/bash",
	"-lc",
	"cd /workspace/app && npm test",
]);

Or pass cwd and env on the launch instead of relying on a previous command:

const process = await sandbox.exec(["npm", "test"], {
	cwd: "/workspace/app",
	env: { NODE_ENV: "test" },
});
const process = await sandbox.exec(["npm", "test"], {
	cwd: "/workspace/app",
	env: { NODE_ENV: "test" },
});

Sandbox-wide values use setEnvVars. Refer to Environment variables.

Process handles

await sandbox.exec(argv) returns a process handle:

Capability Members
Identity id, pid
Observe status(), logs(), output(), waitForExit(), waitForLog(), waitForPort(), exitCode
Control kill(signal?) with a numeric signal (default 15)

Observation timeouts and AbortSignal values cancel only that wait or stream. They do not stop the process. Call kill() when you intend to stop it.

exec(argv, { timeout }) sets a remote lifetime deadline. When the supervisor stops the process for that deadline, completion can report timedOut: true.

For short commands, output() is enough. For large or long-running output, prefer logs({ since, replay, follow }) and keep the latest cursor so a later request can resume the stream while the process is still in the current container. API details: Processes API.

How long a process lives

A process lives only as long as it keeps running in the current container for that sandbox. After the container stops, old process IDs are not valid on a later container that serves the same sandbox ID.

When the container stops

The container for a sandbox is not meant to run forever. After a period with nothing to do, Cloudflare may stop it. The container can also stop after failures, or when the platform replaces it during normal operations (for example after some deploys).

When that happens:

  • Your app still uses the same sandbox ID (user-123).
  • Processes that were running in the old container have exited. Their process IDs and live log buffers from that container are gone.
  • The next time you use the sandbox for real work, Cloudflare may start a new container for the same sandbox ID. You start new processes there. You do not reconnect to process IDs from the previous container. Files from the old container are not still there unless your app restored them (for example from a backup or a mounted bucket).

Container stop and replace are not new in 1.0. The preview makes process handles fail closed after the container that owned them is gone: the SDK does not retarget an old process ID at a new container for the same sandbox ID.

What you see in the API

What you try What happens
The process is still running in the current container getProcess(id) returns it; you can read logs and wait as usual
No container is running for the sandbox yet getProcess and listProcesses return null / []. They do not start a container just to answer the lookup
You still hold a handle from before the container stopped Calls on that handle fail with StaleProcessHandleError
You need the same job after a stop Start a new exec() from the launch and checkpoint your app stored

Recovery procedures: Errors and recovery.

Keep the container running

While a process or terminal is active, the container can stay running so work continues across requests. When nothing is active, the container may stop again after idle time. Long-running product flows should either keep meaningful work active or rely on checkpoints and relaunch.

Continue work across requests

Worker requests are short. Sandbox processes often are not. Design the job so a later request can either resume the same process or start the job again.

What to store

Always useful When you stream logs
Sandbox ID Latest log cursor from delivered events
Full exec argv
cwd and env if the launch needs them
Application checkpoint (repo path, step, agent state)

A process ID is a resume key for the current container only. It is not enough to restart the job after the container may have stopped.

Case 1: The container still has the process

Use this path when the work is still running and the container has not been replaced — for example another request arrives seconds later while a build or server is up.

const process = await sandbox.getProcess(storedProcessId);
if (process) {
	const stream = await process.logs({
		since: storedCursor,
		replay: true,
		follow: true,
	});
	// consume events; keep the latest cursor from each event
	return;
}
const process = await sandbox.getProcess(storedProcessId);
if (process) {
	const stream = await process.logs({
		since: storedCursor,
		replay: true,
		follow: true,
	});
	// consume events; keep the latest cursor from each event
	return;
}

You can also call status(), waitForPort(), waitForExit(), or kill() on that handle. Log cursors apply only while this process still exists in this container.

Case 2: The process is gone — start from the stored job

Use this path when getProcess returns null, a call throws StaleProcessHandleError, or enough time has passed that the container may have stopped or been replaced.

const process = await sandbox.exec(storedCommand, {
	cwd: storedCwd,
	env: storedEnv,
});
// persist process.id (and clear any old cursor)
await process.waitForPort(3000, { timeout: 60_000 });
const process = await sandbox.exec(storedCommand, {
	cwd: storedCwd,
	env: storedEnv,
});
// persist process.id (and clear any old cursor)
await process.waitForPort(3000, { timeout: 60_000 });

If the job also depends on files that lived only in the previous container, back up and restore those directories or mount durable storage before relying on the tree again. Backup and restore replace filesystem state. They do not bring back old process IDs or log buffers.

If the container is not ready yet, you may get ContainerUnavailableError — back off and run the same unit of work again. Refer to Errors and recovery.

Choose a path

async function continueJob(sandbox, job) {
	if (job.processId) {
		const existing = await sandbox.getProcess(job.processId);
		if (existing) {
			return existing; // Case 1 — same container, same process
		}
		// null: no container, or this ID is not in the current container
	}

	// Case 2 — relaunch from stored command and checkpoint
	const process = await sandbox.exec(job.command, {
		cwd: job.cwd,
		env: job.env,
	});
	job.processId = process.id;
	job.cursor = undefined;
	return process;
}
async function continueJob(sandbox: Sandbox, job: StoredJob) {
	if (job.processId) {
		const existing = await sandbox.getProcess(job.processId);
		if (existing) {
			return existing; // Case 1 — same container, same process
		}
		// null: no container, or this ID is not in the current container
	}

	// Case 2 — relaunch from stored command and checkpoint
	const process = await sandbox.exec(job.command, {
		cwd: job.cwd,
		env: job.env,
	});
	job.processId = process.id;
	job.cursor = undefined;
	return process;
}

If you still hold a handle object from before the container stopped, calls on that handle throw StaleProcessHandleError. Prefer getProcess(id) on each new request instead of reusing an old handle across requests.

Processes and terminals

Process (exec) Terminal
Role Supervised argv process Interactive PTY
Input Launch-time argv PTY input (write / browser connect)
Stop kill(signal?) interrupt() / terminate()
Lookup getProcess / listProcesses getTerminal / listTerminals

Both follow the same container lifetime rules. Terminal docs: Terminals. API: Terminals API.

Logs and large output

output() buffers stdout and stderr and may set truncated: true. Prefer logs() when output may be large or the process runs longer than one Worker request.

const stream = await process.logs({ follow: true, replay: true });
// each data/terminal event includes a cursor — store the latest
const stream = await process.logs({ follow: true, replay: true });
// each data/terminal event includes a cursor — store the latest

On a later request against the same still-running container, call getProcess(id) and resume with logs({ since: cursor, replay: true, follow: true }). After a new container starts for the sandbox, start a new process; the old cursor does not apply.

Event shapes, wait options, and readiness checks: Processes API.

Was this helpful?