Skip to main content
Coding Agents

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.

See the full example on GitHub. View on GitHub
Your appprompt, live outputcodexSessiontranscript, sandbox idsandbox-adapterSandbox APISandboxE2B, Daytona, or FreestyleOpenAI APIruns the modelprompt1create, connect2exec runs codex exec3model calls4onData5output events6One per sessionRivet ActorCallReply

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 codexSession Rivet 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 prompt action that runs codex exec with exec and 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;
}
  • INSTALL runs the standalone Codex installer, which puts the CLI at ~/.local/bin/codex. The test -x check skips the download when the sandbox already has it.
  • codex exec runs one prompt and exits. --json prints every event, such as the start of the thread, each command Codex runs, and each message, as one JSON line.
  • --skip-git-repo-check lets Codex work in the sandbox’s home directory, which isn’t a Git repository. Without it, Codex refuses to start there.
  • --sandbox danger-full-access turns off Codex’s own sandbox, which is read-only by default in codex exec. The cloud sandbox is the boundary, and commands such as npm install need to write files and reach the network.
  • resume continues the thread from an earlier run. Codex keeps its sessions under ~/.codex inside the sandbox, so this works for as long as the sandbox exists.
  • readEvent takes 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);
		}
	};
}
  • state is 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.
  • output is the event that carries Codex’s JSON events to every connected client.
  • running lives in vars, which are not saved. It stops a second prompt from starting a second Codex 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 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);
		}
	};
}
  • 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 Codex’s thread id, since the new sandbox has none of the earlier files.
  • INSTALL runs on every connect but returns right away once Codex 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 Codex’s sessions intact. onDestroy deletes 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);
		}
	};
}
  • 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 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 UserError that the client receives with the reason.
  • c.abortSignal stops Codex 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 { 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.