diff --git a/vendor/agents/docs/content/guides/sandbox-code-mode.mdx b/vendor/agents/docs/content/guides/sandbox-code-mode.mdx
new file mode 100644
index 00000000..22ed50e9
--- /dev/null
+++ b/vendor/agents/docs/content/guides/sandbox-code-mode.mdx
@@ -0,0 +1,203 @@
+---
+title: "Code Mode in the Sandbox"
+description: "Let programs the agent writes in its sandbox call your backend tools, authenticated with a scoped JWT."
+---
+
+import ExampleLinkBar from "@/components/docs/ExampleLinkBar.astro";
+
+
+
+
+
+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](/docs/jwt). The model uses each action as a tool, and your Rivet token stays on your worker.
+
+## Quickstart
+
+
+
+
+
+```sh
+npm add @rivet-dev/pi @rivet-dev/sandbox-adapter @earendil-works/pi-durable rivetkit@next
+```
+
+
+
+
+
+```sh
+ANTHROPIC_API_KEY=sk-ant-...
+RIVET_ENDPOINT=https://:@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.
+
+
+
+
+
+
+
+- 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.
+
+
+
+
+
+
+
+
+
+
+
+
+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.
+
+
+
+
+The model writes the arguments, so check them. A `UserError` makes the script exit non-zero with the message.
+
+
+
+
+
+With Docker running, start the server. It first builds the sandbox image from the example's [Dockerfile](https://github.com/rivet-dev/agents/blob/main/examples/docs/sandbox-code-mode/Dockerfile), which adds Python and numpy to `node:22`.
+
+```sh
+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`:
+
+
+
+```sh
+npx tsx cli.ts
+```
+
+```text
+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:
+
+
+
+
+
+`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](/agents/docs/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](/blog/2026-07-27-run-your-harness-outside-the-sandbox) for the full argument.
+
+## How the script works
+
+
+
+- It posts the arguments to [Call Action](/docs/api/gateway/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.
+
+## Related docs
+
+- [JWTs](/docs/jwt): grants and expiry.
+- [Sandboxes](/agents/docs/sandboxes): providers and when the agent connects.
+- [Scoped Access with JWTs](/agents/docs/scoped-access): share a token with a browser instead.
diff --git a/vendor/agents/docs/sidebar.json b/vendor/agents/docs/sidebar.json
index 1433d48f..90293dfa 100644
--- a/vendor/agents/docs/sidebar.json
+++ b/vendor/agents/docs/sidebar.json
@@ -207,6 +207,10 @@
{
"title": "Multi-Agent Systems",
"href": "/guides/multi-agent-system"
+ },
+ {
+ "title": "Code Mode in the Sandbox",
+ "href": "/guides/sandbox-code-mode"
}
]
},
diff --git a/vendor/agents/examples/docs/sandbox-code-mode/Dockerfile b/vendor/agents/examples/docs/sandbox-code-mode/Dockerfile
new file mode 100644
index 00000000..a7b50691
--- /dev/null
+++ b/vendor/agents/examples/docs/sandbox-code-mode/Dockerfile
@@ -0,0 +1,8 @@
+# The sandbox image: Node.js, curl, and git from node:22, plus Python with pip and numpy.
+FROM node:22
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends python3-pip \
+ && rm -rf /var/lib/apt/lists/*
+# Debian marks its Python as externally managed. Let pip install into it so the agent can add packages.
+ENV PIP_BREAK_SYSTEM_PACKAGES=1 PIP_ROOT_USER_ACTION=ignore
+RUN pip install --no-cache-dir numpy
diff --git a/vendor/agents/examples/docs/sandbox-code-mode/callback.ts b/vendor/agents/examples/docs/sandbox-code-mode/callback.ts
new file mode 100644
index 00000000..cd2d407b
--- /dev/null
+++ b/vendor/agents/examples/docs/sandbox-code-mode/callback.ts
@@ -0,0 +1,64 @@
+import type { Sandbox, SandboxActorContext, SandboxProvider } from "@rivet-dev/sandbox-adapter";
+import type { Client } from "rivetkit/client";
+import type { registry } from "./server";
+
+// docs:start provider
+// 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 {
+ // 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;
+ 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}`);
+}
+// docs:end provider
+
+// docs:start script
+// 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} ''
+set -eu
+[ $# -ge 1 ] || { echo "usage: $0 ''" >&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("'", `'"'"'`)}'`;
+}
+// docs:end script
diff --git a/vendor/agents/examples/docs/sandbox-code-mode/cli.ts b/vendor/agents/examples/docs/sandbox-code-mode/cli.ts
new file mode 100644
index 00000000..f92b1e0b
--- /dev/null
+++ b/vendor/agents/examples/docs/sandbox-code-mode/cli.ts
@@ -0,0 +1,48 @@
+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();
+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();
diff --git a/vendor/agents/examples/docs/sandbox-code-mode/readings.ts b/vendor/agents/examples/docs/sandbox-code-mode/readings.ts
new file mode 100644
index 00000000..1c2e1da5
--- /dev/null
+++ b/vendor/agents/examples/docs/sandbox-code-mode/readings.ts
@@ -0,0 +1,40 @@
+import { UserError } from "rivetkit";
+
+// Fake data for the demo: one reading a minute for a week from each sensor,
+// with a few planted spikes for the agent to find.
+export const SENSORS = ["boiler", "pump", "intake", "exhaust", "tank"];
+const READINGS = 7 * 24 * 60;
+const PAGE_SIZE = 1000;
+const SPIKES: Record = {
+ boiler: [[4321, 18]],
+ pump: [[7777, -14]],
+ tank: [
+ [1234, 22],
+ [9001, 20],
+ ],
+};
+
+export function readingsPage(sensor: unknown, cursor: unknown = 0) {
+ if (typeof sensor !== "string" || !SENSORS.includes(sensor)) throw new UserError(`Pass a sensor: ${SENSORS.join(", ")}.`);
+ if (typeof cursor !== "number" || !Number.isInteger(cursor) || cursor < 0) throw new UserError("Pass 0 or the cursor from the previous page.");
+ const next = cursor + PAGE_SIZE;
+ return { readings: series(sensor).slice(cursor, next), next: next < READINGS ? next : null };
+}
+
+function series(sensor: string): number[] {
+ const k = SENSORS.indexOf(sensor);
+ let seed = k + 1;
+ const random = () => {
+ seed = (seed * 1103515245 + 12345) % 2 ** 31;
+ return seed / 2 ** 31;
+ };
+ const values = Array.from({ length: READINGS }, (_, i) => {
+ const daily = [2, 1, 2, 1, 0.5][k] * Math.sin((2 * Math.PI * i) / 1440);
+ // The boiler and the pump share a load, so they move together.
+ const load = [4, 3, 0, 0, 0][k] * Math.sin((2 * Math.PI * i) / 2000);
+ const noise = random() + random() + random() - 1.5;
+ return [70, 40, 15, 120, 55][k] + daily + load + noise;
+ });
+ for (const [index, size] of SPIKES[sensor] ?? []) values[index] += size;
+ return values.map((value) => Math.round(value * 100) / 100);
+}
diff --git a/vendor/agents/examples/docs/sandbox-code-mode/server.ts b/vendor/agents/examples/docs/sandbox-code-mode/server.ts
new file mode 100644
index 00000000..cb1fb572
--- /dev/null
+++ b/vendor/agents/examples/docs/sandbox-code-mode/server.ts
@@ -0,0 +1,94 @@
+import { execFileSync } from "node:child_process";
+import { createRegistry, defineExtension, section } from "@earendil-works/pi-durable";
+import { CodingTools } from "@earendil-works/pi-durable/tools";
+import { pi } from "@rivet-dev/pi";
+import { dockerProvider } from "@rivet-dev/sandbox-adapter/docker";
+import { event, setup, UserError } from "rivetkit";
+import { SCRIPT_PATH, withCallbackScript } from "./callback";
+import { readingsPage, SENSORS } from "./readings";
+
+// Builds the sandbox image from ./Dockerfile. Docker caches it after the first run.
+console.log("Building the sandbox image...");
+execFileSync("docker", ["build", "--quiet", "--tag", "sandbox-code-mode", import.meta.dirname], {
+ stdio: ["ignore", "ignore", "inherit"],
+});
+
+// Local runs only. Without RIVET_ENDPOINT, RivetKit starts a control plane on
+// this machine, so point call-tool.sh at an address the Docker sandbox can reach.
+if (!process.env.RIVET_ENDPOINT && !process.env.RIVET_PUBLIC_ENDPOINT) {
+ const port = process.env.RIVET_RUN_ENGINE_PORT ?? "6420";
+ if (process.platform === "linux") {
+ // The sandbox reaches the host through Docker's bridge, so listen beyond loopback.
+ process.env.RIVET_RUN_ENGINE_HOST ??= "0.0.0.0";
+ process.env.RIVET_PUBLIC_ENDPOINT = `http://172.17.0.1:${port}`;
+ } else {
+ // Docker Desktop forwards host.docker.internal to the host's loopback.
+ process.env.RIVET_PUBLIC_ENDPOINT = `http://host.docker.internal:${port}`;
+ }
+}
+
+// docs:start prompt
+// 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} ''
+It prints {"output": }, or exits non-zero with an error.
+
+Tools:
+- progress [""]: Shows the user a one-line status. Call it as you start each step of a task.
+- saveNote [""]: Saves a note that is kept after the sandbox is deleted.
+- listNotes []: Returns every saved note.
+- getReadings ["", ]: Returns {"readings": [...], "next": }, 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"]'`,
+ ),
+ ],
+ }),
+);
+// docs:end prompt
+
+// docs:start agent
+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(),
+ },
+ // 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;
+}
+// docs:end agent
+
+export const registry = setup({ use: { agent } });
+
+registry.start();
diff --git a/vendor/agents/examples/docs/tsconfig.json b/vendor/agents/examples/docs/tsconfig.json
index 7379a0d8..7cc7d2a4 100644
--- a/vendor/agents/examples/docs/tsconfig.json
+++ b/vendor/agents/examples/docs/tsconfig.json
@@ -17,6 +17,7 @@
"pi/stream.ts",
"react-sdk/Chat.tsx",
"react-sdk/useAgentChat.ts",
+ "sandbox-code-mode/cli.ts",
"session-lifecycle/client.ts"
]
}