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:
kind: dynamic
build: pnpm build
out: dist
sandbox:
SANDBOX: defaultThe 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:
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
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.
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:
kind: dynamic
build: pnpm build
out: dist
ai: true
sandbox:
SANDBOX: defaultWith ai: true, every sandbox launched for the project gets these environment
variables set inside the container:
| Variable | Purpose |
|---|---|
PLOY_AI_URL | Base URL of the Ploy AI gateway. |
ANTHROPIC_BASE_URL / OPENAI_BASE_URL | The gateway's /v1 endpoint, so the Anthropic and OpenAI SDKs route through Ploy. |
PLOY_AI_TOKEN | Short-lived token authorizing requests to the gateway. |
ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN / OPENAI_API_KEY | The 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:
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
How is this guide?
Last updated on