SDK sandbox provisioning: built-in providers, docs restructure, and quickstart overhaul

- Add built-in sandbox providers (local, docker, e2b, daytona, vercel, cloudflare) to the TypeScript SDK so users import directly instead of passing client instances
- Restructure docs: rename architecture to orchestration-architecture, add new architecture page for server overview, improve getting started flow
- Rewrite quickstart to be TypeScript-first with provider CodeGroup and custom provider accordion
- Update all examples to use new provider APIs
- Update persist drivers and foundry for new SDK surface

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Nathan Flurry 2026-03-15 12:39:05 -07:00
parent 3426cbc6ec
commit 6a42f06342
53 changed files with 1689 additions and 667 deletions

View file

@ -74,6 +74,7 @@
- `examples/docker/src/index.ts`
- `examples/e2b/src/index.ts`
- `examples/vercel/src/index.ts`
- `sdks/typescript/src/providers/shared.ts`
- `scripts/release/main.ts`
- `scripts/release/promote-artifacts.ts`
- `scripts/release/sdk.ts`

View file

@ -37,7 +37,7 @@ await writeFile(
const sdk = await SandboxAgent.connect({ baseUrl: "http://127.0.0.1:2468" });
await sdk.createSession({
agent: "claude",
sessionInit: { cwd, mcpServers: [] },
cwd,
});
```

View file

@ -21,10 +21,7 @@ const sdk = await SandboxAgent.connect({
const session = await sdk.createSession({
agent: "codex",
sessionInit: {
cwd: "/",
mcpServers: [],
},
cwd: "/",
});
console.log(session.id, session.agentSessionId);

View file

@ -1,64 +1,59 @@
---
title: "Architecture"
description: "How the client, sandbox, server, and agent fit together."
icon: "microchip"
description: "How the Sandbox Agent server, SDK, and agent processes fit together."
---
Sandbox Agent runs as an HTTP server inside your sandbox. Your app talks to it remotely.
Sandbox Agent is a lightweight HTTP server that runs **inside** a sandbox. It:
- **Agent management**: Installs, spawns, and stops coding agent processes
- **Sessions**: Routes prompts to agents and streams events back in real time
- **Sandbox APIs**: Filesystem, process, and terminal access for the sandbox environment
## Components
- `Your client`: your app code using the `sandbox-agent` SDK.
- `Sandbox`: isolated runtime (E2B, Daytona, Docker, etc.).
- `Sandbox Agent server`: process inside the sandbox exposing HTTP transport.
- `Agent`: Claude/Codex/OpenCode/Amp process managed by Sandbox Agent.
```mermaid placement="top-right"
flowchart LR
CLIENT["Sandbox Agent SDK"]
SERVER["Sandbox Agent server"]
AGENT["Agent process"]
```mermaid
flowchart LR
CLIENT["Your App"]
subgraph SANDBOX["Sandbox"]
direction TB
SERVER --> AGENT
direction TB
SERVER["Sandbox Agent Server"]
AGENT["Agent Process<br/>(Claude, Codex, etc.)"]
SERVER --> AGENT
end
CLIENT -->|HTTP| SERVER
CLIENT -->|"SDK (HTTP)"| SERVER
```
## Suggested Topology
- **Your app**: Uses the `sandbox-agent` TypeScript SDK to talk to the server over HTTP.
- **Sandbox**: An isolated runtime (local process, Docker, E2B, Daytona, Vercel, Cloudflare).
- **Sandbox Agent server**: A single binary inside the sandbox that manages agent lifecycles, routes prompts, streams events, and exposes filesystem/process/terminal APIs.
- **Agent process**: A coding agent (Claude Code, Codex, etc.) spawned by the server. Each session maps to one agent process.
Run the SDK on your backend, then call it from your frontend.
## What `SandboxAgent.start()` does
This extra hop is recommended because it keeps auth/token logic on the backend and makes persistence simpler.
1. **Provision**: The provider creates a sandbox (starts a container, creates a VM, etc.)
2. **Install**: The Sandbox Agent binary is installed inside the sandbox
3. **Boot**: The server starts listening on an HTTP port
4. **Health check**: The SDK waits for `/v1/health` to respond
5. **Ready**: The SDK returns a connected client
```mermaid placement="top-right"
flowchart LR
BROWSER["Browser"]
subgraph BACKEND["Your backend"]
direction TB
SDK["Sandbox Agent SDK"]
end
subgraph SANDBOX_SIMPLE["Sandbox"]
SERVER_SIMPLE["Sandbox Agent server"]
end
For the `local` provider, provisioning is a no-op and the server runs as a local subprocess.
BROWSER --> BACKEND
BACKEND --> SDK --> SERVER_SIMPLE
## Server endpoints
See the [HTTP API reference](/api-reference) for the full list of server endpoints.
## Agent installation
Agents are installed lazily on first use. To avoid the cold-start delay, pre-install them:
```bash
sandbox-agent install-agent --all
```
### Backend requirements
The `rivetdev/sandbox-agent:0.3.2-full` Docker image ships with all agents pre-installed.
Your backend layer needs to handle:
## Production topology
- **Long-running connections**: prompts can take minutes.
- **Session affinity**: follow-up messages must reach the same session.
- **State between requests**: session metadata and event history must persist across requests.
- **Graceful recovery**: sessions should resume after backend restarts.
We recommend [Rivet](https://rivet.dev) over serverless because actors natively support the long-lived connections, session routing, and state persistence that agent workloads require.
## Session persistence
For storage driver options and replay behavior, see [Persisting Sessions](/session-persistence).
For production deployments, see [Orchestration Architecture](/orchestration-architecture) for recommended topology, backend requirements, and session persistence patterns.

View file

@ -80,9 +80,7 @@ await sdk.setMcpConfig(
const session = await sdk.createSession({
agent: "claude",
sessionInit: {
cwd: "/workspace",
},
cwd: "/workspace",
});
await session.prompt([
@ -145,9 +143,7 @@ await sdk.writeFsFile({ path: "/opt/skills/random-number/SKILL.md" }, skill);
```ts
const session = await sdk.createSession({
agent: "claude",
sessionInit: {
cwd: "/workspace",
},
cwd: "/workspace",
});
await session.prompt([

View file

@ -31,7 +31,38 @@ RUN sandbox-agent install-agent claude && sandbox-agent install-agent codex
EXPOSE 8000
```
## TypeScript example
## TypeScript example (with provider)
For standalone scripts, use the `cloudflare` provider:
```bash
npm install sandbox-agent@0.3.x @cloudflare/sandbox
```
```typescript
import { SandboxAgent } from "sandbox-agent";
import { cloudflare } from "sandbox-agent/cloudflare";
const sdk = await SandboxAgent.start({
sandbox: cloudflare(),
});
try {
const session = await sdk.createSession({ agent: "codex" });
const response = await session.prompt([
{ type: "text", text: "Summarize this repository" },
]);
console.log(response.stopReason);
} finally {
await sdk.destroySandbox();
}
```
The `cloudflare` provider uses `containerFetch` under the hood, automatically stripping `AbortSignal` to avoid dropped streaming updates.
## TypeScript example (Durable Objects)
For Workers with Durable Objects, use `SandboxAgent.connect(...)` with a custom `fetch` backed by `sandbox.containerFetch(...)`:
```typescript
import { getSandbox, type Sandbox } from "@cloudflare/sandbox";
@ -109,7 +140,6 @@ app.all("*", (c) => c.env.ASSETS.fetch(c.req.raw));
export default app;
```
Create the SDK client inside the Worker using custom `fetch` backed by `sandbox.containerFetch(...)`.
This keeps all Sandbox Agent calls inside the Cloudflare sandbox routing path and does not require a `baseUrl`.
## Troubleshooting streaming updates

View file

@ -15,40 +15,37 @@ See [Daytona network limits](https://www.daytona.io/docs/en/network-limits/).
## TypeScript example
```typescript
import { Daytona } from "@daytonaio/sdk";
import { SandboxAgent } from "sandbox-agent";
```bash
npm install sandbox-agent@0.3.x @daytonaio/sdk
```
const daytona = new Daytona();
```typescript
import { SandboxAgent } from "sandbox-agent";
import { daytona } from "sandbox-agent/daytona";
const envVars: Record<string, string> = {};
if (process.env.ANTHROPIC_API_KEY) envVars.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
if (process.env.OPENAI_API_KEY) envVars.OPENAI_API_KEY = process.env.OPENAI_API_KEY;
const sandbox = await daytona.create({ envVars });
const sdk = await SandboxAgent.start({
sandbox: daytona({
create: { envVars },
}),
});
await sandbox.process.executeCommand(
"curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh"
);
await sandbox.process.executeCommand("sandbox-agent install-agent claude");
await sandbox.process.executeCommand("sandbox-agent install-agent codex");
await sandbox.process.executeCommand(
"nohup sandbox-agent server --no-token --host 0.0.0.0 --port 3000 >/tmp/sandbox-agent.log 2>&1 &"
);
await new Promise((r) => setTimeout(r, 2000));
const baseUrl = (await sandbox.getSignedPreviewUrl(3000, 4 * 60 * 60)).url;
const sdk = await SandboxAgent.connect({ baseUrl });
const session = await sdk.createSession({ agent: "claude" });
await session.prompt([{ type: "text", text: "Summarize this repository" }]);
await sandbox.delete();
try {
const session = await sdk.createSession({ agent: "claude" });
const response = await session.prompt([
{ type: "text", text: "Summarize this repository" },
]);
console.log(response.stopReason);
} finally {
await sdk.destroySandbox();
}
```
The `daytona` provider uses the `rivetdev/sandbox-agent:0.3.2-full` image by default and starts the server automatically.
## Using snapshots for faster startup
```typescript

View file

@ -15,43 +15,43 @@ Run the published full image with all supported agents pre-installed:
docker run --rm -p 3000:3000 \
-e ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" \
-e OPENAI_API_KEY="$OPENAI_API_KEY" \
rivetdev/sandbox-agent:0.3.1-full \
rivetdev/sandbox-agent:0.3.2-full \
server --no-token --host 0.0.0.0 --port 3000
```
The `0.3.1-full` tag pins the exact version. The moving `full` tag is also published for contributors who want the latest full image.
The `0.3.2-full` tag pins the exact version. The moving `full` tag is also published for contributors who want the latest full image.
## TypeScript with dockerode
## TypeScript with the Docker provider
```bash
npm install sandbox-agent@0.3.x dockerode get-port
```
```typescript
import Docker from "dockerode";
import { SandboxAgent } from "sandbox-agent";
import { docker } from "sandbox-agent/docker";
const docker = new Docker();
const PORT = 3000;
const container = await docker.createContainer({
Image: "rivetdev/sandbox-agent:0.3.1-full",
Cmd: ["server", "--no-token", "--host", "0.0.0.0", "--port", `${PORT}`],
Env: [
`ANTHROPIC_API_KEY=${process.env.ANTHROPIC_API_KEY}`,
`OPENAI_API_KEY=${process.env.OPENAI_API_KEY}`,
`CODEX_API_KEY=${process.env.CODEX_API_KEY}`,
].filter(Boolean),
ExposedPorts: { [`${PORT}/tcp`]: {} },
HostConfig: {
AutoRemove: true,
PortBindings: { [`${PORT}/tcp`]: [{ HostPort: `${PORT}` }] },
},
const sdk = await SandboxAgent.start({
sandbox: docker({
env: [
`ANTHROPIC_API_KEY=${process.env.ANTHROPIC_API_KEY}`,
`OPENAI_API_KEY=${process.env.OPENAI_API_KEY}`,
].filter(Boolean),
}),
});
await container.start();
try {
const session = await sdk.createSession({ agent: "codex" });
await session.prompt([{ type: "text", text: "Summarize this repository." }]);
} finally {
await sdk.destroySandbox();
}
```
const baseUrl = `http://127.0.0.1:${PORT}`;
const sdk = await SandboxAgent.connect({ baseUrl });
The `docker` provider uses the `rivetdev/sandbox-agent:0.3.2-full` image by default. Override with `image`:
const session = await sdk.createSession({ agent: "codex" });
await session.prompt([{ type: "text", text: "Summarize this repository." }]);
```typescript
docker({ image: "my-custom-image:latest" })
```
## Building a custom image with everything preinstalled

View file

@ -10,42 +10,37 @@ description: "Deploy Sandbox Agent inside an E2B sandbox."
## TypeScript example
```bash
npm install sandbox-agent@0.3.x @e2b/code-interpreter
```
```typescript
import { Sandbox } from "@e2b/code-interpreter";
import { SandboxAgent } from "sandbox-agent";
import { e2b } from "sandbox-agent/e2b";
const envs: Record<string, string> = {};
if (process.env.ANTHROPIC_API_KEY) envs.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
if (process.env.OPENAI_API_KEY) envs.OPENAI_API_KEY = process.env.OPENAI_API_KEY;
const sandbox = await Sandbox.create({ allowInternetAccess: true, envs });
await sandbox.commands.run(
"curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh"
);
await sandbox.commands.run("sandbox-agent install-agent claude");
await sandbox.commands.run("sandbox-agent install-agent codex");
await sandbox.commands.run(
"sandbox-agent server --no-token --host 0.0.0.0 --port 3000",
{ background: true, timeoutMs: 0 }
);
const baseUrl = `https://${sandbox.getHost(3000)}`;
const sdk = await SandboxAgent.connect({ baseUrl });
const session = await sdk.createSession({ agent: "claude" });
const off = session.onEvent((event) => {
console.log(event.sender, event.payload);
const sdk = await SandboxAgent.start({
sandbox: e2b({
create: { envs },
}),
});
await session.prompt([{ type: "text", text: "Summarize this repository" }]);
off();
await sandbox.kill();
try {
const session = await sdk.createSession({ agent: "claude" });
const response = await session.prompt([
{ type: "text", text: "Summarize this repository" },
]);
console.log(response.stopReason);
} finally {
await sdk.destroySandbox();
}
```
The `e2b` provider handles sandbox creation, Sandbox Agent installation, agent setup, and server startup automatically.
## Faster cold starts
For faster startup, create a custom E2B template with Sandbox Agent and target agents pre-installed.

View file

@ -32,12 +32,15 @@ Or with npm/Bun:
## With the TypeScript SDK
The SDK can spawn and manage the server as a subprocess:
The SDK can spawn and manage the server as a subprocess using the `local` provider:
```typescript
import { SandboxAgent } from "sandbox-agent";
import { local } from "sandbox-agent/local";
const sdk = await SandboxAgent.start();
const sdk = await SandboxAgent.start({
sandbox: local(),
});
const session = await sdk.createSession({
agent: "claude",
@ -47,7 +50,21 @@ await session.prompt([
{ type: "text", text: "Summarize this repository." },
]);
await sdk.dispose();
await sdk.destroySandbox();
```
This starts the server on an available local port and connects automatically.
Pass options to customize the local provider:
```typescript
const sdk = await SandboxAgent.start({
sandbox: local({
port: 3000,
log: "inherit",
env: {
ANTHROPIC_API_KEY: process.env.MY_ANTHROPIC_KEY,
},
}),
});
```

View file

@ -10,52 +10,40 @@ description: "Deploy Sandbox Agent inside a Vercel Sandbox."
## TypeScript example
```typescript
import { Sandbox } from "@vercel/sandbox";
import { SandboxAgent } from "sandbox-agent";
const envs: Record<string, string> = {};
if (process.env.ANTHROPIC_API_KEY) envs.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
if (process.env.OPENAI_API_KEY) envs.OPENAI_API_KEY = process.env.OPENAI_API_KEY;
const sandbox = await Sandbox.create({
runtime: "node24",
ports: [3000],
});
const run = async (cmd: string, args: string[] = []) => {
const result = await sandbox.runCommand({ cmd, args, env: envs });
if (result.exitCode !== 0) {
throw new Error(`Command failed: ${cmd} ${args.join(" ")}`);
}
};
await run("sh", ["-c", "curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh"]);
await run("sandbox-agent", ["install-agent", "claude"]);
await run("sandbox-agent", ["install-agent", "codex"]);
await sandbox.runCommand({
cmd: "sandbox-agent",
args: ["server", "--no-token", "--host", "0.0.0.0", "--port", "3000"],
env: envs,
detached: true,
});
const baseUrl = sandbox.domain(3000);
const sdk = await SandboxAgent.connect({ baseUrl });
const session = await sdk.createSession({ agent: "claude" });
const off = session.onEvent((event) => {
console.log(event.sender, event.payload);
});
await session.prompt([{ type: "text", text: "Summarize this repository" }]);
off();
await sandbox.stop();
```bash
npm install sandbox-agent@0.3.x @vercel/sandbox
```
```typescript
import { SandboxAgent } from "sandbox-agent";
import { vercel } from "sandbox-agent/vercel";
const env: Record<string, string> = {};
if (process.env.ANTHROPIC_API_KEY) env.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
if (process.env.OPENAI_API_KEY) env.OPENAI_API_KEY = process.env.OPENAI_API_KEY;
const sdk = await SandboxAgent.start({
sandbox: vercel({
create: {
runtime: "node24",
env,
},
}),
});
try {
const session = await sdk.createSession({ agent: "claude" });
const response = await session.prompt([
{ type: "text", text: "Summarize this repository" },
]);
console.log(response.stopReason);
} finally {
await sdk.destroySandbox();
}
```
The `vercel` provider handles sandbox creation, Sandbox Agent installation, agent setup, and server startup automatically.
## Authentication
Vercel Sandboxes support OIDC token auth (recommended) and access-token auth.

View file

@ -58,13 +58,13 @@
"icon": "server",
"pages": [
"deploy/local",
"deploy/computesdk",
"deploy/e2b",
"deploy/daytona",
"deploy/vercel",
"deploy/cloudflare",
"deploy/docker",
"deploy/boxlite"
"deploy/boxlite",
"deploy/computesdk"
]
}
]
@ -79,11 +79,12 @@
},
{
"group": "Orchestration",
"pages": ["architecture", "session-persistence", "observability", "multiplayer", "security"]
"pages": ["orchestration-architecture", "session-persistence", "observability", "multiplayer", "security"]
},
{
"group": "Reference",
"pages": [
"architecture",
"agent-capabilities",
"cli",
"inspector",

View file

@ -27,9 +27,7 @@ await sdk.setMcpConfig(
// Create a session using the configured MCP servers
const session = await sdk.createSession({
agent: "claude",
sessionInit: {
cwd: "/workspace",
},
cwd: "/workspace",
});
await session.prompt([

View file

@ -0,0 +1,43 @@
---
title: "Orchestration Architecture"
description: "Production topology, backend requirements, and session persistence."
icon: "sitemap"
---
This page covers production topology and backend requirements. Read [Architecture](/architecture) first for an overview of how the server, SDK, and agent processes fit together.
## Suggested Topology
Run the SDK on your backend, then call it from your frontend.
This extra hop is recommended because it keeps auth/token logic on the backend and makes persistence simpler.
```mermaid placement="top-right"
flowchart LR
BROWSER["Browser"]
subgraph BACKEND["Your backend"]
direction TB
SDK["Sandbox Agent SDK"]
end
subgraph SANDBOX_SIMPLE["Sandbox"]
SERVER_SIMPLE["Sandbox Agent server"]
end
BROWSER --> BACKEND
BACKEND --> SDK --> SERVER_SIMPLE
```
### Backend requirements
Your backend layer needs to handle:
- **Long-running connections**: prompts can take minutes.
- **Session affinity**: follow-up messages must reach the same session.
- **State between requests**: session metadata and event history must persist across requests.
- **Graceful recovery**: sessions should resume after backend restarts.
We recommend [Rivet](https://rivet.dev) over serverless because actors natively support the long-lived connections, session routing, and state persistence that agent workloads require.
## Session persistence
For storage driver options and replay behavior, see [Persisting Sessions](/session-persistence).

View file

@ -1,20 +1,22 @@
---
title: "Quickstart"
description: "Start the server and send your first message."
description: "Get a coding agent running in a sandbox in under a minute."
icon: "rocket"
---
<Steps>
<Step title="Install skill (optional)">
<Step title="Install">
<Tabs>
<Tab title="npx">
<Tab title="npm">
```bash
npx skills add rivet-dev/skills -s sandbox-agent
npm install sandbox-agent@0.3.x
```
</Tab>
<Tab title="bunx">
<Tab title="bun">
```bash
bunx skills add rivet-dev/skills -s sandbox-agent
bun add sandbox-agent@0.3.x
# Allow Bun to run postinstall scripts for native binaries (required for SandboxAgent.start()).
bun pm trust @sandbox-agent/cli-linux-x64 @sandbox-agent/cli-linux-arm64 @sandbox-agent/cli-darwin-arm64 @sandbox-agent/cli-darwin-x64 @sandbox-agent/cli-win32-x64
```
</Tab>
</Tabs>
@ -23,52 +25,10 @@ icon: "rocket"
<Step title="Set environment variables">
Each coding agent requires API keys to connect to their respective LLM providers.
<Tabs>
<Tab title="Local shell">
```bash
export ANTHROPIC_API_KEY="sk-ant-..."
export OPENAI_API_KEY="sk-..."
```
</Tab>
<Tab title="E2B">
```typescript
import { Sandbox } from "@e2b/code-interpreter";
const envs: Record<string, string> = {};
if (process.env.ANTHROPIC_API_KEY) envs.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
if (process.env.OPENAI_API_KEY) envs.OPENAI_API_KEY = process.env.OPENAI_API_KEY;
const sandbox = await Sandbox.create({ envs });
```
</Tab>
<Tab title="Daytona">
```typescript
import { Daytona } from "@daytonaio/sdk";
const envVars: Record<string, string> = {};
if (process.env.ANTHROPIC_API_KEY) envVars.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
if (process.env.OPENAI_API_KEY) envVars.OPENAI_API_KEY = process.env.OPENAI_API_KEY;
const daytona = new Daytona();
const sandbox = await daytona.create({
snapshot: "sandbox-agent-ready",
envVars,
});
```
</Tab>
<Tab title="Docker">
```bash
docker run -p 2468:2468 \
-e ANTHROPIC_API_KEY="sk-ant-..." \
-e OPENAI_API_KEY="sk-..." \
rivetdev/sandbox-agent:0.3.1-full \
server --no-token --host 0.0.0.0 --port 2468
```
</Tab>
</Tabs>
```bash
export ANTHROPIC_API_KEY="sk-ant-..."
export OPENAI_API_KEY="sk-..."
```
<AccordionGroup>
<Accordion title="Extracting API keys from current machine">
@ -83,173 +43,146 @@ icon: "rocket"
</AccordionGroup>
</Step>
<Step title="Run the server">
<Tabs>
<Tab title="curl">
Install and run the binary directly.
<Step title="Start the sandbox">
`SandboxAgent.start()` provisions a sandbox, starts a lightweight [Sandbox Agent server](/architecture) inside it, and connects your SDK client.
```bash
curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh
sandbox-agent server --no-token --host 0.0.0.0 --port 2468
```
</Tab>
<CodeGroup>
```typescript Local
import { SandboxAgent } from "sandbox-agent";
import { local } from "sandbox-agent/local";
<Tab title="npx">
Run without installing globally.
// Runs on your machine. Best for local development and testing.
const sdk = await SandboxAgent.start({
sandbox: local(),
});
```
```bash
npx @sandbox-agent/cli@0.3.x server --no-token --host 0.0.0.0 --port 2468
```
</Tab>
```typescript E2B
import { SandboxAgent } from "sandbox-agent";
import { e2b } from "sandbox-agent/e2b";
<Tab title="bunx">
Run without installing globally.
const sdk = await SandboxAgent.start({
sandbox: e2b({ create: { envs } }),
});
```
```bash
bunx @sandbox-agent/cli@0.3.x server --no-token --host 0.0.0.0 --port 2468
```
</Tab>
```typescript Daytona
import { SandboxAgent } from "sandbox-agent";
import { daytona } from "sandbox-agent/daytona";
<Tab title="npm i -g">
Install globally, then run.
const sdk = await SandboxAgent.start({
sandbox: daytona({ create: { envVars } }),
});
```
```bash
npm install -g @sandbox-agent/cli@0.3.x
sandbox-agent server --no-token --host 0.0.0.0 --port 2468
```
</Tab>
```typescript Vercel
import { SandboxAgent } from "sandbox-agent";
import { vercel } from "sandbox-agent/vercel";
<Tab title="bun add -g">
Install globally, then run.
const sdk = await SandboxAgent.start({
sandbox: vercel({ create: { runtime: "node24", env } }),
});
```
```bash
bun add -g @sandbox-agent/cli@0.3.x
# Allow Bun to run postinstall scripts for native binaries (required for SandboxAgent.start()).
bun pm -g trust @sandbox-agent/cli-linux-x64 @sandbox-agent/cli-linux-arm64 @sandbox-agent/cli-darwin-arm64 @sandbox-agent/cli-darwin-x64 @sandbox-agent/cli-win32-x64
sandbox-agent server --no-token --host 0.0.0.0 --port 2468
```
</Tab>
```typescript Cloudflare
import { SandboxAgent } from "sandbox-agent";
import { cloudflare } from "sandbox-agent/cloudflare";
<Tab title="Node.js (local)">
For local development, use `SandboxAgent.start()` to spawn and manage the server as a subprocess.
const sdk = await SandboxAgent.start({
sandbox: cloudflare({ sdk: cfSandboxClient }),
});
```
```bash
npm install sandbox-agent@0.3.x
```
```typescript Docker
import { SandboxAgent } from "sandbox-agent";
import { docker } from "sandbox-agent/docker";
```typescript
import { SandboxAgent } from "sandbox-agent";
// Good for testing. Not security-hardened like cloud sandboxes.
const sdk = await SandboxAgent.start({
sandbox: docker({
env: [`ANTHROPIC_API_KEY=${process.env.ANTHROPIC_API_KEY}`],
}),
});
```
</CodeGroup>
const sdk = await SandboxAgent.start();
```
</Tab>
<Tab title="Bun (local)">
For local development, use `SandboxAgent.start()` to spawn and manage the server as a subprocess.
```bash
bun add sandbox-agent@0.3.x
# Allow Bun to run postinstall scripts for native binaries (required for SandboxAgent.start()).
bun pm trust @sandbox-agent/cli-linux-x64 @sandbox-agent/cli-linux-arm64 @sandbox-agent/cli-darwin-arm64 @sandbox-agent/cli-darwin-x64 @sandbox-agent/cli-win32-x64
```
```typescript
import { SandboxAgent } from "sandbox-agent";
const sdk = await SandboxAgent.start();
```
</Tab>
<Tab title="Build from source">
If you're running from source instead of the installed CLI.
```bash
cargo run -p sandbox-agent -- server --no-token --host 0.0.0.0 --port 2468
```
</Tab>
</Tabs>
Binding to `0.0.0.0` allows the server to accept connections from any network interface, which is required when running inside a sandbox where clients connect remotely.
Each provider handles provisioning, server installation, and networking. Install the provider's peer dependency (e.g. `@e2b/code-interpreter`, `dockerode`) in your project. See the [Deploy](/deploy/local) guides for full setup details.
<AccordionGroup>
<Accordion title="Configuring token">
Tokens are usually not required. Most sandbox providers (E2B, Daytona, etc.) already secure networking at the infrastructure layer.
<Accordion title="Implementing a custom provider">
Implement the `SandboxProvider` interface to use any sandbox platform:
If you expose the server publicly, use `--token "$SANDBOX_TOKEN"` to require authentication:
```typescript
import { SandboxAgent, type SandboxProvider } from "sandbox-agent";
```bash
sandbox-agent server --token "$SANDBOX_TOKEN" --host 0.0.0.0 --port 2468
const myProvider: SandboxProvider = {
name: "my-provider",
async create() {
// Provision a sandbox, install & start the server, return an ID
return "sandbox-123";
},
async destroy(sandboxId) {
// Tear down the sandbox
},
async getUrl(sandboxId) {
// Return the Sandbox Agent server URL
return `https://${sandboxId}.my-platform.dev:3000`;
},
};
const sdk = await SandboxAgent.start({
sandbox: myProvider,
});
```
</Accordion>
Then pass the token when connecting:
<Accordion title="Connecting to an existing server">
If you already have a Sandbox Agent server running, connect directly:
```typescript
const sdk = await SandboxAgent.connect({
baseUrl: "http://127.0.0.1:2468",
});
```
</Accordion>
<Accordion title="Starting the server manually">
<Tabs>
<Tab title="TypeScript">
```typescript
import { SandboxAgent } from "sandbox-agent";
const sdk = await SandboxAgent.connect({
baseUrl: "http://your-server:2468",
token: process.env.SANDBOX_TOKEN,
});
```
</Tab>
<Tab title="curl">
```bash
curl "http://your-server:2468/v1/health" \
-H "Authorization: Bearer $SANDBOX_TOKEN"
curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh
sandbox-agent server --no-token --host 0.0.0.0 --port 2468
```
</Tab>
<Tab title="CLI">
<Tab title="npx">
```bash
sandbox-agent --token "$SANDBOX_TOKEN" api agents list \
--endpoint http://your-server:2468
npx @sandbox-agent/cli@0.3.x server --no-token --host 0.0.0.0 --port 2468
```
</Tab>
<Tab title="Docker">
```bash
docker run -p 2468:2468 \
-e ANTHROPIC_API_KEY="sk-ant-..." \
-e OPENAI_API_KEY="sk-..." \
rivetdev/sandbox-agent:0.3.2-full \
server --no-token --host 0.0.0.0 --port 2468
```
</Tab>
</Tabs>
</Accordion>
<Accordion title="CORS">
If you're calling the server from a browser, see the [CORS configuration guide](/cors).
</Accordion>
</AccordionGroup>
</Step>
<Step title="Install agents (optional)">
Supported agent IDs: `claude`, `codex`, `opencode`, `amp`, `pi`, `cursor`, `mock`.
To preinstall agents:
```bash
sandbox-agent install-agent --all
```
If agents are not installed up front, they are lazily installed when creating a session.
</Step>
<Step title="Create a session">
<Step title="Create a session and send a prompt">
```typescript
import { SandboxAgent } from "sandbox-agent";
const sdk = await SandboxAgent.connect({
baseUrl: "http://127.0.0.1:2468",
});
const session = await sdk.createSession({
agent: "claude",
sessionInit: {
cwd: "/",
mcpServers: [],
},
});
console.log(session.id);
```
</Step>
session.onEvent((event) => {
console.log(event.sender, event.payload);
});
<Step title="Send a message">
```typescript
const result = await session.prompt([
{ type: "text", text: "Summarize the repository and suggest next steps." },
]);
@ -258,24 +191,16 @@ icon: "rocket"
```
</Step>
<Step title="Read events">
<Step title="Clean up">
```typescript
const off = session.onEvent((event) => {
console.log(event.sender, event.payload);
});
const page = await sdk.getEvents({
sessionId: session.id,
limit: 50,
});
console.log(page.items.length);
off();
await sdk.destroySandbox(); // tears down the sandbox and disconnects
```
Use `sdk.dispose()` instead to disconnect without destroying the sandbox (for reconnecting later).
</Step>
<Step title="Test with Inspector">
Open the Inspector UI at `/ui/` on your server (for example, `http://localhost:2468/ui/`) to inspect sessions and events in a GUI.
<Step title="Inspect with the UI">
Open the Inspector at `/ui/` on your server (e.g. `http://localhost:2468/ui/`) to view sessions and events in a GUI.
<Frame>
<img src="/images/inspector.png" alt="Sandbox Agent Inspector" />
@ -283,16 +208,40 @@ icon: "rocket"
</Step>
</Steps>
## Full example
```typescript
import { SandboxAgent } from "sandbox-agent";
import { local } from "sandbox-agent/local";
const sdk = await SandboxAgent.start({
sandbox: local(),
});
try {
const session = await sdk.createSession({ agent: "claude" });
session.onEvent((event) => {
console.log(`[${event.sender}]`, JSON.stringify(event.payload));
});
const result = await session.prompt([
{ type: "text", text: "Write a function that checks if a number is prime." },
]);
console.log("Done:", result.stopReason);
} finally {
await sdk.destroySandbox();
}
```
## Next steps
<CardGroup cols={3}>
<Card title="Session Persistence" icon="database" href="/session-persistence">
Configure in-memory, Rivet Actor state, IndexedDB, SQLite, and Postgres persistence.
<CardGroup cols={2}>
<Card title="SDK Overview" icon="compass" href="/sdk-overview">
Full TypeScript SDK API surface.
</Card>
<Card title="Deploy to a Sandbox" icon="box" href="/deploy/local">
Deploy your agent to E2B, Daytona, Docker, Vercel, or Cloudflare.
</Card>
<Card title="SDK Overview" icon="compass" href="/sdk-overview">
Use the latest TypeScript SDK API.
Deploy to E2B, Daytona, Docker, Vercel, or Cloudflare.
</Card>
</CardGroup>

View file

@ -84,25 +84,40 @@ const sdk = await SandboxAgent.connect({
});
```
Local autospawn (Node.js only):
Local spawn with a sandbox provider:
```ts
import { SandboxAgent } from "sandbox-agent";
import { local } from "sandbox-agent/local";
const localSdk = await SandboxAgent.start();
const sdk = await SandboxAgent.start({
sandbox: local(),
});
await localSdk.dispose();
// sdk.sandboxId — prefixed provider ID (e.g. "local/127.0.0.1:2468")
await sdk.destroySandbox(); // tears down sandbox + disposes client
```
`SandboxAgent.start(...)` requires a `sandbox` provider. Built-in providers:
| Import | Provider |
|--------|----------|
| `sandbox-agent/local` | Local subprocess |
| `sandbox-agent/docker` | Docker container |
| `sandbox-agent/e2b` | E2B sandbox |
| `sandbox-agent/daytona` | Daytona workspace |
| `sandbox-agent/vercel` | Vercel Sandbox |
| `sandbox-agent/cloudflare` | Cloudflare Sandbox |
Use `sdk.dispose()` to disconnect without destroying the sandbox, or `sdk.destroySandbox()` to tear down both.
## Session flow
```ts
const session = await sdk.createSession({
agent: "mock",
sessionInit: {
cwd: "/",
mcpServers: [],
},
cwd: "/",
});
const prompt = await session.prompt([
@ -223,6 +238,7 @@ Parameters:
- `token` (optional): Bearer token for authenticated servers
- `headers` (optional): Additional request headers
- `fetch` (optional): Custom fetch implementation used by SDK HTTP and session calls
- `skipHealthCheck` (optional): set `true` to skip the startup `/v1/health` wait
- `waitForHealth` (optional, defaults to enabled): waits for `/v1/health` before HTTP helpers and session setup proceed; pass `false` to disable or `{ timeoutMs }` to bound the wait
- `signal` (optional): aborts the startup `/v1/health` wait used by `connect()`

View file

@ -4,7 +4,7 @@ description: "Backend-first auth and access control patterns."
icon: "shield"
---
As covered in [Architecture](/architecture), run the Sandbox Agent client on your backend, not in the browser.
As covered in [Orchestration Architecture](/orchestration-architecture), run the Sandbox Agent client on your backend, not in the browser.
This keeps sandbox credentials private and gives you one place for authz, rate limiting, and audit logging.
@ -92,7 +92,7 @@ export const workspace = actor({
const session = await sdk.createSession({
agent: "claude",
sessionInit: { cwd: "/workspace" },
cwd: "/workspace",
});
session.onEvent((event) => {

View file

@ -10,7 +10,7 @@ With persistence enabled, sessions can be restored after runtime/session loss. S
Each driver stores:
- `SessionRecord` (`id`, `agent`, `agentSessionId`, `lastConnectionId`, `createdAt`, optional `destroyedAt`, optional `sessionInit`)
- `SessionRecord` (`id`, `agent`, `agentSessionId`, `lastConnectionId`, `createdAt`, optional `destroyedAt`, optional `sandboxId`, optional `sessionInit`)
- `SessionEvent` (`id`, `eventIndex`, `sessionId`, `connectionId`, `sender`, `payload`, `createdAt`)
## Persistence drivers
@ -160,11 +160,11 @@ Implement `SessionPersistDriver` for custom backends.
import type { SessionPersistDriver } from "sandbox-agent";
class MyDriver implements SessionPersistDriver {
async getSession(id) { return null; }
async getSession(id) { return undefined; }
async listSessions(request) { return { items: [] }; }
async updateSession(session) {}
async listEvents(request) { return { items: [] }; }
async insertEvent(event) {}
async insertEvent(sessionId, event) {}
}
```

View file

@ -35,9 +35,7 @@ await sdk.setSkillsConfig(
// Create a session using the configured skills
const session = await sdk.createSession({
agent: "claude",
sessionInit: {
cwd: "/workspace",
},
cwd: "/workspace",
});
await session.prompt([

View file

@ -25,7 +25,7 @@ const baseUrl = "http://localhost:3000";
console.log("Connecting to server...");
const client = await SandboxAgent.connect({ baseUrl });
const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/root", mcpServers: [] } });
const session = await client.createSession({ agent: detectAgent(), cwd: "/root" });
const sessionId = session.id;
console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`);

View file

@ -131,7 +131,7 @@ export async function runComputeSdkExample(): Promise<void> {
process.once("SIGTERM", handleExit);
const client = await SandboxAgent.connect({ baseUrl });
const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/home", mcpServers: [] } });
const session = await client.createSession({ agent: detectAgent(), cwd: "/home" });
const sessionId = session.id;
console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`);

View file

@ -1,42 +1,31 @@
import { Daytona } from "@daytonaio/sdk";
import { SandboxAgent } from "sandbox-agent";
import { detectAgent, buildInspectorUrl } from "@sandbox-agent/example-shared";
const daytona = new Daytona();
import { daytona } from "sandbox-agent/daytona";
import { detectAgent } from "@sandbox-agent/example-shared";
const envVars: Record<string, string> = {};
if (process.env.ANTHROPIC_API_KEY) envVars.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
if (process.env.OPENAI_API_KEY) envVars.OPENAI_API_KEY = process.env.OPENAI_API_KEY;
// Use default image and install sandbox-agent at runtime (faster startup, no snapshot build)
console.log("Creating Daytona sandbox...");
const sandbox = await daytona.create({ envVars, autoStopInterval: 0 });
const client = await SandboxAgent.start({
sandbox: daytona({
create: { envVars },
}),
});
// Install sandbox-agent and start server
console.log("Installing sandbox-agent...");
await sandbox.process.executeCommand("curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh");
console.log(`UI: ${client.inspectorUrl}`);
console.log("Installing agents...");
await sandbox.process.executeCommand("sandbox-agent install-agent claude");
await sandbox.process.executeCommand("sandbox-agent install-agent codex");
const session = await client.createSession({
agent: detectAgent(),
cwd: "/home/daytona",
});
await sandbox.process.executeCommand("nohup sandbox-agent server --no-token --host 0.0.0.0 --port 3000 >/tmp/sandbox-agent.log 2>&1 &");
session.onEvent((event) => {
console.log(`[${event.sender}]`, JSON.stringify(event.payload));
});
const baseUrl = (await sandbox.getSignedPreviewUrl(3000, 4 * 60 * 60)).url;
session.prompt([{ type: "text", text: "Say hello from Daytona in one sentence." }]);
console.log("Connecting to server...");
const client = await SandboxAgent.connect({ baseUrl });
const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/home/daytona", mcpServers: [] } });
const sessionId = session.id;
console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`);
console.log(" Press Ctrl+C to stop.");
const keepAlive = setInterval(() => {}, 60_000);
const cleanup = async () => {
clearInterval(keepAlive);
await sandbox.delete(60);
process.once("SIGINT", async () => {
await client.destroySandbox();
process.exit(0);
};
process.once("SIGINT", cleanup);
process.once("SIGTERM", cleanup);
});

View file

@ -9,10 +9,10 @@
"dependencies": {
"@sandbox-agent/example-shared": "workspace:*",
"dockerode": "latest",
"get-port": "latest",
"sandbox-agent": "workspace:*"
},
"devDependencies": {
"@types/dockerode": "latest",
"@types/node": "latest",
"tsx": "latest",
"typescript": "latest",

View file

@ -1,68 +1,40 @@
import Docker from "dockerode";
import fs from "node:fs";
import path from "node:path";
import { SandboxAgent } from "sandbox-agent";
import { detectAgent, buildInspectorUrl } from "@sandbox-agent/example-shared";
import { docker } from "sandbox-agent/docker";
import { detectAgent } from "@sandbox-agent/example-shared";
import { FULL_IMAGE } from "@sandbox-agent/example-shared/docker";
const IMAGE = FULL_IMAGE;
const PORT = 3000;
const agent = detectAgent();
const codexAuthPath = process.env.HOME ? path.join(process.env.HOME, ".codex", "auth.json") : null;
const bindMounts = codexAuthPath && fs.existsSync(codexAuthPath) ? [`${codexAuthPath}:/home/sandbox/.codex/auth.json:ro`] : [];
const env = [
process.env.ANTHROPIC_API_KEY ? `ANTHROPIC_API_KEY=${process.env.ANTHROPIC_API_KEY}` : "",
process.env.OPENAI_API_KEY ? `OPENAI_API_KEY=${process.env.OPENAI_API_KEY}` : "",
process.env.CODEX_API_KEY ? `CODEX_API_KEY=${process.env.CODEX_API_KEY}` : "",
].filter(Boolean);
const docker = new Docker({ socketPath: "/var/run/docker.sock" });
// Pull image if needed
try {
await docker.getImage(IMAGE).inspect();
} catch {
console.log(`Pulling ${IMAGE}...`);
await new Promise<void>((resolve, reject) => {
docker.pull(IMAGE, (err: Error | null, stream: NodeJS.ReadableStream) => {
if (err) return reject(err);
docker.modem.followProgress(stream, (err: Error | null) => (err ? reject(err) : resolve()));
});
});
}
console.log("Starting container...");
const container = await docker.createContainer({
Image: IMAGE,
Cmd: ["server", "--no-token", "--host", "0.0.0.0", "--port", `${PORT}`],
Env: [
process.env.ANTHROPIC_API_KEY ? `ANTHROPIC_API_KEY=${process.env.ANTHROPIC_API_KEY}` : "",
process.env.OPENAI_API_KEY ? `OPENAI_API_KEY=${process.env.OPENAI_API_KEY}` : "",
process.env.CODEX_API_KEY ? `CODEX_API_KEY=${process.env.CODEX_API_KEY}` : "",
].filter(Boolean),
ExposedPorts: { [`${PORT}/tcp`]: {} },
HostConfig: {
AutoRemove: true,
PortBindings: { [`${PORT}/tcp`]: [{ HostPort: `${PORT}` }] },
Binds: bindMounts,
},
const client = await SandboxAgent.start({
sandbox: docker({
image: FULL_IMAGE,
env,
binds: bindMounts,
}),
});
await container.start();
const baseUrl = `http://127.0.0.1:${PORT}`;
console.log(`UI: ${client.inspectorUrl}`);
const client = await SandboxAgent.connect({ baseUrl });
const session = await client.createSession({ agent, sessionInit: { cwd: "/home/sandbox", mcpServers: [] } });
const sessionId = session.id;
const session = await client.createSession({
agent: detectAgent(),
cwd: "/home/sandbox",
});
console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`);
console.log(" Press Ctrl+C to stop.");
session.onEvent((event) => {
console.log(`[${event.sender}]`, JSON.stringify(event.payload));
});
const keepAlive = setInterval(() => {}, 60_000);
const cleanup = async () => {
clearInterval(keepAlive);
try {
await container.stop({ t: 5 });
} catch {}
try {
await container.remove({ force: true });
} catch {}
session.prompt([{ type: "text", text: "Say hello from Docker in one sentence." }]);
process.once("SIGINT", async () => {
await client.destroySandbox();
process.exit(0);
};
process.once("SIGINT", cleanup);
process.once("SIGTERM", cleanup);
});

View file

@ -1,45 +1,31 @@
import { Sandbox } from "@e2b/code-interpreter";
import { SandboxAgent } from "sandbox-agent";
import { detectAgent, buildInspectorUrl } from "@sandbox-agent/example-shared";
import { e2b } from "sandbox-agent/e2b";
import { detectAgent } from "@sandbox-agent/example-shared";
const envs: Record<string, string> = {};
if (process.env.ANTHROPIC_API_KEY) envs.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
if (process.env.OPENAI_API_KEY) envs.OPENAI_API_KEY = process.env.OPENAI_API_KEY;
console.log("Creating E2B sandbox...");
const sandbox = await Sandbox.create({ allowInternetAccess: true, envs });
const client = await SandboxAgent.start({
sandbox: e2b({
create: { envs },
}),
});
const run = async (cmd: string) => {
const result = await sandbox.commands.run(cmd);
if (result.exitCode !== 0) throw new Error(`Command failed: ${cmd}\n${result.stderr}`);
return result;
};
console.log(`UI: ${client.inspectorUrl}`);
console.log("Installing sandbox-agent...");
await run("curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh");
const session = await client.createSession({
agent: detectAgent(),
cwd: "/home/user",
});
console.log("Installing agents...");
await run("sandbox-agent install-agent claude");
await run("sandbox-agent install-agent codex");
session.onEvent((event) => {
console.log(`[${event.sender}]`, JSON.stringify(event.payload));
});
console.log("Starting server...");
await sandbox.commands.run("sandbox-agent server --no-token --host 0.0.0.0 --port 3000", { background: true, timeoutMs: 0 });
session.prompt([{ type: "text", text: "Say hello from E2B in one sentence." }]);
const baseUrl = `https://${sandbox.getHost(3000)}`;
console.log("Connecting to server...");
const client = await SandboxAgent.connect({ baseUrl });
const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/home/user", mcpServers: [] } });
const sessionId = session.id;
console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`);
console.log(" Press Ctrl+C to stop.");
const keepAlive = setInterval(() => {}, 60_000);
const cleanup = async () => {
clearInterval(keepAlive);
await sandbox.kill();
process.once("SIGINT", async () => {
await client.destroySandbox();
process.exit(0);
};
process.once("SIGINT", cleanup);
process.once("SIGTERM", cleanup);
});

View file

@ -44,7 +44,7 @@ const readmeText = new TextDecoder().decode(readmeBytes);
console.log(` README.md content: ${readmeText.trim()}`);
console.log("Creating session...");
const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/opt/my-project", mcpServers: [] } });
const session = await client.createSession({ agent: detectAgent(), cwd: "/opt/my-project" });
const sessionId = session.id;
console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`);
console.log(' Try: "read the README in /opt/my-project"');

View file

@ -2,6 +2,7 @@ import { createInterface } from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process";
import { Command } from "commander";
import { SandboxAgent, type PermissionReply, type SessionPermissionRequest } from "sandbox-agent";
import { local } from "sandbox-agent/local";
const options = parseOptions();
const agent = options.agent.trim().toLowerCase();
@ -9,10 +10,7 @@ const autoReply = parsePermissionReply(options.reply);
const promptText = options.prompt?.trim() || `Create ./permission-example.txt with the text 'hello from the ${agent} permissions example'.`;
const sdk = await SandboxAgent.start({
spawn: {
enabled: true,
log: "inherit",
},
sandbox: local({ log: "inherit" }),
});
try {
@ -43,10 +41,7 @@ try {
const session = await sdk.createSession({
agent,
...(mode ? { mode } : {}),
sessionInit: {
cwd: process.cwd(),
mcpServers: [],
},
cwd: process.cwd(),
});
const rl = autoReply

View file

@ -36,7 +36,7 @@ await client.setSkillsConfig({ directory: "/", skillName: "random-number" }, { s
// Create a session.
console.log("Creating session with custom skill...");
const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/root", mcpServers: [] } });
const session = await client.createSession({ agent: detectAgent(), cwd: "/root" });
const sessionId = session.id;
console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`);
console.log(' Try: "generate a random number between 1 and 100"');

View file

@ -15,7 +15,7 @@ await client.setSkillsConfig(
);
console.log("Creating session...");
const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/root", mcpServers: [] } });
const session = await client.createSession({ agent: detectAgent(), cwd: "/root" });
const sessionId = session.id;
console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`);
console.log(' Try: "How do I start sandbox-agent?"');

View file

@ -1,56 +1,34 @@
import { Sandbox } from "@vercel/sandbox";
import { SandboxAgent } from "sandbox-agent";
import { detectAgent, buildInspectorUrl } from "@sandbox-agent/example-shared";
import { vercel } from "sandbox-agent/vercel";
import { detectAgent } from "@sandbox-agent/example-shared";
const envs: Record<string, string> = {};
if (process.env.ANTHROPIC_API_KEY) envs.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
if (process.env.OPENAI_API_KEY) envs.OPENAI_API_KEY = process.env.OPENAI_API_KEY;
const env: Record<string, string> = {};
if (process.env.ANTHROPIC_API_KEY) env.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
if (process.env.OPENAI_API_KEY) env.OPENAI_API_KEY = process.env.OPENAI_API_KEY;
console.log("Creating Vercel sandbox...");
const sandbox = await Sandbox.create({
runtime: "node24",
ports: [3000],
const client = await SandboxAgent.start({
sandbox: vercel({
create: {
runtime: "node24",
env,
},
}),
});
const run = async (cmd: string, args: string[] = []) => {
const result = await sandbox.runCommand({ cmd, args, env: envs });
if (result.exitCode !== 0) {
const stderr = await result.stderr();
throw new Error(`Command failed: ${cmd} ${args.join(" ")}\n${stderr}`);
}
return result;
};
console.log(`UI: ${client.inspectorUrl}`);
console.log("Installing sandbox-agent...");
await run("sh", ["-c", "curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh"]);
console.log("Installing agents...");
await run("sandbox-agent", ["install-agent", "claude"]);
await run("sandbox-agent", ["install-agent", "codex"]);
console.log("Starting server...");
await sandbox.runCommand({
cmd: "sandbox-agent",
args: ["server", "--no-token", "--host", "0.0.0.0", "--port", "3000"],
env: envs,
detached: true,
const session = await client.createSession({
agent: detectAgent(),
cwd: "/home/vercel-sandbox",
});
const baseUrl = sandbox.domain(3000);
session.onEvent((event) => {
console.log(`[${event.sender}]`, JSON.stringify(event.payload));
});
console.log("Connecting to server...");
const client = await SandboxAgent.connect({ baseUrl });
const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/home/vercel-sandbox", mcpServers: [] } });
const sessionId = session.id;
session.prompt([{ type: "text", text: "Say hello from Vercel in one sentence." }]);
console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`);
console.log(" Press Ctrl+C to stop.");
const keepAlive = setInterval(() => {}, 60_000);
const cleanup = async () => {
clearInterval(keepAlive);
await sandbox.stop();
process.once("SIGINT", async () => {
await client.destroySandbox();
process.exit(0);
};
process.once("SIGINT", cleanup);
process.once("SIGTERM", cleanup);
});

View file

@ -5,8 +5,11 @@ import { Code, Server, GitBranch } from "lucide-react";
import { CopyButton } from "./ui/CopyButton";
const sdkCodeRaw = `import { SandboxAgent } from "sandbox-agent";
import { local } from "sandbox-agent/local";
const client = await SandboxAgent.start();
const client = await SandboxAgent.start({
sandbox: local(),
});
await client.createSession("my-session", {
agent: "claude-code",
@ -32,13 +35,26 @@ function SdkCodeHighlighted() {
<span className="text-zinc-300"> </span>
<span className="text-green-400">"sandbox-agent"</span>
<span className="text-zinc-300">;</span>
{"\n"}
<span className="text-purple-400">import</span>
<span className="text-zinc-300">{" { "}</span>
<span className="text-white">local</span>
<span className="text-zinc-300">{" } "}</span>
<span className="text-purple-400">from</span>
<span className="text-zinc-300"> </span>
<span className="text-green-400">"sandbox-agent/local"</span>
<span className="text-zinc-300">;</span>
{"\n\n"}
<span className="text-purple-400">const</span>
<span className="text-zinc-300"> client = </span>
<span className="text-purple-400">await</span>
<span className="text-zinc-300"> SandboxAgent.</span>
<span className="text-blue-400">start</span>
<span className="text-zinc-300">();</span>
<span className="text-zinc-300">{"({"}</span>
{"\n"}
<span className="text-zinc-300">{" sandbox: local(),"}</span>
{"\n"}
<span className="text-zinc-300">{"});"}</span>
{"\n\n"}
<span className="text-purple-400">await</span>
<span className="text-zinc-300"> client.</span>

29
pnpm-lock.yaml generated
View file

@ -154,13 +154,13 @@ importers:
dockerode:
specifier: latest
version: 4.0.9
get-port:
specifier: latest
version: 7.1.0
sandbox-agent:
specifier: workspace:*
version: link:../../sdks/typescript
devDependencies:
'@types/dockerode':
specifier: latest
version: 4.0.1
'@types/node':
specifier: latest
version: 25.5.0
@ -1025,12 +1025,33 @@ importers:
specifier: workspace:*
version: link:../cli
devDependencies:
'@cloudflare/sandbox':
specifier: '>=0.1.0'
version: 0.7.17(@opencode-ai/sdk@1.2.24)
'@daytonaio/sdk':
specifier: '>=0.12.0'
version: 0.151.0(ws@8.19.0)
'@e2b/code-interpreter':
specifier: '>=1.0.0'
version: 2.3.3
'@types/dockerode':
specifier: ^4.0.0
version: 4.0.1
'@types/node':
specifier: ^22.0.0
version: 22.19.7
'@types/ws':
specifier: ^8.18.1
version: 8.18.1
'@vercel/sandbox':
specifier: '>=0.1.0'
version: 1.8.1
dockerode:
specifier: '>=4.0.0'
version: 4.0.9
get-port:
specifier: '>=7.0.0'
version: 7.1.0
openapi-typescript:
specifier: ^6.7.0
version: 6.7.6
@ -11273,7 +11294,7 @@ snapshots:
glob: 11.1.0
openapi-fetch: 0.14.1
platform: 1.3.6
tar: 7.5.6
tar: 7.5.7
earcut@2.2.4: {}

View file

@ -31,11 +31,11 @@ export class IndexedDbSessionPersistDriver implements SessionPersistDriver {
this.dbPromise = this.openDatabase();
}
async getSession(id: string): Promise<SessionRecord | null> {
async getSession(id: string): Promise<SessionRecord | undefined> {
const db = await this.dbPromise;
const row = await requestToPromise<IDBValidKey | SessionRow | undefined>(db.transaction(SESSIONS_STORE, "readonly").objectStore(SESSIONS_STORE).get(id));
if (!row || typeof row !== "object") {
return null;
return undefined;
}
return decodeSessionRow(row as SessionRow);
}
@ -84,7 +84,7 @@ export class IndexedDbSessionPersistDriver implements SessionPersistDriver {
};
}
async insertEvent(event: SessionEvent): Promise<void> {
async insertEvent(_sessionId: string, event: SessionEvent): Promise<void> {
const db = await this.dbPromise;
await transactionPromise(db, [EVENTS_STORE], "readwrite", (tx) => {
tx.objectStore(EVENTS_STORE).put(encodeEventRow(event));
@ -139,6 +139,7 @@ type SessionRow = {
lastConnectionId: string;
createdAt: number;
destroyedAt?: number;
sandboxId?: string;
sessionInit?: SessionRecord["sessionInit"];
};
@ -160,6 +161,7 @@ function encodeSessionRow(session: SessionRecord): SessionRow {
lastConnectionId: session.lastConnectionId,
createdAt: session.createdAt,
destroyedAt: session.destroyedAt,
sandboxId: session.sandboxId,
sessionInit: session.sessionInit,
};
}
@ -172,6 +174,7 @@ function decodeSessionRow(row: SessionRow): SessionRecord {
lastConnectionId: row.lastConnectionId,
createdAt: row.createdAt,
destroyedAt: row.destroyedAt,
sandboxId: row.sandboxId,
sessionInit: row.sessionInit,
};
}

View file

@ -28,7 +28,7 @@ describe("IndexedDbSessionPersistDriver", () => {
destroyedAt: 300,
});
await driver.insertEvent({
await driver.insertEvent("s-1", {
id: "evt-1",
eventIndex: 1,
sessionId: "s-1",
@ -38,7 +38,7 @@ describe("IndexedDbSessionPersistDriver", () => {
payload: { jsonrpc: "2.0", method: "session/prompt", params: { sessionId: "a-1" } },
});
await driver.insertEvent({
await driver.insertEvent("s-1", {
id: "evt-2",
eventIndex: 2,
sessionId: "s-1",

View file

@ -33,18 +33,18 @@ export class PostgresSessionPersistDriver implements SessionPersistDriver {
this.initialized = this.initialize();
}
async getSession(id: string): Promise<SessionRecord | null> {
async getSession(id: string): Promise<SessionRecord | undefined> {
await this.ready();
const result = await this.pool.query<SessionRow>(
`SELECT id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, session_init_json
`SELECT id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, sandbox_id, session_init_json
FROM ${this.table("sessions")}
WHERE id = $1`,
[id],
);
if (result.rows.length === 0) {
return null;
return undefined;
}
return decodeSessionRow(result.rows[0]);
@ -57,7 +57,7 @@ export class PostgresSessionPersistDriver implements SessionPersistDriver {
const limit = normalizeLimit(request.limit);
const rowsResult = await this.pool.query<SessionRow>(
`SELECT id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, session_init_json
`SELECT id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, sandbox_id, session_init_json
FROM ${this.table("sessions")}
ORDER BY created_at ASC, id ASC
LIMIT $1 OFFSET $2`,
@ -79,14 +79,15 @@ export class PostgresSessionPersistDriver implements SessionPersistDriver {
await this.pool.query(
`INSERT INTO ${this.table("sessions")} (
id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, session_init_json
) VALUES ($1, $2, $3, $4, $5, $6, $7)
id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, sandbox_id, session_init_json
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT(id) DO UPDATE SET
agent = EXCLUDED.agent,
agent_session_id = EXCLUDED.agent_session_id,
last_connection_id = EXCLUDED.last_connection_id,
created_at = EXCLUDED.created_at,
destroyed_at = EXCLUDED.destroyed_at,
sandbox_id = EXCLUDED.sandbox_id,
session_init_json = EXCLUDED.session_init_json`,
[
session.id,
@ -95,6 +96,7 @@ export class PostgresSessionPersistDriver implements SessionPersistDriver {
session.lastConnectionId,
session.createdAt,
session.destroyedAt ?? null,
session.sandboxId ?? null,
session.sessionInit ?? null,
],
);
@ -127,7 +129,7 @@ export class PostgresSessionPersistDriver implements SessionPersistDriver {
};
}
async insertEvent(event: SessionEvent): Promise<void> {
async insertEvent(_sessionId: string, event: SessionEvent): Promise<void> {
await this.ready();
await this.pool.query(
@ -171,10 +173,16 @@ export class PostgresSessionPersistDriver implements SessionPersistDriver {
last_connection_id TEXT NOT NULL,
created_at BIGINT NOT NULL,
destroyed_at BIGINT,
sandbox_id TEXT,
session_init_json JSONB
)
`);
await this.pool.query(`
ALTER TABLE ${this.table("sessions")}
ADD COLUMN IF NOT EXISTS sandbox_id TEXT
`);
await this.pool.query(`
CREATE TABLE IF NOT EXISTS ${this.table("events")} (
id TEXT PRIMARY KEY,
@ -228,6 +236,7 @@ type SessionRow = {
last_connection_id: string;
created_at: string | number;
destroyed_at: string | number | null;
sandbox_id: string | null;
session_init_json: unknown | null;
};
@ -249,6 +258,7 @@ function decodeSessionRow(row: SessionRow): SessionRecord {
lastConnectionId: row.last_connection_id,
createdAt: parseInteger(row.created_at),
destroyedAt: row.destroyed_at === null ? undefined : parseInteger(row.destroyed_at),
sandboxId: row.sandbox_id ?? undefined,
sessionInit: row.session_init_json ? (row.session_init_json as SessionRecord["sessionInit"]) : undefined,
};
}

View file

@ -50,9 +50,9 @@ export class RivetSessionPersistDriver implements SessionPersistDriver {
return this.ctx.state[this.stateKey] as RivetPersistData;
}
async getSession(id: string): Promise<SessionRecord | null> {
async getSession(id: string): Promise<SessionRecord | undefined> {
const session = this.data.sessions[id];
return session ? cloneSessionRecord(session) : null;
return session ? cloneSessionRecord(session) : undefined;
}
async listSessions(request: ListPageRequest = {}): Promise<ListPage<SessionRecord>> {
@ -112,15 +112,15 @@ export class RivetSessionPersistDriver implements SessionPersistDriver {
};
}
async insertEvent(event: SessionEvent): Promise<void> {
const events = this.data.events[event.sessionId] ?? [];
async insertEvent(sessionId: string, event: SessionEvent): Promise<void> {
const events = this.data.events[sessionId] ?? [];
events.push(cloneSessionEvent(event));
if (events.length > this.maxEventsPerSession) {
events.splice(0, events.length - this.maxEventsPerSession);
}
this.data.events[event.sessionId] = events;
this.data.events[sessionId] = events;
}
}

View file

@ -59,7 +59,7 @@ describe("RivetSessionPersistDriver", () => {
expect(loaded?.destroyedAt).toBe(300);
const missing = await driver.getSession("s-nonexistent");
expect(missing).toBeNull();
expect(missing).toBeUndefined();
});
it("pages sessions sorted by createdAt", async () => {
@ -103,7 +103,7 @@ describe("RivetSessionPersistDriver", () => {
createdAt: 1,
});
await driver.insertEvent({
await driver.insertEvent("s-1", {
id: "evt-1",
eventIndex: 1,
sessionId: "s-1",
@ -113,7 +113,7 @@ describe("RivetSessionPersistDriver", () => {
payload: { jsonrpc: "2.0", method: "session/prompt", params: { sessionId: "a-1" } },
});
await driver.insertEvent({
await driver.insertEvent("s-1", {
id: "evt-2",
eventIndex: 2,
sessionId: "s-1",
@ -159,9 +159,9 @@ describe("RivetSessionPersistDriver", () => {
createdAt: 300,
});
expect(await driver.getSession("s-1")).toBeNull();
expect(await driver.getSession("s-2")).not.toBeNull();
expect(await driver.getSession("s-3")).not.toBeNull();
expect(await driver.getSession("s-1")).toBeUndefined();
expect(await driver.getSession("s-2")).toBeDefined();
expect(await driver.getSession("s-3")).toBeDefined();
});
it("trims oldest events when maxEventsPerSession exceeded", async () => {
@ -176,7 +176,7 @@ describe("RivetSessionPersistDriver", () => {
});
for (let i = 1; i <= 3; i++) {
await driver.insertEvent({
await driver.insertEvent("s-1", {
id: `evt-${i}`,
eventIndex: i,
sessionId: "s-1",

View file

@ -15,16 +15,16 @@ export class SQLiteSessionPersistDriver implements SessionPersistDriver {
this.initialize();
}
async getSession(id: string): Promise<SessionRecord | null> {
async getSession(id: string): Promise<SessionRecord | undefined> {
const row = this.db
.prepare(
`SELECT id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, session_init_json
`SELECT id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, sandbox_id, session_init_json
FROM sessions WHERE id = ?`,
)
.get(id) as SessionRow | undefined;
if (!row) {
return null;
return undefined;
}
return decodeSessionRow(row);
@ -36,7 +36,7 @@ export class SQLiteSessionPersistDriver implements SessionPersistDriver {
const rows = this.db
.prepare(
`SELECT id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, session_init_json
`SELECT id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, sandbox_id, session_init_json
FROM sessions
ORDER BY created_at ASC, id ASC
LIMIT ? OFFSET ?`,
@ -56,14 +56,15 @@ export class SQLiteSessionPersistDriver implements SessionPersistDriver {
this.db
.prepare(
`INSERT INTO sessions (
id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, session_init_json
) VALUES (?, ?, ?, ?, ?, ?, ?)
id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, sandbox_id, session_init_json
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
agent = excluded.agent,
agent_session_id = excluded.agent_session_id,
last_connection_id = excluded.last_connection_id,
created_at = excluded.created_at,
destroyed_at = excluded.destroyed_at,
sandbox_id = excluded.sandbox_id,
session_init_json = excluded.session_init_json`,
)
.run(
@ -73,6 +74,7 @@ export class SQLiteSessionPersistDriver implements SessionPersistDriver {
session.lastConnectionId,
session.createdAt,
session.destroyedAt ?? null,
session.sandboxId ?? null,
session.sessionInit ? JSON.stringify(session.sessionInit) : null,
);
}
@ -101,7 +103,7 @@ export class SQLiteSessionPersistDriver implements SessionPersistDriver {
};
}
async insertEvent(event: SessionEvent): Promise<void> {
async insertEvent(_sessionId: string, event: SessionEvent): Promise<void> {
this.db
.prepare(
`INSERT INTO events (
@ -131,10 +133,16 @@ export class SQLiteSessionPersistDriver implements SessionPersistDriver {
last_connection_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
destroyed_at INTEGER,
sandbox_id TEXT,
session_init_json TEXT
)
`);
const sessionColumns = this.db.prepare(`PRAGMA table_info(sessions)`).all() as TableInfoRow[];
if (!sessionColumns.some((column) => column.name === "sandbox_id")) {
this.db.exec(`ALTER TABLE sessions ADD COLUMN sandbox_id TEXT`);
}
this.ensureEventsTable();
}
@ -223,6 +231,7 @@ type SessionRow = {
last_connection_id: string;
created_at: number;
destroyed_at: number | null;
sandbox_id: string | null;
session_init_json: string | null;
};
@ -249,6 +258,7 @@ function decodeSessionRow(row: SessionRow): SessionRecord {
lastConnectionId: row.last_connection_id,
createdAt: row.created_at,
destroyedAt: row.destroyed_at ?? undefined,
sandboxId: row.sandbox_id ?? undefined,
sessionInit: row.session_init_json ? (JSON.parse(row.session_init_json) as SessionRecord["sessionInit"]) : undefined,
};
}

View file

@ -14,6 +14,58 @@
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./local": {
"types": "./dist/providers/local.d.ts",
"import": "./dist/providers/local.js"
},
"./e2b": {
"types": "./dist/providers/e2b.d.ts",
"import": "./dist/providers/e2b.js"
},
"./daytona": {
"types": "./dist/providers/daytona.d.ts",
"import": "./dist/providers/daytona.js"
},
"./docker": {
"types": "./dist/providers/docker.d.ts",
"import": "./dist/providers/docker.js"
},
"./vercel": {
"types": "./dist/providers/vercel.d.ts",
"import": "./dist/providers/vercel.js"
},
"./cloudflare": {
"types": "./dist/providers/cloudflare.d.ts",
"import": "./dist/providers/cloudflare.js"
}
},
"peerDependencies": {
"@cloudflare/sandbox": ">=0.1.0",
"@daytonaio/sdk": ">=0.12.0",
"@e2b/code-interpreter": ">=1.0.0",
"@vercel/sandbox": ">=0.1.0",
"dockerode": ">=4.0.0",
"get-port": ">=7.0.0"
},
"peerDependenciesMeta": {
"@cloudflare/sandbox": {
"optional": true
},
"@daytonaio/sdk": {
"optional": true
},
"@e2b/code-interpreter": {
"optional": true
},
"@vercel/sandbox": {
"optional": true
},
"dockerode": {
"optional": true
},
"get-port": {
"optional": true
}
},
"dependencies": {
@ -33,8 +85,15 @@
"test:watch": "vitest"
},
"devDependencies": {
"@cloudflare/sandbox": ">=0.1.0",
"@daytonaio/sdk": ">=0.12.0",
"@e2b/code-interpreter": ">=1.0.0",
"@types/dockerode": "^4.0.0",
"@types/node": "^22.0.0",
"@types/ws": "^8.18.1",
"@vercel/sandbox": ">=0.1.0",
"dockerode": ">=4.0.0",
"get-port": ">=7.0.0",
"openapi-typescript": "^6.7.0",
"tsup": "^8.0.0",
"typescript": "^5.7.0",

View file

@ -22,7 +22,7 @@ import {
type SetSessionModeResponse,
type SetSessionModeRequest,
} from "acp-http-client";
import type { SandboxAgentSpawnHandle, SandboxAgentSpawnOptions } from "./spawn.ts";
import type { SandboxProvider } from "./providers/types.ts";
import {
type AcpServerListResponse,
type AgentInfo,
@ -101,6 +101,8 @@ interface SandboxAgentConnectCommonOptions {
replayMaxChars?: number;
signal?: AbortSignal;
token?: string;
skipHealthCheck?: boolean;
/** @deprecated Use skipHealthCheck instead. */
waitForHealth?: boolean | SandboxAgentHealthWaitOptions;
}
@ -115,17 +117,24 @@ export type SandboxAgentConnectOptions =
});
export interface SandboxAgentStartOptions {
sandbox: SandboxProvider;
sandboxId?: string;
skipHealthCheck?: boolean;
fetch?: typeof fetch;
headers?: HeadersInit;
persist?: SessionPersistDriver;
replayMaxEvents?: number;
replayMaxChars?: number;
spawn?: SandboxAgentSpawnOptions | boolean;
signal?: AbortSignal;
token?: string;
}
export interface SessionCreateRequest {
id?: string;
agent: string;
/** Shorthand for `sessionInit.cwd`. Ignored when `sessionInit` is provided. */
cwd?: string;
/** Full session init. When omitted, built from `cwd` (or default) with empty `mcpServers`. */
sessionInit?: Omit<NewSessionRequest, "_meta">;
model?: string;
mode?: string;
@ -135,6 +144,9 @@ export interface SessionCreateRequest {
export interface SessionResumeOrCreateRequest {
id: string;
agent: string;
/** Shorthand for `sessionInit.cwd`. Ignored when `sessionInit` is provided. */
cwd?: string;
/** Full session init. When omitted, built from `cwd` (or default) with empty `mcpServers`. */
sessionInit?: Omit<NewSessionRequest, "_meta">;
model?: string;
mode?: string;
@ -824,12 +836,14 @@ export class SandboxAgent {
private readonly defaultHeaders?: HeadersInit;
private readonly healthWait: NormalizedHealthWaitOptions;
private readonly healthWaitAbortController = new AbortController();
private sandboxProvider?: SandboxProvider;
private sandboxProviderId?: string;
private sandboxProviderRawId?: string;
private readonly persist: SessionPersistDriver;
private readonly replayMaxEvents: number;
private readonly replayMaxChars: number;
private spawnHandle?: SandboxAgentSpawnHandle;
private healthPromise?: Promise<void>;
private healthError?: Error;
private disposed = false;
@ -857,7 +871,7 @@ export class SandboxAgent {
}
this.fetcher = resolvedFetch;
this.defaultHeaders = options.headers;
this.healthWait = normalizeHealthWaitOptions(options.waitForHealth, options.signal);
this.healthWait = normalizeHealthWaitOptions(options.skipHealthCheck, options.waitForHealth, options.signal);
this.persist = options.persist ?? new InMemorySessionPersistDriver();
this.replayMaxEvents = normalizePositiveInt(options.replayMaxEvents, DEFAULT_REPLAY_MAX_EVENTS);
@ -870,29 +884,79 @@ export class SandboxAgent {
return new SandboxAgent(options);
}
static async start(options: SandboxAgentStartOptions = {}): Promise<SandboxAgent> {
const spawnOptions = normalizeSpawnOptions(options.spawn, true);
if (!spawnOptions.enabled) {
throw new Error("SandboxAgent.start requires spawn to be enabled.");
static async start(options: SandboxAgentStartOptions): Promise<SandboxAgent> {
const provider = options.sandbox;
if (!provider.getUrl && !provider.getFetch) {
throw new Error(`Sandbox provider '${provider.name}' must implement getUrl() or getFetch().`);
}
const { spawnSandboxAgent } = await import("./spawn.js");
const resolvedFetch = options.fetch ?? globalThis.fetch?.bind(globalThis);
const handle = await spawnSandboxAgent(spawnOptions, resolvedFetch);
const existingSandbox = options.sandboxId ? parseSandboxProviderId(options.sandboxId) : null;
const client = new SandboxAgent({
baseUrl: handle.baseUrl,
token: handle.token,
fetch: options.fetch,
headers: options.headers,
waitForHealth: false,
persist: options.persist,
replayMaxEvents: options.replayMaxEvents,
replayMaxChars: options.replayMaxChars,
});
if (existingSandbox && existingSandbox.provider !== provider.name) {
throw new Error(
`SandboxAgent.start received sandboxId '${options.sandboxId}' for provider '${existingSandbox.provider}', but the configured provider is '${provider.name}'.`,
);
}
client.spawnHandle = handle;
return client;
const rawSandboxId = existingSandbox?.rawId ?? (await provider.create());
const prefixedSandboxId = `${provider.name}/${rawSandboxId}`;
const createdSandbox = !existingSandbox;
if (existingSandbox) {
await provider.wake?.(rawSandboxId);
}
try {
const fetcher = await resolveProviderFetch(provider, rawSandboxId);
const baseUrl = provider.getUrl ? await provider.getUrl(rawSandboxId) : undefined;
const providerFetch = options.fetch ?? fetcher;
const commonConnectOptions = {
headers: options.headers,
persist: options.persist,
replayMaxEvents: options.replayMaxEvents,
replayMaxChars: options.replayMaxChars,
signal: options.signal,
skipHealthCheck: options.skipHealthCheck,
token: options.token ?? (await resolveProviderToken(provider, rawSandboxId)),
};
const client = providerFetch
? new SandboxAgent({
...commonConnectOptions,
baseUrl,
fetch: providerFetch,
})
: new SandboxAgent({
...commonConnectOptions,
baseUrl: requireSandboxBaseUrl(baseUrl, provider.name),
});
client.sandboxProvider = provider;
client.sandboxProviderId = prefixedSandboxId;
client.sandboxProviderRawId = rawSandboxId;
return client;
} catch (error) {
if (createdSandbox) {
try {
await provider.destroy(rawSandboxId);
} catch {
// Best-effort cleanup if connect fails after provisioning.
}
}
throw error;
}
}
get sandboxId(): string | undefined {
return this.sandboxProviderId;
}
get sandbox(): SandboxProvider | undefined {
return this.sandboxProvider;
}
get inspectorUrl(): string {
return `${this.baseUrl.replace(/\/+$/, "")}/ui/`;
}
async dispose(): Promise<void> {
@ -922,10 +986,23 @@ export class SandboxAgent {
await connection.close();
}),
);
}
if (this.spawnHandle) {
await this.spawnHandle.dispose();
this.spawnHandle = undefined;
async destroySandbox(): Promise<void> {
const provider = this.sandboxProvider;
const rawSandboxId = this.sandboxProviderRawId;
try {
if (provider && rawSandboxId) {
await provider.destroy(rawSandboxId);
} else if (!provider || !rawSandboxId) {
throw new Error("SandboxAgent is not attached to a provisioned sandbox.");
}
} finally {
await this.dispose();
this.sandboxProvider = undefined;
this.sandboxProviderId = undefined;
this.sandboxProviderRawId = undefined;
}
}
@ -956,7 +1033,7 @@ export class SandboxAgent {
const localSessionId = request.id?.trim() || randomId();
const live = await this.getLiveConnection(request.agent.trim());
const sessionInit = normalizeSessionInit(request.sessionInit);
const sessionInit = normalizeSessionInit(request.sessionInit, request.cwd);
const response = await live.createRemoteSession(localSessionId, sessionInit);
@ -966,6 +1043,7 @@ export class SandboxAgent {
agentSessionId: response.sessionId,
lastConnectionId: live.connectionId,
createdAt: nowMs(),
sandboxId: this.sandboxProviderId,
sessionInit,
configOptions: cloneConfigOptions(response.configOptions),
modes: cloneModes(response.modes),
@ -2255,17 +2333,17 @@ function toAgentQuery(options: AgentQueryOptions | undefined): Record<string, Qu
};
}
function normalizeSessionInit(value: Omit<NewSessionRequest, "_meta"> | undefined): Omit<NewSessionRequest, "_meta"> {
function normalizeSessionInit(value: Omit<NewSessionRequest, "_meta"> | undefined, cwdShorthand?: string): Omit<NewSessionRequest, "_meta"> {
if (!value) {
return {
cwd: defaultCwd(),
cwd: cwdShorthand ?? defaultCwd(),
mcpServers: [],
};
}
return {
...value,
cwd: value.cwd ?? defaultCwd(),
cwd: value.cwd ?? cwdShorthand ?? defaultCwd(),
mcpServers: value.mcpServers ?? [],
};
}
@ -2405,16 +2483,23 @@ function normalizePositiveInt(value: number | undefined, fallback: number): numb
return Math.floor(value as number);
}
function normalizeHealthWaitOptions(value: boolean | SandboxAgentHealthWaitOptions | undefined, signal: AbortSignal | undefined): NormalizedHealthWaitOptions {
if (value === false) {
function normalizeHealthWaitOptions(
skipHealthCheck: boolean | undefined,
waitForHealth: boolean | SandboxAgentHealthWaitOptions | undefined,
signal: AbortSignal | undefined,
): NormalizedHealthWaitOptions {
if (skipHealthCheck === true || waitForHealth === false) {
return { enabled: false };
}
if (value === true || value === undefined) {
if (waitForHealth === true || waitForHealth === undefined) {
return { enabled: true, signal };
}
const timeoutMs = typeof value.timeoutMs === "number" && Number.isFinite(value.timeoutMs) && value.timeoutMs > 0 ? Math.floor(value.timeoutMs) : undefined;
const timeoutMs =
typeof waitForHealth.timeoutMs === "number" && Number.isFinite(waitForHealth.timeoutMs) && waitForHealth.timeoutMs > 0
? Math.floor(waitForHealth.timeoutMs)
: undefined;
return {
enabled: true,
@ -2423,24 +2508,47 @@ function normalizeHealthWaitOptions(value: boolean | SandboxAgentHealthWaitOptio
};
}
function normalizeSpawnOptions(
spawn: SandboxAgentSpawnOptions | boolean | undefined,
defaultEnabled: boolean,
): SandboxAgentSpawnOptions & { enabled: boolean } {
if (spawn === false) {
return { enabled: false };
}
if (spawn === true || spawn === undefined) {
return { enabled: defaultEnabled };
function parseSandboxProviderId(sandboxId: string): { provider: string; rawId: string } {
const slashIndex = sandboxId.indexOf("/");
if (slashIndex < 1 || slashIndex === sandboxId.length - 1) {
throw new Error(`Sandbox IDs must be prefixed as "{provider}/{id}". Received '${sandboxId}'.`);
}
return {
...spawn,
enabled: spawn.enabled ?? defaultEnabled,
provider: sandboxId.slice(0, slashIndex),
rawId: sandboxId.slice(slashIndex + 1),
};
}
function requireSandboxBaseUrl(baseUrl: string | undefined, providerName: string): string {
if (!baseUrl) {
throw new Error(`Sandbox provider '${providerName}' did not return a base URL.`);
}
return baseUrl;
}
async function resolveProviderFetch(provider: SandboxProvider, rawSandboxId: string): Promise<typeof globalThis.fetch | undefined> {
if (provider.getFetch) {
return await provider.getFetch(rawSandboxId);
}
return undefined;
}
async function resolveProviderToken(provider: SandboxProvider, rawSandboxId: string): Promise<string | undefined> {
const maybeGetToken = (
provider as SandboxProvider & {
getToken?: (sandboxId: string) => string | undefined | Promise<string | undefined>;
}
).getToken;
if (typeof maybeGetToken !== "function") {
return undefined;
}
const token = await maybeGetToken.call(provider, rawSandboxId);
return typeof token === "string" && token ? token : undefined;
}
async function readProblem(response: Response): Promise<ProblemDetails | undefined> {
try {
const text = await response.clone().text();

View file

@ -38,6 +38,7 @@ export type {
export type { InspectorUrlOptions } from "./inspector.ts";
export { InMemorySessionPersistDriver } from "./types.ts";
export type { SandboxProvider } from "./providers/types.ts";
export type {
AcpEnvelope,

View file

@ -0,0 +1,79 @@
import type { SandboxProvider } from "./types.ts";
const DEFAULT_AGENT_PORT = 3000;
export interface CloudflareSandboxClient {
create?(options?: Record<string, unknown>): Promise<{ id?: string; sandboxId?: string }>;
connect?(
sandboxId: string,
options?: Record<string, unknown>,
): Promise<{
close?(): Promise<void>;
stop?(): Promise<void>;
containerFetch(input: RequestInfo | URL, init?: RequestInit, port?: number): Promise<Response>;
}>;
}
export interface CloudflareProviderOptions {
sdk: CloudflareSandboxClient;
create?: Record<string, unknown> | (() => Record<string, unknown> | Promise<Record<string, unknown>>);
agentPort?: number;
}
async function resolveCreateOptions(value: CloudflareProviderOptions["create"]): Promise<Record<string, unknown>> {
if (!value) {
return {};
}
if (typeof value === "function") {
return await value();
}
return value;
}
export function cloudflare(options: CloudflareProviderOptions): SandboxProvider {
const agentPort = options.agentPort ?? DEFAULT_AGENT_PORT;
const sdk = options.sdk;
return {
name: "cloudflare",
async create(): Promise<string> {
if (typeof sdk.create !== "function") {
throw new Error('sandbox provider "cloudflare" requires a sdk with a `create()` method.');
}
const sandbox = await sdk.create(await resolveCreateOptions(options.create));
const sandboxId = sandbox.sandboxId ?? sandbox.id;
if (!sandboxId) {
throw new Error("cloudflare sandbox did not return an id");
}
return sandboxId;
},
async destroy(sandboxId: string): Promise<void> {
if (typeof sdk.connect !== "function") {
throw new Error('sandbox provider "cloudflare" requires a sdk with a `connect()` method.');
}
const sandbox = await sdk.connect(sandboxId);
if (typeof sandbox.close === "function") {
await sandbox.close();
return;
}
if (typeof sandbox.stop === "function") {
await sandbox.stop();
}
},
async getFetch(sandboxId: string): Promise<typeof globalThis.fetch> {
if (typeof sdk.connect !== "function") {
throw new Error('sandbox provider "cloudflare" requires a sdk with a `connect()` method.');
}
const sandbox = await sdk.connect(sandboxId);
return async (input, init) =>
sandbox.containerFetch(
input,
{
...(init ?? {}),
signal: undefined,
},
agentPort,
);
},
};
}

View file

@ -0,0 +1,65 @@
import { Daytona } from "@daytonaio/sdk";
import type { SandboxProvider } from "./types.ts";
import { DEFAULT_SANDBOX_AGENT_IMAGE, buildServerStartCommand } from "./shared.ts";
const DEFAULT_AGENT_PORT = 3000;
const DEFAULT_PREVIEW_TTL_SECONDS = 4 * 60 * 60;
type DaytonaCreateParams = NonNullable<Parameters<Daytona["create"]>[0]>;
export interface DaytonaProviderOptions {
create?: DaytonaCreateParams | (() => DaytonaCreateParams | Promise<DaytonaCreateParams>);
image?: string;
agentPort?: number;
previewTtlSeconds?: number;
deleteTimeoutSeconds?: number;
}
async function resolveCreateOptions(value: DaytonaProviderOptions["create"]): Promise<DaytonaCreateParams | undefined> {
if (!value) return undefined;
if (typeof value === "function") return await value();
return value;
}
export function daytona(options: DaytonaProviderOptions = {}): SandboxProvider {
const agentPort = options.agentPort ?? DEFAULT_AGENT_PORT;
const image = options.image ?? DEFAULT_SANDBOX_AGENT_IMAGE;
const previewTtlSeconds = options.previewTtlSeconds ?? DEFAULT_PREVIEW_TTL_SECONDS;
const client = new Daytona();
return {
name: "daytona",
async create(): Promise<string> {
const createOpts = await resolveCreateOptions(options.create);
const sandbox = await client.create({
image,
autoStopInterval: 0,
...createOpts,
} as DaytonaCreateParams);
await sandbox.process.executeCommand(buildServerStartCommand(agentPort));
return sandbox.id;
},
async destroy(sandboxId: string): Promise<void> {
const sandbox = await client.get(sandboxId);
if (!sandbox) {
return;
}
await sandbox.delete(options.deleteTimeoutSeconds);
},
async getUrl(sandboxId: string): Promise<string> {
const sandbox = await client.get(sandboxId);
if (!sandbox) {
throw new Error(`daytona sandbox not found: ${sandboxId}`);
}
const preview = await sandbox.getSignedPreviewUrl(agentPort, previewTtlSeconds);
return typeof preview === "string" ? preview : preview.url;
},
async wake(sandboxId: string): Promise<void> {
const sandbox = await client.get(sandboxId);
if (!sandbox) {
throw new Error(`daytona sandbox not found: ${sandboxId}`);
}
await sandbox.process.executeCommand(buildServerStartCommand(agentPort));
},
};
}

View file

@ -0,0 +1,85 @@
import Docker from "dockerode";
import getPort from "get-port";
import type { SandboxProvider } from "./types.ts";
import { DEFAULT_SANDBOX_AGENT_IMAGE } from "./shared.ts";
const DEFAULT_HOST = "127.0.0.1";
const DEFAULT_AGENT_PORT = 3000;
export interface DockerProviderOptions {
image?: string;
host?: string;
agentPort?: number;
env?: string[] | (() => string[] | Promise<string[]>);
binds?: string[] | (() => string[] | Promise<string[]>);
createContainerOptions?: Record<string, unknown>;
}
async function resolveValue<T>(value: T | (() => T | Promise<T>) | undefined, fallback: T): Promise<T> {
if (value === undefined) {
return fallback;
}
if (typeof value === "function") {
return await (value as () => T | Promise<T>)();
}
return value;
}
function extractMappedPort(
inspect: { NetworkSettings?: { Ports?: Record<string, Array<{ HostPort?: string }> | null | undefined> } },
containerPort: number,
): number {
const hostPort = inspect.NetworkSettings?.Ports?.[`${containerPort}/tcp`]?.[0]?.HostPort;
if (!hostPort) {
throw new Error(`docker sandbox-agent port ${containerPort} is not published`);
}
return Number(hostPort);
}
export function docker(options: DockerProviderOptions = {}): SandboxProvider {
const image = options.image ?? DEFAULT_SANDBOX_AGENT_IMAGE;
const host = options.host ?? DEFAULT_HOST;
const agentPort = options.agentPort ?? DEFAULT_AGENT_PORT;
const client = new Docker({ socketPath: "/var/run/docker.sock" });
return {
name: "docker",
async create(): Promise<string> {
const hostPort = await getPort();
const env = await resolveValue(options.env, []);
const binds = await resolveValue(options.binds, []);
const container = await client.createContainer({
Image: image,
Cmd: ["server", "--no-token", "--host", "0.0.0.0", "--port", String(agentPort)],
Env: env,
ExposedPorts: { [`${agentPort}/tcp`]: {} },
HostConfig: {
AutoRemove: true,
Binds: binds,
PortBindings: {
[`${agentPort}/tcp`]: [{ HostPort: String(hostPort) }],
},
},
...(options.createContainerOptions ?? {}),
});
await container.start();
return container.id;
},
async destroy(sandboxId: string): Promise<void> {
const container = client.getContainer(sandboxId);
try {
await container.stop({ t: 5 });
} catch {}
try {
await container.remove({ force: true });
} catch {}
},
async getUrl(sandboxId: string): Promise<string> {
const container = client.getContainer(sandboxId);
const hostPort = extractMappedPort(await container.inspect(), agentPort);
return `http://${host}:${hostPort}`;
},
};
}

View file

@ -0,0 +1,57 @@
import { Sandbox } from "@e2b/code-interpreter";
import type { SandboxProvider } from "./types.ts";
import { DEFAULT_AGENTS, SANDBOX_AGENT_INSTALL_SCRIPT } from "./shared.ts";
const DEFAULT_AGENT_PORT = 3000;
export interface E2BProviderOptions {
create?: Record<string, unknown> | (() => Record<string, unknown> | Promise<Record<string, unknown>>);
connect?: Record<string, unknown> | ((sandboxId: string) => Record<string, unknown> | Promise<Record<string, unknown>>);
agentPort?: number;
}
async function resolveOptions(value: E2BProviderOptions["create"] | E2BProviderOptions["connect"], sandboxId?: string): Promise<Record<string, unknown>> {
if (!value) return {};
if (typeof value === "function") {
if (sandboxId) {
return await (value as (id: string) => Record<string, unknown> | Promise<Record<string, unknown>>)(sandboxId);
}
return await (value as () => Record<string, unknown> | Promise<Record<string, unknown>>)();
}
return value;
}
export function e2b(options: E2BProviderOptions = {}): SandboxProvider {
const agentPort = options.agentPort ?? DEFAULT_AGENT_PORT;
return {
name: "e2b",
async create(): Promise<string> {
const createOpts = await resolveOptions(options.create);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const sandbox = await Sandbox.create({ allowInternetAccess: true, ...createOpts } as any);
await sandbox.commands.run(`curl -fsSL ${SANDBOX_AGENT_INSTALL_SCRIPT} | sh`).then((r) => {
if (r.exitCode !== 0) throw new Error(`e2b install failed:\n${r.stderr}`);
});
for (const agent of DEFAULT_AGENTS) {
await sandbox.commands.run(`sandbox-agent install-agent ${agent}`).then((r) => {
if (r.exitCode !== 0) throw new Error(`e2b agent install failed: ${agent}\n${r.stderr}`);
});
}
await sandbox.commands.run(`sandbox-agent server --no-token --host 0.0.0.0 --port ${agentPort}`, { background: true, timeoutMs: 0 });
return sandbox.sandboxId;
},
async destroy(sandboxId: string): Promise<void> {
const connectOpts = await resolveOptions(options.connect, sandboxId);
const sandbox = await Sandbox.connect(sandboxId, connectOpts as any);
await sandbox.kill();
},
async getUrl(sandboxId: string): Promise<string> {
const connectOpts = await resolveOptions(options.connect, sandboxId);
const sandbox = await Sandbox.connect(sandboxId, connectOpts as any);
return `https://${sandbox.getHost(agentPort)}`;
},
};
}

View file

@ -0,0 +1,84 @@
import { spawnSandboxAgent, type SandboxAgentSpawnHandle, type SandboxAgentSpawnLogMode, type SandboxAgentSpawnOptions } from "../spawn.ts";
import type { SandboxProvider } from "./types.ts";
export interface LocalProviderOptions {
host?: string;
port?: number;
token?: string;
binaryPath?: string;
log?: SandboxAgentSpawnLogMode;
env?: Record<string, string>;
}
const localSandboxes = new Map<string, SandboxAgentSpawnHandle>();
type LocalSandboxProvider = SandboxProvider & {
getToken(sandboxId: string): Promise<string | undefined>;
};
export function local(options: LocalProviderOptions = {}): SandboxProvider {
const provider: LocalSandboxProvider = {
name: "local",
async create(): Promise<string> {
const handle = await spawnSandboxAgent(
{
host: options.host,
port: options.port,
token: options.token,
binaryPath: options.binaryPath,
log: options.log,
env: options.env,
} satisfies SandboxAgentSpawnOptions,
globalThis.fetch?.bind(globalThis),
);
const rawSandboxId = baseUrlToSandboxId(handle.baseUrl);
localSandboxes.set(rawSandboxId, handle);
return rawSandboxId;
},
async destroy(sandboxId: string): Promise<void> {
const handle = localSandboxes.get(sandboxId);
if (!handle) {
return;
}
localSandboxes.delete(sandboxId);
await handle.dispose();
},
async getUrl(sandboxId: string): Promise<string> {
return `http://${sandboxId}`;
},
async getFetch(sandboxId: string): Promise<typeof globalThis.fetch> {
const handle = localSandboxes.get(sandboxId);
const token = options.token ?? handle?.token;
const fetcher = globalThis.fetch?.bind(globalThis);
if (!fetcher) {
throw new Error("Fetch API is not available; provide a fetch implementation.");
}
if (!token) {
return fetcher;
}
return async (input, init) => {
const request = new Request(input, init);
const targetUrl = new URL(request.url);
targetUrl.protocol = "http:";
targetUrl.host = sandboxId;
const headers = new Headers(request.headers);
if (!headers.has("authorization")) {
headers.set("authorization", `Bearer ${token}`);
}
const forwarded = new Request(targetUrl.toString(), request);
return fetcher(new Request(forwarded, { headers }));
};
},
async getToken(sandboxId: string): Promise<string | undefined> {
return options.token ?? localSandboxes.get(sandboxId)?.token;
},
};
return provider;
}
function baseUrlToSandboxId(baseUrl: string): string {
return new URL(baseUrl).host;
}

View file

@ -0,0 +1,7 @@
export const DEFAULT_SANDBOX_AGENT_IMAGE = "rivetdev/sandbox-agent:0.3.2-full";
export const SANDBOX_AGENT_INSTALL_SCRIPT = "https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh";
export const DEFAULT_AGENTS = ["claude", "codex"] as const;
export function buildServerStartCommand(port: number): string {
return `nohup sandbox-agent server --no-token --host 0.0.0.0 --port ${port} >/tmp/sandbox-agent.log 2>&1 &`;
}

View file

@ -0,0 +1,28 @@
export interface SandboxProvider {
/** Provider name. Must match the prefix in sandbox IDs (for example "e2b"). */
name: string;
/** Provision a new sandbox and return the provider-specific ID. */
create(): Promise<string>;
/** Permanently tear down a sandbox. */
destroy(sandboxId: string): Promise<void>;
/**
* Return the sandbox-agent base URL for this sandbox.
* Providers that cannot expose a URL should implement `getFetch()` instead.
*/
getUrl?(sandboxId: string): Promise<string>;
/**
* Return a fetch implementation that routes requests to the sandbox.
* Providers that expose a URL can implement `getUrl()` instead.
*/
getFetch?(sandboxId: string): Promise<typeof globalThis.fetch>;
/**
* Optional hook invoked before reconnecting to an existing sandbox.
* Useful for providers where the sandbox-agent process may need to be restarted.
*/
wake?(sandboxId: string): Promise<void>;
}

View file

@ -0,0 +1,57 @@
import { Sandbox } from "@vercel/sandbox";
import type { SandboxProvider } from "./types.ts";
import { DEFAULT_AGENTS, SANDBOX_AGENT_INSTALL_SCRIPT } from "./shared.ts";
const DEFAULT_AGENT_PORT = 3000;
export interface VercelProviderOptions {
create?: Record<string, unknown> | (() => Record<string, unknown> | Promise<Record<string, unknown>>);
agentPort?: number;
}
async function resolveCreateOptions(value: VercelProviderOptions["create"], agentPort: number): Promise<Record<string, unknown>> {
const resolved = typeof value === "function" ? await value() : (value ?? {});
return {
ports: [agentPort],
...resolved,
};
}
async function runVercelCommand(sandbox: InstanceType<typeof Sandbox>, cmd: string, args: string[] = []): Promise<void> {
const result = await sandbox.runCommand({ cmd, args });
if (result.exitCode !== 0) {
const stderr = await result.stderr();
throw new Error(`vercel command failed: ${cmd} ${args.join(" ")}\n${stderr}`);
}
}
export function vercel(options: VercelProviderOptions = {}): SandboxProvider {
const agentPort = options.agentPort ?? DEFAULT_AGENT_PORT;
return {
name: "vercel",
async create(): Promise<string> {
const sandbox = await Sandbox.create((await resolveCreateOptions(options.create, agentPort)) as Parameters<typeof Sandbox.create>[0]);
await runVercelCommand(sandbox, "sh", ["-c", `curl -fsSL ${SANDBOX_AGENT_INSTALL_SCRIPT} | sh`]);
for (const agent of DEFAULT_AGENTS) {
await runVercelCommand(sandbox, "sandbox-agent", ["install-agent", agent]);
}
await sandbox.runCommand({
cmd: "sandbox-agent",
args: ["server", "--no-token", "--host", "0.0.0.0", "--port", String(agentPort)],
detached: true,
});
return sandbox.sandboxId;
},
async destroy(sandboxId: string): Promise<void> {
const sandbox = await Sandbox.get({ sandboxId });
await sandbox.stop();
},
async getUrl(sandboxId: string): Promise<string> {
const sandbox = await Sandbox.get({ sandboxId });
return sandbox.domain(agentPort);
},
};
}

View file

@ -98,6 +98,7 @@ export interface SessionRecord {
lastConnectionId: string;
createdAt: number;
destroyedAt?: number;
sandboxId?: string;
sessionInit?: Omit<NewSessionRequest, "_meta">;
configOptions?: SessionConfigOption[];
modes?: SessionModeState | null;
@ -131,11 +132,11 @@ export interface ListEventsRequest extends ListPageRequest {
}
export interface SessionPersistDriver {
getSession(id: string): Promise<SessionRecord | null>;
getSession(id: string): Promise<SessionRecord | undefined>;
listSessions(request?: ListPageRequest): Promise<ListPage<SessionRecord>>;
updateSession(session: SessionRecord): Promise<void>;
listEvents(request: ListEventsRequest): Promise<ListPage<SessionEvent>>;
insertEvent(event: SessionEvent): Promise<void>;
insertEvent(sessionId: string, event: SessionEvent): Promise<void>;
}
export interface InMemorySessionPersistDriverOptions {
@ -158,9 +159,9 @@ export class InMemorySessionPersistDriver implements SessionPersistDriver {
this.maxEventsPerSession = normalizeCap(options.maxEventsPerSession, DEFAULT_MAX_EVENTS_PER_SESSION);
}
async getSession(id: string): Promise<SessionRecord | null> {
async getSession(id: string): Promise<SessionRecord | undefined> {
const session = this.sessions.get(id);
return session ? cloneSessionRecord(session) : null;
return session ? cloneSessionRecord(session) : undefined;
}
async listSessions(request: ListPageRequest = {}): Promise<ListPage<SessionRecord>> {
@ -219,15 +220,15 @@ export class InMemorySessionPersistDriver implements SessionPersistDriver {
};
}
async insertEvent(event: SessionEvent): Promise<void> {
const events = this.eventsBySession.get(event.sessionId) ?? [];
async insertEvent(sessionId: string, event: SessionEvent): Promise<void> {
const events = this.eventsBySession.get(sessionId) ?? [];
events.push(cloneSessionEvent(event));
if (events.length > this.maxEventsPerSession) {
events.splice(0, events.length - this.maxEventsPerSession);
}
this.eventsBySession.set(event.sessionId, events);
this.eventsBySession.set(sessionId, events);
}
}

View file

@ -0,0 +1,379 @@
import { describe, it, expect, beforeAll, afterAll, afterEach } from "vitest";
import { createRequire } from "node:module";
import { existsSync, mkdtempSync, rmSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { tmpdir } from "node:os";
import { execSync } from "node:child_process";
const _require = createRequire(import.meta.url);
import { InMemorySessionPersistDriver, SandboxAgent, type SandboxProvider } from "../src/index.ts";
import { local } from "../src/providers/local.ts";
import { docker } from "../src/providers/docker.ts";
import { e2b } from "../src/providers/e2b.ts";
import { daytona } from "../src/providers/daytona.ts";
import { vercel } from "../src/providers/vercel.ts";
import { prepareMockAgentDataHome } from "./helpers/mock-agent.ts";
const __dirname = dirname(fileURLToPath(import.meta.url));
function findBinary(): string | null {
if (process.env.SANDBOX_AGENT_BIN) {
return process.env.SANDBOX_AGENT_BIN;
}
const cargoPaths = [resolve(__dirname, "../../../target/debug/sandbox-agent"), resolve(__dirname, "../../../target/release/sandbox-agent")];
for (const candidate of cargoPaths) {
if (existsSync(candidate)) {
return candidate;
}
}
return null;
}
const BINARY_PATH = findBinary();
if (!BINARY_PATH) {
throw new Error("sandbox-agent binary not found. Build it (cargo build -p sandbox-agent) or set SANDBOX_AGENT_BIN.");
}
if (!process.env.SANDBOX_AGENT_BIN) {
process.env.SANDBOX_AGENT_BIN = BINARY_PATH;
}
function isModuleAvailable(name: string): boolean {
try {
_require.resolve(name);
return true;
} catch {
return false;
}
}
function isDockerAvailable(): boolean {
try {
execSync("docker info", { stdio: "ignore", timeout: 5_000 });
return true;
} catch {
return false;
}
}
// ---------------------------------------------------------------------------
// Provider registry — each entry defines how to create a provider and
// what preconditions are required for it to run.
// ---------------------------------------------------------------------------
interface ProviderEntry {
name: string;
/** Human-readable reasons this provider can't run, or empty if ready. */
skipReasons: string[];
/** Return a fresh provider instance for a single test. */
createProvider: () => SandboxProvider;
/** Optional per-provider setup (e.g. create temp dirs). Returns cleanup fn. */
setup?: () => { cleanup: () => void };
/** Agent to use for session tests. */
agent: string;
/** Timeout for start() — remote providers need longer. */
startTimeoutMs?: number;
/** Some providers (e.g. local) can verify the sandbox is gone after destroy. */
canVerifyDestroyedSandbox?: boolean;
/**
* Whether session tests (createSession, prompt) should run.
* The mock agent only works with local provider (requires mock-acp process binary).
* Remote providers need a real agent (claude) which requires compatible server version + API keys.
*/
sessionTestsEnabled: boolean;
}
function missingEnvVars(...vars: string[]): string[] {
const missing = vars.filter((v) => !process.env[v]);
return missing.length > 0 ? [`missing env: ${missing.join(", ")}`] : [];
}
function missingModules(...modules: string[]): string[] {
const missing = modules.filter((m) => !isModuleAvailable(m));
return missing.length > 0 ? [`missing npm packages: ${missing.join(", ")}`] : [];
}
function collectApiKeys(): Record<string, string> {
const keys: Record<string, string> = {};
if (process.env.ANTHROPIC_API_KEY) keys.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
if (process.env.OPENAI_API_KEY) keys.OPENAI_API_KEY = process.env.OPENAI_API_KEY;
return keys;
}
function buildProviders(): ProviderEntry[] {
const entries: ProviderEntry[] = [];
// --- local ---
// Uses the mock-acp process binary created by prepareMockAgentDataHome.
{
let dataHome: string | undefined;
entries.push({
name: "local",
skipReasons: [],
agent: "mock",
canVerifyDestroyedSandbox: true,
sessionTestsEnabled: true,
setup() {
dataHome = mkdtempSync(join(tmpdir(), "sdk-provider-local-"));
return {
cleanup: () => {
if (dataHome) rmSync(dataHome, { recursive: true, force: true });
},
};
},
createProvider() {
return local({
log: "silent",
env: prepareMockAgentDataHome(dataHome!),
});
},
});
}
// --- docker ---
// Requires SANDBOX_AGENT_DOCKER_IMAGE (e.g. "sandbox-agent-dev:local").
// Session tests disabled: released server images use a different ACP protocol
// version than the current SDK branch, causing "Query closed before response
// received" errors on session creation.
{
entries.push({
name: "docker",
skipReasons: [
...missingEnvVars("SANDBOX_AGENT_DOCKER_IMAGE"),
...missingModules("dockerode", "get-port"),
...(isDockerAvailable() ? [] : ["Docker daemon not available"]),
],
agent: "claude",
startTimeoutMs: 180_000,
canVerifyDestroyedSandbox: false,
sessionTestsEnabled: false,
createProvider() {
const apiKeys = [
process.env.ANTHROPIC_API_KEY ? `ANTHROPIC_API_KEY=${process.env.ANTHROPIC_API_KEY}` : "",
process.env.OPENAI_API_KEY ? `OPENAI_API_KEY=${process.env.OPENAI_API_KEY}` : "",
].filter(Boolean);
return docker({
image: process.env.SANDBOX_AGENT_DOCKER_IMAGE,
env: apiKeys,
});
},
});
}
// --- e2b ---
// Session tests disabled: see docker comment above (ACP protocol mismatch).
{
entries.push({
name: "e2b",
skipReasons: [...missingEnvVars("E2B_API_KEY"), ...missingModules("@e2b/code-interpreter")],
agent: "claude",
startTimeoutMs: 300_000,
canVerifyDestroyedSandbox: false,
sessionTestsEnabled: false,
createProvider() {
return e2b({
create: { envs: collectApiKeys() },
});
},
});
}
// --- daytona ---
// Session tests disabled: see docker comment above (ACP protocol mismatch).
{
entries.push({
name: "daytona",
skipReasons: [...missingEnvVars("DAYTONA_API_KEY"), ...missingModules("@daytonaio/sdk")],
agent: "claude",
startTimeoutMs: 300_000,
canVerifyDestroyedSandbox: false,
sessionTestsEnabled: false,
createProvider() {
return daytona({
create: { envVars: collectApiKeys() },
});
},
});
}
// --- vercel ---
// Session tests disabled: see docker comment above (ACP protocol mismatch).
{
entries.push({
name: "vercel",
skipReasons: [...missingEnvVars("VERCEL_ACCESS_TOKEN"), ...missingModules("@vercel/sandbox")],
agent: "claude",
startTimeoutMs: 300_000,
canVerifyDestroyedSandbox: false,
sessionTestsEnabled: false,
createProvider() {
return vercel({
create: { env: collectApiKeys() },
});
},
});
}
return entries;
}
// ---------------------------------------------------------------------------
// Shared test suite — runs the same assertions against every provider.
//
// Provider lifecycle tests (start, sandboxId, reconnect, destroy) use only
// listAgents() and never create sessions — these work regardless of which
// agents are installed or whether API keys are present.
//
// Session tests (createSession, prompt) are only enabled for providers where
// the agent is known to work. For local, the mock-acp process binary is
// created by test setup. For remote providers, a real agent (claude) is used
// which requires ANTHROPIC_API_KEY and a compatible server version.
// ---------------------------------------------------------------------------
function providerSuite(entry: ProviderEntry) {
const skip = entry.skipReasons.length > 0;
const descFn = skip ? describe.skip : describe;
descFn(`SandboxProvider: ${entry.name}`, () => {
let sdk: SandboxAgent | undefined;
let cleanupFn: (() => void) | undefined;
if (skip) {
it.skip(`skipped — ${entry.skipReasons.join("; ")}`, () => {});
return;
}
beforeAll(() => {
const result = entry.setup?.();
cleanupFn = result?.cleanup;
});
afterEach(async () => {
if (!sdk) return;
await sdk.destroySandbox().catch(async () => {
await sdk?.dispose().catch(() => {});
});
sdk = undefined;
}, 30_000);
afterAll(() => {
cleanupFn?.();
});
// -- lifecycle tests (no session creation) --
it(
"starts with a prefixed sandboxId and passes health",
async () => {
sdk = await SandboxAgent.start({ sandbox: entry.createProvider() });
expect(sdk.sandboxId).toMatch(new RegExp(`^${entry.name}/`));
// listAgents() awaits the internal health gate, confirming the server is ready.
const agents = await sdk.listAgents();
expect(agents.agents.length).toBeGreaterThan(0);
},
entry.startTimeoutMs,
);
it("rejects mismatched sandboxId prefixes", async () => {
await expect(
SandboxAgent.start({
sandbox: entry.createProvider(),
sandboxId: "wrong-provider/example",
}),
).rejects.toThrow(/provider/i);
});
it(
"reconnects after dispose without destroying the sandbox",
async () => {
sdk = await SandboxAgent.start({ sandbox: entry.createProvider() });
const sandboxId = sdk.sandboxId;
expect(sandboxId).toBeTruthy();
await sdk.dispose();
const reconnected = await SandboxAgent.start({
sandbox: entry.createProvider(),
sandboxId,
});
const agents = await reconnected.listAgents();
expect(agents.agents.length).toBeGreaterThan(0);
sdk = reconnected;
},
entry.startTimeoutMs ? entry.startTimeoutMs * 2 : undefined,
);
it(
"destroySandbox tears the sandbox down",
async () => {
sdk = await SandboxAgent.start({ sandbox: entry.createProvider() });
const sandboxId = sdk.sandboxId;
expect(sandboxId).toBeTruthy();
await sdk.destroySandbox();
sdk = undefined;
if (entry.canVerifyDestroyedSandbox) {
const reconnected = await SandboxAgent.start({
sandbox: entry.createProvider(),
sandboxId,
skipHealthCheck: true,
});
await expect(reconnected.listAgents()).rejects.toThrow();
}
},
entry.startTimeoutMs,
);
// -- session tests (require working agent) --
const sessionIt = entry.sessionTestsEnabled ? it : it.skip;
sessionIt(
"creates sessions with persisted sandboxId",
async () => {
const persist = new InMemorySessionPersistDriver();
sdk = await SandboxAgent.start({ sandbox: entry.createProvider(), persist });
const session = await sdk.createSession({ agent: entry.agent });
const record = await persist.getSession(session.id);
expect(record?.sandboxId).toBe(sdk.sandboxId);
},
entry.startTimeoutMs,
);
sessionIt(
"sends a prompt and receives a response",
async () => {
sdk = await SandboxAgent.start({ sandbox: entry.createProvider() });
const session = await sdk.createSession({ agent: entry.agent });
const events: unknown[] = [];
const off = session.onEvent((event) => {
events.push(event);
});
const result = await session.prompt([{ type: "text", text: "Say hello in one word." }]);
off();
expect(result.stopReason).toBe("end_turn");
expect(events.length).toBeGreaterThan(0);
},
entry.startTimeoutMs ? entry.startTimeoutMs * 2 : 30_000,
);
});
}
// ---------------------------------------------------------------------------
// Register all providers
// ---------------------------------------------------------------------------
for (const entry of buildProviders()) {
providerSuite(entry);
}

View file

@ -1,9 +1,18 @@
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/index.ts"],
entry: [
"src/index.ts",
"src/providers/local.ts",
"src/providers/e2b.ts",
"src/providers/daytona.ts",
"src/providers/docker.ts",
"src/providers/vercel.ts",
"src/providers/cloudflare.ts",
],
format: ["esm"],
dts: true,
clean: true,
sourcemap: true,
external: ["@cloudflare/sandbox", "@daytonaio/sdk", "@e2b/code-interpreter", "@vercel/sandbox", "dockerode", "get-port"],
});

View file

@ -4,5 +4,7 @@ export default defineConfig({
test: {
include: ["tests/**/*.test.ts"],
testTimeout: 30000,
teardownTimeout: 10000,
pool: "forks",
},
});