Codex
Run the OpenAI Codex CLI in a cloud sandbox from your app, one session per Rivet Actor, and stream its output as it works.
Run the Codex CLI for your users in cloud sandboxes, with one Rivet Actor per session that remembers the conversation and the sandbox.
What we’ll build:
- A
codexSessionRivet Actor per session, keyed by your session id, that stores the transcript, the sandbox id, and Codex’s own thread id. - An E2B sandbox per session, created through the sandbox adapter, with the Codex CLI installed on first use.
- A
promptaction that runscodex execwithexecand streams each JSON event to connected clients as it arrives. - Follow-up prompts that reconnect to the same sandbox and resume the same Codex thread.
Install
npm add @rivet-dev/sandbox-adapter e2b rivetkit
Codex 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 on the OpenAI platform, then set both on your worker:
E2B_API_KEY=e2b_...
CODEX_API_KEY=sk-...
The E2B key never enters the sandbox. The OpenAI key reaches only the Codex process, as the next step shows.
Build the command
cli.ts holds everything that is specific to Codex: how to install it, how to run it without a terminal, and how to read what it prints:
// The Codex installer puts the CLI in ~/.local/bin.
const CLI = "$HOME/.local/bin/codex";
// Installs the Codex CLI unless this sandbox already has it.
export const INSTALL = `test -x ${CLI} || (curl -fsSL https://chatgpt.com/codex/install.sh | sh)`;
// One run of Codex with no terminal. It prints one JSON event per line.
// Passing the thread id from an earlier run continues that conversation.
export function command(prompt: string, cliSessionId: string | null): string {
const resume = cliSessionId ? ` resume ${quote(cliSessionId)}` : "";
return `${CLI} exec --json --skip-git-repo-check --sandbox danger-full-access${resume} ${quote(prompt)}`;
}
// The API key reaches Codex as an environment variable of this one process.
export function cliEnv(): Record<string, string> {
return { CODEX_API_KEY: required("CODEX_API_KEY") };
}
// The fields of Codex's JSON events that this example reads.
export interface CliEvent {
type: string;
thread_id?: string;
item?: { type: string; text?: string };
}
// The first event, `thread.started`, names the thread. Each finished agent message is text, and the last one is the answer.
export function readEvent(event: CliEvent): { cliSessionId?: string; answer?: string } {
return {
cliSessionId: event.type === "thread.started" ? event.thread_id : undefined,
answer: event.type === "item.completed" && event.item?.type === "agent_message" ? event.item.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 the standalone Codex installer, which puts the CLI at~/.local/bin/codex. Thetest -xcheck skips the download when the sandbox already has it.codex execruns one prompt and exits.--jsonprints every event, such as the start of the thread, each command Codex runs, and each message, as one JSON line.--skip-git-repo-checklets Codex work in the sandbox’s home directory, which isn’t a Git repository. Without it, Codex refuses to start there.--sandbox danger-full-accessturns off Codex’s own sandbox, which is read-only by default incodex exec. The cloud sandbox is the boundary, and commands such asnpm installneed to write files and reach the network.resumecontinues the thread from an earlier run. Codex keeps its sessions under~/.codexinside the sandbox, so this works for as long as the sandbox exists.readEventtakes the thread id from the first event,thread.started, and the answer from the last finished agent message.
cliEnv passes CODEX_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 Codex 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 a codexSession 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 codexSession = actor({
state: { sandboxId: null, cliSessionId: null, transcript: [] } as SessionState,
events: {
// Each JSON event Codex 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 Codex 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("Codex ran past the time limit.");
if (result.exitCode !== 0) {
throw new UserError(`Codex 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 Codex 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 thread id Codex gave its own session, which is different from your session id.outputis the event that carries Codex’s JSON events to every connected client.runninglives invars, which are not saved. It stops a second prompt from starting a second Codex 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 codexSession = actor({
state: { sandboxId: null, cliSessionId: null, transcript: [] } as SessionState,
events: {
// Each JSON event Codex 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 Codex 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("Codex ran past the time limit.");
if (result.exitCode !== 0) {
throw new UserError(`Codex 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 Codex 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 Codex’s thread id, since the new sandbox has none of the earlier files.INSTALLruns on every connect but returns right away once Codex 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 Codex’s sessions intact.onDestroydeletes it.
agentOS can’t run Codex, 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. Freestyle works too, with a firewall rule that allows HTTPS out. See Providers.
Run a prompt
The prompt action runs Codex 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 codexSession = actor({
state: { sandboxId: null, cliSessionId: null, transcript: [] } as SessionState,
events: {
// Each JSON event Codex 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 Codex 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("Codex ran past the time limit.");
if (result.exitCode !== 0) {
throw new UserError(`Codex 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 Codex 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 commands and messages while Codex works.
- The first event carries Codex’s thread id, which the Actor saves so the next prompt passes it to
resume. - The transcript gets the prompt and the final answer only after Codex exits cleanly. A failed run throws a
UserErrorthat the client receives with the reason. c.abortSignalstops Codex 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 { codexSession } from "./session";
export const registry = setup({ use: { codexSession } });
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.codexSession.getOrCreate(["session-123"]).connect();
// Codex'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 Codex 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 Codex. The follow-up reuses both and continues the same thread, 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, OpenCode, and Amp: the same setup with other coding agents.
- Sandbox Agent: drive Codex, Claude Code, OpenCode, 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.
- Codex non-interactive mode: every option
codex execaccepts.