diff --git a/docs/deploy/e2b.mdx b/docs/deploy/e2b.mdx
index 4e056ee..e6465f2 100644
--- a/docs/deploy/e2b.mdx
+++ b/docs/deploy/e2b.mdx
@@ -39,7 +39,7 @@ try {
}
```
-The `e2b` provider handles sandbox creation, Sandbox Agent installation, agent setup, and server startup automatically.
+The `e2b` provider handles sandbox creation, Sandbox Agent installation, agent setup, and server startup automatically. Sandboxes pause by default instead of being deleted, and reconnecting with the same `sandboxId` resumes them automatically.
## Faster cold starts
diff --git a/docs/quickstart.mdx b/docs/quickstart.mdx
index 5c299c3..3701c74 100644
--- a/docs/quickstart.mdx
+++ b/docs/quickstart.mdx
@@ -357,10 +357,10 @@ icon: "rocket"
```typescript
- await client.destroySandbox(); // tears down the sandbox and disconnects
+ await client.destroySandbox(); // provider-defined cleanup and disconnect
```
- Use `client.dispose()` instead to disconnect without destroying the sandbox (for reconnecting later).
+ Use `client.dispose()` instead to disconnect without changing sandbox state. On E2B, `client.pauseSandbox()` pauses the sandbox and `client.killSandbox()` deletes it permanently.
diff --git a/docs/sdk-overview.mdx b/docs/sdk-overview.mdx
index a0f9b84..8e7c8f6 100644
--- a/docs/sdk-overview.mdx
+++ b/docs/sdk-overview.mdx
@@ -87,7 +87,7 @@ const sdk = await SandboxAgent.start({
// sdk.sandboxId — prefixed provider ID (e.g. "local/127.0.0.1:2468")
-await sdk.destroySandbox(); // tears down sandbox + disposes client
+await sdk.destroySandbox(); // provider-defined cleanup + disposes client
```
`SandboxAgent.start(...)` requires a `sandbox` provider. Built-in providers:
@@ -101,7 +101,7 @@ await sdk.destroySandbox(); // tears down sandbox + disposes client
| `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.
+Use `sdk.dispose()` to disconnect without changing sandbox state, `sdk.pauseSandbox()` for graceful suspension when supported, or `sdk.killSandbox()` for permanent deletion.
## Session flow
diff --git a/examples/daytona/src/daytona.ts b/examples/daytona/src/daytona.ts
new file mode 100644
index 0000000..ccffc94
--- /dev/null
+++ b/examples/daytona/src/daytona.ts
@@ -0,0 +1,33 @@
+import { SandboxAgent } from "sandbox-agent";
+import { daytona } from "sandbox-agent/daytona";
+
+function collectEnvVars(): Record {
+ const envVars: Record = {};
+ 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;
+ return envVars;
+}
+
+function inspectorUrlToBaseUrl(inspectorUrl: string): string {
+ return inspectorUrl.replace(/\/ui\/$/, "");
+}
+
+export async function setupDaytonaSandboxAgent(): Promise<{
+ baseUrl: string;
+ token?: string;
+ extraHeaders?: Record;
+ cleanup: () => Promise;
+}> {
+ const client = await SandboxAgent.start({
+ sandbox: daytona({
+ create: { envVars: collectEnvVars() },
+ }),
+ });
+
+ return {
+ baseUrl: inspectorUrlToBaseUrl(client.inspectorUrl),
+ cleanup: async () => {
+ await client.killSandbox();
+ },
+ };
+}
diff --git a/examples/e2b/src/e2b.ts b/examples/e2b/src/e2b.ts
new file mode 100644
index 0000000..bfd5bda
--- /dev/null
+++ b/examples/e2b/src/e2b.ts
@@ -0,0 +1,32 @@
+import { SandboxAgent } from "sandbox-agent";
+import { e2b } from "sandbox-agent/e2b";
+
+function collectEnvVars(): Record {
+ const envs: Record = {};
+ 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;
+ return envs;
+}
+
+function inspectorUrlToBaseUrl(inspectorUrl: string): string {
+ return inspectorUrl.replace(/\/ui\/$/, "");
+}
+
+export async function setupE2BSandboxAgent(): Promise<{
+ baseUrl: string;
+ token?: string;
+ cleanup: () => Promise;
+}> {
+ const client = await SandboxAgent.start({
+ sandbox: e2b({
+ create: { envs: collectEnvVars() },
+ }),
+ });
+
+ return {
+ baseUrl: inspectorUrlToBaseUrl(client.inspectorUrl),
+ cleanup: async () => {
+ await client.killSandbox();
+ },
+ };
+}
diff --git a/examples/vercel/src/vercel.ts b/examples/vercel/src/vercel.ts
new file mode 100644
index 0000000..742cd5a
--- /dev/null
+++ b/examples/vercel/src/vercel.ts
@@ -0,0 +1,35 @@
+import { SandboxAgent } from "sandbox-agent";
+import { vercel } from "sandbox-agent/vercel";
+
+function collectEnvVars(): Record {
+ const env: Record = {};
+ 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;
+ return env;
+}
+
+function inspectorUrlToBaseUrl(inspectorUrl: string): string {
+ return inspectorUrl.replace(/\/ui\/$/, "");
+}
+
+export async function setupVercelSandboxAgent(): Promise<{
+ baseUrl: string;
+ token?: string;
+ cleanup: () => Promise;
+}> {
+ const client = await SandboxAgent.start({
+ sandbox: vercel({
+ create: {
+ runtime: "node24",
+ env: collectEnvVars(),
+ },
+ }),
+ });
+
+ return {
+ baseUrl: inspectorUrlToBaseUrl(client.inspectorUrl),
+ cleanup: async () => {
+ await client.killSandbox();
+ },
+ };
+}
diff --git a/sdks/typescript/src/client.ts b/sdks/typescript/src/client.ts
index 4752c0a..10200bc 100644
--- a/sdks/typescript/src/client.ts
+++ b/sdks/typescript/src/client.ts
@@ -216,6 +216,18 @@ export class SandboxAgentError extends Error {
}
}
+export class SandboxDestroyedError extends Error {
+ readonly sandboxId: string;
+ readonly provider: string;
+
+ constructor(sandboxId: string, provider: string, options?: { cause?: unknown }) {
+ super(`Sandbox '${provider}/${sandboxId}' no longer exists and cannot be reconnected.`, options);
+ this.name = "SandboxDestroyedError";
+ this.sandboxId = sandboxId;
+ this.provider = provider;
+ }
+}
+
export class UnsupportedSessionCategoryError extends Error {
readonly sessionId: string;
readonly category: string;
@@ -904,6 +916,7 @@ export class SandboxAgent {
const createdSandbox = !existingSandbox;
if (existingSandbox) {
+ await provider.reconnect?.(rawSandboxId);
await provider.ensureServer?.(rawSandboxId);
}
@@ -1007,6 +1020,50 @@ export class SandboxAgent {
}
}
+ async pauseSandbox(): Promise {
+ const provider = this.sandboxProvider;
+ const rawSandboxId = this.sandboxProviderRawId;
+
+ try {
+ if (provider && rawSandboxId) {
+ if (provider.pause) {
+ await provider.pause(rawSandboxId);
+ } else {
+ 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;
+ }
+ }
+
+ async killSandbox(): Promise {
+ const provider = this.sandboxProvider;
+ const rawSandboxId = this.sandboxProviderRawId;
+
+ try {
+ if (provider && rawSandboxId) {
+ if (provider.kill) {
+ await provider.kill(rawSandboxId);
+ } else {
+ 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;
+ }
+ }
+
async listSessions(request: ListPageRequest = {}): Promise> {
const page = await this.persist.listSessions(request);
return {
diff --git a/sdks/typescript/src/index.ts b/sdks/typescript/src/index.ts
index f0ebe2e..15537dd 100644
--- a/sdks/typescript/src/index.ts
+++ b/sdks/typescript/src/index.ts
@@ -3,6 +3,7 @@ export {
ProcessTerminalSession,
SandboxAgent,
SandboxAgentError,
+ SandboxDestroyedError,
Session,
UnsupportedPermissionReplyError,
UnsupportedSessionCategoryError,
diff --git a/sdks/typescript/src/providers/e2b.ts b/sdks/typescript/src/providers/e2b.ts
index 84d767c..8e99c64 100644
--- a/sdks/typescript/src/providers/e2b.ts
+++ b/sdks/typescript/src/providers/e2b.ts
@@ -1,13 +1,20 @@
-import { Sandbox } from "@e2b/code-interpreter";
+import { NotFoundError, Sandbox, type SandboxBetaCreateOpts, type SandboxConnectOpts } from "@e2b/code-interpreter";
+import { SandboxDestroyedError } from "../client.ts";
import type { SandboxProvider } from "./types.ts";
import { DEFAULT_AGENTS, SANDBOX_AGENT_INSTALL_SCRIPT } from "./shared.ts";
const DEFAULT_AGENT_PORT = 3000;
+const DEFAULT_TIMEOUT_MS = 3_600_000;
+
+type E2BCreateOverrides = Omit, "timeoutMs" | "autoPause">;
+type E2BConnectOverrides = Omit, "timeoutMs">;
export interface E2BProviderOptions {
- create?: Record | (() => Record | Promise>);
- connect?: Record | ((sandboxId: string) => Record | Promise>);
+ create?: E2BCreateOverrides | (() => E2BCreateOverrides | Promise);
+ connect?: E2BConnectOverrides | ((sandboxId: string) => E2BConnectOverrides | Promise);
agentPort?: number;
+ timeoutMs?: number;
+ autoPause?: boolean;
}
async function resolveOptions(value: E2BProviderOptions["create"] | E2BProviderOptions["connect"], sandboxId?: string): Promise> {
@@ -23,13 +30,15 @@ async function resolveOptions(value: E2BProviderOptions["create"] | E2BProviderO
export function e2b(options: E2BProviderOptions = {}): SandboxProvider {
const agentPort = options.agentPort ?? DEFAULT_AGENT_PORT;
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
+ const autoPause = options.autoPause ?? true;
return {
name: "e2b",
async create(): Promise {
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);
+ const sandbox = await Sandbox.betaCreate({ allowInternetAccess: true, ...createOpts, timeoutMs, autoPause } 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}`);
@@ -44,18 +53,37 @@ export function e2b(options: E2BProviderOptions = {}): SandboxProvider {
return sandbox.sandboxId;
},
async destroy(sandboxId: string): Promise {
+ await this.pause?.(sandboxId);
+ },
+ async reconnect(sandboxId: string): Promise {
const connectOpts = await resolveOptions(options.connect, sandboxId);
- const sandbox = await Sandbox.connect(sandboxId, connectOpts as any);
+ try {
+ await Sandbox.connect(sandboxId, { ...connectOpts, timeoutMs } as SandboxConnectOpts);
+ } catch (error) {
+ if (error instanceof NotFoundError) {
+ throw new SandboxDestroyedError(sandboxId, "e2b", { cause: error });
+ }
+ throw error;
+ }
+ },
+ async pause(sandboxId: string): Promise {
+ const connectOpts = await resolveOptions(options.connect, sandboxId);
+ const sandbox = await Sandbox.connect(sandboxId, { ...connectOpts, timeoutMs } as SandboxConnectOpts);
+ await sandbox.betaPause();
+ },
+ async kill(sandboxId: string): Promise {
+ const connectOpts = await resolveOptions(options.connect, sandboxId);
+ const sandbox = await Sandbox.connect(sandboxId, { ...connectOpts, timeoutMs } as SandboxConnectOpts);
await sandbox.kill();
},
async getUrl(sandboxId: string): Promise {
const connectOpts = await resolveOptions(options.connect, sandboxId);
- const sandbox = await Sandbox.connect(sandboxId, connectOpts as any);
+ const sandbox = await Sandbox.connect(sandboxId, { ...connectOpts, timeoutMs } as SandboxConnectOpts);
return `https://${sandbox.getHost(agentPort)}`;
},
async ensureServer(sandboxId: string): Promise {
const connectOpts = await resolveOptions(options.connect, sandboxId);
- const sandbox = await Sandbox.connect(sandboxId, connectOpts as any);
+ const sandbox = await Sandbox.connect(sandboxId, { ...connectOpts, timeoutMs } as SandboxConnectOpts);
await sandbox.commands.run(`sandbox-agent server --no-token --host 0.0.0.0 --port ${agentPort}`, { background: true, timeoutMs: 0 });
},
};
diff --git a/sdks/typescript/src/providers/types.ts b/sdks/typescript/src/providers/types.ts
index ea778de..ab996e1 100644
--- a/sdks/typescript/src/providers/types.ts
+++ b/sdks/typescript/src/providers/types.ts
@@ -8,6 +8,25 @@ export interface SandboxProvider {
/** Permanently tear down a sandbox. */
destroy(sandboxId: string): Promise;
+ /**
+ * Reconnect to an existing sandbox before the SDK attempts health checks.
+ * Providers can use this to resume paused sandboxes or surface provider-specific
+ * reconnect errors.
+ */
+ reconnect?(sandboxId: string): Promise;
+
+ /**
+ * Gracefully stop or pause a sandbox without permanently deleting it.
+ * When omitted, callers should fall back to `destroy()`.
+ */
+ pause?(sandboxId: string): Promise;
+
+ /**
+ * Permanently delete a sandbox. When omitted, callers should fall back to
+ * `destroy()`.
+ */
+ kill?(sandboxId: string): Promise;
+
/**
* Return the sandbox-agent base URL for this sandbox.
* Providers that cannot expose a URL should implement `getFetch()` instead.
diff --git a/sdks/typescript/tests/provider-lifecycle.test.ts b/sdks/typescript/tests/provider-lifecycle.test.ts
new file mode 100644
index 0000000..06c85f5
--- /dev/null
+++ b/sdks/typescript/tests/provider-lifecycle.test.ts
@@ -0,0 +1,193 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { SandboxAgent, SandboxDestroyedError, type SandboxProvider } from "../src/index.ts";
+
+const e2bMocks = vi.hoisted(() => {
+ class MockNotFoundError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = "NotFoundError";
+ }
+ }
+
+ return {
+ MockNotFoundError,
+ betaCreate: vi.fn(),
+ connect: vi.fn(),
+ };
+});
+
+vi.mock("@e2b/code-interpreter", () => ({
+ NotFoundError: e2bMocks.MockNotFoundError,
+ Sandbox: {
+ betaCreate: e2bMocks.betaCreate,
+ connect: e2bMocks.connect,
+ },
+}));
+
+import { e2b } from "../src/providers/e2b.ts";
+
+function createFetch(): typeof fetch {
+ return async () => new Response(null, { status: 200 });
+}
+
+function createBaseProvider(overrides: Partial = {}): SandboxProvider {
+ return {
+ name: "mock",
+ async create(): Promise {
+ return "created";
+ },
+ async destroy(): Promise {},
+ async getUrl(): Promise {
+ return "http://127.0.0.1:3000";
+ },
+ ...overrides,
+ };
+}
+
+function createMockSandbox() {
+ return {
+ sandboxId: "sbx-123",
+ getHost: vi.fn(() => "sandbox.example"),
+ betaPause: vi.fn(async () => true),
+ kill: vi.fn(async () => undefined),
+ commands: {
+ run: vi.fn(async () => ({ exitCode: 0, stderr: "" })),
+ },
+ };
+}
+
+describe("SandboxAgent provider lifecycle", () => {
+ it("reconnects an existing sandbox before ensureServer", async () => {
+ const order: string[] = [];
+ const provider = createBaseProvider({
+ reconnect: vi.fn(async () => {
+ order.push("reconnect");
+ }),
+ ensureServer: vi.fn(async () => {
+ order.push("ensureServer");
+ }),
+ });
+
+ const sdk = await SandboxAgent.start({
+ sandbox: provider,
+ sandboxId: "mock/existing",
+ skipHealthCheck: true,
+ fetch: createFetch(),
+ });
+
+ expect(order).toEqual(["reconnect", "ensureServer"]);
+
+ await sdk.killSandbox();
+ });
+
+ it("surfaces SandboxDestroyedError from reconnect", async () => {
+ const provider = createBaseProvider({
+ reconnect: vi.fn(async () => {
+ throw new SandboxDestroyedError("existing", "mock");
+ }),
+ ensureServer: vi.fn(async () => undefined),
+ });
+
+ await expect(
+ SandboxAgent.start({
+ sandbox: provider,
+ sandboxId: "mock/existing",
+ skipHealthCheck: true,
+ fetch: createFetch(),
+ }),
+ ).rejects.toBeInstanceOf(SandboxDestroyedError);
+
+ expect(provider.ensureServer).not.toHaveBeenCalled();
+ });
+
+ it("uses provider pause and kill hooks for explicit lifecycle control", async () => {
+ const pause = vi.fn(async () => undefined);
+ const kill = vi.fn(async () => undefined);
+ const provider = createBaseProvider({ pause, kill });
+
+ const paused = await SandboxAgent.start({
+ sandbox: provider,
+ skipHealthCheck: true,
+ fetch: createFetch(),
+ });
+ await paused.pauseSandbox();
+ expect(pause).toHaveBeenCalledWith("created");
+
+ const killed = await SandboxAgent.start({
+ sandbox: provider,
+ skipHealthCheck: true,
+ fetch: createFetch(),
+ });
+ await killed.killSandbox();
+ expect(kill).toHaveBeenCalledWith("created");
+ });
+});
+
+describe("e2b provider", () => {
+ beforeEach(() => {
+ e2bMocks.betaCreate.mockReset();
+ e2bMocks.connect.mockReset();
+ });
+
+ it("creates sandboxes with betaCreate, autoPause, and the default timeout", async () => {
+ const sandbox = createMockSandbox();
+ e2bMocks.betaCreate.mockResolvedValue(sandbox);
+
+ const provider = e2b({
+ create: {
+ envs: { ANTHROPIC_API_KEY: "test" },
+ },
+ });
+
+ await expect(provider.create()).resolves.toBe("sbx-123");
+
+ expect(e2bMocks.betaCreate).toHaveBeenCalledWith(
+ expect.objectContaining({
+ allowInternetAccess: true,
+ autoPause: true,
+ timeoutMs: 3_600_000,
+ envs: { ANTHROPIC_API_KEY: "test" },
+ }),
+ );
+ });
+
+ it("allows timeoutMs and autoPause to be overridden", async () => {
+ const sandbox = createMockSandbox();
+ e2bMocks.betaCreate.mockResolvedValue(sandbox);
+
+ const provider = e2b({
+ timeoutMs: 123_456,
+ autoPause: false,
+ });
+
+ await provider.create();
+
+ expect(e2bMocks.betaCreate).toHaveBeenCalledWith(
+ expect.objectContaining({
+ autoPause: false,
+ timeoutMs: 123_456,
+ }),
+ );
+ });
+
+ it("pauses by default in destroy and uses kill for permanent deletion", async () => {
+ const sandbox = createMockSandbox();
+ e2bMocks.connect.mockResolvedValue(sandbox);
+ const provider = e2b();
+
+ await provider.destroy("sbx-123");
+ expect(e2bMocks.connect).toHaveBeenLastCalledWith("sbx-123", { timeoutMs: 3_600_000 });
+ expect(sandbox.betaPause).toHaveBeenCalledTimes(1);
+ expect(sandbox.kill).not.toHaveBeenCalled();
+
+ await provider.kill?.("sbx-123");
+ expect(sandbox.kill).toHaveBeenCalledTimes(1);
+ });
+
+ it("maps missing reconnect targets to SandboxDestroyedError", async () => {
+ e2bMocks.connect.mockRejectedValue(new e2bMocks.MockNotFoundError("gone"));
+ const provider = e2b();
+
+ await expect(provider.reconnect?.("missing-sandbox")).rejects.toBeInstanceOf(SandboxDestroyedError);
+ });
+});
diff --git a/sdks/typescript/tests/providers.test.ts b/sdks/typescript/tests/providers.test.ts
index 3376026..d98672d 100644
--- a/sdks/typescript/tests/providers.test.ts
+++ b/sdks/typescript/tests/providers.test.ts
@@ -291,7 +291,7 @@ function providerSuite(entry: ProviderEntry) {
afterEach(async () => {
if (!sdk) return;
- await sdk.destroySandbox().catch(async () => {
+ await sdk.killSandbox().catch(async () => {
await sdk?.dispose().catch(() => {});
});
sdk = undefined;
@@ -364,6 +364,11 @@ function providerSuite(entry: ProviderEntry) {
});
await expect(reconnected.listAgents()).rejects.toThrow();
}
+
+ if (entry.name === "e2b") {
+ const rawSandboxId = sandboxId?.slice(sandboxId.indexOf("/") + 1);
+ await entry.createProvider().kill?.(rawSandboxId!);
+ }
},
entry.startTimeoutMs,
);