From 553abe90fa5f2f4e4e17f3a2fbbad501bdec9c14 Mon Sep 17 00:00:00 2001 From: "rivet-docs-sync[bot]" Date: Thu, 29 Oct 2026 11:08:03 +0000 Subject: [PATCH] docs(agents): sync from rivet-dev/agents@e963879 --- .../docs/content/guides/sandbox-code-mode.mdx | 203 ++++++++++++++++++ vendor/agents/docs/sidebar.json | 4 + .../docs/sandbox-code-mode/Dockerfile | 8 + .../docs/sandbox-code-mode/callback.ts | 64 ++++++ .../examples/docs/sandbox-code-mode/cli.ts | 48 +++++ .../docs/sandbox-code-mode/readings.ts | 40 ++++ .../examples/docs/sandbox-code-mode/server.ts | 94 ++++++++ vendor/agents/examples/docs/tsconfig.json | 1 + 8 files changed, 462 insertions(+) create mode 100644 vendor/agents/docs/content/guides/sandbox-code-mode.mdx create mode 100644 vendor/agents/examples/docs/sandbox-code-mode/Dockerfile create mode 100644 vendor/agents/examples/docs/sandbox-code-mode/callback.ts create mode 100644 vendor/agents/examples/docs/sandbox-code-mode/cli.ts create mode 100644 vendor/agents/examples/docs/sandbox-code-mode/readings.ts create mode 100644 vendor/agents/examples/docs/sandbox-code-mode/server.ts 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: + +
+ + + + + + + Code Mode (Today) + + YOUR BACKEND + Agent + bash tool + Code mode tool (JS) + MCP & API + authed calls + Sandbox + Node.js, Python, Bash + runs script + CANNOT REACH YOUR TOOLS + + + + Code Mode (With Tools) + + YOUR BACKEND + Agent + bash tool + Actions + MCP & API + authed calls + Sandbox + Node.js, Python, Bash + runs script + call-tool.sh + executes tool + calls 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" ] }