Sandboxes and execution
A sandbox is a remote environment where you can run commands and work with files. The examples below use a Beachside client named beachside, created with your chosen adapter as shown in Getting started.
Adapter setup supplies the defaults for sandbox creation. Available operations and lifecycle behavior depend on the adapter; provider differences are called out below.
Create and clean up
Section titled “Create and clean up”Use sandboxes.with() for a task that needs a sandbox only while it runs:
try { const text = await beachside.sandboxes.with( {}, // Uses the configured adapter defaults. async (box) => { console.log(box.id, (await box.inspect()).state); return (await box.exec(["printf", "hello"])).stdoutText(); }, ); console.log(text); // Returned after sandbox cleanup is confirmed.} finally { await beachside.close();}with() creates one sandbox, passes it to your callback, and attempts to destroy it when the callback finishes, throws, or is cancelled. It does not delete retained snapshots or volumes. After cleanup succeeds, it returns the callback’s value or rethrows the callback’s error.
Use sandboxes.create() and box.destroy() when you need to manage the sandbox’s lifetime yourself. Reopening an existing sandbox with sandboxes.get() does not arrange automatic cleanup.
close() does not destroy a sandbox. It releases local client resources. Destroy compute you no longer need, and close the client after your work and cleanup finish.
Choose a sandbox size
Section titled “Choose a sandbox size”Set resources in adapter setup to ask for at least a number of vCPUs and MiB of memory. The adapter picks the smallest native size that meets both, so you may get more than you asked for, never less:
const client = await Beachside.connect({ adapter: boxdAdapter, credentials: { apiKey: process.env.BOXD_API_KEY! }, config: { org: "my-org", network: "internet", resources: { vcpus: 2, memoryMiB: 4096 } },});const box = await client.sandboxes.create();console.log((await box.inspect()).resources); // { status: "known", value: { vcpus: 2, memoryMiB: 8192 } }When no native size is big enough, Beachside.connect rejects with UNSUPPORTED before contacting the provider, and the message names the provider’s largest size. inspect().resources reports the size the sandbox actually has, or unknown when the provider does not say.
| Adapter | How resources is applied |
|---|---|
| boxd | Smallest size class: 1, 2, 4 or 8 vCPUs with 4 GiB of memory per vCPU. |
| Vercel | Smallest vCPU count (1 or an even number up to 32) with 2 GiB per vCPU. Your plan’s limit still applies. |
| Boat | Smallest machine type: small 2/4 GiB, default 4/8 GiB, large 8/16 GiB, xlarge 16/32 GiB. |
| Runloop | Smallest size from X_SMALL 0.5/1 GiB to XX_LARGE 8/32 GiB, then a custom 16 vCPU size with 32 or 64 GiB. |
| Modal | Reserved exactly as given; vcpus may be fractional. Reported only when both numbers are set. |
| Daytona | Applied when the adapter builds a snapshot from an OCI image. A prepared snapshot must already meet the size. |
| E2B | Applied when the adapter builds a template from an OCI image. A prepared template must already meet the size. |
Daytona and E2B fix a sandbox’s size when they build its snapshot or template. With resources set, they build OCI images (including images.build) at that size, rounded up to whole vCPUs and whole GiB on Daytona or an even number of MiB on E2B. Before creating from a prepared image, they read its size and reject with UNSUPPORTED if it is smaller or unreported, so E2B’s public base template is rejected. Snapshot restores keep the size of the captured sandbox.
Cleanup options and failures
Section titled “Cleanup options and failures”The third argument to with(input, callback, options) accepts:
| Option | Purpose |
|---|---|
signal |
Stop waiting for the callback and begin cleanup when cancelled. Pass the same signal to operations inside the callback too. |
cleanupTimeoutMs |
Give cleanup its own time limit, independent of the work signal. Defaults to 30,000 ms; accepts integers from 1 to 300,000. |
storage |
Override the connection’s storage cleanup policy with "require-durable" or "allow-unconfirmed". Neither option deletes retained storage. |
Cancellation cannot force JavaScript in your callback to stop. A callback that ignores the signal can keep running while cleanup starts. Stopping local waiting also does not prove that a remote command stopped.
If destruction fails or cannot be confirmed, with() throws SandboxCleanupError. It keeps both the work result and the cleanup failure:
callbackOutcome: the callback’s completed value, thrown error, or local cancellation error.cleanupError: the destruction error.sandboxIdandsandboxReference: the sandbox’s available identity.cleanupOperationandcleanupReference: available cleanup recovery information.outcome: any confirmed partial cleanup result.
Save recovery references from both work and cleanup errors when present. Recover the original operation before deciding what to do next. The runnable examples show how to report these failures.
If creation itself has an uncertain outcome, with() throws the original error. It does not run your callback or try to destroy a sandbox whose identity is unknown.
Reopen and manage lifetime
Section titled “Reopen and manage lifetime”Save the complete box.reference in storage your application controls. Later, use a new client with current credentials for the same provider and scope, such as the original team, project, organization, and region:
const box = await freshClient.sandboxes.get(savedReference);const info = await box.inspect();if (info.expires.status === "known") console.log(info.expires.at, info.expires.scope);get() reopens the same sandbox. It does not create a replacement, resume it, or extend its lifetime. inspect() reads its current state. Commands and file access require a running sandbox.
A missing or deleted sandbox returns NOT_FOUND. Other problems, such as denied access or a scope mismatch, have their own errors. A saved reference cannot move a sandbox to another provider.
box.reference can be null, including when an adapter cannot verify identity during creation. Try inspect() and check its returned reference; do not construct one from box.id. For E2B credential rotation, configure the verified teamId when establishing the original connection. See the reopening example.
Inspection reports state, provider state, observation time, expiry, idle-stop policy, retention policy, and execution identity when known. Unknown expiry does not mean unlimited lifetime. A past expiry time alone does not prove that the sandbox was deleted.
List sandboxes
Section titled “List sandboxes”sandboxes.list() returns one page of sandboxes in the connection’s scope. Each item has an id, a state, a reference that sandboxes.get() accepts, the labels set at create and the provider’s createdAt time:
let cursor: string | undefined;do { const page = await beachside.sandboxes.list({ cursor }); // limit: 1-100, default 50 for (const item of page.items) if (item.state === "running" && item.reference) console.log(item.id, (await beachside.sandboxes.get(item.reference)).id); cursor = page.nextCursor;} while (cursor);Pass the returned nextCursor to read the next page; it is absent on the last page. A page can hold fewer items than limit when the adapter filters native results. coverage reports what the listing includes, using the same values as snapshots.list() and volumes.list():
"provider-scope": every sandbox the connection’s account and scope can see, including ones not created through Beachside."managed": only sandboxes carrying verifiable Beachside creation markers.
reference is null when the adapter cannot rebuild a verified identity for that sandbox, for example when creation markers are missing or the adapter cannot reopen sandboxes. The sandbox is still listed. Listed references never claim more than the provider shows: ownership is "verified-created" only when native creation markers prove that Beachside created it, and is otherwise "unknown". A listing does not prove that your application created a sandbox, so do not use it alone to decide what to clean up. Destroyed sandboxes can still appear with state: "destroyed"; get() reports NOT_FOUND for them.
labels holds the labels you passed to create() or restore(), without the adapter’s own markers. It is null on providers that keep no labels. createdAt is an ISO time the provider reported, or null when it reports none. Labels you set per job, such as { run: "42" }, are the reliable way to find a crashed job’s sandboxes later; the CLI’s beachside gc does exactly that.
| Adapter | Coverage | Listed references | Labels and createdAt |
|---|---|---|---|
| Daytona | managed (organization and target) |
verified-created from Beachside labels; null when a creation label is missing. One detail read per labelled candidate. |
Labels; creation time. |
| E2B | managed (team or API key, and template) |
verified-created from Beachside metadata. Paused sandboxes list as suspended. |
Labels; start time. |
| boxd (experimental) | provider-scope (verified org) |
unknown ownership; null for machines without a UUID. |
No labels (null); creation time. |
| Vercel Sandbox (experimental) | Sessions: provider-scope; persistent: managed |
unknown ownership with no creation receipt. Persistent references keep the native generation used to reopen by name. |
Labels; creation time of the named sandbox. |
| Runloop (experimental) | provider-scope (verified account) |
unknown ownership. |
No labels (null); creation time. |
| Modal (experimental) | provider-scope (verified App) |
Always null: the Modal adapter cannot reopen sandboxes. |
Labels; no creation time (null). |
operations.inventory() remains available and returns only id and state.
Sandbox lifetime
Section titled “Sandbox lifetime”Every sandbox has a wall-clock lifetime, after which the provider removes it (Boat archives it instead). It is your backstop for a job that crashes or is cancelled before it can destroy its sandbox. Every adapter uses the same setting, lifecycle.lifetimeSeconds in adapter setup, and defaults to 900 seconds (15 minutes):
const client = await Beachside.connect({ adapter: e2bAdapter, credentials: { apiKey: process.env.E2B_API_KEY! }, config: { lifecycle: { lifetimeSeconds: 1800 } },});
const box = await client.sandboxes.create(); // 30 minutes, from setup.const long = await client.sandboxes.create({ lifecycle: { lifetimeSeconds: 7200 } }); // This one only.A per-create lifetime replaces the setup value for that sandbox. An adapter never shortens a lifetime: it may round up to its native step (whole minutes on Daytona, at least 60 seconds on E2B, Vercel and boxd), and inspect() reports the real expiry. A value it cannot apply rejects with UNSUPPORTED before anything is created. capabilities().lifetime reports the accepted range, or null when only setup can set it.
| Adapter | Per-create range | Notes |
|---|---|---|
| Daytona | any, in whole minutes | Rounded up. |
| E2B | up to 24 hours | Under 60 seconds becomes 60. Plan limits apply. |
| boxd (experimental) | any | Under 60 seconds becomes 60. |
| Vercel Sandbox (experimental) | up to 24 hours | Not with persistent: true. Plan limits apply. |
| Modal (experimental) | up to 24 hours | |
| Runloop (experimental) | 60 seconds to 48 hours | Not when lifecycle.idleSeconds is set. |
| Boat (experimental) | 60 seconds to 30 days |
Older adapter-specific names still work for one minor release and reject when set together with lifecycle.lifetimeSeconds: E2B, Vercel and Modal timeoutSeconds, Daytona ttlMinutes, and boxd’s top-level lifetimeSeconds. From the command line, beachside create --ttl 30m and beachside run --ttl 2h set a per-create lifetime.
Renew lifetime
Section titled “Renew lifetime”On adapters with renewal, the setup lifecycle.lifetimeSeconds is also the default renew() window.
While the sandbox is running:
const renewed = await box.renew(); // Uses this connection's configured window.console.log(renewed.requested.forSeconds, renewed.observation?.expires);await box.renew({ forSeconds: 61 }); // Requests a different window.Renewal sets a new expiry window from when the provider processes the request. It does not add time to the old deadline, and can shorten a longer lifetime.
| Adapter | Renewal window |
|---|---|
| E2B | Positive integer seconds, raised to at least 60 seconds; maximum 86,400 seconds (Pro); E2B enforces Hobby’s 1 hour. |
| Daytona | Positive integer seconds, rounded up to whole minutes: 61 becomes 120. No adapter maximum. |
Provider account limits can be tighter. renewed.requested.forSeconds reports the resolved setting. acknowledged: true means the provider accepted the request, even if the follow-up read failed and observation is null. If the acknowledgement is lost, recover the operation instead of automatically renewing again.
The experimental boxd adapter also supports renewal. Vercel Sandbox renewal instead adds 1–86,400 seconds per request to the running session; Vercel caps total session time by plan. See the renewal example for saving and reopening a sandbox before renewal.
Suspend and resume
Section titled “Suspend and resume”On adapters that support it, suspend() pauses a sandbox and resume() starts it again under the same identity:
const suspended = await box.suspend();console.log(suspended.preserve, suspended.processes);// Save box.reference if you need to reopen it later.const resumed = await box.resume();console.log(resumed.execution);What survives suspension depends on the provider:
| Adapter | What to expect |
|---|---|
| Daytona | Containers stop: private files are kept and processes end; resume starts fresh processes under the same sandbox ID. Linux VMs pause: files, memory and processes are kept and resume continues them. Requires no mounts and deletion after stopping disabled. The sandbox’s expiry clock keeps running while inactive; resume does not reset it. |
| E2B | Preserves private files and memory, and drops connections. Requires a verified kill-on-timeout policy with automatic resume disabled; known native mounts are unsupported. Resume uses the current connection’s configured lifetime. execution: "unknown" means Beachside cannot verify that the original processes survived intervening lifecycle changes. |
| boxd (experimental) | Maps native pause and wake with memory, processes, and sockets preserved. Inbound traffic can also wake sleeping machines. This lifecycle mapping has not been live-qualified. |
| Vercel Sandbox (experimental) | Suspension and resumption are unsupported. |
E2B’s paused state has no active-session expiry and needs explicit cleanup. It is saved compute, not a reusable snapshot. Neither E2B nor Daytona promises external-storage durability or connection continuity through suspension.
For E2B and Daytona, configure lifecycle: { suspension: { preserve: "filesystem" } } to require at least filesystem preservation. "filesystem+memory" requires memory too; E2B and Daytona Linux VMs support it, and Daytona containers reject it. These operations require a verified sandbox reference and eligible native lifecycle settings.
On E2B and Daytona, suspending an already inactive sandbox or resuming a running one returns CONFLICT. Avoid concurrent lifecycle changes from your application or other controllers. Recover uncertain operations using their saved references; cancellation does not undo a submitted change. Destroy the sandbox when you no longer need its saved state.
submitSuspend(), submitResume(), and submitRenew() let you separate submission from waiting. See the suspend/resume example.
Pass arguments literally
Section titled “Pass arguments literally”Pass an argument array to run a command without shell expansion. Characters such as $, ;, and * are passed literally. Use the object form to set a working directory, environment variables, deadline, or output limit:
const output = await box.exec({ command: { kind: "argv", argv: ["python3", "-c", "print('hello')"] }, cwd: "/home/user", env: { APP_MODE: "example" }, deadlineSeconds: 30, maxOutputBytes: 65_536,});console.log(output.stdoutText());The sandbox image must contain the executable and working directory. deadlineSeconds defaults to 300 and accepts integers from 1 to 3,600. The default combined stdout/stderr capture limit is 1 MiB. Inspect output.truncated to check whether output was cut short.
A command deadline does not extend sandbox lifetime or provide a total time limit for the SDK call. Setup and output retrieval can take additional time, and adapters apply the deadline differently.
Execution and waiting timeouts
Section titled “Execution and waiting timeouts”There are separate limits for how long your application waits, how long a command runs, and how long its sandbox exists:
| Limit | Meaning |
|---|---|
Caller signal |
Stops local waiting. After submission, WAIT_ABORTED keeps an operation reference for recovery. The remote command may continue. |
| Provider request timeout | Limits a network request or response wait. A failed request does not prove that the command stopped. |
Command deadlineSeconds |
Sets the adapter’s execution or observation deadline. See the provider differences below. |
| Sandbox lifetime | Controls the sandbox’s expiry independently of commands. Expiry can interrupt work; it is not confirmation of cleanup and does not expire retained snapshots or volumes. |
Use a signal to limit how long your application waits:
const result = await box.exec( { command: { kind: "argv", argv: ["node", "job.js"] }, deadlineSeconds: 10 }, { signal: AbortSignal.timeout(15_000) },);The signal stops local waiting after 15 seconds. It does not kill remote compute. The 10-second command deadline has the adapter-specific behavior below. If the result is uncertain, recover the original execution rather than automatically running the command again.
| Adapter | How the command deadline is used |
|---|---|
| E2B | Sets the native command RPC and initial connection timeout. A timeout does not confirm remote termination. Later polling and output reads can take longer than the deadline. |
| Daytona | Sets the native command timeout, plus separate local request and result-observation windows. Daytona documents command termination, but Beachside has not live-verified cleanup of its command wrapper or child processes at timeout. |
| boxd (experimental) | Sets a deadline for the buffered execution transport. Local cancellation is not confirmation that the guest command stopped. |
| Vercel Sandbox (experimental) | A guest Node helper attempts to kill the command’s process group at the deadline. Separate request timeouts cover launch and observation. This behavior has fixture coverage but no live verification. |
| Modal (experimental) | Sets the native command timeout. Initial lookup and result observation share a local window of the deadline plus 5 seconds. Modal documents a process runtime limit; Beachside has not live-verified the private transport or child-process termination. |
These mappings describe implementation and provider documentation, not a guarantee that every remote process has stopped. The E2B client artifact, Daytona process reference, and Modal command guide describe the underlying timers. Modal’s separate sandbox lifetime is described in its sandbox guide.
Local timeout handling does not automatically kill or destroy a sandbox, or replay the command. A confirmed exit and complete output are separate facts: E2B can return a known exit with partial output and truncated: true; other failures can leave the result uncertain. Follow the error’s recovery information.
Use a shell explicitly
Section titled “Use a shell explicitly”Use the shell form when you need pipes, redirection, or shell expansion:
const output = await box.exec({ command: { kind: "shell", script: "printf hello | wc -c" }, deadlineSeconds: 30,});Avoid inserting untrusted text into shell scripts. Use argument arrays when you do not need shell syntax.
Handle command failures
Section titled “Handle command failures”exec() returns output when the command exits with code zero. A confirmed nonzero exit throws NonzeroExitError, which includes the command’s result:
import { NonzeroExitError } from "beachside";
try { await box.exec(["sh", "-c", "exit 1"]);} catch (error) { if (error instanceof NonzeroExitError) { console.error(error.result.exitCode, error.result.stderrText()); } else { throw error; }}NoExitCodeError means execution completed without a confirmed exit code. A network failure or missing result can instead leave the outcome uncertain. See Errors and recovery before retrying work that may already have run.
Submit and wait separately
Section titled “Submit and wait separately”Most code can use exec() and create(), which submit the operation and wait for the result.
Use box.submitExec(input) or beachside.sandboxes.submitCreate(input) when you need to save a recovery reference or control waiting yourself. They return a handle with reference, observe(), and wait().
Cancelling wait({ signal }) stops local waiting. It does not terminate the command or undo creation. Save the reference and recover the submitted operation before deciding whether to retry.
For ongoing output and explicit process control, use processes and streaming. Signal and termination support depends on the adapter and process handle; an operation handle is not a process handle.
Serializable resource identity
Section titled “Serializable resource identity”A ResourceReference is a serializable description of a sandbox or stored resource. It records the provider, scope, native ID, ownership evidence, and generation when needed to distinguish reused IDs. Save the full reference rather than only the ID. It contains no credentials and does not grant access; reopening still needs valid credentials.
For applications that validate stored references:
validateResourceReference()checks the format and returns a detached copy.assertResourceScope()checks the provider and full scope.assertResourceIdentity()also checks the resource kind, native ID, and generation. When one reference has a generation, the other must have the same generation.
These checks do not contact the provider or prove that a resource still exists. See Snapshots and volumes for opening and managing stored resources.
A resource reference identifies a resource. An operation recovery reference identifies a submitted operation. Recovering an operation observes the original attempt; it does not automatically submit it again.