Sandboxes

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, and access is granted per account rather than self-served. Attaching from an account without it returns SandboxNotEnabledError.

A sandbox is a virtual machine with its own kernel and filesystem, separate from the process your agent runs in and from the cluster around it. Use one to execute code your agent generates.

Install

bun add @astropods/adapter-core
# or: npm install @astropods/adapter-core

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

Client

new SandboxClient(options?: SandboxOptions)
OptionTypeDefault
identityTokenstringprocess.env.ASTRO_AUTHZ_TOKEN
serverUrlstringThe iss claim of the identity token
timeoutSecondsnumber30
fetchImpltypeof fetchGlobal fetch

Zero-argument construction works in a deployed agent, because Astropods injects ASTRO_AUTHZ_TOKEN and the server URL comes from that token.

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"

Sandbox names

Every method takes the sandbox name as its first argument. The same name resolves to the same filesystem, and two names never share one. Names match ^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$.

Use the conversation or thread id, so a resumed conversation reattaches to its own files. Commands run in /workspace.

Attaching

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

Creates a sandbox on the first call, reuses a running one, and resumes a suspended one. The server settles concurrent first calls onto a single sandbox, so parallel tool calls do not create several.

exec, run, and spawn attach on first use, so calling attach directly is optional. sandboxClass accepts only default; any other value returns 409.

const handle = await sandboxes.attach("thread-42");
SandboxHandleTypeValue
namestringThe sandbox name.
classstringAlways default.
statestringrunning.
endpointstringBase URL of the data plane, scheme included.
headersRecord<string, string>Opaque credentials for the data plane.
expiresAtstringRFC 3339. When headers stop working.

The client holds the handle and refreshes it, so an agent does not use these fields directly.

Running commands

MethodReturnsOutput limit
execAfter the command exits1 MiB per stream, buffered
runAfter the command exits, draining output as it goesNone
spawnImmediatelyNone; read with poll

exec

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

Holds one HTTP request open for the life of the command. The client bounds the command’s deadline to one second under its own request timeout, so a command cannot outlive the request waiting for it.

ExecRequestTypeNotes
commandstring[]Argv. Not a shell line.
cwdstringDefaults to /workspace.
envRecord<string, string>
timeoutMsnumberBounded by timeoutSeconds.
ExecResultTypeNotes
exitCodenumber
stdoutstring
stderrstring
durationMsnumber
timedOutbooleanKilled at its timeout rather than exiting.
truncatedbooleanOutput 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);

command is argv, so a shell line needs an explicit shell:

await sandboxes.exec("thread-42", {
command: ["/bin/sh", "-c", "ls -1 | wc -l"],
});

execCombined

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

Runs a shell line with stderr folded into stdout, the way a terminal shows it, so output stays interleaved.

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

run

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

Spawns the command and polls until it exits. Neither the output cap nor the request timeout applies, which suits installs, builds, and test suites.

RunOptionsTypeDefault
intervalMsnumber500
onOutput(chunk: ProcessOutput) => voidnone
timeoutMsnumbernone
signalAbortSignalnone

RunResult extends ProcessOutput with two fields:

FieldTypeNotes
timedOutbooleantimeoutMs elapsed.
killedbooleanStopped by timeoutMs or signal rather than exiting.
const result = await sandboxes.run(
"thread-42",
{ command: ["npm", "install"], cwd: "/workspace" },
{
intervalMs: 500,
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>

Starts a command and returns before it finishes, so the process outlives the call.

SpawnRequestType
commandstring[]
cwdstring
envRecord<string, string>
ProcessStatusTypeNotes
processIdstring
commandstring[]
state"running" | "exited"
exitCodenumberAbsent while running.
startedAtstringRFC 3339.
exitedAtstringAbsent while running.
const proc = await sandboxes.spawn("thread-42", {
command: ["npm", "run", "dev"],
});

poll

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

Reads a process’s status and the output after the offsets you ask from. PollOptions takes stdoutFrom and stderrFrom, both number.

ProcessOutput extends ProcessStatus with:

FieldTypeNotes
stdoutstringOutput after stdoutFrom.
stderrstringOutput after stderrFrom.
stdoutNextnumberPass as the next stdoutFrom.
stderrNextnumberPass as the next stderrFrom.
stdoutDroppednumberBytes discarded before this read.
stderrDroppednumberAs above, for stderr.

A sandbox retains 1 MiB per stream per process and discards oldest-first beyond that. A non-zero dropped count means output was lost, not delayed.

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));
}

run is this loop, so reach for poll only when the process should outlive the turn.

processes, signal, and kill

processes(name: string): Promise<ProcessStatus[]>
signal(name: string, processId: string, signal?: Signal): Promise<void>
kill(name: string, processId: string): Promise<void>

signal defaults to TERM and sends to the 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 reap what you spawn.

for (const p of await sandboxes.processes("thread-42")) {
if (p.state === "running") await sandboxes.kill("thread-42", p.processId);
}
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[]>

listDir and grep default path to .. DirEntry is { name: string; isDirectory: boolean }. GrepMatch is { path: string; line: number; text: string }.

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);
}
const matches = await sandboxes.grep("thread-42", "TODO", "/workspace");
for (const m of matches) console.log(`${m.path}:${m.line}: ${m.text}`);

grep returns an empty array when nothing matches, because grep exits 1 on no match. readFile and readFileBytes throw SandboxRequestError with status 404 when the path cannot be read.

Both byte methods move data as base64 through a shell command, and writes are chunked at 48 KiB, so large files are slow.

Inspecting and ending a sandbox

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

get and list carry no credentials. list returns every sandbox for the deployment.

SandboxRecordTypeNotes
namestring
classstring
statestringrunning, suspended, or stopped.
createdAtstringRFC 3339.
lastActiveAtstringRFC 3339.
ceilingAtstringWhen the runtime reclaims it regardless of activity. Absent when nothing runs.
for (const row of await sandboxes.list()) {
console.log(row.name, row.state, row.ceilingAt);
}
await sandboxes.stop("thread-42"); // suspends compute early
await sandboxes.delete("thread-42"); // drops the sandbox and its filesystem

Lifecycle

ConditionThresholdResult
Idle15 minutesSuspended. Memory and files intact, and the next attach resumes it.
Suspended and untouched1 hourReclaimed.
Age since creation8 hoursReclaimed, whether running or suspended.

An attach after reclamation succeeds and returns an empty sandbox rather than failing. Treat /workspace as scratch space and store anything that must outlive the conversation elsewhere.

Suspend and resume restore the most recent state only. There are no addressable checkpoints.

Errors

ErrorStatusCause
SandboxNotEnabledError409The account does not have sandboxes. Extends SandboxRequestError.
SandboxRequestErrorCarries statusThe control plane refused the request.
SandboxUnavailableErrorNoneThe control plane was unreachable. Carries cause.

SandboxRequestError.status is 402 when a cap is reached, 404 for an unknown sandbox or unreadable path, and 502 when the sandbox could not be brought up.

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.

Credentials last 15 minutes. The client re-attaches once on its own when a sandbox rejects them, so callers do not handle expiry. It does not retry a transport failure, because a command that timed out client-side may have run to completion.

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

Limitations

AreaLimit
LanguagesTypeScript only. astropods-adapter-core has no sandbox client.
SizingOne class, default. CPU and memory are not configurable.
PersistenceNone beyond the thresholds above. No addressable checkpoints, and no filesystem export.
Count20 per deployment by default, plus the account limit.
Processes64 per sandbox.
exec output1 MiB per stream, buffered, then truncated.
spawn output1 MiB retained per stream per process, oldest discarded first.
Binary filesBase64 through a shell command, writes chunked at 48 KiB.
ScopeA sandbox belongs to one deployment. Two deployments cannot share one.

Credentials are minted for the data-plane port alone, so a service your agent starts inside a sandbox is not reachable from outside it.

Framework adapters

PackageExposesDriven by
@astropods/adapter-mastraAstroSandbox, a MastraSandbox provider, with AstroProcessManager on sandbox.processesA Mastra Workspace
@astropods/adapter-langchainAstroSandbox, a deepagents BaseSandboxcreateDeepAgent’s backend

Both take { name } plus the SandboxClient options, and accept a client to share one instance. Each framework builds its own filesystem tools on the provider, so neither adds a separate toolset.

// Mastra
new Workspace({ sandbox: new AstroSandbox({ name: threadId }) });
// LangChain, through Deep Agents
createDeepAgent({ model, backend: new AstroSandbox({ name: threadId }) });

Mastra adapter covers the Mastra mapping in full.

Next steps