SDK: Add ensureServer() for automatic server recovery

Add ensureServer() to SandboxProvider interface to handle cases where the
sandbox-agent server stops or goes to sleep. The SDK now calls this method
after 3 consecutive health-check failures, allowing providers to restart the
server if needed. Most built-in providers (E2B, Daytona, Vercel, Modal,
ComputeSDK) implement this. Docker and Cloudflare manage server lifecycle
differently, and Local uses managed child processes.

Also update docs for quickstart, architecture, multiplayer, and session
persistence; mark persist-* packages as deprecated; and add ensureServer
implementations to all applicable providers.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
Nathan Flurry 2026-03-15 20:14:30 -07:00
parent d008283c17
commit 35840facdd
38 changed files with 620 additions and 205 deletions

View file

@ -46,6 +46,18 @@
- `docs/agent-capabilities.mdx` lists models/modes/thought levels per agent. Update it when adding a new agent or changing `fallback_config_options`. If its "Last updated" date is >2 weeks old, re-run `cd scripts/agent-configs && npx tsx dump.ts` and update the doc to match. Source data: `scripts/agent-configs/resources/*.json` and hardcoded entries in `server/packages/sandbox-agent/src/router/support.rs` (`fallback_config_options`).
- Some agent models are gated by subscription (e.g. Claude `opus`). The live report only shows models available to the current credentials. The static doc and JSON resource files should list all known models regardless of subscription tier.
## Persist Packages (Deprecated)
- The `@sandbox-agent/persist-*` npm packages (`persist-sqlite`, `persist-postgres`, `persist-indexeddb`, `persist-rivet`) are deprecated stubs. They still publish to npm but throw a deprecation error at import time.
- Driver implementations now live inline in examples and consuming packages:
- SQLite: `examples/persist-sqlite/src/persist.ts`
- Postgres: `examples/persist-postgres/src/persist.ts`
- IndexedDB: `frontend/packages/inspector/src/persist-indexeddb.ts`
- Rivet: inlined in `docs/multiplayer.mdx`
- In-memory: built into the main `sandbox-agent` SDK (`InMemorySessionPersistDriver`)
- Docs (`docs/session-persistence.mdx`) link to the example implementations on GitHub instead of referencing the packages.
- Do not re-add `@sandbox-agent/persist-*` as dependencies anywhere. New persist drivers should be copied into the consuming project directly.
## Install Version References
- Channel policy:

View file

@ -10,7 +10,6 @@ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY frontend/packages/inspector/package.json ./frontend/packages/inspector/
COPY sdks/cli-shared/package.json ./sdks/cli-shared/
COPY sdks/acp-http-client/package.json ./sdks/acp-http-client/
COPY sdks/persist-indexeddb/package.json ./sdks/persist-indexeddb/
COPY sdks/react/package.json ./sdks/react/
COPY sdks/typescript/package.json ./sdks/typescript/
@ -21,15 +20,13 @@ RUN pnpm install --filter @sandbox-agent/inspector...
COPY docs/openapi.json ./docs/
COPY sdks/cli-shared ./sdks/cli-shared
COPY sdks/acp-http-client ./sdks/acp-http-client
COPY sdks/persist-indexeddb ./sdks/persist-indexeddb
COPY sdks/react ./sdks/react
COPY sdks/typescript ./sdks/typescript
# Build cli-shared, acp-http-client, SDK, then persist-indexeddb and react (depends on SDK)
# Build cli-shared, acp-http-client, SDK, then react (depends on SDK)
RUN cd sdks/cli-shared && pnpm exec tsup
RUN cd sdks/acp-http-client && pnpm exec tsup
RUN cd sdks/typescript && SKIP_OPENAPI_GEN=1 pnpm exec tsup
RUN cd sdks/persist-indexeddb && pnpm exec tsup
RUN cd sdks/react && pnpm exec tsup
# Copy inspector source and build

View file

@ -10,7 +10,6 @@ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY frontend/packages/inspector/package.json ./frontend/packages/inspector/
COPY sdks/cli-shared/package.json ./sdks/cli-shared/
COPY sdks/acp-http-client/package.json ./sdks/acp-http-client/
COPY sdks/persist-indexeddb/package.json ./sdks/persist-indexeddb/
COPY sdks/react/package.json ./sdks/react/
COPY sdks/typescript/package.json ./sdks/typescript/
@ -21,15 +20,13 @@ RUN pnpm install --filter @sandbox-agent/inspector...
COPY docs/openapi.json ./docs/
COPY sdks/cli-shared ./sdks/cli-shared
COPY sdks/acp-http-client ./sdks/acp-http-client
COPY sdks/persist-indexeddb ./sdks/persist-indexeddb
COPY sdks/react ./sdks/react
COPY sdks/typescript ./sdks/typescript
# Build cli-shared, acp-http-client, SDK, then persist-indexeddb and react (depends on SDK)
# Build cli-shared, acp-http-client, SDK, then react (depends on SDK)
RUN cd sdks/cli-shared && pnpm exec tsup
RUN cd sdks/acp-http-client && pnpm exec tsup
RUN cd sdks/typescript && SKIP_OPENAPI_GEN=1 pnpm exec tsup
RUN cd sdks/persist-indexeddb && pnpm exec tsup
RUN cd sdks/react && pnpm exec tsup
# Copy inspector source and build

View file

@ -10,7 +10,6 @@ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY frontend/packages/inspector/package.json ./frontend/packages/inspector/
COPY sdks/cli-shared/package.json ./sdks/cli-shared/
COPY sdks/acp-http-client/package.json ./sdks/acp-http-client/
COPY sdks/persist-indexeddb/package.json ./sdks/persist-indexeddb/
COPY sdks/react/package.json ./sdks/react/
COPY sdks/typescript/package.json ./sdks/typescript/
@ -21,15 +20,13 @@ RUN pnpm install --filter @sandbox-agent/inspector...
COPY docs/openapi.json ./docs/
COPY sdks/cli-shared ./sdks/cli-shared
COPY sdks/acp-http-client ./sdks/acp-http-client
COPY sdks/persist-indexeddb ./sdks/persist-indexeddb
COPY sdks/react ./sdks/react
COPY sdks/typescript ./sdks/typescript
# Build cli-shared, acp-http-client, SDK, then persist-indexeddb and react (depends on SDK)
# Build cli-shared, acp-http-client, SDK, then react (depends on SDK)
RUN cd sdks/cli-shared && pnpm exec tsup
RUN cd sdks/acp-http-client && pnpm exec tsup
RUN cd sdks/typescript && SKIP_OPENAPI_GEN=1 pnpm exec tsup
RUN cd sdks/persist-indexeddb && pnpm exec tsup
RUN cd sdks/react && pnpm exec tsup
# Copy inspector source and build

View file

@ -10,7 +10,6 @@ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY frontend/packages/inspector/package.json ./frontend/packages/inspector/
COPY sdks/cli-shared/package.json ./sdks/cli-shared/
COPY sdks/acp-http-client/package.json ./sdks/acp-http-client/
COPY sdks/persist-indexeddb/package.json ./sdks/persist-indexeddb/
COPY sdks/react/package.json ./sdks/react/
COPY sdks/typescript/package.json ./sdks/typescript/
@ -21,15 +20,13 @@ RUN pnpm install --filter @sandbox-agent/inspector...
COPY docs/openapi.json ./docs/
COPY sdks/cli-shared ./sdks/cli-shared
COPY sdks/acp-http-client ./sdks/acp-http-client
COPY sdks/persist-indexeddb ./sdks/persist-indexeddb
COPY sdks/react ./sdks/react
COPY sdks/typescript ./sdks/typescript
# Build cli-shared, acp-http-client, SDK, then persist-indexeddb and react (depends on SDK)
# Build cli-shared, acp-http-client, SDK, then react (depends on SDK)
RUN cd sdks/cli-shared && pnpm exec tsup
RUN cd sdks/acp-http-client && pnpm exec tsup
RUN cd sdks/typescript && SKIP_OPENAPI_GEN=1 pnpm exec tsup
RUN cd sdks/persist-indexeddb && pnpm exec tsup
RUN cd sdks/react && pnpm exec tsup
# Copy inspector source and build

View file

@ -10,7 +10,6 @@ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY frontend/packages/inspector/package.json ./frontend/packages/inspector/
COPY sdks/cli-shared/package.json ./sdks/cli-shared/
COPY sdks/acp-http-client/package.json ./sdks/acp-http-client/
COPY sdks/persist-indexeddb/package.json ./sdks/persist-indexeddb/
COPY sdks/react/package.json ./sdks/react/
COPY sdks/typescript/package.json ./sdks/typescript/
@ -21,15 +20,13 @@ RUN pnpm install --filter @sandbox-agent/inspector...
COPY docs/openapi.json ./docs/
COPY sdks/cli-shared ./sdks/cli-shared
COPY sdks/acp-http-client ./sdks/acp-http-client
COPY sdks/persist-indexeddb ./sdks/persist-indexeddb
COPY sdks/react ./sdks/react
COPY sdks/typescript ./sdks/typescript
# Build cli-shared, acp-http-client, SDK, then persist-indexeddb and react (depends on SDK)
# Build cli-shared, acp-http-client, SDK, then react (depends on SDK)
RUN cd sdks/cli-shared && pnpm exec tsup
RUN cd sdks/acp-http-client && pnpm exec tsup
RUN cd sdks/typescript && SKIP_OPENAPI_GEN=1 pnpm exec tsup
RUN cd sdks/persist-indexeddb && pnpm exec tsup
RUN cd sdks/react && pnpm exec tsup
# Copy inspector source and build

View file

@ -12,7 +12,6 @@ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY frontend/packages/inspector/package.json ./frontend/packages/inspector/
COPY sdks/cli-shared/package.json ./sdks/cli-shared/
COPY sdks/acp-http-client/package.json ./sdks/acp-http-client/
COPY sdks/persist-indexeddb/package.json ./sdks/persist-indexeddb/
COPY sdks/react/package.json ./sdks/react/
COPY sdks/typescript/package.json ./sdks/typescript/
@ -23,7 +22,6 @@ RUN pnpm install --filter @sandbox-agent/inspector...
COPY docs/openapi.json ./docs/
COPY sdks/cli-shared ./sdks/cli-shared
COPY sdks/acp-http-client ./sdks/acp-http-client
COPY sdks/persist-indexeddb ./sdks/persist-indexeddb
COPY sdks/react ./sdks/react
COPY sdks/typescript ./sdks/typescript
@ -31,7 +29,6 @@ COPY sdks/typescript ./sdks/typescript
RUN cd sdks/cli-shared && pnpm exec tsup
RUN cd sdks/acp-http-client && pnpm exec tsup
RUN cd sdks/typescript && SKIP_OPENAPI_GEN=1 pnpm exec tsup
RUN cd sdks/persist-indexeddb && pnpm exec tsup
RUN cd sdks/react && pnpm exec tsup
# Copy inspector source and build

View file

@ -11,7 +11,6 @@ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY frontend/packages/inspector/package.json ./frontend/packages/inspector/
COPY sdks/cli-shared/package.json ./sdks/cli-shared/
COPY sdks/acp-http-client/package.json ./sdks/acp-http-client/
COPY sdks/persist-indexeddb/package.json ./sdks/persist-indexeddb/
COPY sdks/react/package.json ./sdks/react/
COPY sdks/typescript/package.json ./sdks/typescript/
@ -20,14 +19,12 @@ RUN pnpm install --filter @sandbox-agent/inspector...
COPY docs/openapi.json ./docs/
COPY sdks/cli-shared ./sdks/cli-shared
COPY sdks/acp-http-client ./sdks/acp-http-client
COPY sdks/persist-indexeddb ./sdks/persist-indexeddb
COPY sdks/react ./sdks/react
COPY sdks/typescript ./sdks/typescript
RUN cd sdks/cli-shared && pnpm exec tsup
RUN cd sdks/acp-http-client && pnpm exec tsup
RUN cd sdks/typescript && SKIP_OPENAPI_GEN=1 pnpm exec tsup
RUN cd sdks/persist-indexeddb && pnpm exec tsup
RUN cd sdks/react && pnpm exec tsup
COPY frontend/packages/inspector ./frontend/packages/inspector

View file

@ -40,7 +40,11 @@ flowchart LR
For the `local` provider, provisioning is a no-op and the server runs as a local subprocess.
## Server endpoints
### Server recovery
If the server process stops, the SDK automatically calls the provider's `ensureServer()` after 3 consecutive health-check failures. Most built-in providers implement this. Custom providers can add `ensureServer(sandboxId)` to their `SandboxProvider` object.
## Server HTTP API
See the [HTTP API reference](/api-reference) for the full list of server endpoints.
@ -54,6 +58,6 @@ sandbox-agent install-agent --all
The `rivetdev/sandbox-agent:0.3.2-full` Docker image ships with all agents pre-installed.
## Production topology
## Production-ready agent orchestration
For production deployments, see [Orchestration Architecture](/orchestration-architecture) for recommended topology, backend requirements, and session persistence patterns.

View file

@ -22,16 +22,13 @@ Use [actor keys](https://rivet.dev/docs/actors/keys) to map each workspace to on
import { actor, setup } from "rivetkit";
import { SandboxAgent, type SessionPersistDriver, type SessionRecord, type SessionEvent, type ListPageRequest, type ListPage, type ListEventsRequest } from "sandbox-agent";
// Inline Rivet persist driver — copy into your project.
// See https://github.com/nichochar/sandbox-agent/tree/main/examples/persist-rivet
interface ActorContextLike { state: Record<string, unknown>; }
interface RivetPersistData { sessions: Record<string, SessionRecord>; events: Record<string, SessionEvent[]>; }
type RivetPersistState = { _sandboxAgentPersist: RivetPersistData };
class RivetSessionPersistDriver implements SessionPersistDriver {
private readonly stateKey: string;
private readonly ctx: ActorContextLike;
constructor(ctx: ActorContextLike, options: { stateKey?: string } = {}) {
private readonly ctx: { state: Record<string, unknown> };
constructor(ctx: { state: Record<string, unknown> }, options: { stateKey?: string } = {}) {
this.ctx = ctx;
this.stateKey = options.stateKey ?? "_sandboxAgentPersist";
if (!this.ctx.state[this.stateKey]) {
@ -146,5 +143,5 @@ await conn.prompt({
## Notes
- Keep sandbox calls actor-only. Browser clients should not call Sandbox Agent directly.
- Inline the Rivet persist driver (shown above) so session history persists in actor state.
- Copy the Rivet persist driver from the example above into your project so session history persists in actor state.
- For client connection patterns, see [Rivet JavaScript client](https://rivet.dev/docs/clients/javascript).

View file

@ -23,112 +23,177 @@ icon: "rocket"
</Step>
<Step title="Start the sandbox">
`SandboxAgent.start()` provisions a sandbox, starts a lightweight [Sandbox Agent server](/architecture) inside it, and connects your SDK client. Pass your LLM API keys so the agent can reach its provider.
`SandboxAgent.start()` provisions a sandbox, starts a lightweight [Sandbox Agent server](/architecture) inside it, and connects your SDK client.
<CodeGroup>
```typescript Local
import { SandboxAgent } from "sandbox-agent";
import { local } from "sandbox-agent/local";
<Tabs>
<Tab title="Local">
```bash
npm install sandbox-agent@0.3.x
```
// Runs on your machine. Inherits process.env automatically.
const sdk = await SandboxAgent.start({
sandbox: local(),
});
```
```typescript
import { SandboxAgent } from "sandbox-agent";
import { local } from "sandbox-agent/local";
```typescript E2B
import { SandboxAgent } from "sandbox-agent";
import { e2b } from "sandbox-agent/e2b";
// Runs on your machine. Inherits process.env automatically.
const client = await SandboxAgent.start({
sandbox: local(),
});
```
const sdk = await SandboxAgent.start({
sandbox: e2b({
create: {
// Pass whichever keys your agent needs
envs: {
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
},
},
}),
});
```
See [Local deploy guide](/deploy/local)
```typescript Daytona
import { SandboxAgent } from "sandbox-agent";
import { daytona } from "sandbox-agent/daytona";
<Accordion title="Passing LLM credentials">
Local inherits `process.env` automatically, so no extra config is needed if your environment variables are already set.
</Accordion>
</Tab>
const sdk = await SandboxAgent.start({
sandbox: daytona({
create: {
envVars: {
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
},
},
}),
});
```
<Tab title="E2B">
```bash
npm install sandbox-agent@0.3.x @e2b/code-interpreter
```
```typescript Vercel
import { SandboxAgent } from "sandbox-agent";
import { vercel } from "sandbox-agent/vercel";
```typescript
import { SandboxAgent } from "sandbox-agent";
import { e2b } from "sandbox-agent/e2b";
const sdk = await SandboxAgent.start({
sandbox: vercel({
create: {
runtime: "node24",
env: {
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
},
},
}),
});
```
// Provisions a cloud sandbox on E2B, installs the server, and connects.
const client = await SandboxAgent.start({
sandbox: e2b(),
});
```
```typescript Modal
import { SandboxAgent } from "sandbox-agent";
import { modal } from "sandbox-agent/modal";
See [E2B deploy guide](/deploy/e2b)
const sdk = await SandboxAgent.start({
sandbox: modal({
create: {
// Pass whichever keys your agent needs
secrets: {
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
},
},
}),
});
```
<Accordion title="Passing LLM credentials">
```typescript
e2b({ create: { envs: { ANTHROPIC_API_KEY: "..." } } })
```
</Accordion>
</Tab>
```typescript Cloudflare
import { SandboxAgent } from "sandbox-agent";
import { cloudflare } from "sandbox-agent/cloudflare";
<Tab title="Daytona">
```bash
npm install sandbox-agent@0.3.x @daytonaio/sdk
```
const sdk = await SandboxAgent.start({
sandbox: cloudflare({ sdk: cfSandboxClient }),
});
```
```typescript
import { SandboxAgent } from "sandbox-agent";
import { daytona } from "sandbox-agent/daytona";
```typescript Docker
import { SandboxAgent } from "sandbox-agent";
import { docker } from "sandbox-agent/docker";
// Provisions a Daytona workspace with the server pre-installed.
const client = await SandboxAgent.start({
sandbox: daytona(),
});
```
// 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}`,
`OPENAI_API_KEY=${process.env.OPENAI_API_KEY}`,
],
}),
});
```
</CodeGroup>
See [Daytona deploy guide](/deploy/daytona)
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. For multi-tenant billing, per-user keys, and gateway options, see [LLM Credentials](/llm-credentials).
<Accordion title="Passing LLM credentials">
```typescript
daytona({ create: { envVars: { ANTHROPIC_API_KEY: "..." } } })
```
</Accordion>
</Tab>
<Tab title="Vercel">
```bash
npm install sandbox-agent@0.3.x @vercel/sandbox
```
```typescript
import { SandboxAgent } from "sandbox-agent";
import { vercel } from "sandbox-agent/vercel";
// Provisions a Vercel sandbox with the server installed on boot.
const client = await SandboxAgent.start({
sandbox: vercel(),
});
```
See [Vercel deploy guide](/deploy/vercel)
<Accordion title="Passing LLM credentials">
```typescript
vercel({ create: { env: { ANTHROPIC_API_KEY: "..." } } })
```
</Accordion>
</Tab>
<Tab title="Modal">
```bash
npm install sandbox-agent@0.3.x modal
```
```typescript
import { SandboxAgent } from "sandbox-agent";
import { modal } from "sandbox-agent/modal";
// Builds a container image with agents pre-installed (cached after first run),
// starts a Modal sandbox from that image, and connects.
const client = await SandboxAgent.start({
sandbox: modal(),
});
```
See [Modal deploy guide](/deploy/modal)
<Accordion title="Passing LLM credentials">
```typescript
modal({ create: { secrets: { ANTHROPIC_API_KEY: "..." } } })
```
</Accordion>
</Tab>
<Tab title="Cloudflare">
```bash
npm install sandbox-agent@0.3.x @cloudflare/sandbox
```
```typescript
import { SandboxAgent } from "sandbox-agent";
import { cloudflare } from "sandbox-agent/cloudflare";
import { SandboxClient } from "@cloudflare/sandbox";
// Uses the Cloudflare Sandbox SDK to provision and connect.
// The Cloudflare SDK handles server lifecycle internally.
const cfSandboxClient = new SandboxClient();
const client = await SandboxAgent.start({
sandbox: cloudflare({ sdk: cfSandboxClient }),
});
```
See [Cloudflare deploy guide](/deploy/cloudflare)
<Accordion title="Passing LLM credentials">
Pass credentials via the Cloudflare SDK's environment configuration. See the [Cloudflare deploy guide](/deploy/cloudflare) for details.
</Accordion>
</Tab>
<Tab title="Docker">
```bash
npm install sandbox-agent@0.3.x dockerode get-port
```
```typescript
import { SandboxAgent } from "sandbox-agent";
import { docker } from "sandbox-agent/docker";
// Runs a Docker container locally. Good for testing.
const client = await SandboxAgent.start({
sandbox: docker(),
});
```
See [Docker deploy guide](/deploy/docker)
<Accordion title="Passing LLM credentials">
```typescript
docker({ env: ["ANTHROPIC_API_KEY=..."] })
```
</Accordion>
</Tab>
</Tabs>
<AccordionGroup>
<Accordion title="Implementing a custom provider">
@ -152,7 +217,7 @@ icon: "rocket"
},
};
const sdk = await SandboxAgent.start({
const client = await SandboxAgent.start({
sandbox: myProvider,
});
```
@ -162,7 +227,7 @@ icon: "rocket"
If you already have a Sandbox Agent server running, connect directly:
```typescript
const sdk = await SandboxAgent.connect({
const client = await SandboxAgent.connect({
baseUrl: "http://127.0.0.1:2468",
});
```
@ -196,29 +261,115 @@ icon: "rocket"
</Step>
<Step title="Create a session and send a prompt">
```typescript
const session = await sdk.createSession({
agent: "claude",
});
<CodeGroup>
session.onEvent((event) => {
console.log(event.sender, event.payload);
});
```typescript Claude
const session = await client.createSession({
agent: "claude",
});
const result = await session.prompt([
{ type: "text", text: "Summarize the repository and suggest next steps." },
]);
session.onEvent((event) => {
console.log(event.sender, event.payload);
});
console.log(result.stopReason);
```
const result = await session.prompt([
{ type: "text", text: "Summarize the repository and suggest next steps." },
]);
console.log(result.stopReason);
```
```typescript Codex
const session = await client.createSession({
agent: "codex",
});
session.onEvent((event) => {
console.log(event.sender, event.payload);
});
const result = await session.prompt([
{ type: "text", text: "Summarize the repository and suggest next steps." },
]);
console.log(result.stopReason);
```
```typescript OpenCode
const session = await client.createSession({
agent: "opencode",
});
session.onEvent((event) => {
console.log(event.sender, event.payload);
});
const result = await session.prompt([
{ type: "text", text: "Summarize the repository and suggest next steps." },
]);
console.log(result.stopReason);
```
```typescript Cursor
const session = await client.createSession({
agent: "cursor",
});
session.onEvent((event) => {
console.log(event.sender, event.payload);
});
const result = await session.prompt([
{ type: "text", text: "Summarize the repository and suggest next steps." },
]);
console.log(result.stopReason);
```
```typescript Amp
const session = await client.createSession({
agent: "amp",
});
session.onEvent((event) => {
console.log(event.sender, event.payload);
});
const result = await session.prompt([
{ type: "text", text: "Summarize the repository and suggest next steps." },
]);
console.log(result.stopReason);
```
```typescript Pi
const session = await client.createSession({
agent: "pi",
});
session.onEvent((event) => {
console.log(event.sender, event.payload);
});
const result = await session.prompt([
{ type: "text", text: "Summarize the repository and suggest next steps." },
]);
console.log(result.stopReason);
```
</CodeGroup>
See [Agent Sessions](/agent-sessions) for the full sessions API.
</Step>
<Step title="Clean up">
```typescript
await sdk.destroySandbox(); // tears down the sandbox and disconnects
await client.destroySandbox(); // tears down the sandbox and disconnects
```
Use `sdk.dispose()` instead to disconnect without destroying the sandbox (for reconnecting later).
Use `client.dispose()` instead to disconnect without destroying the sandbox (for reconnecting later).
</Step>
<Step title="Inspect with the UI">
@ -236,7 +387,7 @@ icon: "rocket"
import { SandboxAgent } from "sandbox-agent";
import { e2b } from "sandbox-agent/e2b";
const sdk = await SandboxAgent.start({
const client = await SandboxAgent.start({
sandbox: e2b({
create: {
envs: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY },
@ -245,7 +396,7 @@ const sdk = await SandboxAgent.start({
});
try {
const session = await sdk.createSession({ agent: "claude" });
const session = await client.createSession({ agent: "claude" });
session.onEvent((event) => {
console.log(`[${event.sender}]`, JSON.stringify(event.payload));
@ -257,7 +408,7 @@ try {
console.log("Done:", result.stopReason);
} finally {
await sdk.destroySandbox();
await client.destroySandbox();
}
```

View file

@ -10,11 +10,19 @@ With persistence enabled, sessions can be restored after runtime/session loss. S
Each driver stores:
- `SessionRecord` (`id`, `agent`, `agentSessionId`, `lastConnectionId`, `createdAt`, optional `destroyedAt`, optional `sandboxId`, optional `sessionInit`)
- `SessionRecord` (`id`, `agent`, `agentSessionId`, `lastConnectionId`, `createdAt`, optional `destroyedAt`, optional `sandboxId`, optional `sessionInit`, optional `configOptions`, optional `modes`)
- `SessionEvent` (`id`, `eventIndex`, `sessionId`, `connectionId`, `sender`, `payload`, `createdAt`)
## Persistence drivers
### Rivet
Recommended for sandbox orchestration with actor state. See [Multiplayer](/multiplayer) for a full Rivet actor example with persistence in actor state.
### IndexedDB (browser)
Best for browser apps that should survive reloads. See the [Inspector source](https://github.com/rivet-dev/sandbox-agent/tree/main/frontend/packages/inspector/src/persist-indexeddb.ts) for a complete IndexedDB driver you can copy into your project.
### In-memory (built-in)
Best for local dev and ephemeral workloads. No extra dependencies required.
@ -55,7 +63,7 @@ const sdk = await SandboxAgent.connect({
});
```
See the [full SQLite example](https://github.com/nichochar/sandbox-agent/tree/main/examples/persist-sqlite) for the complete driver implementation you can copy into your project.
See the [full SQLite example](https://github.com/rivet-dev/sandbox-agent/tree/main/examples/persist-sqlite) for the complete driver implementation you can copy into your project.
### Postgres
@ -80,15 +88,7 @@ const sdk = await SandboxAgent.connect({
});
```
See the [full Postgres example](https://github.com/nichochar/sandbox-agent/tree/main/examples/persist-postgres) for the complete driver implementation you can copy into your project.
### IndexedDB (browser)
Best for browser apps that should survive reloads. See the [Inspector source](https://github.com/nichochar/sandbox-agent/tree/main/frontend/packages/inspector/src/persist-indexeddb.ts) for a complete IndexedDB driver you can copy into your project.
### Rivet
Recommended for sandbox orchestration with actor state. See [Multiplayer](/multiplayer) for a full Rivet actor example with inline persistence.
See the [full Postgres example](https://github.com/rivet-dev/sandbox-agent/tree/main/examples/persist-postgres) for the complete driver implementation you can copy into your project.
### Custom driver

View file

@ -0,0 +1,154 @@
import { describe, it, expect } from "vitest";
import { spawn, type ChildProcess } from "node:child_process";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { execSync } from "node:child_process";
const __dirname = dirname(fileURLToPath(import.meta.url));
const PROJECT_DIR = resolve(__dirname, "..");
/**
* Cloudflare Workers integration test.
*
* Set RUN_CLOUDFLARE_EXAMPLES=1 to enable. Requires wrangler and Docker.
*
* This starts `wrangler dev` which:
* 1. Builds the Dockerfile (cloudflare/sandbox base + sandbox-agent)
* 2. Starts a local Workers runtime with Durable Objects and containers
* 3. Exposes the app on a local port
*
* We then test through the proxy endpoint which forwards to sandbox-agent
* running inside the container.
*/
const shouldRun = process.env.RUN_CLOUDFLARE_EXAMPLES === "1";
const timeoutMs = Number.parseInt(process.env.SANDBOX_TEST_TIMEOUT_MS || "", 10) || 600_000;
const testFn = shouldRun ? it : it.skip;
interface WranglerDev {
baseUrl: string;
cleanup: () => void;
}
async function startWranglerDev(): Promise<WranglerDev> {
// Build frontend assets first (wrangler expects dist/ to exist)
execSync("npx vite build", { cwd: PROJECT_DIR, stdio: "pipe" });
return new Promise<WranglerDev>((resolve, reject) => {
const child: ChildProcess = spawn("npx", ["wrangler", "dev", "--port", "0"], {
cwd: PROJECT_DIR,
stdio: ["ignore", "pipe", "pipe"],
detached: true,
env: {
...process.env,
// Ensure wrangler picks up API keys to pass to the container
NODE_ENV: "development",
},
});
let stdout = "";
let stderr = "";
let resolved = false;
const cleanup = () => {
if (child.pid) {
// Kill process group to ensure wrangler and its children are cleaned up
try {
process.kill(-child.pid, "SIGTERM");
} catch {
try {
child.kill("SIGTERM");
} catch {}
}
}
};
const timer = setTimeout(() => {
if (!resolved) {
resolved = true;
cleanup();
reject(new Error(`wrangler dev did not start within 120s.\nstdout: ${stdout}\nstderr: ${stderr}`));
}
}, 120_000);
const onData = (chunk: Buffer) => {
const text = chunk.toString();
stdout += text;
// wrangler dev prints "Ready on http://localhost:XXXX" when ready
const match = stdout.match(/Ready on (https?:\/\/[^\s]+)/i) ?? stdout.match(/(https?:\/\/(?:localhost|127\.0\.0\.1):\d+)/);
if (match && !resolved) {
resolved = true;
clearTimeout(timer);
resolve({ baseUrl: match[1], cleanup });
}
};
child.stdout?.on("data", onData);
child.stderr?.on("data", (chunk: Buffer) => {
const text = chunk.toString();
stderr += text;
// Some wrangler versions print ready message to stderr
const match = text.match(/Ready on (https?:\/\/[^\s]+)/i) ?? text.match(/(https?:\/\/(?:localhost|127\.0\.0\.1):\d+)/);
if (match && !resolved) {
resolved = true;
clearTimeout(timer);
resolve({ baseUrl: match[1], cleanup });
}
});
child.on("error", (err) => {
if (!resolved) {
resolved = true;
clearTimeout(timer);
reject(new Error(`wrangler dev failed to start: ${err.message}`));
}
});
child.on("exit", (code) => {
if (!resolved) {
resolved = true;
clearTimeout(timer);
reject(new Error(`wrangler dev exited with code ${code}.\nstdout: ${stdout}\nstderr: ${stderr}`));
}
});
});
}
describe("cloudflare example", () => {
testFn(
"starts wrangler dev and sandbox-agent responds via proxy",
async () => {
const { baseUrl, cleanup } = await startWranglerDev();
try {
// The Cloudflare example proxies requests through /sandbox/:name/proxy/*
// Wait for the container inside the Durable Object to start sandbox-agent
const healthUrl = `${baseUrl}/sandbox/test/proxy/v1/health`;
let healthy = false;
for (let i = 0; i < 120; i++) {
try {
const res = await fetch(healthUrl);
if (res.ok) {
const data = await res.json();
// The proxied health endpoint returns {name: "Sandbox Agent", ...}
if (data.status === "ok" || data.name === "Sandbox Agent") {
healthy = true;
break;
}
}
} catch {}
await new Promise((r) => setTimeout(r, 2000));
}
expect(healthy).toBe(true);
// Confirm a second request also works
const response = await fetch(healthUrl);
expect(response.ok).toBe(true);
} finally {
cleanup();
}
},
timeoutMs,
);
});

View file

@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
root: ".",
include: ["tests/**/*.test.ts"],
},
});

View file

@ -1,8 +1,15 @@
import { describe, it, expect } from "vitest";
import { buildHeaders } from "@sandbox-agent/example-shared";
import { setupDockerSandboxAgent } from "../src/docker.ts";
import { startDockerSandbox } from "@sandbox-agent/example-shared/docker";
const shouldRun = process.env.RUN_DOCKER_EXAMPLES === "1";
/**
* Docker integration test.
*
* Set SANDBOX_AGENT_DOCKER_IMAGE to the image tag to test (e.g. a locally-built
* full image). The test starts a container from that image, waits for
* sandbox-agent to become healthy, and validates the /v1/health endpoint.
*/
const image = process.env.SANDBOX_AGENT_DOCKER_IMAGE;
const shouldRun = Boolean(image);
const timeoutMs = Number.parseInt(process.env.SANDBOX_TEST_TIMEOUT_MS || "", 10) || 300_000;
const testFn = shouldRun ? it : it.skip;
@ -11,11 +18,29 @@ describe("docker example", () => {
testFn(
"starts sandbox-agent and responds to /v1/health",
async () => {
const { baseUrl, token, cleanup } = await setupDockerSandboxAgent();
const { baseUrl, cleanup } = await startDockerSandbox({
port: 2468,
image: image!,
});
try {
const response = await fetch(`${baseUrl}/v1/health`, {
headers: buildHeaders({ token }),
});
// Wait for health check
let healthy = false;
for (let i = 0; i < 60; i++) {
try {
const res = await fetch(`${baseUrl}/v1/health`);
if (res.ok) {
const data = await res.json();
if (data.status === "ok") {
healthy = true;
break;
}
}
} catch {}
await new Promise((r) => setTimeout(r, 1000));
}
expect(healthy).toBe(true);
const response = await fetch(`${baseUrl}/v1/health`);
expect(response.ok).toBe(true);
const data = await response.json();
expect(data.status).toBe("ok");

View file

@ -37,7 +37,7 @@ export class PostgresSessionPersistDriver implements SessionPersistDriver {
await this.ready();
const result = await this.pool.query<SessionRow>(
`SELECT id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, sandbox_id, session_init_json
`SELECT id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, sandbox_id, session_init_json, config_options_json, modes_json
FROM ${this.table("sessions")}
WHERE id = $1`,
[id],
@ -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, sandbox_id, session_init_json
`SELECT id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, sandbox_id, session_init_json, config_options_json, modes_json
FROM ${this.table("sessions")}
ORDER BY created_at ASC, id ASC
LIMIT $1 OFFSET $2`,
@ -79,8 +79,8 @@ 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, sandbox_id, session_init_json
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, sandbox_id, session_init_json, config_options_json, modes_json
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT(id) DO UPDATE SET
agent = EXCLUDED.agent,
agent_session_id = EXCLUDED.agent_session_id,
@ -88,7 +88,9 @@ export class PostgresSessionPersistDriver implements SessionPersistDriver {
created_at = EXCLUDED.created_at,
destroyed_at = EXCLUDED.destroyed_at,
sandbox_id = EXCLUDED.sandbox_id,
session_init_json = EXCLUDED.session_init_json`,
session_init_json = EXCLUDED.session_init_json,
config_options_json = EXCLUDED.config_options_json,
modes_json = EXCLUDED.modes_json`,
[
session.id,
session.agent,
@ -97,7 +99,9 @@ export class PostgresSessionPersistDriver implements SessionPersistDriver {
session.createdAt,
session.destroyedAt ?? null,
session.sandboxId ?? null,
session.sessionInit ?? null,
session.sessionInit ? JSON.stringify(session.sessionInit) : null,
session.configOptions ? JSON.stringify(session.configOptions) : null,
session.modes !== undefined ? JSON.stringify(session.modes) : null,
],
);
}
@ -174,7 +178,9 @@ export class PostgresSessionPersistDriver implements SessionPersistDriver {
created_at BIGINT NOT NULL,
destroyed_at BIGINT,
sandbox_id TEXT,
session_init_json JSONB
session_init_json JSONB,
config_options_json JSONB,
modes_json JSONB
)
`);
@ -183,6 +189,16 @@ export class PostgresSessionPersistDriver implements SessionPersistDriver {
ADD COLUMN IF NOT EXISTS sandbox_id TEXT
`);
await this.pool.query(`
ALTER TABLE ${this.table("sessions")}
ADD COLUMN IF NOT EXISTS config_options_json JSONB
`);
await this.pool.query(`
ALTER TABLE ${this.table("sessions")}
ADD COLUMN IF NOT EXISTS modes_json JSONB
`);
await this.pool.query(`
CREATE TABLE IF NOT EXISTS ${this.table("events")} (
id TEXT PRIMARY KEY,
@ -238,6 +254,8 @@ type SessionRow = {
destroyed_at: string | number | null;
sandbox_id: string | null;
session_init_json: unknown | null;
config_options_json: unknown | null;
modes_json: unknown | null;
};
type EventRow = {
@ -260,6 +278,8 @@ function decodeSessionRow(row: SessionRow): SessionRecord {
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,
configOptions: row.config_options_json ? (row.config_options_json as SessionRecord["configOptions"]) : undefined,
modes: row.modes_json ? (row.modes_json as SessionRecord["modes"]) : undefined,
};
}

View file

@ -18,7 +18,7 @@ export class SQLiteSessionPersistDriver implements SessionPersistDriver {
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, sandbox_id, session_init_json
`SELECT id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, sandbox_id, session_init_json, config_options_json, modes_json
FROM sessions WHERE id = ?`,
)
.get(id) as SessionRow | undefined;
@ -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, sandbox_id, session_init_json
`SELECT id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, sandbox_id, session_init_json, config_options_json, modes_json
FROM sessions
ORDER BY created_at ASC, id ASC
LIMIT ? OFFSET ?`,
@ -56,8 +56,8 @@ export class SQLiteSessionPersistDriver implements SessionPersistDriver {
this.db
.prepare(
`INSERT INTO sessions (
id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, sandbox_id, session_init_json
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, sandbox_id, session_init_json, config_options_json, modes_json
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
agent = excluded.agent,
agent_session_id = excluded.agent_session_id,
@ -65,7 +65,9 @@ export class SQLiteSessionPersistDriver implements SessionPersistDriver {
created_at = excluded.created_at,
destroyed_at = excluded.destroyed_at,
sandbox_id = excluded.sandbox_id,
session_init_json = excluded.session_init_json`,
session_init_json = excluded.session_init_json,
config_options_json = excluded.config_options_json,
modes_json = excluded.modes_json`,
)
.run(
session.id,
@ -76,6 +78,8 @@ export class SQLiteSessionPersistDriver implements SessionPersistDriver {
session.destroyedAt ?? null,
session.sandboxId ?? null,
session.sessionInit ? JSON.stringify(session.sessionInit) : null,
session.configOptions ? JSON.stringify(session.configOptions) : null,
session.modes !== undefined ? JSON.stringify(session.modes) : null,
);
}
@ -134,7 +138,9 @@ export class SQLiteSessionPersistDriver implements SessionPersistDriver {
created_at INTEGER NOT NULL,
destroyed_at INTEGER,
sandbox_id TEXT,
session_init_json TEXT
session_init_json TEXT,
config_options_json TEXT,
modes_json TEXT
)
`);
@ -142,6 +148,12 @@ export class SQLiteSessionPersistDriver implements SessionPersistDriver {
if (!sessionColumns.some((column) => column.name === "sandbox_id")) {
this.db.exec(`ALTER TABLE sessions ADD COLUMN sandbox_id TEXT`);
}
if (!sessionColumns.some((column) => column.name === "config_options_json")) {
this.db.exec(`ALTER TABLE sessions ADD COLUMN config_options_json TEXT`);
}
if (!sessionColumns.some((column) => column.name === "modes_json")) {
this.db.exec(`ALTER TABLE sessions ADD COLUMN modes_json TEXT`);
}
this.ensureEventsTable();
}
@ -233,6 +245,8 @@ type SessionRow = {
destroyed_at: number | null;
sandbox_id: string | null;
session_init_json: string | null;
config_options_json: string | null;
modes_json: string | null;
};
type EventRow = {
@ -260,6 +274,8 @@ function decodeSessionRow(row: SessionRow): SessionRecord {
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,
configOptions: row.config_options_json ? (JSON.parse(row.config_options_json) as SessionRecord["configOptions"]) : undefined,
modes: row.modes_json ? (JSON.parse(row.modes_json) as SessionRecord["modes"]) : undefined,
};
}

View file

@ -78,11 +78,11 @@ function readClaudeCredentialFiles(): ClaudeCredentialFile[] {
const candidates: Array<{ hostPath: string; containerPath: string }> = [
{
hostPath: path.join(homeDir, ".claude", ".credentials.json"),
containerPath: "/root/.claude/.credentials.json",
containerPath: ".claude/.credentials.json",
},
{
hostPath: path.join(homeDir, ".claude-oauth-credentials.json"),
containerPath: "/root/.claude-oauth-credentials.json",
containerPath: ".claude-oauth-credentials.json",
},
];
@ -180,10 +180,9 @@ export async function startDockerSandbox(opts: DockerSandboxOptions): Promise<Do
const credentialBootstrapCommands = claudeCredentialFiles.flatMap((file, index) => {
const envKey = `SANDBOX_AGENT_CLAUDE_CREDENTIAL_${index}_B64`;
bootstrapEnv[envKey] = file.base64Content;
return [
`mkdir -p ${shellSingleQuotedLiteral(path.posix.dirname(file.containerPath))}`,
`printf %s "$${envKey}" | base64 -d > ${shellSingleQuotedLiteral(file.containerPath)}`,
];
// Use $HOME-relative paths so credentials work regardless of container user
const containerDir = path.posix.dirname(file.containerPath);
return [`mkdir -p "$HOME/${containerDir}"`, `printf %s "$${envKey}" | base64 -d > "$HOME/${file.containerPath}"`];
});
setupCommands.unshift(...credentialBootstrapCommands);
}
@ -200,8 +199,9 @@ export async function startDockerSandbox(opts: DockerSandboxOptions): Promise<Do
const container = await docker.createContainer({
Image: image,
Entrypoint: ["/bin/sh", "-c"],
WorkingDir: "/home/sandbox",
Cmd: ["sh", "-c", bootCommands.join(" && ")],
Cmd: [bootCommands.join(" && ")],
Env: [...Object.entries(credentialEnv).map(([key, value]) => `${key}=${value}`), ...Object.entries(bootstrapEnv).map(([key, value]) => `${key}=${value}`)],
ExposedPorts: { [`${port}/tcp`]: {} },
HostConfig: {
@ -253,10 +253,13 @@ export async function startDockerSandbox(opts: DockerSandboxOptions): Promise<Do
try {
await container.remove({ force: true });
} catch {}
};
const signalCleanup = async () => {
await cleanup();
process.exit(0);
};
process.once("SIGINT", cleanup);
process.once("SIGTERM", cleanup);
process.once("SIGINT", signalCleanup);
process.once("SIGTERM", signalCleanup);
return { baseUrl, cleanup };
}

View file

@ -141,6 +141,8 @@ type SessionRow = {
destroyedAt?: number;
sandboxId?: string;
sessionInit?: SessionRecord["sessionInit"];
configOptions?: SessionRecord["configOptions"];
modes?: SessionRecord["modes"];
};
type EventRow = {
@ -163,6 +165,8 @@ function encodeSessionRow(session: SessionRecord): SessionRow {
destroyedAt: session.destroyedAt,
sandboxId: session.sandboxId,
sessionInit: session.sessionInit,
configOptions: session.configOptions,
modes: session.modes,
};
}
@ -176,6 +180,8 @@ function decodeSessionRow(row: SessionRow): SessionRecord {
destroyedAt: row.destroyedAt,
sandboxId: row.sandboxId,
sessionInit: row.sessionInit,
configOptions: row.configOptions,
modes: row.modes,
};
}

View file

@ -2,4 +2,4 @@
> **Deprecated:** This package has been deprecated and removed.
Copy the driver source directly into your project. See the [session persistence docs](https://sandboxagent.dev/session-persistence) for guidance.
Copy the driver source into your project. See the [reference implementation](https://github.com/rivet-dev/sandbox-agent/tree/main/frontend/packages/inspector/src/persist-indexeddb.ts) and the [session persistence docs](https://sandboxagent.dev/session-persistence) for guidance.

View file

@ -1,7 +1,7 @@
{
"name": "@sandbox-agent/persist-indexeddb",
"version": "0.3.2",
"description": "IndexedDB persistence driver for the Sandbox Agent TypeScript SDK",
"description": "IndexedDB persistence driver for the Sandbox Agent TypeScript SDK (DEPRECATED)",
"license": "Apache-2.0",
"repository": {
"type": "git",

View file

@ -1,5 +1,5 @@
throw new Error(
"@sandbox-agent/persist-indexeddb has been deprecated and removed. " +
"Copy the reference implementation into your project instead. " +
"See https://github.com/nichochar/sandbox-agent/tree/main/examples/persist-indexeddb",
"Copy the reference implementation from frontend/packages/inspector/src/persist-indexeddb.ts into your project instead. " +
"See https://github.com/rivet-dev/sandbox-agent/tree/main/frontend/packages/inspector/src/persist-indexeddb.ts",
);

View file

@ -1,5 +1,5 @@
# @sandbox-agent/persist-postgres
> **Deprecated:** This package has been deprecated and removed. The implementation now lives as a copy-paste reference in [`examples/persist-postgres`](../../examples/persist-postgres).
> **Deprecated:** This package has been deprecated and removed.
Install `pg` directly and copy the driver source into your project. See the [full example](https://github.com/nichochar/sandbox-agent/tree/main/examples/persist-postgres).
Install `pg` directly and copy the driver source into your project. See the [full example](https://github.com/rivet-dev/sandbox-agent/tree/main/examples/persist-postgres) and the [session persistence docs](https://sandboxagent.dev/session-persistence) for guidance.

View file

@ -1,5 +1,5 @@
throw new Error(
"@sandbox-agent/persist-postgres has been deprecated and removed. " +
"Copy the reference implementation from examples/persist-postgres into your project instead. " +
"See https://github.com/nichochar/sandbox-agent/tree/main/examples/persist-postgres",
"See https://github.com/rivet-dev/sandbox-agent/tree/main/examples/persist-postgres",
);

View file

@ -2,4 +2,4 @@
> **Deprecated:** This package has been deprecated and removed.
Copy the driver source directly into your project. See the [session persistence docs](https://sandboxagent.dev/session-persistence) for guidance.
Copy the driver source into your project. See the [multiplayer docs](https://github.com/rivet-dev/sandbox-agent/tree/main/docs/multiplayer.mdx) and the [session persistence docs](https://sandboxagent.dev/session-persistence) for guidance.

View file

@ -1,7 +1,7 @@
{
"name": "@sandbox-agent/persist-rivet",
"version": "0.3.2",
"description": "Rivet Actor persistence driver for the Sandbox Agent TypeScript SDK",
"description": "Rivet Actor persistence driver for the Sandbox Agent TypeScript SDK (DEPRECATED)",
"license": "Apache-2.0",
"repository": {
"type": "git",

View file

@ -1,5 +1,5 @@
throw new Error(
"@sandbox-agent/persist-rivet has been deprecated and removed. " +
"Copy the reference implementation into your project instead. " +
"See https://github.com/nichochar/sandbox-agent/tree/main/examples/persist-rivet",
"Copy the reference implementation from docs/multiplayer.mdx into your project instead. " +
"See https://github.com/rivet-dev/sandbox-agent/tree/main/docs/multiplayer.mdx",
);

View file

@ -1,5 +1,5 @@
# @sandbox-agent/persist-sqlite
> **Deprecated:** This package has been deprecated and removed. The implementation now lives as a copy-paste reference in [`examples/persist-sqlite`](../../examples/persist-sqlite).
> **Deprecated:** This package has been deprecated and removed.
Install `better-sqlite3` directly and copy the driver source into your project. See the [full example](https://github.com/nichochar/sandbox-agent/tree/main/examples/persist-sqlite).
Install `better-sqlite3` directly and copy the driver source into your project. See the [full example](https://github.com/rivet-dev/sandbox-agent/tree/main/examples/persist-sqlite) and the [session persistence docs](https://sandboxagent.dev/session-persistence) for guidance.

View file

@ -1,7 +1,7 @@
{
"name": "@sandbox-agent/persist-sqlite",
"version": "0.3.2",
"description": "SQLite persistence driver for the Sandbox Agent TypeScript SDK",
"description": "SQLite persistence driver for the Sandbox Agent TypeScript SDK (DEPRECATED)",
"license": "Apache-2.0",
"repository": {
"type": "git",

View file

@ -1,5 +1,5 @@
throw new Error(
"@sandbox-agent/persist-sqlite has been deprecated and removed. " +
"Copy the reference implementation from examples/persist-sqlite into your project instead. " +
"See https://github.com/nichochar/sandbox-agent/tree/main/examples/persist-sqlite",
"See https://github.com/rivet-dev/sandbox-agent/tree/main/examples/persist-sqlite",
);

View file

@ -89,6 +89,7 @@ const HEALTH_WAIT_MIN_DELAY_MS = 500;
const HEALTH_WAIT_MAX_DELAY_MS = 15_000;
const HEALTH_WAIT_LOG_AFTER_MS = 5_000;
const HEALTH_WAIT_LOG_EVERY_MS = 10_000;
const HEALTH_WAIT_ENSURE_SERVER_AFTER_FAILURES = 3;
export interface SandboxAgentHealthWaitOptions {
timeoutMs?: number;
@ -903,7 +904,7 @@ export class SandboxAgent {
const createdSandbox = !existingSandbox;
if (existingSandbox) {
await provider.wake?.(rawSandboxId);
await provider.ensureServer?.(rawSandboxId);
}
try {
@ -2118,6 +2119,7 @@ export class SandboxAgent {
let delayMs = HEALTH_WAIT_MIN_DELAY_MS;
let nextLogAt = startedAt + HEALTH_WAIT_LOG_AFTER_MS;
let lastError: unknown;
let consecutiveFailures = 0;
while (!this.disposed && (deadline === undefined || Date.now() < deadline)) {
throwIfAborted(signal);
@ -2128,11 +2130,22 @@ export class SandboxAgent {
return;
}
lastError = new Error(`Unexpected health response: ${JSON.stringify(health)}`);
consecutiveFailures++;
} catch (error) {
if (isAbortError(error)) {
throw error;
}
lastError = error;
consecutiveFailures++;
}
if (consecutiveFailures >= HEALTH_WAIT_ENSURE_SERVER_AFTER_FAILURES && this.sandboxProvider?.ensureServer && this.sandboxProviderRawId) {
try {
await this.sandboxProvider.ensureServer(this.sandboxProviderRawId);
} catch {
// Best-effort; the next health check will determine if it worked.
}
consecutiveFailures = 0;
}
const now = Date.now();

View file

@ -49,5 +49,12 @@ export function computesdk(options: ComputeSdkProviderOptions = {}): SandboxProv
if (!sandbox) throw new Error(`computesdk sandbox not found: ${sandboxId}`);
return sandbox.getUrl({ port: agentPort });
},
async ensureServer(sandboxId: string): Promise<void> {
const sandbox = await compute.sandbox.getById(sandboxId);
if (!sandbox) throw new Error(`computesdk sandbox not found: ${sandboxId}`);
await sandbox.runCommand(`sandbox-agent server --no-token --host 0.0.0.0 --port ${agentPort}`, {
background: true,
});
},
};
}

View file

@ -56,7 +56,7 @@ export function daytona(options: DaytonaProviderOptions = {}): SandboxProvider {
const preview = await sandbox.getSignedPreviewUrl(agentPort, previewTtlSeconds);
return typeof preview === "string" ? preview : preview.url;
},
async wake(sandboxId: string): Promise<void> {
async ensureServer(sandboxId: string): Promise<void> {
const sandbox = await client.get(sandboxId);
if (!sandbox) {
throw new Error(`daytona sandbox not found: ${sandboxId}`);

View file

@ -53,5 +53,10 @@ export function e2b(options: E2BProviderOptions = {}): SandboxProvider {
const sandbox = await Sandbox.connect(sandboxId, connectOpts as any);
return `https://${sandbox.getHost(agentPort)}`;
},
async ensureServer(sandboxId: string): Promise<void> {
const connectOpts = await resolveOptions(options.connect, sandboxId);
const sandbox = await Sandbox.connect(sandboxId, connectOpts as any);
await sandbox.commands.run(`sandbox-agent server --no-token --host 0.0.0.0 --port ${agentPort}`, { background: true, timeoutMs: 0 });
},
};
}

View file

@ -66,5 +66,9 @@ export function modal(options: ModalProviderOptions = {}): SandboxProvider {
}
return tunnel.url;
},
async ensureServer(sandboxId: string): Promise<void> {
const sb = await client.sandboxes.fromId(sandboxId);
sb.exec(["sandbox-agent", "server", "--no-token", "--host", "0.0.0.0", "--port", String(agentPort)]);
},
};
}

View file

@ -21,8 +21,11 @@ export interface SandboxProvider {
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.
* Ensure the sandbox-agent server process is running inside the sandbox.
* Called during health-wait after consecutive failures, and before
* reconnecting to an existing sandbox. Implementations should be
* idempotent if the server is already running, this should be a no-op
* (e.g. the duplicate process exits on port conflict).
*/
wake?(sandboxId: string): Promise<void>;
ensureServer?(sandboxId: string): Promise<void>;
}

View file

@ -53,5 +53,13 @@ export function vercel(options: VercelProviderOptions = {}): SandboxProvider {
const sandbox = await Sandbox.get({ sandboxId });
return sandbox.domain(agentPort);
},
async ensureServer(sandboxId: string): Promise<void> {
const sandbox = await Sandbox.get({ sandboxId });
await sandbox.runCommand({
cmd: "sandbox-agent",
args: ["server", "--no-token", "--host", "0.0.0.0", "--port", String(agentPort)],
detached: true,
});
},
};
}

View file

@ -415,7 +415,7 @@ impl AcpProxyRuntime {
async fn is_ready(&self, agent: AgentId) -> bool {
if agent == AgentId::Mock {
return self.inner.agent_manager.agent_process_path(agent).exists();
return true;
}
self.inner.agent_manager.is_installed(agent)
}