Skip to main content

Sandbox

Sandboxes give Mastra agents an environment for running commands and working with files. Agents can also start background processes when the selected backend supports process management; otherwise, background-process tools are unavailable.

In Mastra, you attach a sandbox backend to a Workspace. You can use one sandbox for an application or agent, or resolve separate environments for each user, tenant, or thread.

Supported backends can FUSE-mount filesystems into the sandbox, letting you seed ephemeral environments with files and persist their output between runs.

When to use sandboxes?
Direct link to When to use sandboxes?

Sandboxes are useful for:

  • Coding agents and software factories: Clone repositories and run shell, Git, build, or test workflows without giving autonomous agents access to the host system.
  • Deep research and analysis: Use specialized libraries to process downloaded PDFs or presentations and produce new artifacts.
  • Long-running and parallel tasks: Run work in separate environments without tying it to one request or sharing files and processes.

Quickstart
Direct link to Quickstart

In Mastra, a sandbox operates through a Workspace. Its sandbox backend defines where commands run.

Create an agent with a workspace and sandbox:

src/mastra/agents/coding-agent.ts
import { Agent } from '@mastra/core/agent'
import { LocalSandbox, Workspace } from '@mastra/core/workspace'

export const codingAgent = new Agent({
id: 'coding-agent',
name: 'Coding agent',
instructions: 'Use the sandbox to complete coding tasks.',
model: 'openai/gpt-5.6-sol',
workspace: new Workspace({
sandbox: new LocalSandbox({
workingDirectory: './workspace',
}),
}),
})

await codingAgent.generate('List the files in the workspace directory')
warning

LocalSandbox is the quickest sandbox to set up, but commands run on the host by default. Enable native isolation whenever possible. For applications exposed to untrusted users, use a remote or container backend with a stronger isolation boundary instead.

To use a different sandbox for each user, tenant, or thread, configure a sandbox resolver instead.

Using the sandbox
Direct link to Using the sandbox

Agents receive tools for the capabilities supported by the sandbox backend:

ToolDoes
execute_commandRuns a shell command and returns stdout, stderr, and the exit code. Supports background: true when the backend can spawn long-running processes.
get_process_outputGets stdout, stderr, and status for a background process. Supports tail to limit output and wait: true to wait for the process to exit. This tool and kill_process are only added when the backend supports background processes.
kill_processStops a background process and returns its recent output.

You can disable or rename each tool and require approval before it runs. You can also limit how much output it adds to the model's context. See the workspace tool configuration reference.

Authored runtime functions, including tools and workflow steps, can get the live sandbox from their execution context. Start a server, watcher, notebook kernel, browser, or worker and interact with it across agent turns.

src/mastra/tools/start-server.ts
import type { ProcessHandle } from '@mastra/core/sandbox'
import { createTool } from '@mastra/core/tools'
import { z } from 'zod'

export const startServerTool = createTool({
id: 'start-server',
description: 'Starts a server in the sandbox',
inputSchema: z.object({
command: z.string(),
}),
execute: async ({ command }, ctx) => {
const sandbox = await ctx.getSandbox()
const server: ProcessHandle = await sandbox.spawn({ command })
return { pid: server.pid }
},
})

The authored function itself runs in your application process. Only operations called through the sandbox handle run inside the sandbox.

Supported backends
Direct link to Supported backends

LocalSandbox
Direct link to localsandbox

LocalSandbox executes commands on the same machine as your Mastra application. By default, commands run directly on the host with the permissions of the application process.

Enable native isolation to restrict filesystem and network access at the operating-system level:

  • macOS: Seatbelt (sandbox-exec)
  • Linux: Bubblewrap (bwrap)
const sandbox = new LocalSandbox({
workingDirectory: './workspace',
isolation: 'seatbelt', // Use 'bwrap' on Linux
nativeSandbox: {
allowNetwork: false,
readOnlyPaths: ['./reference-data'],
},
})

Use LocalSandbox.detectIsolation() to check whether Seatbelt or Bubblewrap is available on the current operating system. The nativeSandbox options control network access, read-only or writable paths, workspace write access, and system binaries. You can also provide a custom Seatbelt profile or Bubblewrap arguments. See LocalSandbox for the full configuration.

Other backends
Direct link to Other backends

Use a remote or container backend when commands need a stronger boundary from the host application. Each backend has its own isolation, persistence, networking, and mount behavior:

If Mastra doesn't support your execution backend, implement WorkspaceSandbox to add it.

Persistent storage
Direct link to Persistent storage

Some execution backends can also mount cloud filesystems through FUSE. Mounted files appear as local directories inside the sandbox, so commands such as cat /data/report.md or python /data/analyze.py can use them directly.

Configure mounts on the workspace alongside the sandbox:

import { Workspace } from '@mastra/core/workspace'
import { DaytonaSandbox } from '@mastra/daytona'
import { S3Filesystem } from '@mastra/s3'

const workspace = new Workspace({
mounts: {
'/data': new S3Filesystem({
bucket: 'agent-data',
region: 'us-east-1',
}),
},
sandbox: new DaytonaSandbox(),
})

The agent receives filesystem tools for the mounted storage, and commands in the sandbox can access the same files under /data. This lets you seed an ephemeral sandbox with existing files and persist its output after the sandbox stops.

Mount support varies by sandbox backend. See Filesystem for supported storage backends, detailed mount configuration, and how to mount multiple filesystems.

Multi-tenant sandboxes
Direct link to Multi-tenant sandboxes

Use a resolver when each user, tenant, or thread needs a separate sandbox. Set sandboxCacheKey to the identity that owns the sandbox so later requests reuse the same live environment.

This example creates one Daytona sandbox and one S3 storage prefix per memory thread. The S3 filesystem is also mounted at /workspace inside the sandbox:

src/mastra/workspaces.ts
import { MASTRA_THREAD_ID_KEY, type RequestContext } from '@mastra/core/request-context'
import { Workspace } from '@mastra/core/workspace'
import { DaytonaSandbox } from '@mastra/daytona'
import { S3Filesystem } from '@mastra/s3'

const getThreadId = (requestContext: RequestContext) => {
const threadId = requestContext.get(MASTRA_THREAD_ID_KEY)
if (typeof threadId !== 'string' || !threadId) {
throw new Error('A memory thread is required to use this workspace')
}

return threadId
}

const createThreadFilesystem = (threadId: string) =>
new S3Filesystem({
bucket: process.env.S3_BUCKET!,
region: process.env.S3_REGION!,
prefix: `threads/${threadId}`,
})

const workspace = new Workspace({
filesystem: ({ requestContext }) => createThreadFilesystem(getThreadId(requestContext)),
sandbox: async ({ requestContext }) => {
const threadId = getThreadId(requestContext)
const sandbox = new DaytonaSandbox({
id: `thread-${threadId}`,
language: 'typescript',
})

await sandbox.start()
await sandbox.mount(createThreadFilesystem(threadId), '/workspace')
return sandbox
},
sandboxCacheKey: ({ requestContext }) => getThreadId(requestContext),
})

The first request in a thread runs the resolver and starts the sandbox. Later requests with the same thread ID reuse the cached sandbox. A different thread ID creates a different sandbox and storage prefix.

Workspace-level mounts can't be combined with a sandbox resolver. This is a known limitation, so the example mounts the filesystem inside the resolver instead. Workspace tools resolve the filesystem and sandbox from the request context automatically.

Resolver ownership
Direct link to Resolver ownership

The workspace doesn't own sandboxes returned by a resolver. Return a sandbox that's ready to use, and destroy it through your application's lifecycle code when it's no longer needed. workspace.destroy() doesn't destroy resolver-returned sandboxes.

Resolvers are incompatible with mounts and lsp: true, because both require a static sandbox when the workspace is constructed. Using a resolver with mounts throws an INVALID_CONFIG error. With lsp: true, Mastra disables LSP and logs a warning.

Tool availability
Direct link to Tool availability

With a static sandbox, Mastra knows which capabilities the backend supports and only gives the agent the corresponding tools. With a resolver-backed sandbox, the backend isn't known until a request runs, so Mastra initially makes all sandbox tools available. If the resolved backend doesn't support the tool the agent calls, the call fails with SandboxFeatureNotSupportedError.

For example, this resolver returns a Daytona sandbox for development requests and an AgentCore sandbox for other requests:

const workspace = new Workspace({
sandbox: ({ requestContext }) =>
requestContext.get('environment') === 'development'
? new DaytonaSandbox()
: new AgentCoreRuntimeSandbox({
agentRuntimeArn: process.env.AGENTCORE_RUNTIME_ARN!,
}),
})

Mastra exposes the process tools because DaytonaSandbox supports background processes. If a request resolves to AgentCoreRuntimeSandbox, one-shot commands still work, but calling get_process_output or kill_process fails with SandboxFeatureNotSupportedError because that backend doesn't support background processes.

Background processes
Direct link to Background processes

When agents start background processes through execute_command, you can receive lifecycle callbacks for stdout, stderr, and process exit. Configure these through the backgroundProcesses option on its WORKSPACE_TOOLS.SANDBOX.EXECUTE_COMMAND entry:

src/mastra/workspaces.ts
import { Workspace, LocalSandbox, WORKSPACE_TOOLS } from '@mastra/core/workspace'

const workspace = new Workspace({
sandbox: new LocalSandbox({ workingDirectory: './workspace' }),
tools: {
[WORKSPACE_TOOLS.SANDBOX.EXECUTE_COMMAND]: {
backgroundProcesses: {
onStdout: (data, { pid }) => console.log(`[${pid}] ${data}`),
onStderr: (data, { pid }) => console.error(`[${pid}] ${data}`),
onExit: ({ pid, exitCode }) => console.log(`Process ${pid} exited: ${exitCode}`),
},
},
},
})

These callbacks fire for all background processes started by the agent through execute_command.

By default, background processes inherit the agent's abort signal and stop when the agent disconnects. Set abortSignal to a custom signal, or use null or false when the process should continue after the request ends.

For the full SandboxProcessManager API (spawning processes programmatically and reading output, plus sending stdin), see the SandboxProcessManager reference.

Lifecycle and persistence
Direct link to Lifecycle and persistence

Sandbox scope depends on where you assign the workspace and whether you use a resolver:

ConfigurationSandbox scope
Mastra-level workspaceAgents that inherit the workspace from the Mastra instance use the same sandbox.
Agent-level workspaceEvery request handled by that agent instance uses the same sandbox.
Resource-scoped resolverThe resolver caches one sandbox for each resource ID.
Thread-scoped resolverA memory thread keeps its sandbox across requests in that thread.

A static sandbox isn't automatically scoped to the current resource or memory thread. For resource or thread scope, use a resolver and set sandboxCacheKey to the corresponding ID. See Multi-tenant sandboxes.

Start
Direct link to Start

Static sandbox backends are instantiated with your application, but their execution environment usually starts lazily on the first command. Call workspace.init() when you want to provision it during application startup instead.

Resolver-backed sandboxes aren't started by workspace.init() because no backend is selected until the resolver runs. Return a sandbox that's already started or can start itself on first use.

Hooks
Direct link to Hooks

Use onStart, onStop, and onDestroy to run application code during lifecycle transitions. Hooks receive the live sandbox instance:

const sandbox = new LocalSandbox({
workingDirectory: './workspace',
onStart: ({ sandbox }) => console.log(`Started ${sandbox.id}`),
onStop: ({ sandbox }) => console.log(`Stopping ${sandbox.id}`),
onDestroy: ({ sandbox }) => console.log(`Destroying ${sandbox.id}`),
})

onStart runs after the sandbox starts. onStop and onDestroy run before their corresponding operation.

Cleanup
Direct link to Cleanup

Sandboxes passed directly to a workspace are owned by that workspace. mastra dev and the generated Mastra server handle shutdown signals and destroy registered workspaces automatically. If you embed Mastra in a custom server or process, call mastra.shutdown() from its shutdown hook. For a standalone workspace, call workspace.destroy() directly. The effect of stopping or destroying the underlying environment depends on the backend.

Sandboxes returned by a resolver are owned by your application. workspace.destroy() and mastra.shutdown() clear workspace references but don't destroy those resolved sandboxes. Your resolver or application lifecycle must keep track of them, call destroy() when their user, thread, or session ends, and then call workspace.clearSandboxCache(cacheKey) for keyed entries. This prevents unused compute from continuing to run and later requests from reusing a stale sandbox.

Persistence
Direct link to Persistence

Persistence is backend-specific. Files and processes aren't guaranteed to survive when a sandbox stops or the application restarts. An idle timeout may also discard them. Some backends reconnect by sandbox ID or preserve snapshots and volumes. Others create a fresh environment.

Every sandbox has an id, but the ID isn't a cross-backend persistence guarantee. Read the selected backend's reference before relying on reconnection or persisted state.

Network and credential safety
Direct link to Network and credential safety

Mastra doesn't define one network policy that applies to every sandbox backend. Defaults and supported controls vary, so check the selected backend before running untrusted code. For sensitive or production workloads, prefer blocking outbound access or allowing only the destinations the task needs. Apply that policy when you create the sandbox so it takes effect before commands run.

If an agent starts a web server inside a remote sandbox, that server initially listens on a port inside the sandbox. Backends that support port exposure implement sandbox.networking.getPortUrl() to return a URL you can use to preview or test the server from outside the sandbox:

const url = await sandbox.networking?.getPortUrl(8000)

Depending on the backend's access controls, the URL may make the service reachable to others. This inbound exposure is separate from outbound sandbox access. LocalSandbox with native isolation blocks network access by default unless allowNetwork is enabled. Without native isolation, local commands use the host network.

For example, DaytonaSandbox allows outbound access unless you configure a restriction. Set networkBlockAll with a domain allowlist when the task only needs specific services:

import { DaytonaSandbox } from '@mastra/daytona'

const sandbox = new DaytonaSandbox({
networkBlockAll: true,
domainAllowList: 'registry.npmjs.org,api.github.com',
})

Daytona applies these settings when it creates the sandbox. It also supports CIDR rules through networkAllowList. See the Daytona integration page for full configuration details.

Environment variables passed to a sandbox or command are available to code running there. Mastra doesn't provide a backend-neutral credential broker that keeps secrets outside the sandbox while authorizing its requests. Give the command only the credentials it needs, scoped to the task. Follow the selected backend's secret-management guidance.

For example, pass a read-only token to the command that needs it instead of configuring it for every command in the sandbox:

await sandbox.executeCommand('node', ['scripts/download-reports.js'], {
env: {
REPORTS_READ_TOKEN: process.env.REPORTS_READ_TOKEN,
},
})