> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.astropods.com/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.astropods.com/_mcp/server.

# Sandboxes

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

```bash
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

```ts
new SandboxClient(options?: SandboxOptions)
```

| Option           | Type           | Default                               |
| ---------------- | -------------- | ------------------------------------- |
| `identityToken`  | `string`       | `process.env.ASTRO_AUTHZ_TOKEN`       |
| `serverUrl`      | `string`       | The `iss` claim of the identity token |
| `timeoutSeconds` | `number`       | `30`                                  |
| `fetchImpl`      | `typeof fetch` | Global `fetch`                        |

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

```ts
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

```ts
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.

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

| `SandboxHandle` | Type                     | Value                                        |
| --------------- | ------------------------ | -------------------------------------------- |
| `name`          | `string`                 | The sandbox name.                            |
| `class`         | `string`                 | Always `default`.                            |
| `state`         | `string`                 | `running`.                                   |
| `endpoint`      | `string`                 | Base URL of the data plane, scheme included. |
| `headers`       | `Record<string, string>` | Opaque credentials for the data plane.       |
| `expiresAt`     | `string`                 | RFC 3339. When `headers` stop working.       |

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

## Running commands

| Method  | Returns                                             | Output limit               |
| ------- | --------------------------------------------------- | -------------------------- |
| `exec`  | After the command exits                             | 1 MiB per stream, buffered |
| `run`   | After the command exits, draining output as it goes | None                       |
| `spawn` | Immediately                                         | None; read with `poll`     |

### exec

```ts
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.

| `ExecRequest` | Type                     | Notes                        |
| ------------- | ------------------------ | ---------------------------- |
| `command`     | `string[]`               | Argv. Not a shell line.      |
| `cwd`         | `string`                 | Defaults to `/workspace`.    |
| `env`         | `Record<string, string>` |                              |
| `timeoutMs`   | `number`                 | Bounded by `timeoutSeconds`. |

| `ExecResult` | Type      | Notes                                      |
| ------------ | --------- | ------------------------------------------ |
| `exitCode`   | `number`  |                                            |
| `stdout`     | `string`  |                                            |
| `stderr`     | `string`  |                                            |
| `durationMs` | `number`  |                                            |
| `timedOut`   | `boolean` | Killed at its timeout rather than exiting. |
| `truncated`  | `boolean` | Output was cut at 1 MiB.                   |

```ts
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:

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

### execCombined

```ts
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.

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

### run

```ts
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.

| `RunOptions` | Type                             | Default |
| ------------ | -------------------------------- | ------- |
| `intervalMs` | `number`                         | `500`   |
| `onOutput`   | `(chunk: ProcessOutput) => void` | none    |
| `timeoutMs`  | `number`                         | none    |
| `signal`     | `AbortSignal`                    | none    |

`RunResult` extends `ProcessOutput` with two fields:

| Field      | Type      | Notes                                                   |
| ---------- | --------- | ------------------------------------------------------- |
| `timedOut` | `boolean` | `timeoutMs` elapsed.                                    |
| `killed`   | `boolean` | Stopped by `timeoutMs` or `signal` rather than exiting. |

```ts
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

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

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

| `SpawnRequest` | Type                     |
| -------------- | ------------------------ |
| `command`      | `string[]`               |
| `cwd`          | `string`                 |
| `env`          | `Record<string, string>` |

| `ProcessStatus` | Type                    | Notes                 |
| --------------- | ----------------------- | --------------------- |
| `processId`     | `string`                |                       |
| `command`       | `string[]`              |                       |
| `state`         | `"running" \| "exited"` |                       |
| `exitCode`      | `number`                | Absent while running. |
| `startedAt`     | `string`                | RFC 3339.             |
| `exitedAt`      | `string`                | Absent while running. |

```ts
const proc = await sandboxes.spawn("thread-42", {
  command: ["npm", "run", "dev"],
});
```

### poll

```ts
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:

| Field           | Type     | Notes                             |
| --------------- | -------- | --------------------------------- |
| `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 discarded before this read. |
| `stderrDropped` | `number` | As 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.

```ts
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

```ts
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.

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

## Files and search

```ts
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 }`.

```ts
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

```ts
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.

| `SandboxRecord` | Type     | Notes                                                                          |
| --------------- | -------- | ------------------------------------------------------------------------------ |
| `name`          | `string` |                                                                                |
| `class`         | `string` |                                                                                |
| `state`         | `string` | `running`, `suspended`, or `stopped`.                                          |
| `createdAt`     | `string` | RFC 3339.                                                                      |
| `lastActiveAt`  | `string` | RFC 3339.                                                                      |
| `ceilingAt`     | `string` | When the runtime reclaims it regardless of activity. Absent when nothing runs. |

```ts
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

| Condition               | Threshold  | Result                                                              |
| ----------------------- | ---------- | ------------------------------------------------------------------- |
| Idle                    | 15 minutes | Suspended. Memory and files intact, and the next attach resumes it. |
| Suspended and untouched | 1 hour     | Reclaimed.                                                          |
| Age since creation      | 8 hours    | Reclaimed, 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

| Error                     | Status           | Cause                                                               |
| ------------------------- | ---------------- | ------------------------------------------------------------------- |
| `SandboxNotEnabledError`  | 409              | The account does not have sandboxes. Extends `SandboxRequestError`. |
| `SandboxRequestError`     | Carries `status` | The control plane refused the request.                              |
| `SandboxUnavailableError` | None             | The 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.

```ts
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

| Area           | Limit                                                                                   |
| -------------- | --------------------------------------------------------------------------------------- |
| Languages      | TypeScript only. `astropods-adapter-core` has no sandbox client.                        |
| Sizing         | One class, `default`. CPU and memory are not configurable.                              |
| Persistence    | None beyond the thresholds above. No addressable checkpoints, and no filesystem export. |
| Count          | 20 per deployment by default, plus the account limit.                                   |
| Processes      | 64 per sandbox.                                                                         |
| `exec` output  | 1 MiB per stream, buffered, then `truncated`.                                           |
| `spawn` output | 1 MiB retained per stream per process, oldest discarded first.                          |
| Binary files   | Base64 through a shell command, writes chunked at 48 KiB.                               |
| Scope          | A 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

| Package                        | Exposes                                                                                       | Driven by                     |
| ------------------------------ | --------------------------------------------------------------------------------------------- | ----------------------------- |
| `@astropods/adapter-mastra`    | `AstroSandbox`, a `MastraSandbox` provider, with `AstroProcessManager` on `sandbox.processes` | A Mastra `Workspace`          |
| `@astropods/adapter-langchain` | `AstroSandbox`, a deepagents `BaseSandbox`                                                    | `createDeepAgent`'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.

```ts
// Mastra
new Workspace({ sandbox: new AstroSandbox({ name: threadId }) });

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

[Mastra adapter](/adapters/mastra#sandboxes) covers the Mastra mapping in full.

## Next steps

* [Custom adapter (Node)](/adapters/node/custom-adapter) covers the rest of `@astropods/adapter-core`.
* [Usage limits](/usage-limits) covers the caps a 402 reports against.
* [Managing your agents](/managing-agents) covers the environment a deployed agent receives.