OpenCode
Run OpenCode in a cloud sandbox from your app, one session per Rivet Actor, and stream its output as it works.
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
opencodeSessionRivet 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
promptaction that runsopencode runwithexecand 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;
}
INSTALLruns OpenCode’s install script, which puts the CLI at~/.opencode/bin/opencode. Thetest -xcheck skips the download when the sandbox already has it.opencode runruns one prompt and exits.--format jsonprints every text block, tool call, and step as one JSON line.--autoapproves every permission request that the configuration doesn’t deny. Without it,opencode runrejects any request that needs an answer, because nobody is there to give one. The sandbox is the boundary.--modelpicks the model asprovider/model. To use another provider, changeMODELand pass that provider’s key incliEnv. Runopencode modelsin a sandbox to list the names.--sessioncontinues 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.readEventtakes the session id from any event, since every event carriessessionID. OpenCode has no final result event, so the answer is the text of the lasttextevent.
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);
}
};
}
stateis 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.outputis the event that carries OpenCode’s JSON events to every connected client.runninglives invars, which are not saved. It stops a second prompt from starting a second OpenCode in the same sandbox while the first one works.actionTimeoutraises 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);
}
};
}
e2bProviderstarts sandboxes from E2B’sbasetemplate. 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.openSandboxconnects 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.INSTALLruns on every connect but returns right away once OpenCode is installed, so an install that failed partway is retried on the next prompt.onSleeppauses the sandbox while the Actor sleeps. The next prompt resumes it with its files and OpenCode’s sessions intact.onDestroydeletes 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);
}
};
}
execruns the command in the sandbox’s working directory. ItsonDatacallback receives output as the sandbox reports it, andjsonLinesturns 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
UserErrorthat the client receives with the reason. c.abortSignalstops OpenCode if the Actor shuts down mid-run, andtimeoutMsstops 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.
Related docs
- Sandboxes: every provider and what each does while the Actor sleeps.
- Sandbox Adapter: the
create,connect, andexecAPI this guide uses. - Events and State: how the Actor broadcasts output and keeps the session.
- OpenCode CLI: every option
opencode runaccepts.