Amp
Run the Amp CLI in a cloud sandbox from your app, one session per Rivet Actor, and stream its output as it works.
Run the Amp CLI for your users in cloud sandboxes, with one Rivet Actor per session that remembers the conversation and the sandbox.
What we’ll build:
- An
ampSessionRivet Actor per session, keyed by your session id, that stores the transcript, the sandbox id, and the id of the Amp thread. - An E2B sandbox per session, created through the sandbox adapter, with the Amp CLI installed on first use.
- A
promptaction that runsamp -xwithexecand streams each JSON event to connected clients as it arrives. - Follow-up prompts that reconnect to the same sandbox and continue the same Amp thread.
Install
npm add @rivet-dev/sandbox-adapter e2b rivetkit
The Amp CLI 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 an access token in your Amp settings, then set both on your worker:
E2B_API_KEY=e2b_...
AMP_API_KEY=sgamp_...
The E2B key never enters the sandbox. The Amp token reaches only the Amp process, as the next step shows. Use an access token from settings. The CLI rejects the short-lived token that amp login saves.
Build the command
cli.ts holds everything that is specific to Amp: how to install it, how to run it without a terminal, and how to read what it prints:
// The Amp installer puts the CLI in ~/.amp/bin.
const CLI = "$HOME/.amp/bin/amp";
// Installs the Amp CLI unless this sandbox already has it.
export const INSTALL = `test -x ${CLI} || (curl -fsSL https://ampcode.com/install.sh | bash)`;
// One run of Amp in execute mode. It prints one JSON event per line.
// Passing the thread id from an earlier run continues that thread.
export function command(prompt: string, cliSessionId: string | null): string {
const resume = cliSessionId ? ` threads continue ${quote(cliSessionId)}` : "";
return `${CLI}${resume} -x ${quote(prompt)} --stream-json`;
}
// The access token reaches Amp as an environment variable of this one process.
export function cliEnv(): Record<string, string> {
return { AMP_API_KEY: required("AMP_API_KEY") };
}
// The fields of Amp's JSON events that this example reads.
export interface CliEvent {
type: string;
session_id?: string;
result?: string;
}
// Every event carries the thread id. The last event, `result`, holds the final answer.
export function readEvent(event: CliEvent): { cliSessionId?: string; answer?: string } {
return {
cliSessionId: event.session_id,
answer: event.type === "result" ? event.result : 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 Amp’s install script, which puts the CLI at~/.amp/bin/amp. Thetest -xcheck skips the download when the sandbox already has it.-xturns on execute mode: Amp sends the prompt, waits for the agent to finish its turn, and exits.--stream-jsonprints every message, tool call, and the final result as one JSON line, in the same shape as Claude Code’s stream JSON.- Amp doesn’t ask before running tools by default, so the command needs no flag to skip approvals. The sandbox is the boundary.
threads continuewith a thread id sends the prompt to an existing thread instead of starting a new one. Amp stores threads on its own servers, not in the sandbox.readEventtakes the thread id from any event, which Amp namessession_id, and the answer from the last event,result.
cliEnv passes AMP_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 Amp runs inherit it, though, so code in the sandbox can read it and spend from your Amp account. Use a token from an account or workspace you’re willing to expose to the sandbox.
Create the session Actor
Each session is an ampSession 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 ampSession = actor({
state: { sandboxId: null, cliSessionId: null, transcript: [] } as SessionState,
events: {
// Each JSON event Amp 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 Amp 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("Amp ran past the time limit.");
if (result.exitCode !== 0) {
throw new UserError(`Amp 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 Amp 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 of the Amp thread, which is different from your session id.outputis the event that carries Amp’s JSON events to every connected client.runninglives invars, which are not saved. It stops a second prompt from starting a second Amp 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 Amp token. 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 ampSession = actor({
state: { sandboxId: null, cliSessionId: null, transcript: [] } as SessionState,
events: {
// Each JSON event Amp 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 Amp 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("Amp ran past the time limit.");
if (result.exitCode !== 0) {
throw new UserError(`Amp 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 Amp 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 the thread id. The old thread still exists on Amp’s servers, but it talks about files the new sandbox doesn’t have, so the next prompt starts a new thread.INSTALLruns on every connect but returns right away once Amp 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 intact.onDestroydeletes it.
agentOS can’t run Amp, 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 the Amp service. See Providers.
Run a prompt
The prompt action runs Amp 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 ampSession = actor({
state: { sandboxId: null, cliSessionId: null, transcript: [] } as SessionState,
events: {
// Each JSON event Amp 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 Amp 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("Amp ran past the time limit.");
if (result.exitCode !== 0) {
throw new UserError(`Amp 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 Amp 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 messages while Amp works.
- The first event,
init, carries the thread id, which the Actor saves so the next prompt passes it tothreads continue. - The transcript gets the prompt and the final answer only after Amp exits cleanly. A failed run throws a
UserErrorthat the client receives with the reason. c.abortSignalstops Amp 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 { ampSession } from "./session";
export const registry = setup({ use: { ampSession } });
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.ampSession.getOrCreate(["session-123"]).connect();
// Amp'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 Amp thread.
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 Amp. 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, Codex, and OpenCode: the same setup with other coding agents.
- Sandbox Agent: drive Amp, Claude Code, Codex, OpenCode, 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.
- Amp execute mode and streaming JSON: the options
amp -xaccepts and the format of every message.