Skip to main content
Coding Agents

Use a Sandbox from Any Actor

Use one API to create and drive sandboxes on agentOS, E2B, Daytona, Freestyle, or Docker from any Rivet Actor, without the agent harness.

PROVIDERSAny Rivet Actorstores the sandbox idsandbox-adapterSandboxProvider, SandboxagentOSE2BDaytonaFreestyleDockercreate, connect1exec, files2provider APIOne sandbox per ActorRivet ActoragentOS VMCall

@rivet-dev/sandbox-adapter gives a Rivet Actor one API to create a sandbox, run commands, use files, and clean up. Use it directly when your Actor runs its own process, such as a build or a coding CLI.

To give a Pi agent a sandbox, pass a provider to pi() instead. Pi then makes all the calls on this page for you. See Sandboxes, which also lists each provider’s options.

Use a sandbox from an Actor

This example uses Docker because it needs no account. Your worker must have the docker CLI.

npm add @rivet-dev/sandbox-adapter
import { posix } from "node:path";
import type { Sandbox, SandboxActorContext } from "@rivet-dev/sandbox-adapter";
import { dockerProvider } from "@rivet-dev/sandbox-adapter/docker";
import { actor, event, setup } from "rivetkit";

// One provider object serves every Actor of this definition.
const provider = dockerProvider({ image: "ubuntu:24.04" });

interface BuildState {
	sandboxId: string | null;
}

export const build = actor({
	state: { sandboxId: null } as BuildState,
	events: {
		// Command output, as it arrives.
		output: event<string>(),
	},
	actions: {
		run: async (c, command: string) => {
			const sandbox = await openSandbox(c, c.state);
			const decoder = new TextDecoder();
			return sandbox.exec(command, {
				cwd: sandbox.cwd,
				timeoutMs: 60_000,
				// Stops the command if the Actor shuts down.
				signal: c.abortSignal,
				onData: (chunk) => c.broadcast("output", decoder.decode(chunk, { stream: true })),
			});
		},
		saveFile: async (c, path: string, content: string) => {
			const sandbox = await openSandbox(c, c.state);
			const target = posix.resolve(sandbox.cwd, path);
			await sandbox.mkdir(posix.dirname(target));
			await sandbox.writeFile(target, content);
		},
		readFile: async (c, path: string) => {
			const sandbox = await openSandbox(c, c.state);
			const bytes = await sandbox.readFile(posix.resolve(sandbox.cwd, path));
			return new TextDecoder().decode(bytes);
		},
	},
	// The sandbox stops while the Actor sleeps and is removed along with the Actor.
	onSleep: async (c) => {
		if (c.state.sandboxId) await provider.suspend?.(c, c.state.sandboxId);
	},
	onDestroy: async (c) => {
		if (c.state.sandboxId) await provider.destroy?.(c, c.state.sandboxId);
	},
});

// Connects to this Actor's sandbox. The first call creates it.
async function openSandbox(c: SandboxActorContext, state: BuildState): Promise<Sandbox> {
	const existing = state.sandboxId ? await provider.connect(c, state.sandboxId) : undefined;
	if (existing) return existing;
	state.sandboxId = await provider.create(c);
	const sandbox = await provider.connect(c, state.sandboxId);
	if (!sandbox) throw new Error(`Sandbox ${state.sandboxId} was not found right after it was created.`);
	return sandbox;
}

export const registry = setup({ use: { build } });

registry.start();

The first run, saveFile, or readFile creates the Actor’s sandbox. Later calls connect to the same sandbox.

Choose a provider

Every provider returns the same SandboxProvider, so you change one import and one call. See Providers for the options of each one.

ProviderFunctionImport fromAlso installCredentials
agentOSagentOSProvider()@rivet-dev/sandbox-adapter/agentosNothingNone
E2Be2bProvider()@rivet-dev/sandbox-adapter/e2be2bE2B_API_KEY
DaytonadaytonaProvider()@rivet-dev/sandbox-adapter/daytona@daytonaio/sdkDAYTONA_API_KEY
FreestylefreestyleProvider({ create })@rivet-dev/sandbox-adapter/freestylefreestyleFREESTYLE_API_KEY
DockerdockerProvider()@rivet-dev/sandbox-adapter/dockerNothingNone

Manage the lifecycle

The adapter stores nothing. Your Actor stores the sandbox id and decides when to make each call.

CallWhen to call itResult
create(c)No id is stored, or connect returned undefined.A new sandbox id. Store it right away.
connect(c, id)Before you use the sandbox after a wake.A Sandbox, or undefined when the provider reports that the sandbox no longer exists.
suspend?.(c, id)In onSleep.The sandbox stops using resources while the Actor sleeps. agentOS has no suspend.
destroy?.(c, id)In onDestroy.The sandbox is deleted. agentOS has no destroy, so its VM stays.

openSandbox, onSleep, and onDestroy in the example above make these calls.

  • Store the id, not the Sandbox. A Sandbox object exists only in memory, so connect again after a wake.
  • Keep the id when connect throws. It throws on any failure other than a missing sandbox, so a provider outage does not replace the sandbox.
  • A new sandbox starts empty. When connect returns undefined, the old files are gone.

Run commands

exec runs one command in the sandbox and waits for it to end. A non-zero exit code is a normal result, not an error.

OptionDescription
cwdRequired. Absolute working directory. Use sandbox.cwd for the sandbox’s own.
envVariables for this command. Your worker’s environment is never passed in.
timeoutMsKills the command after this many milliseconds. Leave it out, or pass 0, for no timeout.
signalAborting kills the command, and exec rejects with Error("aborted").
onDataReceives stdout and stderr chunks as bytes, in one stream, as they arrive.
ResultDescription
exitCodeThe exit code. null when the command timed out or a signal killed it.
timedOuttrue when timeoutMs ended the command.
stdoutAll standard output.
stderrAll standard error.

Some providers report output differently:

  • Daytona returns stdout and stderr together in stdout, leaves stderr empty, and calls onData once when the command ends.
  • E2B and Daytona return empty stdout and stderr after a timeout. On E2B, onData has already received the output.

Use files

All paths are absolute paths inside the sandbox.

OperationResult
readFile(path)The contents as a Uint8Array.
writeFile(path, content)Writes a string. Binary content is not supported. Create the parent directory first.
mkdir(path)Creates the directory.
stat(path){ isDirectory }.
readdir(path)The entry names.
exists(path)true or false.

Docker image requirements

The Docker provider has no agent inside the container. It runs every operation through a shell and a few command-line tools from the image. ubuntu:24.04, debian:bookworm-slim, alpine:3.22, and busybox:1.37 have all of them.

The image needsUsed forWithout it
/bin/shEverything. Any POSIX shell with built-in echo, printf, [, cd, and kill, such as dash or BusyBox ash.connect fails.
tailThe idle process that keeps the container running. Not needed with keepImageEntrypoint.create succeeds, then connect fails because the container is not running.
mkdirconnect, mkdir, and exec.connect fails.
catreadFile, writeFile, and stopping a command.File reads and writes fail. After a timeout or abort, the command keeps running and exec does not return until the command ends.
rmCleaning up after exec.Every exec gets rm: not found in stderr and leaves a file in /tmp/.rivet-exec.
touchStopping a command that is still starting.A kill that arrives before the command starts does not stop it.
stat (GNU, BusyBox, or BSD)Pi’s read and edit tools. The Sandbox API does not use it.Those two tools return an error.
A writable /tmpRecording the process that exec starts, so a kill can find it.Every exec gets a mkdir error in stderr. After a timeout or abort, the command keeps running and exec does not return until the command ends.

Check an image with this command. It prints missing or not writable for each problem:

docker run --rm --entrypoint /bin/sh my-image -c 'for t in tail cat mkdir rm touch stat; do command -v "$t" >/dev/null || echo "missing: $t"; done; [ -w /tmp ] || echo "not writable: /tmp"'

Two more rules apply to your own images:

  • Non-root users need a writable cwd. Docker creates the default /workspace as root, so a non-root USER can’t write there. Set cwd to a directory that the user owns, such as /home/app.
  • Commands bring their own tools. A command you run, or that an agent runs with bash, can only use what the image has, such as git or node.

With keepImageEntrypoint, the image’s entrypoint must keep running. The container stops when it exits.

Write your own provider

Implement SandboxProvider over your sandbox service. See Write your own provider for what each member does.

import {
	type RemoteProcess,
	runRemoteProcess,
	type Sandbox,
	type SandboxProvider,
} from "@rivet-dev/sandbox-adapter";

// The client of your sandbox service.
export interface VmClient {
	create(): Promise<string>;
	// Starts a stopped VM. Returns false when the VM no longer exists.
	start(id: string): Promise<boolean>;
	stop(id: string): Promise<void>;
	delete(id: string): Promise<void>;
	spawn(id: string, command: string, options: { cwd: string; env?: Record<string, string> }): Promise<RemoteProcess>;
	readFile(id: string, path: string): Promise<Uint8Array>;
	writeFile(id: string, path: string, content: string): Promise<void>;
	mkdir(id: string, path: string): Promise<void>;
	isDirectory(id: string, path: string): Promise<boolean>;
	list(id: string, path: string): Promise<string[]>;
	exists(id: string, path: string): Promise<boolean>;
}

export function vmProvider(vms: VmClient): SandboxProvider {
	const cwd = "/workspace";
	return {
		name: "my-vms",
		cwd,
		create: () => vms.create(),
		connect: async (_c, id) => {
			if (!(await vms.start(id))) return undefined;
			await vms.mkdir(id, cwd);
			return vmSandbox(vms, id, cwd);
		},
		suspend: (_c, id) => vms.stop(id),
		destroy: (_c, id) => vms.delete(id),
	};
}

function vmSandbox(vms: VmClient, id: string, cwd: string): Sandbox {
	return {
		cwd,
		exec: async (command, options) =>
			// Streams output and applies the timeout and abort signal.
			runRemoteProcess(await vms.spawn(id, command, { cwd: options.cwd, env: options.env }), options),
		readFile: (path) => vms.readFile(id, path),
		writeFile: (path, content) => vms.writeFile(id, path, content),
		mkdir: (path) => vms.mkdir(id, path),
		stat: async (path) => ({ isDirectory: await vms.isDirectory(id, path) }),
		readdir: (path) => vms.list(id, path),
		exists: (path) => vms.exists(id, path),
	};
}
  • cwd is optional. Set it when the working directory is known before a connect. Pi then connects only when a tool first uses the sandbox. Without it, Pi must connect to learn the directory.
  • runRemoteProcess is optional. Use it when your service keeps a process’s output by sequence number and has no way to wait for exit. It polls every 100 milliseconds, streams output to onData, and applies timeoutMs and signal.

Pass the result to your Actor in place of dockerProvider(), or to pi() as sandbox.

Next: Sandboxes, which gives a Pi agent’s tools a sandbox through this adapter.