Sandbox Actor
Run sandbox-agent sessions behind a Rivet Actor with provider-backed sandbox creation.
The Sandbox Actor wraps the sandbox-agent TypeScript SDK in a Rivet Actor.
- One sandbox actor key maps to one backing sandbox.
onBeforeConnectis supported for auth and connection validation.- All non-hook
sandbox-agentinstance methods are exposed as actor actions. - The hook surface matches the SDK callback methods:
onSessionEventandonPermissionRequest. - The actor also adds
destroyandgetSandboxUrlhelper actions. - Transcript data is persisted automatically in the actor’s built-in SQLite database.
It is not a drop-in replacement for the full actor() API. Sandbox actors are
purpose-built around sandbox lifecycle and session management, so they do not
currently expose custom actor events, queues, onConnect, onDisconnect,
onRequest, onWebSocket, createState, createVars, or custom database
configuration.
Feature surface
Sandbox actors support these configuration options:
| Option | Description |
|---|---|
provider | Use one provider instance for every actor. |
createProvider | Resolve the provider dynamically from actor context such as c.key or environment. |
onBeforeConnect | Validate or reject client connections before they attach to the actor. |
onSessionEvent | Observe sandbox-agent session events. |
onPermissionRequest | Observe permission requests and keep the actor awake while they are pending. |
persistRawEvents | Store raw event payload JSON in SQLite in addition to the normalized transcript records. |
destroyActor | Destroy the actor after the custom destroy() action tears down the backing sandbox. |
options.warningAfterMs / options.staleAfterMs | Control active-turn warning and stale-session cleanup timers. |
The action surface includes:
- every public non-hook
SandboxAgentinstance method getSandboxUrl()for direct helper access when the provider exposesgetUrldestroy()for tearing down the backing sandbox while keeping transcript data readable
Basic setup
Use provider when every actor instance should use the same sandbox backend.
import { setup } from "rivetkit";
import { sandboxActor } from "rivetkit/sandbox";
import { docker } from "rivetkit/sandbox/docker";
export const codingSandbox = sandboxActor({
provider: docker({
image: "node:22-bookworm-slim",
}),
onSessionEvent: async (_c, sessionId, event) => {
console.log("session event", sessionId, event.payload);
},
onPermissionRequest: async (_c, sessionId, request) => {
console.log("permission request", sessionId, request.id);
},
});
export const registry = setup({
use: { codingSandbox },
});
registry.start();
import { createClient } from "rivetkit/client";
import type { registry } from "./index";
const client = createClient<typeof registry>("http://localhost:6420");
const sandbox = client.codingSandbox.getOrCreate(["task-123"]);
const session = await sandbox.resumeOrCreateSession({
id: "main",
agent: "codex",
sessionInit: {
cwd: "/root",
},
});
await sandbox.rawSendSessionMethod(session.id, "session/prompt", {
sessionId: session.id,
prompt: [{ type: "text", text: "Explain the current project structure." }],
});
const events = await sandbox.getEvents({
sessionId: session.id,
limit: 50,
});
console.log(events.items);
Dynamic providers
Use createProvider when provider selection depends on actor context, such as
the actor key or environment.
createProvider receives the sandbox actor context. Sandbox actors do not take
custom actor creation input.
import { setup } from "rivetkit";
import { sandboxActor } from "rivetkit/sandbox";
import { daytona } from "rivetkit/sandbox/daytona";
import { docker } from "rivetkit/sandbox/docker";
import { e2b } from "rivetkit/sandbox/e2b";
export const codingSandbox = sandboxActor({
createProvider: async (c) => {
switch (c.key[0]) {
case "daytona":
return daytona();
case "e2b":
return e2b();
default:
return docker();
}
},
});
export const registry = setup({
use: { codingSandbox },
});
registry.start();
import { createClient } from "rivetkit/client";
import type { registry } from "./index";
const client = createClient<typeof registry>("http://localhost:6420");
const sandbox = client.codingSandbox.getOrCreate(["daytona", "task-456"]);
await sandbox.listAgents();
The sandbox actor pins the resolved provider name in actor state. If a later wake or reconnect resolves a different provider for the same actor, the actor throws instead of silently switching backends.
Active turn sleep behavior
The sandbox actor always keeps itself awake while a subscribed session still looks like it is in the middle of a turn.
import { sandboxActor } from "rivetkit/sandbox";
import { docker } from "rivetkit/sandbox/docker";
const codingSandbox = sandboxActor({
provider: docker(),
options: {
warningAfterMs: 30_000,
staleAfterMs: 5 * 60_000,
},
});
This tracks active sessions from observed session/prompt envelopes and
permission requests. RivetKit sets preventSleep while any session still looks
active, logs if the stream goes quiet, and eventually clears stale state if no
terminal response arrives.
Lifecycle and persistence behavior
The sandbox actor adds a few behaviors on top of plain SDK parity:
destroy()tears down the backing sandbox without deleting the actor by default- after
destroy(),listSessions,getSession, andgetEventscontinue to read from persisted SQLite data destroyActor: truemakesdestroy()also destroy the actor itselfpersistRawEvents: truestores raw event payload JSON for each persisted session event
Providers
Providers are re-exported from the sandbox-agent package. Each provider is available as a separate subpackage import to keep your bundle lean. Install the provider’s peer dependency to use it.
Docker
Requires the dockerode and get-port packages.
pnpm add dockerode get-port
import { docker } from "rivetkit/sandbox/docker";
const provider = docker({
image: "node:22-bookworm-slim",
host: "127.0.0.1",
env: ["MY_VAR=value"],
binds: ["/host/path:/container/path"],
createContainerOptions: { User: "node" },
});
| Option | Default | Description |
|---|---|---|
image | node:22-bookworm-slim | Docker image to use. |
host | 127.0.0.1 | Host address for connecting to the container. |
agentPort | Provider default | Port the sandbox-agent server listens on. |
env | [] | Environment variables. Can be a static array or an async function. |
binds | [] | Volume binds. Can be a static array or an async function. |
createContainerOptions | {} | Additional options passed to dockerode’s createContainer. |
Daytona
Requires the @daytonaio/sdk package.
pnpm add @daytonaio/sdk
import { daytona } from "rivetkit/sandbox/daytona";
const provider = daytona({
create: { image: "node:22" },
previewTtlSeconds: 4 * 60 * 60,
deleteTimeoutSeconds: 10,
});
| Option | Default | Description |
|---|---|---|
create | {} | Options passed to client.create(). Can be a static object or an async function. |
image | Provider default | Docker image for the Daytona workspace. |
agentPort | Provider default | Port the sandbox-agent server listens on. |
previewTtlSeconds | 14400 (4 hours) | TTL for the signed preview URL used to connect. |
deleteTimeoutSeconds | undefined | Timeout passed to sandbox.delete() on destroy. |
E2B
Requires the @e2b/code-interpreter package.
pnpm add @e2b/code-interpreter
import { e2b } from "rivetkit/sandbox/e2b";
const provider = e2b({
create: { template: "base" },
connect: async (sandboxId) => ({ sandboxId }),
});
| Option | Default | Description |
|---|---|---|
create | {} | Options passed to Sandbox.create(). Can be a static object or an async function. |
connect | {} | Options passed to Sandbox.connect() when reconnecting. Can be a static object or an async function receiving the sandbox ID. |
agentPort | Provider default | Port the sandbox-agent server listens on. |
Vercel
Requires the @vercel/sandbox package.
pnpm add @vercel/sandbox
import { vercel } from "rivetkit/sandbox/vercel";
const provider = vercel({
create: { template: "nextjs" },
});
Modal
Requires the modal package.
pnpm add modal
import { modal } from "rivetkit/sandbox/modal";
const provider = modal({
create: { secrets: { MY_SECRET: "value" } },
});
Local
Runs sandbox-agent locally on the host machine. No additional dependencies required.
import { local } from "rivetkit/sandbox/local";
const provider = local({
port: 2468,
});
ComputeSDK
Requires the computesdk package.
pnpm add computesdk
import { computesdk } from "rivetkit/sandbox/computesdk";
const provider = computesdk({
create: {},
});
Sprites
Requires the @fly/sprites package.
pnpm add @fly/sprites
import { sprites } from "rivetkit/sandbox/sprites";
const provider = sprites({});
Unsupported providers
Cloudflare Sandbox is available in sandbox-agent but is not re-exported from RivetKit.
Providers that only expose getFetch can still back the proxied sandbox actor
actions, but they cannot use getSandboxUrl() or the direct helper APIs in
rivetkit/sandbox/client, because those helpers require a reachable sandbox URL.
If you need Cloudflare sandboxes, use sandbox-agent/cloudflare directly and
do not rely on the direct URL helper flow.
Custom providers
Implement the SandboxProvider interface from sandbox-agent to use any sandbox backend.
import { type SandboxProvider } from "rivetkit/sandbox";
const provisionSandbox = async (): Promise<string> => "sandbox-123";
const teardownSandbox = async (_sandboxId: string): Promise<void> => {};
const lookupSandboxUrl = async (_sandboxId: string): Promise<string> =>
"http://127.0.0.1:3000";
const restartAgentIfNeeded = async (_sandboxId: string): Promise<void> => {};
const myProvider: SandboxProvider = {
name: "my-provider",
async create() {
// Provision a sandbox and return a string ID.
const sandboxId = await provisionSandbox();
return sandboxId;
},
async destroy(sandboxId) {
// Tear down the sandbox identified by `sandboxId`.
await teardownSandbox(sandboxId);
},
async getUrl(sandboxId) {
// Return the sandbox-agent base URL.
return await lookupSandboxUrl(sandboxId);
},
async ensureServer(sandboxId) {
// Restart the sandbox-agent process if it stopped.
// Called automatically before connecting. Must be idempotent.
await restartAgentIfNeeded(sandboxId);
},
};
Use it like any built-in provider:
import { sandboxActor, type SandboxProvider } from "rivetkit/sandbox";
declare const myProvider: SandboxProvider;
const mySandbox = sandboxActor({
provider: myProvider,
});
The provider methods map to the sandbox lifecycle:
createis called once when the actor first needs a sandbox. Return a stable string ID.getUrlreturns the sandbox-agent base URL for direct filesystem, terminal, and log-stream helpers. Alternatively, implementgetFetchfor providers that cannot expose a URL.ensureServer(optional) is called before connecting to ensure the sandbox-agent server process is running. Must be idempotent.destroyis called when the actor is destroyed. Clean up all external resources.
When a provider implements only getFetch, the sandbox actor can still proxy
structured SDK actions, but getSandboxUrl() and the direct helper APIs are not
available.
Direct sandbox access
Some sandbox-agent operations involve raw binary data, WebSocket streams, or SSE
event streams that cannot be efficiently proxied through JSON-based actor actions.
For these, rivetkit/sandbox/client provides helper functions that talk directly
to the sandbox-agent HTTP API, bypassing the actor.
Use the getSandboxUrl action to obtain the sandbox’s base URL, then pass it to
the helpers.
Filesystem helpers
import { setup } from "rivetkit";
import { sandboxActor } from "rivetkit/sandbox";
import { docker } from "rivetkit/sandbox/docker";
export const codingSandbox = sandboxActor({
provider: docker({ image: "node:22-bookworm-slim" }),
});
export const registry = setup({
use: { codingSandbox },
});
import { createClient } from "rivetkit/client";
import {
uploadFile,
downloadFile,
uploadBatch,
listFiles,
statFile,
deleteFile,
mkdirFs,
moveFile,
} from "rivetkit/sandbox/client";
import type { registry } from "./index";
const client = createClient<typeof registry>("http://localhost:6420");
const sandbox = client.codingSandbox.getOrCreate(["task-789"]);
const tarBuffer = new Uint8Array([0x75, 0x73, 0x74, 0x61, 0x72]);
// Get the direct URL to the sandbox-agent server.
const { url } = await sandbox.getSandboxUrl();
// Upload a file (raw binary, no base64 encoding).
const csvFile = new Blob(["id,name\n1,Alice"], { type: "text/csv" });
await uploadFile(url, "/workspace/data.csv", csvFile);
// Download a file.
const contents = await downloadFile(url, "/workspace/data.csv");
// Batch upload a tar archive.
await uploadBatch(url, "/workspace", tarBuffer);
// List, stat, delete, mkdir, move.
const entries = await listFiles(url, "/workspace");
const info = await statFile(url, "/workspace/data.csv");
await mkdirFs(url, "/workspace/output");
await moveFile(url, "/workspace/data.csv", "/workspace/output/data.csv");
await deleteFile(url, "/workspace/output/data.csv");
Process terminal
import {
connectTerminal,
buildTerminalWebSocketUrl,
} from "rivetkit/sandbox/client";
const url = "http://127.0.0.1:3000";
const processId = "proc-123";
// Connect to a process terminal via WebSocket.
const terminal = await connectTerminal(url, processId);
terminal.onData((data) => console.log("output:", data));
terminal.sendInput("ls\n");
terminal.close();
// Or get the raw WebSocket URL for use with xterm.js or another client.
const wsUrl = buildTerminalWebSocketUrl(url, processId);
Log streaming
import { followProcessLogs } from "rivetkit/sandbox/client";
const url = "http://127.0.0.1:3000";
const processId = "proc-123";
// Stream process logs via SSE.
const subscription = await followProcessLogs(url, processId, (entry) => {
console.log(`[${entry.stream}] ${entry.data}`);
});
// Stop streaming.
subscription.close();
Why direct access?
The sandbox actor proxies all structured sandbox-agent methods as actor
actions. However, three categories of operations do not fit JSON-based RPC:
- Binary filesystem I/O (
readFsFile,writeFsFile,uploadFsBatch): base64 encoding adds ~33% overhead. - WebSocket terminals (
connectProcessTerminal): bidirectional binary streams. - SSE log streaming (
followProcessLogs): continuous event streams with callbacks.
These helpers bypass the actor for the data plane while the actor remains the control plane for sessions, permissions, and lifecycle management.
SDK parity
The public action surface intentionally mirrors sandbox-agent.
- Hooks:
onSessionEvent,onPermissionRequest - Actions: every other public
SandboxAgentinstance method - Direct access: filesystem, terminal, and log streaming helpers in
rivetkit/sandbox/client
This is enforced by a parity test in RivetKit so SDK upgrades fail fast if the sandbox actor falls out of sync.
Notes
- Use actor keys or runtime context when you need per-actor provider selection.
- Use actions to call sandbox-agent methods from clients or other actors.
- The transcript store is internal to the sandbox actor. You do not need to configure a transcript adapter yourself.
- The sandbox actor automatically provisions and migrates its SQLite tables. You do not need to pass a database config.