Skip to navigation

Sandbox client

Alpha

Run agent-generated code in an isolated VM from the TypeScript SDK

View as Markdown

Sandboxes are experimental. The API may change without a deprecation period.

SandboxClient gives your agent a sandbox: a virtual machine with its own kernel and filesystem, apart from your agent’s container and the cluster it runs on. Your agent writes files there, runs the code it generated, and reads back the results. Remote Sandbox explains how the pieces fit together.

Get started

1

Declare a sandbox

Add a sandbox section to astropods.yml that lists what your agent’s code needs. A sandbox starts with core utilities only, so declare every language and package the code uses.

astropods.yml
sandbox:
toolchain: auto
python:
version: "3.12"
packages: [pandas]

toolchain is the only required field. The spec’s sandbox section lists the rest. Redeploy after you change it.

2

Install the SDK

bun add @astropods/adapter-core

SandboxClient is exported from the package root and from the @astropods/adapter-core/sandbox subpath.

3

Run a command

In a deployed agent, the client needs no options.

import { SandboxClient } from "@astropods/adapter-core";
const sandboxes = new SandboxClient();
const result = await sandboxes.exec("thread-42", {
command: ["python3", "-c", "print(2 + 2)"],
});
console.log(result.stdout); // "4\n"

The first call creates the sandbox and installs your declaration, so it takes longer than the calls after it.

Configure the client

new SandboxClient(options?: SandboxOptions)

Every option has a default that works in a deployed agent. Set one only to change how long the client waits.

identityToken
stringDefaults to process.env.ASTRO_AUTHZ_TOKEN

The token that identifies your deployment. Astropods sets it for every deployed agent.

serverUrl
stringDefaults to The token's iss claim

The Astropods server to call. The client reads it from the token.

timeoutSeconds
numberDefaults to 30

How long one request, including one exec, may take.

prepareTimeoutSeconds
numberDefaults to 900

How long the first attach waits for a new sandbox to finish setting up.

fetchImpl
typeof fetchDefaults to Global fetch

The fetch the client sends requests with.

Name your sandboxes

Every method takes a sandbox name as its first argument. The same name always reaches the same filesystem, and two names never share one.

Use the conversation or thread id as the name. Each conversation then gets its own files, and a resumed conversation finds the files it left. Commands run in /workspace unless you pass another cwd.

Names match ^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$.

Attach to a sandbox

attach(name: string, sandboxClass?: string): Promise<SandboxHandle>

attach creates a sandbox on the first call, reuses a running one, and resumes a suspended one. Calls in parallel with the same name share one sandbox, so it is safe to call on every turn.

You rarely call it yourself. exec, run, and spawn attach on first use, and the client keeps the handle and renews its credentials.

const handle = await sandboxes.attach("thread-42");

sandboxClass accepts only default. Any other value returns 409.

The first attach

The first attach for a name installs what you declared, so your first command finds everything ready. A large declaration can take a few minutes, and attach waits until it finishes. Later attaches to the same name return quickly.

  • Setup takes too long. When setup outlasts prepareTimeoutSeconds, attach throws SandboxPreparingError. Raise the option for a large declaration.
  • The install fails. attach throws an error carrying the package manager’s message. Fix the declaration and redeploy, and the next attach tries again.
name
string
The sandbox name.
class
string
Always default.
state
string
running.
endpoint
string
Base URL of the sandbox, scheme included.
headers
Record<string, string>
Opaque credentials for the sandbox.
expiresAt
string
When headers stop working, in RFC 3339.

Run commands

Pick a method by how long the command runs and whether the turn should wait for it.

MethodReturnsOutput limitUse it for
execWhen the command exits1 MiB per streamQuick commands, within timeoutSeconds
runWhen the command exits, streaming output as it goesNoneInstalls, builds, and test suites
spawnImmediatelyNone, read with pollWork that outlives the turn

A non-zero exitCode is a result, not an exception, so check the field.

exec

exec(name: string, request: ExecRequest): Promise<ExecResult>

exec runs a command and returns when it exits. A command can run for at most timeoutSeconds, so use run for anything longer.

command
string[]Required

The argv to run. It is not a shell line.

cwd
stringDefaults to /workspace

The working directory.

env
Record<string, string>

Variables added to the sandbox’s environment. They override entries with the same name.

timeoutMs
number

A shorter limit for this command, bounded by timeoutSeconds.

exitCode
number
The command’s exit code.
stdout
string
Standard output.
stderr
string
Standard error.
durationMs
number
How long the command ran.
timedOut
boolean
The command was killed at its timeout instead of exiting.
truncated
boolean
Output was cut at 1 MiB.
const result = await sandboxes.exec("thread-42", {
command: ["node", "--version"],
cwd: "/workspace",
env: { NODE_ENV: "production" },
timeoutMs: 5_000,
});
if (result.exitCode !== 0) throw new Error(result.stderr);

execCombined

execCombined(
name: string,
command: string,
timeoutMs?: number,
): Promise<{ output: string; exitCode: number; truncated: boolean }>

execCombined runs a shell line and folds stderr into stdout, the way a terminal shows it. Use it when the order of output matters more than which stream it came from.

const { output, exitCode } = await sandboxes.execCombined(
"thread-42",
"npm test | tail -20",
);

run

run(
name: string,
request: SpawnRequest,
options?: RunOptions,
): Promise<RunResult>

run starts the command and polls it until it exits. Neither the output limit nor the request timeout applies, which suits installs, builds, and test suites. It takes the same request as spawn.

intervalMs
numberDefaults to 500

How often to poll for output.

onOutput
(chunk: ProcessOutput) => void

Called with each chunk of new output.

timeoutMs
number

Kill the command after this long. No limit by default.

signal
AbortSignal

Kill the command when this signal aborts.

RunResult has every ProcessOutput field, plus timedOut when timeoutMs elapsed and killed when timeoutMs or signal stopped the command.

const result = await sandboxes.run(
"thread-42",
{ command: ["npm", "install"], cwd: "/workspace" },
{
timeoutMs: 120_000,
onOutput: (chunk) => process.stdout.write(chunk.stdout),
},
);
if (result.killed) throw new Error("install did not finish");
if (result.exitCode !== 0) throw new Error(result.stderr);

spawn

spawn(name: string, request: SpawnRequest): Promise<ProcessStatus>

spawn starts a command and returns at once. The process keeps running after the call, and after the turn. Save its processId and check on it later with poll.

A SpawnRequest takes command, cwd, and env, the same as exec.

const proc = await sandboxes.spawn("thread-42", {
command: ["npm", "run", "dev"],
});
processId
string
Pass it to poll, signal, and kill.
command
string[]
The argv that started the process.
state
"running" | "exited"
Whether the process is still running.
exitCode
number
Absent while the process runs.
startedAt
string
RFC 3339.
exitedAt
string
RFC 3339. Absent while the process runs.

poll

poll(
name: string,
processId: string,
options?: PollOptions,
): Promise<ProcessOutput>

poll reads a process’s status and any output after the offsets you pass. Send back the stdoutNext and stderrNext it returns, and each poll picks up where the last one stopped.

let stdoutFrom = 0;
let stderrFrom = 0;
for (;;) {
const chunk = await sandboxes.poll("thread-42", proc.processId, {
stdoutFrom,
stderrFrom,
});
stdoutFrom = chunk.stdoutNext;
stderrFrom = chunk.stderrNext;
if (chunk.stdoutDropped > 0) console.warn(`lost ${chunk.stdoutDropped} bytes`);
process.stdout.write(chunk.stdout);
if (chunk.state === "exited") break;
await new Promise((r) => setTimeout(r, 500));
}

This loop is what run does for you. Reach for poll only when the process should outlive the turn.

A sandbox keeps the newest 1 MiB of each stream for each process. A reader that falls further behind gets a non-zero dropped count: that output was lost, not delayed.

ProcessOutput has every ProcessStatus field, plus these:

stdout
string
Output after stdoutFrom.
stderr
string
Output after stderrFrom.
stdoutNext
number
Pass as the next stdoutFrom.
stderrNext
number
Pass as the next stderrFrom.
stdoutDropped
number
Bytes of stdout discarded before this read.
stderrDropped
number
Bytes of stderr discarded before this read.

Manage processes

processes(name: string): Promise<ProcessStatus[]>
signal(name: string, processId: string, signal?: Signal): Promise<void>
kill(name: string, processId: string): Promise<void>
  • processes lists every process in the sandbox, with status but no output.
  • signal sends TERM by default, to the whole process group, so a shell’s children receive it too. Signal is "TERM" | "KILL" | "INT" | "HUP" | "QUIT" | "USR1" | "USR2".
  • kill stops the process if it is still running, then forgets it.

A sandbox runs at most 64 processes, so clean up what you spawn:

for (const p of await sandboxes.processes("thread-42")) {
if (p.state === "running") await sandboxes.kill("thread-42", p.processId);
}

Work with files

readFile(name: string, path: string): Promise<string>
readFileBytes(name: string, path: string): Promise<Uint8Array>
writeFile(name: string, path: string, contents: string): Promise<void>
writeFileBytes(name: string, path: string, contents: Uint8Array): Promise<void>
listDir(name: string, path?: string): Promise<DirEntry[]>
grep(name: string, pattern: string, path?: string): Promise<GrepMatch[]>

Use the string methods for text and the Bytes methods for anything else, such as an image or an upload. listDir and grep search . when you pass no path.

await sandboxes.writeFile("thread-42", "/workspace/main.py", "print('hi')\n");
for (const entry of await sandboxes.listDir("thread-42", "/workspace")) {
console.log(entry.isDirectory ? `${entry.name}/` : entry.name);
}
for (const m of await sandboxes.grep("thread-42", "TODO", "/workspace")) {
console.log(`${m.path}:${m.line}: ${m.text}`);
}
  • A DirEntry is { name: string; isDirectory: boolean }.
  • A GrepMatch is { path: string; line: number; text: string }, and grep returns an empty array when nothing matches.
  • readFile and readFileBytes throw SandboxRequestError with status 404 when the path cannot be read.

Large files are slow to read and write.

Inspect and end a sandbox

get(name: string): Promise<SandboxRecord>
list(): Promise<SandboxRecord[]>
stop(name: string): Promise<void>
delete(name: string): Promise<void>

get returns one sandbox and list returns every sandbox for the deployment. Neither returns credentials. stop suspends a sandbox before it would go idle, and delete removes it with its files.

for (const row of await sandboxes.list()) {
console.log(row.name, row.state, row.ceilingAt);
}
await sandboxes.stop("thread-42");
await sandboxes.delete("thread-42");
name
string
The sandbox name.
class
string
Always default.
state
string
creating while it is set up, then running, suspended, or stopped.
createdAt
string
RFC 3339.
lastActiveAt
string
RFC 3339.
ceilingAt
string
When the sandbox is replaced, whatever its activity. Absent when nothing runs.

Lifecycle

The client handles every transition. Your code only needs to know what survives each one.

WhenAfterWhat happens
Idle15 minutesThe sandbox is suspended. Memory and files stay, and the next attach resumes it.
Suspended1 hourThe sandbox is removed with its files.
Age8 hoursThe sandbox is replaced on the next attach. Files, installed software, and anything the agent installed carry over.

After a removal, the next attach succeeds with your declared software installed and an empty /workspace. Treat /workspace as scratch space, and store anything that must outlast the conversation elsewhere.

Suspend and resume restore the latest state only. There are no checkpoints to return to.

Handle errors

ErrorStatusCause
SandboxNotEnabledError409The agent cannot use a sandbox as deployed. It declares no sandbox section, or the declaration failed to install, and the message says which.
SandboxPreparingErrorNoneThe sandbox was still setting up after prepareTimeoutSeconds. Import it from @astropods/adapter-core/sandbox.
SandboxRequestErrorCarries statusAstropods refused the request.
SandboxUnavailableErrorNoneAstropods could not be reached. Carries cause.

SandboxRequestError.status is 402 when a limit is reached, 404 for an unknown sandbox or unreadable path, and 502 when the sandbox could not start.

import {
SandboxNotEnabledError,
SandboxRequestError,
SandboxUnavailableError,
} from "@astropods/adapter-core";
try {
await sandboxes.exec("thread-42", { command });
} catch (err) {
if (err instanceof SandboxNotEnabledError) return runWithoutSandbox();
if (err instanceof SandboxRequestError && err.status === 402) {
await sandboxes.delete("thread-01");
return;
}
if (err instanceof SandboxUnavailableError) return retryNextTurn();
throw err;
}

Check SandboxNotEnabledError before SandboxRequestError, because it extends it.

The client renews expired credentials on its own. It does not retry a command after a network failure, because the command may already have run.

Limits

AreaLimit
LanguagesTypeScript only. The Python SDK has no sandbox client yet.
SizeOne class, default. CPU and memory are not configurable.
Count20 per deployment by default, plus your account’s limit.
Processes64 per sandbox.
exec output1 MiB per stream, then truncated.
spawn output1 MiB kept per stream for each process, oldest discarded first.
PersistenceA removed sandbox loses /workspace and anything installed after setup. No checkpoints, and no filesystem export.
ScopeA sandbox belongs to one deployment. Two deployments cannot share one.

Local development

Run your agent with ast project start, after signing in with ast login. Your local agent gets a real sandbox, the same kind a deployed agent gets. It uses the declaration in your local astropods.yml, so a change applies on the next start without a deploy.

ast project stop deletes the sandboxes your local agent created.

Framework adapters

The Mastra and LangChain adapters wrap SandboxClient for their frameworks. Both take { name } plus any SandboxClient option, and accept a client to share one instance.

import { Workspace } from "@mastra/core/workspace";
import { AstroSandbox } from "@astropods/adapter-mastra";
new Workspace({ sandbox: new AstroSandbox({ name: threadId }) });
PackageExposesDriven by
@astropods/adapter-mastraAstroSandbox, a MastraSandbox provider, with AstroProcessManager on sandbox.processesA Mastra Workspace
@astropods/adapter-langchainAstroSandbox, a Deep Agents BaseSandboxcreateDeepAgent’s backend

Mastra adapter covers the Mastra workspace in more detail.

FAQ

Astropods gives every deployed agent an ASTRO_AUTHZ_TOKEN environment variable. The client reads it and takes the server’s address from the token, so there is nothing to configure. ast project start sets the same variable for an agent running locally.

The first attach installs what your sandbox section declares. Astropods keeps the result, so later sandboxes with the same declaration usually start faster. Changing the declaration installs it once more.

No. A web server or other service your agent starts inside a sandbox cannot be reached from outside it.

No. Mastra and Deep Agents each build their own file and shell tools on top of the sandbox, so the adapters add nothing of their own.

Next steps