Skip to main content
Agents

Code Mode in the Sandbox

Let programs the agent writes in its sandbox call your backend tools, authenticated with a scoped JWT.

See the full example on GitHub. View on GitHub
YOUR BACKENDAgentSandboxbash toolactionsMCP servers and APIsuse your keysNode.js, Python, Bashcall-tool.sh scriptruns a script1runs2scoped JWT3your keys4Call

In code mode, the model writes a program that calls tools instead of calling them one at a time. This guide runs that program in the agent’s sandbox. When the agent connects, it writes call-tool.sh, a shell script that calls the agent’s actions with a scoped JWT. The model uses each action as a tool, and your Rivet token stays on your worker.

Quickstart

Install

npm add @rivet-dev/pi @rivet-dev/sandbox-adapter @earendil-works/pi-durable rivetkit@next

Set the keys

ANTHROPIC_API_KEY=sk-ant-...
RIVET_ENDPOINT=https://<namespace>:<rivet-token>@api.rivet.dev

The sandbox calls your Rivet endpoint, so it must be public. That’s safe: call-tool.sh authenticates with a scoped JWT, not your Rivet token.

Write the script on connect

// Outside the workspace, so the agent doesn't commit it.
export const SCRIPT_PATH = "/tmp/call-tool.sh";

// The maximum. The script is rewritten after every wake.
const TOKEN_TTL_SECONDS = 24 * 60 * 60;

// Writes the script into every sandbox the agent connects to.
export function withCallbackScript(provider: SandboxProvider): SandboxProvider {
	return {
		...provider,
		connect: async (c, id) => {
			const sandbox = await provider.connect(c, id);
			if (sandbox) await writeScript(sandbox, c);
			return sandbox;
		},
	};
}

async function writeScript(sandbox: Sandbox, c: SandboxActorContext): Promise<void> {
	// The agent's own client uses your Rivet token. The scoped token reaches only
	// this Actor, but can call every action on it.
	const client = c.client() as Client<typeof registry>;
	const { token } = await client.agent.getForId(c.actorId).issueToken({ subject: "sandbox", expiresIn: TOKEN_TTL_SECONDS });
	await sandbox.writeFile(SCRIPT_PATH, callbackScript(`${gatewayEndpoint()}/gateway/${c.actorId}`, token));
	const chmod = await sandbox.exec(`chmod 700 ${SCRIPT_PATH}`, { cwd: sandbox.cwd });
	if (chmod.exitCode !== 0) throw new Error(`Installing ${SCRIPT_PATH} failed. ${chmod.stderr}`);
}
  • The token reaches only this agent. Every other Actor returns 403.
  • The script is rewritten after every wake. A token lasts at most 24 hours.

Tell the model

// Coding tools run in the sandbox. The section tells the model about the script.
const extensions = createRegistry();
extensions.install(CodingTools);
extensions.install(
	defineExtension({
		name: "agent-callbacks",
		sections: [
			section(
				"agent_callbacks",
				() => `To call the app that runs you, use bash to run:
${SCRIPT_PATH} <tool> '<JSON array of arguments>'
It prints {"output": <result>}, or exits non-zero with an error.

Tools:
- progress ["<message>"]: Shows the user a one-line status. Call it as you start each step of a task.
- saveNote ["<text>"]: Saves a note that is kept after the sandbox is deleted.
- listNotes []: Returns every saved note.
- getReadings ["<sensor>", <cursor>]: Returns {"readings": [...], "next": <cursor or null>}, 1,000 readings a page, one a minute for a week. Start at cursor 0. Sensors: ${SENSORS.join(", ")}.

Example: ${SCRIPT_PATH} progress '["Running the tests"]'`,
			),
		],
	}),
);

Add the tools

The token can call every action on this agent, including Pi’s own actions such as prompt. Anything in the sandbox can read it. Only add tools you’re willing to let sandboxed code call.

export const agent = pi({
	model: "anthropic/claude-opus-5-5",
	registry: extensions,
	// Writes call-tool.sh into the sandbox. The image has curl, Node.js, and Python with numpy.
	// Docker is only for local development, where the sandbox can reach the control plane
	// on this machine. In production, switch to a hosted sandbox provider such as Daytona
	// or E2B, and set RIVET_PUBLIC_ENDPOINT to an address the sandbox can reach.
	sandbox: withCallbackScript(dockerProvider({ image: "sandbox-code-mode" })),
	state: { notes: [] as string[] },
	events: {
		progress: event<string>(),
	},
	// The tools call-tool.sh calls. Each one is an action on the agent.
	actions: {
		progress: (c, message: unknown) => {
			c.broadcast("progress", requireText(message));
			return null;
		},
		saveNote: (c, text: unknown) => {
			c.state.notes.push(requireText(text));
			return { saved: c.state.notes.length };
		},
		listNotes: (c) => c.state.notes,
		// In a real app, this queries your metrics database with your keys.
		getReadings: (_c, sensor: unknown, cursor: unknown) => readingsPage(sensor, cursor),
	},
});

function requireText(value: unknown): string {
	if (typeof value !== "string" || value.length === 0) throw new UserError("Pass a non-empty string.");
	return value;
}

The model writes the arguments, so check them. A UserError makes the script exit non-zero with the message.

Try it

With Docker running, start the server. It first builds the sandbox image from the example’s Dockerfile, which adds Python and numpy to node:22.

npx tsx server.ts

In another terminal, start the CLI. It prints the agent’s answer as it streams, every command it runs, and each status it reports through call-tool.sh:

import { createInterface } from "node:readline";
import { createClient } from "rivetkit/client";
import type { registry } from "./server";

// One agent per key. Pass a key to pick up an earlier session: `npx tsx cli.ts my-session`.
const client = createClient<typeof registry>();
const agent = client.agent.getOrCreate([process.argv[2] ?? "demo"]).connect();

// Status the agent sends from the sandbox through call-tool.sh.
agent.on("progress", (message) => console.log(`\n[progress] ${message}`));

// Everything else the agent does: its answer as it streams, and each tool it runs.
let streamed = false;
agent.on("pi.events", ({ events }) => {
	for (const event of events) {
		if (event.type === "message_update") {
			for (const change of event.changes) {
				if (change.type === "text_delta") {
					process.stdout.write(change.delta);
					streamed = true;
				}
			}
		}
		if (event.type === "tool_execution_start") {
			const call = event.toolName === "bash" ? `$ ${event.args.command}` : `${event.toolName} ${JSON.stringify(event.args)}`;
			console.log(`\n> ${call}`);
		}
	}
});
await agent.conversation.watchEvents((await agent.harness.root()).id);

// Reads one prompt per line until Ctrl-D.
const input = createInterface({ input: process.stdin, output: process.stdout, prompt: "\nyou> " });
let closed = false;
input.on("close", () => (closed = true));
input.prompt();
for await (const line of input) {
	if (line.trim()) {
		streamed = false;
		const result = await agent.prompt(line, { requestId: crypto.randomUUID() });
		if (result.status === "unanswered") console.log(`\n(no answer: ${result.reason})`);
		// A short answer can arrive whole, without streaming.
		else if (!streamed) console.log(result.text);
		console.log(`\nnotes: ${JSON.stringify(await agent.listNotes())}`);
	}
	if (!closed) input.prompt();
}
await agent.dispose();
npx tsx cli.ts
you> In a single Python script, pull every reading for every sensor with getReadings, use numpy to find spikes more than 5 standard deviations from a rolling mean, find which two sensors are most correlated, and save a note for each spike. Keep your answer short.

> write {"path":"/workspace/spikes.py","content":"..."}
> $ python3 /workspace/spikes.py
[progress] Fetching readings for 5 sensors
[progress] Saving 4 spike notes
Boiler and pump are the most correlated pair (r = 0.95). I found 4 spikes:

| Sensor | Minute | Value | z     |
|--------|--------|-------|-------|
| tank   | 1234   | 77.18 | +45.9 |
| boiler | 4321   | 90.92 | +29.2 |
| pump   | 7777   | 25.25 | -29.7 |
| tank   | 9001   | 75.64 | +40.4 |

notes: ["Spike: boiler at minute 4321 = 90.92 (z=+29.2)", ...]

The script made all 55 getReadings calls itself, so none of the 50,400 readings passed through the model. The model picks its own commands and messages, so yours will differ.

Why run code mode in the sandbox

Code mode gives an agent an “execute script” tool that lets it write JavaScript to call your tools. That script usually runs in a tiny embedded JavaScript runtime such as QuickJS, cut off from the system it’s working on. It can’t:

  • Save scripts that call your tools and reuse them later
  • Use npm or PyPI packages installed on the system
  • Use a rich standard library, such as Node’s or Bun’s fs, streams, and child_process
  • Work with files too large to load into memory
  • Call tools in the background after the turn ends, such as from a file watcher or a long job

A script in the sandbox runs on that system, so it can do all of this, but it can’t reach your tools:

Code Mode (Today)YOUR BACKENDAgentbash toolCode mode tool (JS)MCP & APIauthed callsSandboxNode.js, Python, Bashruns scriptCANNOT REACH YOUR TOOLSCode Mode (With Tools)YOUR BACKENDAgentbash toolActionsMCP & APIauthed callsSandboxNode.js, Python, Bashruns scriptcall-tool.shexecutes toolcalls tools

call-tool.sh closes the gap. The program runs in the sandbox next to your files and packages, and each tool call goes to an action in your backend, where the keys are.

Use it when a script calls tools in a loop, or when sandbox work should reach your app. If the model calling tools one at a time is enough, use Custom Tools.

Why not put the agent in the sandbox

Running the whole agent inside the sandbox would also put the tools next to the files, but the sandbox is the wrong place for the agent:

  • A broken sandbox takes the agent with it. An out-of-memory build or a corrupted filesystem kills the agent loop and its history. Here Pi runs in the agent Actor and keeps the conversation in its own database.
  • Anything in the sandbox can leak. Model keys and tool credentials would sit next to untrusted code. Here they stay on your worker, and the sandbox holds only a token for one agent.
  • Sandboxes sleep. Schedules, workflows, and reading history shouldn’t wait for a sandbox to start.

See Run Your Harness Outside the Sandbox for the full argument.

How the script works

// Calls one tool (an action on the agent) through the gateway and prints the JSON reply.
function callbackScript(actorUrl: string, token: string): string {
	return `#!/bin/sh
# Usage: ${SCRIPT_PATH} <tool> '<JSON array of arguments>'
set -eu
[ $# -ge 1 ] || { echo "usage: $0 <tool> '<JSON array of arguments>'" >&2; exit 2; }
exec curl -sS --fail-with-body -X POST \\
	-H ${quote(`x-rivet-token: ${token}`)} \\
	-H 'content-type: application/json' \\
	--data "{\\"args\\":\${2:-[]}}" \\
	${quote(`${actorUrl}/action/`)}"$1"
`;
}

// A public gateway address, without your Rivet token.
function gatewayEndpoint(): string {
	const endpoint = process.env.RIVET_PUBLIC_ENDPOINT ?? process.env.RIVET_ENDPOINT;
	if (!endpoint) throw new Error("Set RIVET_PUBLIC_ENDPOINT to an address the sandbox can reach.");
	const url = new URL(endpoint);
	url.username = "";
	url.password = "";
	return `${url.origin}${url.pathname.replace(/\/$/, "")}`;
}

// Quotes a value for the sandbox's shell.
function quote(value: string): string {
	return `'${value.replaceAll("'", `'"'"'`)}'`;
}
  • It posts the arguments to Call Action with the token in x-rivet-token.
  • gatewayEndpoint strips your Rivet token from the endpoint URL, so only the scoped token reaches the sandbox.