PlatformSandbox
Client for provisioning sandboxes in a Mastra Platform environment. Each PlatformSandbox instance owns one remote sandbox: start() provisions it, executeCommand() runs against it, and destroy() tears it down. Construct additional instances to own additional remote sandboxes. Use clone() to derive them from a configured template (see Cloning).
Sandboxes boot from a pre-built provider template with Python 3, Node 22, TypeScript, tsx, and common build tooling already installed. You can also build a reusable template with the portable Template() builder. Pass a stable id to opt into checkpoint recovery so a new sandbox boots from the previous one's filesystem.
Related providers: RailwaySandbox for self-hosted Railway sandboxes, LocalSandbox for local sandboxes.
For interface details, see WorkspaceSandbox interface.
InstallationDirect link to Installation
- npm
- pnpm
- Yarn
- Bun
npm install @mastra/platform-workspace
pnpm add @mastra/platform-workspace
yarn add @mastra/platform-workspace
bun add @mastra/platform-workspace
Configure the platform credentials. The access token, project ID, and environment ID fall back to environment variables, so a Mastra Platform deployment can pass zero constructor options.
- .env file
- Constructor
MASTRA_PLATFORM_ACCESS_TOKEN=your-platform-access-token
MASTRA_PROJECT_ID=your-project-id
MASTRA_ENVIRONMENT_ID=your-environment-id
SANDBOX_PROVIDER=railway
new PlatformSandbox({
accessToken: 'your-platform-access-token',
projectId: 'your-project-id',
environmentId: 'your-environment-id',
})
On a Mastra Platform deployment, MASTRA_PLATFORM_ACCESS_TOKEN, MASTRA_PROJECT_ID, and MASTRA_ENVIRONMENT_ID are injected automatically, so the constructor can be called with no options. For local development, MASTRA_PLATFORM_ACCESS_TOKEN can contain an sk_ API token from your organization's settings page under API Tokens.
The sandbox provider resolves from the constructor's sandboxProvider option, then SANDBOX_PROVIDER, then defaults to railway.
UsageDirect link to Usage
Add a PlatformSandbox to a workspace and assign it to an agent:
import { Agent } from '@mastra/core/agent'
import { Workspace } from '@mastra/core/workspace'
import { PlatformSandbox } from '@mastra/platform-workspace'
const workspace = new Workspace({
sandbox: new PlatformSandbox({
// accessToken, projectId, environmentId all fall back to env vars
idleTimeoutMinutes: 30,
}),
})
const agent = new Agent({
id: 'code-agent',
name: 'Code Agent',
instructions: 'You are a coding assistant working in this workspace.',
model: 'anthropic/claude-sonnet-4-6',
workspace,
})
const response = await agent.generate(
'Print "Hello, world!" and show the current working directory.',
)
console.log(response.text)
Private networkingDirect link to Private networking
Set networkIsolation to PRIVATE to join the environment's private network and reach other services running in the same Mastra Platform environment:
const workspace = new Workspace({
sandbox: new PlatformSandbox({
networkIsolation: 'PRIVATE',
}),
})
The default ISOLATED mode allows outbound internet access only, with no private network connectivity.
Reattaching to a running sandboxDirect link to Reattaching to a running sandbox
Pass an existing sandboxId to reattach to a live sandbox instead of creating a new one:
const sandbox = new PlatformSandbox({
sandboxId: 'sbx_abc123',
})
await sandbox.start()
const result = await sandbox.executeCommand('cat', ['/workspace/state.json'])
When sandboxId is set, environmentId isn't required because the sandbox already exists.
start() reports { outcome: 'connected' } on reattach and { outcome: 'created' } on a fresh provision (including a checkpoint-recovered boot, which is a new VM even when its filesystem was restored). See start() for the shared contract.
Reusable templatesDirect link to Reusable templates
Pass a reusable template builder to PlatformSandbox. A fresh sandbox start sends the serialized definition to Platform, which content-addresses it and starts or reuses the provider build. Sandbox creation never blocks on a build. When the exact template isn't built yet, Platform boots the sandbox on the best available fallback and rebuilds the requested template in the background. The fallback is a same-lineage stale template with matching resources when one exists, otherwise the provider base template:
import { PlatformSandbox, Template } from '@mastra/platform-workspace'
const commitSha = process.env.REPOSITORY_COMMIT_SHA!
const template = Template()
.cpuCount(4)
.memoryMB(8_192)
.setWorkdir('/workspace/repo')
.setEnvs({ BUILD_CONFIG_MARKER: 'template-v1' })
.aptInstall(['git', 'jq'])
.runCmd('git clone https://github.com/mastra-ai/mastra.git /workspace/repo')
.runCmd(`git checkout ${commitSha}`)
.runCmd('pnpm install --frozen-lockfile')
const sandbox = new PlatformSandbox({
environmentId: 'env_abc',
sandboxProvider: 'e2b',
template,
})
await sandbox.start()
Template() supports cpuCount, memoryMB, runCmd, setWorkdir, setEnvs, aptInstall, pipInstall, and npmInstall. Platform serializes the builder internally and computes the content hash. Call await template.build(options) to start or reuse that provider build without provisioning a sandbox. It returns ready, pending, or failed, with the template ID and an optional retry hint or error.
For E2B, cpuCount() and memoryMB() set the template build resources inherited by sandboxes created from the exact template or a resource-matched stale template. Both methods accept a positive safe integer. They default to 2 CPUs and 1,024 MB. Resource setters use last-value-wins semantics, and their effective values are part of the content identity. Omitting both setters is equivalent to explicitly selecting the defaults. If a pending build falls back to the provider base, that sandbox may use the provider's default resources. Check templatePending to detect this case.
Railway converts the command, working directory, environment, and package operations to Railway template instructions. Railway currently ignores cpuCount() and memoryMB() because its sandbox template API doesn't expose matching resource settings.
For public GitHub repositories, createRepoTemplate() can resolve and build the commit-pinned definition lazily when start() needs a fresh sandbox:
import { createRepoTemplate, PlatformSandbox } from '@mastra/platform-workspace'
const sandbox = new PlatformSandbox({
environmentId: 'env_abc',
template: createRepoTemplate({
getRepositoryAccess: async () => ({ cloneUrl: 'https://github.com/mastra-ai/mastra.git' }),
setupCommand: 'pnpm install --frozen-lockfile',
memoryMB: 2048,
}),
})
await sandbox.start()
createRepoTemplate() accepts the same cpuCount and memoryMB sizing as the Template() builder methods, as plain options. They carry the identity and stale-fallback semantics described above. Omit them for the provider defaults.
getRepositoryAccess mirrors the resolver a Factory sandbox context carries, so a host can pass its context straight through; when it's absent, createRepoTemplate() returns undefined and the sandbox boots the provider default. The resolver doesn't run when PlatformSandbox reattaches to an existing sandboxId. It resolves the repository's default-branch head at fresh-start time, then Platform starts or reuses the corresponding template build. If the repository head can't be resolved or the provider build fails, sandbox creation continues with the provider's default template so runtime setup can perform a cold checkout. For private repositories, the short-lived authorization token is sent as an ephemeral build environment value that stays out of the serialized definition and the persisted template record. It has no effect on content identity.
createRepoTemplate() also attaches a commit-independent family key (repo:<cloneUrl>:<workdir>, with the workdir derived from the clone URL) to the definition. family groups successive builds of the "same thing", so every commit of the same repository belongs to the same family. Platform uses it to find a prior ready build in the same family and boot the new commit on that warm filesystem while the exact commit template builds in the background. For E2B, stale lookup is also partitioned by the effective CPU and memory settings so a fallback can't silently change the requested machine size. Callers using the raw Template() builder can attach their own family key with .withFamily(key) (any non-empty string up to 200 characters). Omit it to opt out of family fallback. The family key never influences the content-addressed template identity: two definitions that differ only in family share the same cache slot.
Platform stores build state under the definition's server-derived content hash within the selected environment and provider.
Observing a pending template buildDirect link to Observing a pending template build
When a sandbox boots on a fallback template (either a resource-matched prior member of the same family or the provider base), the platform response includes a templatePending field that PlatformSandbox surfaces for observability:
await sandbox.start()
if (sandbox.templatePending) {
// The sandbox is running on a fallback template.
// A later start() of a sandbox with the same template definition
// will pick up the exact template once the background build finishes.
console.log('exact template still baking:', sandbox.templatePending.templateId)
}
templatePending is undefined when the sandbox booted on the exact template. PlatformSandbox never re-executes template operations inside a running sandbox. Reconcile any commit-specific filesystem state in your own runtime setup (for example, an onStart hook that runs git fetch && git checkout <sha>) exactly as you would with a fresh template. Reprovision on a later start() to pick up the ready exact template.
By default, every template operation is serialized and sent to Platform, which interprets it according to the selected provider's capabilities. Use setEnvs(values, { ephemeral: true }) for short-lived build credentials: these values are sent separately and excluded from both content identity and persistence. They're unavailable at runtime and take precedence over serialized environment values with the same key. Supply them again on every build or fresh provision that may need to build. On Railway, the provider cache includes the transient build variables. Rotating a value can therefore trigger another provider build even though the Platform template ID stays stable.
Checkpoint recoveryDirect link to Checkpoint recovery
The constructor id (explicit or auto-generated) is sent to the platform on POST /sandbox as an advisory recovery key:
- If the platform recognises the
idfrom a previous session, the new sandbox boots from the most recent checkpoint of that earlier sandbox's filesystem instead of the base recipe. - If the
idisn't recognised, the platform starts a fresh sandbox from the base recipe. Auto-generated ids never match, so omittingiddisables checkpoint recovery.
Pass a stable id to preserve a sandbox's filesystem across sessions or across a destroy()/start() cycle:
const sandbox = new PlatformSandbox({
id: `project-${projectId}`,
})
await sandbox.start() // Boots from the most recent checkpoint for this id, or fresh if unknown
Checkpoint recovery is coarser than sandboxId reattachment. Reattaching (via sandboxId) rejoins the exact live sandbox and its running processes. Checkpoint recovery constructs a brand new sandbox and restores its filesystem from the latest checkpoint the platform captured for the previous sandbox with that id. Running processes and any filesystem writes made after the last checkpoint aren't restored.
Call snapshot() after a filesystem update to capture the configured recovery checkpoint immediately. It resolves without capturing when the sandbox has no caller-provided id or isn't running.
await sandbox.snapshot()
Each id maps to one independent filesystem. Reusing the same id across unrelated sandboxes causes the platform to boot them from each other's checkpoint.
Set seedCheckpointName to provide a boot-only fallback when the checkpoint for id doesn't exist yet. The platform restores the checkpoint for id first when both exist. Later snapshots continue writing to the checkpoint for id, so the seed remains unchanged.
const sandbox = new PlatformSandbox({
id: `project-session-${sessionId}`,
seedCheckpointName: 'project-base',
})
Cloning for a fleet of sandboxesDirect link to Cloning for a fleet of sandboxes
clone() returns an independent sibling PlatformSandbox that inherits credentials and defaults (access token, project, environment, provider, template, network isolation, timeout, instructions, env, idle timeout) with per-instance overrides. The returned sandbox is unstarted and provisions on its own start(), so clone() performs no I/O:
const template = new PlatformSandbox({
networkIsolation: 'PRIVATE',
idleTimeoutMinutes: 30,
})
const perProject = template.clone({ id: `project-${projectId}` })
await perProject.start()
Combine clone() with a stable id per clone to opt each clone into checkpoint recovery independently.
Executing commandsDirect link to Executing commands
executeCommand runs a command on the remote sandbox and returns its output. Pass args to have arguments safely shell-quoted:
const result = await sandbox.executeCommand('python', ['analyze.py'], {
timeout: 30_000,
cwd: '/workspace',
env: { INPUT: 'repo' },
})
console.log(result.stdout)
console.log(result.exitCode)
The command argument is a shell string and is concatenated verbatim into the remote shell. This lets you use pipes, redirects, and chaining (ls -la | grep foo) but means untrusted input must be passed through args (safely quoted) or shell-quoted by the caller. Untrusted command values allow arbitrary shell execution on the sandbox.
Constructor parametersDirect link to Constructor parameters
accessToken?:
projectId?:
sandboxProvider?:
environmentId?:
sandboxId?:
seedCheckpointName?:
template?:
idleTimeoutMinutes?:
networkIsolation?:
env?:
timeout?:
instructions?:
id?:
fetch?:
PropertiesDirect link to Properties
id:
name:
provider:
status:
processes:
templatePending:
MethodsDirect link to Methods
start:
destroy:
stop:
executeCommand:
clone:
snapshot:
getInfo:
getInstructions:
ErrorsDirect link to Errors
Platform API failures raise PlatformApiError. Structured { error: { message, type } } responses are parsed into .code (machine-readable kind) and .proxyMessage (human string); the raw response body stays available on .body:
import { PlatformApiError } from '@mastra/platform-workspace'
try {
await sandbox.executeCommand('cat', ['/missing.txt'])
} catch (err) {
if (err instanceof PlatformApiError) {
if (err.code === 'not_found') {
// handle missing resource
} else if (err.code === 'authentication_error') {
// refresh token
}
console.error(err.status, err.code, err.proxyMessage)
}
}
code and proxyMessage are undefined when the response body isn't JSON, for example an HTML 502 from a load balancer.
executeCommand runs over the direct-exec data plane (a WebSocket to the Railway tcp-proxy) and can also throw two typed sandbox errors on unrecoverable failure:
import { SandboxDestroyedError, SandboxExecTransportError } from '@mastra/platform-workspace'
try {
await sandbox.executeCommand('pytest')
} catch (err) {
if (err instanceof SandboxDestroyedError) {
// /exec-lease returned 410; the sandbox has been destroyed.
// The cached sandbox id and lease have already been cleared,
// so reusing the instance will reprovision on the next call.
} else if (err instanceof SandboxExecTransportError) {
// Both the initial WebSocket attempt and the built-in retry
// closed without an exit frame against a live sandbox.
console.error(err.closeCode, err.closeReason, err.wsEndpoint)
}
}
SandboxExecTransportError carries diagnostic fields (opened, closeCode, closeReason, wsEndpoint, plus sandboxId, command, and attempts) so operators can distinguish a broken Railway data plane from a failed command.