Skip to main content
Coding Agents

OpenCode

Run OpenCode in a cloud sandbox from your app, one session per Rivet Actor, and stream its output as it works.

See the full example on GitHub. View on GitHub
Your appprompt, live outputopencodeSessiontranscript, sandbox idsandbox-adapterSandbox APISandboxE2B, Daytona, or FreestyleModel providerAnthropic APIprompt1create, connect2exec runs opencode run3model calls4onData5output events6One per sessionRivet ActorCallReply

Run OpenCode for your users in cloud sandboxes, with one Rivet Actor per session that remembers the conversation and the sandbox.

What we’ll build:

  • An opencodeSession Rivet Actor per session, keyed by your session id, that stores the transcript, the sandbox id, and OpenCode’s own session id.
  • An E2B sandbox per session, created through the sandbox adapter, with OpenCode installed on first use.
  • A prompt action that runs opencode run with exec and streams each JSON event to connected clients as it arrives.
  • Follow-up prompts that reconnect to the same sandbox and continue the same OpenCode session.

Install

npm add @rivet-dev/sandbox-adapter e2b rivetkit

OpenCode itself isn’t installed on your worker. Each sandbox installs its own copy.

Set the keys

Create an API key in the E2B dashboard and one in the Claude Console, then set both on your worker:

E2B_API_KEY=e2b_...
ANTHROPIC_API_KEY=sk-ant-...

The E2B key never enters the sandbox. The Anthropic key reaches only the OpenCode process, as the next step shows. OpenCode works with many model providers, so you can use another provider’s key instead.

Build the command

cli.ts holds everything that is specific to OpenCode: how to install it, how to run it without a terminal, and how to read what it prints:

// The OpenCode installer puts the CLI in ~/.opencode/bin.
const CLI = "$HOME/.opencode/bin/opencode";

// Any model OpenCode supports, as provider/model. Set that provider's key in cliEnv.
const MODEL = "anthropic/claude-opus-5-5";

// Installs OpenCode unless this sandbox already has it.
export const INSTALL = `test -x ${CLI} || (curl -fsSL https://opencode.ai/install | bash)`;

// One run of OpenCode with no terminal. It prints one JSON event per line.
// Passing the session id from an earlier run continues that conversation.
export function command(prompt: string, cliSessionId: string | null): string {
	const resume = cliSessionId ? ` --session ${quote(cliSessionId)}` : "";
	return `${CLI} run --format json --auto --model ${MODEL}${resume} ${quote(prompt)}`;
}

// The API key reaches OpenCode as an environment variable of this one process.
export function cliEnv(): Record<string, string> {
	return { ANTHROPIC_API_KEY: required("ANTHROPIC_API_KEY") };
}

// The fields of OpenCode's JSON events that this example reads.
export interface CliEvent {
	type: string;
	sessionID?: string;
	part?: { text?: string };
}

// Every event carries the session id. Each `text` event is a finished block of text, and the last one is the answer.
export function readEvent(event: CliEvent): { cliSessionId?: string; answer?: string } {
	return {
		cliSessionId: event.sessionID,
		answer: event.type === "text" ? event.part?.text : undefined,
	};
}

// Quotes a value for the sandbox's shell.
function quote(value: string): string {
	return `'${value.replaceAll("'", `'"'"'`)}'`;
}

function required(name: string): string {
	const value = process.env[name];
	if (!value) throw new Error(`Set ${name} on the worker.`);
	return value;
}
  • INSTALL runs OpenCode’s install script, which puts the CLI at ~/.opencode/bin/opencode. The test -x check skips the download when the sandbox already has it.
  • opencode run runs one prompt and exits. --format json prints every text block, tool call, and step as one JSON line.
  • --auto approves every permission request that the configuration doesn’t deny. Without it, opencode run rejects any request that needs an answer, because nobody is there to give one. The sandbox is the boundary.
  • --model picks the model as provider/model. To use another provider, change MODEL and pass that provider’s key in cliEnv. Run opencode models in a sandbox to list the names.
  • --session continues the conversation from an earlier run. OpenCode keeps its sessions in its data directory inside the sandbox, so this works for as long as the sandbox exists.
  • readEvent takes the session id from any event, since every event carries sessionID. OpenCode has no final result event, so the answer is the text of the last text event.

cliEnv passes ANTHROPIC_API_KEY in the env of a single exec call. The provider hands it to that one process, and it is never written to the sandbox’s disk. Commands that OpenCode runs inherit it, though, so code in the sandbox can read it. Give the sandbox a key with a spending limit.

Create the session Actor

Each session is an opencodeSession Actor, keyed by your session id:

import type { Sandbox, SandboxActorContext } from "@rivet-dev/sandbox-adapter";
import { e2bProvider } from "@rivet-dev/sandbox-adapter/e2b";
import { actor, event, UserError } from "rivetkit";
import { type CliEvent, cliEnv, command, INSTALL, readEvent } from "./cli";

// A coding task can run for many minutes. The CLI is stopped at this limit.
const PROMPT_TIMEOUT_MS = 30 * 60_000;

// E2B pauses the sandbox while the Actor sleeps. The timeout keeps it running through a long task.
const provider = e2bProvider({ create: { timeoutMs: 60 * 60_000 } });

interface Turn {
	role: "user" | "assistant";
	text: string;
}

interface SessionState {
	sandboxId: string | null;
	cliSessionId: string | null;
	transcript: Turn[];
}

// One per session, keyed by your session id.
export const opencodeSession = actor({
	state: { sandboxId: null, cliSessionId: null, transcript: [] } as SessionState,
	events: {
		// Each JSON event OpenCode prints, as it arrives.
		output: event<CliEvent>(),
	},
	// Set while a prompt runs, so a second prompt can't start a second CLI in the same sandbox.
	createVars: () => ({ running: false }),
	options: { actionTimeout: PROMPT_TIMEOUT_MS + 60_000 },
	actions: {
		// Runs one prompt and returns the final answer. Events reach connected clients as they arrive.
		prompt: async (c, text: string) => {
			if (c.vars.running) throw new UserError("A prompt is already running in this session.");
			c.vars.running = true;
			try {
				const sandbox = await openSandbox(c, c.state);
				let answer = "";
				const result = await sandbox.exec(command(text, c.state.cliSessionId), {
					cwd: sandbox.cwd,
					env: cliEnv(),
					timeoutMs: PROMPT_TIMEOUT_MS,
					// Stops OpenCode if the Actor shuts down mid-run.
					signal: c.abortSignal,
					onData: jsonLines((event) => {
						const read = readEvent(event);
						if (read.cliSessionId) c.state.cliSessionId = read.cliSessionId;
						if (read.answer !== undefined) answer = read.answer;
						c.broadcast("output", event);
					}),
				});
				if (result.timedOut) throw new UserError("OpenCode ran past the time limit.");
				if (result.exitCode !== 0) {
					throw new UserError(`OpenCode exited with code ${result.exitCode}. ${result.stderr.slice(-2000)}`);
				}
				c.state.transcript.push({ role: "user", text }, { role: "assistant", text: answer });
				return answer;
			} finally {
				c.vars.running = false;
			}
		},
		getTranscript: (c) => c.state.transcript,
	},
	// The sandbox pauses while the Actor sleeps and is deleted 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 session's sandbox. The first prompt creates it.
async function openSandbox(c: SandboxActorContext, state: SessionState): Promise<Sandbox> {
	let sandbox = state.sandboxId ? await provider.connect(c, state.sandboxId) : undefined;
	if (!sandbox) {
		// A new sandbox has none of the earlier files, so the conversation starts over too.
		state.sandboxId = await provider.create(c);
		state.cliSessionId = null;
		sandbox = await provider.connect(c, state.sandboxId);
		if (!sandbox) throw new Error(`Sandbox ${state.sandboxId} was not found right after it was created.`);
	}
	const install = await sandbox.exec(INSTALL, { cwd: sandbox.cwd });
	if (install.exitCode !== 0) {
		throw new Error(`Installing OpenCode failed. ${install.stdout}${install.stderr}`);
	}
	return sandbox;
}

// Splits streamed output into lines and parses the JSON ones. stderr shares the stream, so other lines are skipped.
function jsonLines(onEvent: (event: CliEvent) => void): (chunk: Uint8Array) => void {
	const decoder = new TextDecoder();
	let pending = "";
	return (chunk) => {
		pending += decoder.decode(chunk, { stream: true });
		const lines = pending.split("\n");
		pending = lines.pop() ?? "";
		for (const line of lines) {
			let parsed: unknown;
			try {
				parsed = JSON.parse(line);
			} catch {
				continue;
			}
			if (typeof parsed === "object" && parsed !== null) onEvent(parsed as CliEvent);
		}
	};
}
  • state is saved with the Actor, so it survives sleep, crashes, and upgrades. It holds the transcript, the session’s sandbox id, and the id OpenCode gave its own session, which is different from your session id.
  • output is the event that carries OpenCode’s JSON events to every connected client.
  • running lives in vars, which are not saved. It stops a second prompt from starting a second OpenCode in the same sandbox while the first one works.
  • actionTimeout raises RivetKit’s 60-second limit on an action, because one coding task can take many minutes.

Any client that knows a session id can prompt it, and every prompt runs commands with your API key. In production, protect the Actor with authentication.

Connect the sandbox

The Actor creates its sandbox on the first prompt and reconnects to it on every prompt after that:

import type { Sandbox, SandboxActorContext } from "@rivet-dev/sandbox-adapter";
import { e2bProvider } from "@rivet-dev/sandbox-adapter/e2b";
import { actor, event, UserError } from "rivetkit";
import { type CliEvent, cliEnv, command, INSTALL, readEvent } from "./cli";

// A coding task can run for many minutes. The CLI is stopped at this limit.
const PROMPT_TIMEOUT_MS = 30 * 60_000;

// E2B pauses the sandbox while the Actor sleeps. The timeout keeps it running through a long task.
const provider = e2bProvider({ create: { timeoutMs: 60 * 60_000 } });

interface Turn {
	role: "user" | "assistant";
	text: string;
}

interface SessionState {
	sandboxId: string | null;
	cliSessionId: string | null;
	transcript: Turn[];
}

// One per session, keyed by your session id.
export const opencodeSession = actor({
	state: { sandboxId: null, cliSessionId: null, transcript: [] } as SessionState,
	events: {
		// Each JSON event OpenCode prints, as it arrives.
		output: event<CliEvent>(),
	},
	// Set while a prompt runs, so a second prompt can't start a second CLI in the same sandbox.
	createVars: () => ({ running: false }),
	options: { actionTimeout: PROMPT_TIMEOUT_MS + 60_000 },
	actions: {
		// Runs one prompt and returns the final answer. Events reach connected clients as they arrive.
		prompt: async (c, text: string) => {
			if (c.vars.running) throw new UserError("A prompt is already running in this session.");
			c.vars.running = true;
			try {
				const sandbox = await openSandbox(c, c.state);
				let answer = "";
				const result = await sandbox.exec(command(text, c.state.cliSessionId), {
					cwd: sandbox.cwd,
					env: cliEnv(),
					timeoutMs: PROMPT_TIMEOUT_MS,
					// Stops OpenCode if the Actor shuts down mid-run.
					signal: c.abortSignal,
					onData: jsonLines((event) => {
						const read = readEvent(event);
						if (read.cliSessionId) c.state.cliSessionId = read.cliSessionId;
						if (read.answer !== undefined) answer = read.answer;
						c.broadcast("output", event);
					}),
				});
				if (result.timedOut) throw new UserError("OpenCode ran past the time limit.");
				if (result.exitCode !== 0) {
					throw new UserError(`OpenCode exited with code ${result.exitCode}. ${result.stderr.slice(-2000)}`);
				}
				c.state.transcript.push({ role: "user", text }, { role: "assistant", text: answer });
				return answer;
			} finally {
				c.vars.running = false;
			}
		},
		getTranscript: (c) => c.state.transcript,
	},
	// The sandbox pauses while the Actor sleeps and is deleted 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 session's sandbox. The first prompt creates it.
async function openSandbox(c: SandboxActorContext, state: SessionState): Promise<Sandbox> {
	let sandbox = state.sandboxId ? await provider.connect(c, state.sandboxId) : undefined;
	if (!sandbox) {
		// A new sandbox has none of the earlier files, so the conversation starts over too.
		state.sandboxId = await provider.create(c);
		state.cliSessionId = null;
		sandbox = await provider.connect(c, state.sandboxId);
		if (!sandbox) throw new Error(`Sandbox ${state.sandboxId} was not found right after it was created.`);
	}
	const install = await sandbox.exec(INSTALL, { cwd: sandbox.cwd });
	if (install.exitCode !== 0) {
		throw new Error(`Installing OpenCode failed. ${install.stdout}${install.stderr}`);
	}
	return sandbox;
}

// Splits streamed output into lines and parses the JSON ones. stderr shares the stream, so other lines are skipped.
function jsonLines(onEvent: (event: CliEvent) => void): (chunk: Uint8Array) => void {
	const decoder = new TextDecoder();
	let pending = "";
	return (chunk) => {
		pending += decoder.decode(chunk, { stream: true });
		const lines = pending.split("\n");
		pending = lines.pop() ?? "";
		for (const line of lines) {
			let parsed: unknown;
			try {
				parsed = JSON.parse(line);
			} catch {
				continue;
			}
			if (typeof parsed === "object" && parsed !== null) onEvent(parsed as CliEvent);
		}
	};
}
  • e2bProvider starts sandboxes from E2B’s base template. E2B stops a sandbox when its timeout runs out, so the one-hour timeout covers a long task, and the provider pauses the sandbox at the timeout instead of deleting it.
  • openSandbox connects with the stored id. On the first prompt, or after the provider has deleted the sandbox, it creates a new one, saves its id, and clears OpenCode’s session id, since the new sandbox has none of the earlier files or sessions.
  • INSTALL runs on every connect but returns right away once OpenCode is installed, so an install that failed partway is retried on the next prompt.
  • onSleep pauses the sandbox while the Actor sleeps. The next prompt resumes it with its files and OpenCode’s sessions intact. onDestroy deletes it.

agentOS can’t run OpenCode, because it only runs software from its own registry, not binaries downloaded with curl. Daytona can: pass daytonaProvider() from @rivet-dev/sandbox-adapter/daytona instead, but Daytona returns a command’s output only when it exits, so clients get the events all at once at the end. On Freestyle, the VM’s firewall must allow the install script’s downloads and your model provider’s API. See Providers.

Run a prompt

The prompt action runs OpenCode once and streams what it prints:

import type { Sandbox, SandboxActorContext } from "@rivet-dev/sandbox-adapter";
import { e2bProvider } from "@rivet-dev/sandbox-adapter/e2b";
import { actor, event, UserError } from "rivetkit";
import { type CliEvent, cliEnv, command, INSTALL, readEvent } from "./cli";

// A coding task can run for many minutes. The CLI is stopped at this limit.
const PROMPT_TIMEOUT_MS = 30 * 60_000;

// E2B pauses the sandbox while the Actor sleeps. The timeout keeps it running through a long task.
const provider = e2bProvider({ create: { timeoutMs: 60 * 60_000 } });

interface Turn {
	role: "user" | "assistant";
	text: string;
}

interface SessionState {
	sandboxId: string | null;
	cliSessionId: string | null;
	transcript: Turn[];
}

// One per session, keyed by your session id.
export const opencodeSession = actor({
	state: { sandboxId: null, cliSessionId: null, transcript: [] } as SessionState,
	events: {
		// Each JSON event OpenCode prints, as it arrives.
		output: event<CliEvent>(),
	},
	// Set while a prompt runs, so a second prompt can't start a second CLI in the same sandbox.
	createVars: () => ({ running: false }),
	options: { actionTimeout: PROMPT_TIMEOUT_MS + 60_000 },
	actions: {
		// Runs one prompt and returns the final answer. Events reach connected clients as they arrive.
		prompt: async (c, text: string) => {
			if (c.vars.running) throw new UserError("A prompt is already running in this session.");
			c.vars.running = true;
			try {
				const sandbox = await openSandbox(c, c.state);
				let answer = "";
				const result = await sandbox.exec(command(text, c.state.cliSessionId), {
					cwd: sandbox.cwd,
					env: cliEnv(),
					timeoutMs: PROMPT_TIMEOUT_MS,
					// Stops OpenCode if the Actor shuts down mid-run.
					signal: c.abortSignal,
					onData: jsonLines((event) => {
						const read = readEvent(event);
						if (read.cliSessionId) c.state.cliSessionId = read.cliSessionId;
						if (read.answer !== undefined) answer = read.answer;
						c.broadcast("output", event);
					}),
				});
				if (result.timedOut) throw new UserError("OpenCode ran past the time limit.");
				if (result.exitCode !== 0) {
					throw new UserError(`OpenCode exited with code ${result.exitCode}. ${result.stderr.slice(-2000)}`);
				}
				c.state.transcript.push({ role: "user", text }, { role: "assistant", text: answer });
				return answer;
			} finally {
				c.vars.running = false;
			}
		},
		getTranscript: (c) => c.state.transcript,
	},
	// The sandbox pauses while the Actor sleeps and is deleted 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 session's sandbox. The first prompt creates it.
async function openSandbox(c: SandboxActorContext, state: SessionState): Promise<Sandbox> {
	let sandbox = state.sandboxId ? await provider.connect(c, state.sandboxId) : undefined;
	if (!sandbox) {
		// A new sandbox has none of the earlier files, so the conversation starts over too.
		state.sandboxId = await provider.create(c);
		state.cliSessionId = null;
		sandbox = await provider.connect(c, state.sandboxId);
		if (!sandbox) throw new Error(`Sandbox ${state.sandboxId} was not found right after it was created.`);
	}
	const install = await sandbox.exec(INSTALL, { cwd: sandbox.cwd });
	if (install.exitCode !== 0) {
		throw new Error(`Installing OpenCode failed. ${install.stdout}${install.stderr}`);
	}
	return sandbox;
}

// Splits streamed output into lines and parses the JSON ones. stderr shares the stream, so other lines are skipped.
function jsonLines(onEvent: (event: CliEvent) => void): (chunk: Uint8Array) => void {
	const decoder = new TextDecoder();
	let pending = "";
	return (chunk) => {
		pending += decoder.decode(chunk, { stream: true });
		const lines = pending.split("\n");
		pending = lines.pop() ?? "";
		for (const line of lines) {
			let parsed: unknown;
			try {
				parsed = JSON.parse(line);
			} catch {
				continue;
			}
			if (typeof parsed === "object" && parsed !== null) onEvent(parsed as CliEvent);
		}
	};
}
  • exec runs the command in the sandbox’s working directory. Its onData callback receives output as the sandbox reports it, and jsonLines turns that into one event per JSON line.
  • Each event is broadcast to connected clients right away, so your app can show tool calls and text while OpenCode works.
  • Every event carries OpenCode’s session id, which the Actor saves so the next prompt passes it to --session.
  • The transcript gets the prompt and the final answer only after OpenCode exits cleanly. A failed run throws a UserError that the client receives with the reason.
  • c.abortSignal stops OpenCode if the Actor shuts down mid-run, and timeoutMs stops it if it runs too long.

Try it

Register the Actor and start the worker:

import { setup } from "rivetkit";
import { opencodeSession } from "./session";

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

registry.start();
npx tsx server.ts

In another terminal, connect to a session and send a prompt and a follow-up:

import { createClient } from "rivetkit/client";
import type { registry } from "./server";

const client = createClient<typeof registry>();

// One Actor per session. Use the same id later to come back to this session.
const session = client.opencodeSession.getOrCreate(["session-123"]).connect();

// OpenCode's events arrive while it works.
session.on("output", (event) => console.log(JSON.stringify(event)));

const answer = await session.prompt("Write fib.py that prints the first 10 Fibonacci numbers, then run it.");
console.log(`\nAnswer: ${answer}`);

// The follow-up runs in the same sandbox and continues the same OpenCode session.
const followUp = await session.prompt("Make it print 20 numbers and run it again.");
console.log(`\nAnswer: ${followUp}`);

await session.dispose();
npx tsx client.ts

The first prompt takes longer because it creates the sandbox and installs OpenCode. The follow-up reuses both and continues the same conversation, so “it” refers to fib.py. Run client.ts again later with the same session id and it picks up where it left off. Call getTranscript to show the earlier turns.

Next steps

  • Claude Code, Codex, and Amp: the same setup with other coding agents.
  • Sandbox Agent: drive OpenCode, Claude Code, Codex, Amp, and others through one HTTP API with a shared event format and permission requests, instead of each CLI’s own flags.
  • Pi: a coding agent that runs inside the Actor, with a durable conversation and tools that run in a sandbox.
  • Authentication: check that the caller may use a session before it runs a prompt.
  • Sandboxes: every provider and what each does while the Actor sleeps.
  • Sandbox Adapter: the create, connect, and exec API this guide uses.
  • Events and State: how the Actor broadcasts output and keeps the session.
  • OpenCode CLI: every option opencode run accepts.