Ploy
Ploy
Features

Sandboxes

Run commands, manage files, and execute code in isolated, long-lived sandboxes from your workers.

Sandboxes

Ploy Sandboxes give your workers an isolated, long-lived environment to run shell commands, read and write files, clone repositories, and stream output. Each sandbox is identified by an id, persists across requests, and is launched lazily on its first use — perfect for build steps, code execution, and AI agents.

Configuration

Add a sandbox binding in your ploy.yaml:

ploy.yaml
kind: dynamic
build: pnpm build
out: dist
sandbox:
  SANDBOX: default

The key (SANDBOX) is the binding name available in your worker's env. The value (default) is the sandbox identifier.

Run ploy types to generate TypeScript types:

env.d.ts
import type { SandboxBinding } from "@meetploy/types";

export interface Env {
	SANDBOX: SandboxBinding;
}

Call env.SANDBOX.get(id) to get a client scoped to one sandbox. Reusing the same id across requests resolves the same sandbox, so its files and state persist between calls.

Basic Example

src/index.ts
export default {
	async fetch(request, env) {
		const url = new URL(request.url);
		const id = url.searchParams.get("id") ?? "default";
		const sandbox = env.SANDBOX.get(id);

		// Run a command
		if (url.pathname === "/exec") {
			const result = await sandbox.exec("echo hello from ploy sandbox");
			return Response.json(result);
		}

		// Write then read a file back
		if (url.pathname === "/file") {
			await sandbox.writeFile("note.txt", "written by the worker\n");
			const { content } = await sandbox.readFile("note.txt");
			return Response.json({ content });
		}

		return new Response("Sandbox Worker");
	},
} satisfies Ploy;

API Methods

exec(command, options?) — Run a Command

Runs a command and resolves once it finishes, returning its output and exit status.

const result = await sandbox.exec("ls -la", {
	cwd: "/tmp",
	env: { GREETING: "hi" },
	timeoutMs: 5000,
});

console.log(result.stdout);
console.log(result.exitCode);
console.log(result.success);

The result is a SandboxExecResult with stdout, stderr, exitCode, and a success boolean.

execStream(command, options?) — Stream Command Output

Returns a ReadableStream of the command's output as it runs. Useful for long-running tasks like build logs or agent output.

const stream = await sandbox.execStream("npm install");

return new Response(stream, {
	headers: { "Content-Type": "text/plain; charset=utf-8" },
});

writeFile(path, content) / readFile(path) — Files

await sandbox.writeFile("config.json", JSON.stringify({ debug: true }));

const { content } = await sandbox.readFile("config.json");

deleteFile(path) — Remove a File

await sandbox.deleteFile("config.json");

mkdir(path, options?) — Create a Directory

await sandbox.mkdir("/data/nested", { recursive: true });

listFiles(path) — List Directory Contents

Returns an array of entries, each with a name, path, type ("file" or "directory"), and optional size.

const entries = await sandbox.listFiles(".");

for (const entry of entries) {
	console.log(entry.type, entry.name);
}

gitCheckout(repoUrl, options?) — Clone a Repository

await sandbox.gitCheckout("https://github.com/owner/repo", {
	branch: "main",
	depth: 1,
	targetDir: "repo",
});

setEnvVars(vars) — Set Environment Variables

Sets environment variables that apply to subsequent commands in the sandbox.

await sandbox.setEnvVars({ NODE_ENV: "production" });

destroy() — Tear Down the Sandbox

Destroys the sandbox and its contents.

await sandbox.destroy();

Background Processes

exec() and execStream() block until the command finishes, so they tie up your worker request for the whole run. For long-running work — a dev server, a build, a coding agent that runs for minutes — use startProcess() instead. It launches the command in the background, returns immediately with a process handle, and lets the process keep running after your worker has responded.

There is no completion callback: you start a process, persist its id, and check back on it from later requests by polling getProcess() or streaming its logs.

src/index.ts
export default {
	async fetch(request, env) {
		const url = new URL(request.url);
		const sandbox = env.SANDBOX.get("builder");

		// Kick off a long job and return its handle right away.
		if (url.pathname === "/build/start") {
			const proc = await sandbox.startProcess("npm run build");
			return Response.json(proc);
			// → { id, pid, command, status: "running", exitCode: null, startedAt }
		}

		// Later request: check whether it finished.
		if (url.pathname === "/build/status") {
			const id = url.searchParams.get("id");
			const proc = await sandbox.getProcess(id);
			return Response.json(proc);
		}

		return new Response("Builder");
	},
} satisfies Ploy;

A SandboxProcessInfo handle looks like:

interface SandboxProcessInfo {
	id: string; // process id — persist this to check back later
	pid: number | null; // process group id inside the sandbox
	command: string;
	status: "running" | "completed" | "failed";
	exitCode: number | null; // set once the process finishes
	startedAt: string; // ISO timestamp
}

startProcess(command, options?) — Start in the Background

Same options as exec() (cwd, env, timeoutMs). Returns as soon as the process is launched.

const proc = await sandbox.startProcess("python worker.py", {
	cwd: "/app",
	env: { WORKERS: "4" },
});

listProcesses() / getProcess(id) — Inspect

const all = await sandbox.listProcesses();

const proc = await sandbox.getProcess(id);
if (proc?.status === "completed") {
	// done
}

getProcessLogs(id) — Read Accumulated Output

Returns the process's combined stdout and stderr captured so far.

const { logs } = await sandbox.getProcessLogs(id);

streamProcessLogs(id) — Stream Output Live

Replays the log from the start, follows it as new output arrives, and ends when the process exits.

const stream = await sandbox.streamProcessLogs(id);

return new Response(stream, {
	headers: { "Content-Type": "text/plain; charset=utf-8" },
});

killProcess(id) — Terminate

Stops the process and its child tree.

await sandbox.killProcess(id);

A sandbox is not suspended for idleness while a background process is still running. Background processes do not survive a sandbox being stopped or destroyed, so treat them as living for the lifetime of the sandbox.

Sessions

A session is a persistent execution context within a sandbox: its working directory and environment variables carry across commands and file operations. Create one with createSession().

const session = await sandbox.createSession({
	cwd: "/tmp",
	env: { GREETING: "hello from a session" },
});

await session.exec("echo started > log.txt");
const greeting = await session.exec("echo $GREETING");
const log = await session.exec("cat /tmp/log.txt");

console.log(session.sessionId);
console.log(greeting.stdout.trim());
console.log(log.stdout.trim());

Sessions expose the same exec, execStream, writeFile, readFile, deleteFile, mkdir, listFiles, gitCheckout, setEnvVars, and the background-process methods (startProcess, getProcess, …) as the sandbox client. Relative file paths resolve against the session's working directory.

Use a session when you need a series of commands to share the same working directory and environment. Use the sandbox client directly for one-off commands.

AI for Coding Agents

The default sandbox image ships a coding agent (and you can install your own). When your project has AI enabled, Ploy injects credentials for the built-in AI gateway into the sandbox automatically, so an agent running inside it can call models without you managing any API keys.

Enable it by adding ai: true alongside the sandbox binding:

ploy.yaml
kind: dynamic
build: pnpm build
out: dist
ai: true
sandbox:
  SANDBOX: default

With ai: true, every sandbox launched for the project gets these environment variables set inside the container:

VariablePurpose
PLOY_AI_URLBase URL of the Ploy AI gateway.
ANTHROPIC_BASE_URL / OPENAI_BASE_URLThe gateway's /v1 endpoint, so the Anthropic and OpenAI SDKs route through Ploy.
PLOY_AI_TOKENShort-lived token authorizing requests to the gateway.
ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN / OPENAI_API_KEYThe same token under the names each SDK / agent expects.

Because the agent and the SDKs read these standard variables, an Anthropic- or OpenAI-compatible tool works out of the box — a bare model name is routed through the Ploy gateway with no extra configuration:

src/index.ts
const sandbox = env.SANDBOX.get("agent");

// soulforge (the bundled agent) picks up OPENAI_BASE_URL / OPENAI_API_KEY and
// streams its work back. No model credentials are passed in by you.
const stream = await sandbox.execStream(
	`soulforge --headless --model openai/gpt-5.5 "summarize this repository"`,
);

return new Response(stream, {
	headers: { "Content-Type": "text/plain; charset=utf-8" },
});

You can override or add to these variables per command with the env option on exec / startProcess, or for the whole sandbox with setEnvVars().

The AI credentials are only injected when the project has ai: true — the same gate as the AI binding. Without it, no gateway token is placed in the sandbox. The injected token is scoped to your project and minted with a long TTL so agent sessions that run for several minutes don't expire mid-task.

Local development

Sandbox agents work under ploy dev too. Make sure your project has ai: true and that you are signed in with ploy login — Ploy then injects the same gateway credentials into the local sandbox. Without them the agent has no model access and fails to authenticate (for example OPENAI_API_KEY is not set).

Next Steps

  • Workers - Learn about Ploy workers
  • AI - Use the built-in AI gateway from your workers

How is this guide?

Last updated on