Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions docs/deploy/modal.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
---
title: "Modal"
description: "Deploy Sandbox Agent inside a Modal sandbox."
---

## Prerequisites

- `MODAL_TOKEN_ID` and `MODAL_TOKEN_SECRET` from [modal.com/settings](https://modal.com/settings)
- `ANTHROPIC_API_KEY` or `OPENAI_API_KEY`

## TypeScript example

```typescript
import { ModalClient } from "modal";
import { SandboxAgent } from "sandbox-agent";

const modal = new ModalClient();
const app = await modal.apps.fromName("sandbox-agent", { createIfMissing: true });

const image = modal.images
.fromRegistry("ubuntu:22.04")
.dockerfileCommands([
"RUN apt-get update && apt-get install -y curl ca-certificates",
"RUN curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh",
]);

const envs: Record<string, string> = {};
if (process.env.ANTHROPIC_API_KEY) envs.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
if (process.env.OPENAI_API_KEY) envs.OPENAI_API_KEY = process.env.OPENAI_API_KEY;

const secrets = Object.keys(envs).length > 0
? [await modal.secrets.fromObject(envs)]
: [];

const sb = await modal.sandboxes.create(app, image, {
encryptedPorts: [3000],
secrets,
});

const exec = async (cmd: string) => {
const p = await sb.exec(["bash", "-c", cmd], { stdout: "pipe", stderr: "pipe" });
const exitCode = await p.wait();
if (exitCode !== 0) {
const stderr = await p.stderr.readText();
throw new Error(`Command failed (exit ${exitCode}): ${cmd}\n${stderr}`);
}
};

await exec("sandbox-agent install-agent claude");
await exec("sandbox-agent install-agent codex");

await sb.exec(
["bash", "-c", "sandbox-agent server --no-token --host 0.0.0.0 --port 3000 &"],
);

const tunnels = await sb.tunnels();
const baseUrl = tunnels[3000].url;

const sdk = await SandboxAgent.connect({ baseUrl });

const session = await sdk.createSession({ agent: "claude" });
const off = session.onEvent((event) => {
console.log(event.sender, event.payload);
});

await session.prompt([{ type: "text", text: "Summarize this repository" }]);
off();

await sb.terminate();
```

## Faster cold starts

Modal caches image layers, so the `dockerfileCommands` that install `curl` and `sandbox-agent` only run on the first build. Subsequent sandbox creates reuse the cached image.

## Running the test

The example includes a health-check test. First, build the SDK:

```bash
pnpm --filter sandbox-agent build
```

Then run the test with your Modal credentials:

```bash
MODAL_TOKEN_ID=<your-token-id> MODAL_TOKEN_SECRET=<your-token-secret> npx vitest run
```

Run from `examples/modal/`. The test will skip if credentials are not set.

## Notes

- Modal sandboxes use [gVisor](https://gvisor.dev/) for strong isolation.
- Ports are exposed via encrypted tunnels (`encryptedPorts`). Use `sb.tunnels()` to get the public HTTPS URL.
- Environment variables (API keys) are passed as Modal [Secrets](https://modal.com/docs/guide/secrets) rather than plain env vars for security.
- Always call `sb.terminate()` when done to avoid leaking sandbox resources.
20 changes: 20 additions & 0 deletions examples/modal/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"name": "@sandbox-agent/example-modal",
"private": true,
"type": "module",
"scripts": {
"start": "tsx src/modal.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"modal": "latest",
"@sandbox-agent/example-shared": "workspace:*",
"sandbox-agent": "workspace:*"
},
"devDependencies": {
"@types/node": "latest",
"tsx": "latest",
"typescript": "latest",
"vitest": "^3.0.0"
}
}
123 changes: 123 additions & 0 deletions examples/modal/src/modal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { ModalClient } from "modal";
import { SandboxAgent } from "sandbox-agent";
import { detectAgent, buildInspectorUrl, waitForHealth } from "@sandbox-agent/example-shared";
import { fileURLToPath } from "node:url";
import { resolve } from "node:path";
import { run } from "node:test";

const PORT = 3000;
const APP_NAME = "sandbox-agent";

async function buildSecrets(modal: ModalClient) {
const envVars: Record<string, string> = {};
if (process.env.ANTHROPIC_API_KEY)
envVars.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
if (process.env.OPENAI_API_KEY)
envVars.OPENAI_API_KEY = process.env.OPENAI_API_KEY;

if (Object.keys(envVars).length === 0) return [];
return [await modal.secrets.fromObject(envVars)];
}

export async function setupModalSandboxAgent(): Promise<{
baseUrl: string;
cleanup: () => Promise<void>;
}> {
const modal = new ModalClient();
const app = await modal.apps.fromName(APP_NAME, { createIfMissing: true });

const image = modal.images
.fromRegistry("ubuntu:22.04")
.dockerfileCommands([
"RUN apt-get update && apt-get install -y curl ca-certificates",
"RUN curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh",
]);

const secrets = await buildSecrets(modal);

console.log("Creating Modal sandbox!");
const sb = await modal.sandboxes.create(app, image, {
secrets: secrets,
encryptedPorts: [PORT],
});
console.log(`Sandbox created: ${sb.sandboxId}`);

const exec = async (cmd: string) => {
const p = await sb.exec(["bash", "-c", cmd], {
stdout: "pipe",
stderr: "pipe",
});
const exitCode = await p.wait();
if (exitCode !== 0) {
const stderr = await p.stderr.readText();
throw new Error(`Command failed (exit ${exitCode}): ${cmd}\n${stderr}`);
}
};

if (process.env.ANTHROPIC_API_KEY) {
console.log("Installing Claude agent...");
await exec("sandbox-agent install-agent claude");
}
if (process.env.OPENAI_API_KEY) {
console.log("Installing Codex agent...");
await exec("sandbox-agent install-agent codex");
}

console.log("Starting server...");

await sb.exec(
["bash", "-c", `sandbox-agent server --no-token --host 0.0.0.0 --port ${PORT} &`],
);

const tunnels = await sb.tunnels();
const tunnel = tunnels[PORT];
if (!tunnel) {
throw new Error(`No tunnel found for port ${PORT}`);
}
const baseUrl = tunnel.url;

console.log("Waiting for server...");
await waitForHealth({ baseUrl });

const cleanup = async () => {
try {
await sb.terminate();
} catch (error) {
console.warn("Cleanup failed:", error instanceof Error ? error.message : error);
}
};

return { baseUrl, cleanup };
}

export async function runModalExample(): Promise<void> {
const { baseUrl, cleanup } = await setupModalSandboxAgent();

const handleExit = async () => {
await cleanup();
process.exit(0);
};

process.once("SIGINT", handleExit);
process.once("SIGTERM", handleExit);

const client = await SandboxAgent.connect({ baseUrl });
const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/root", mcpServers: [] } });
const sessionId = session.id;

console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`);
console.log(" Press Ctrl+C to stop.");

await new Promise(() => {});
}

const isDirectRun = Boolean(
process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url),
);

if (isDirectRun) {
runModalExample().catch((error) => {
console.error(error instanceof Error ? error.message : error);
process.exit(1);
});
}
28 changes: 28 additions & 0 deletions examples/modal/tests/modal.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { describe, it, expect } from "vitest";
import { buildHeaders } from "@sandbox-agent/example-shared";
import { setupModalSandboxAgent } from "../src/modal.ts";

const shouldRun = Boolean(process.env.MODAL_TOKEN_ID && process.env.MODAL_TOKEN_SECRET);
const timeoutMs = Number.parseInt(process.env.SANDBOX_TEST_TIMEOUT_MS || "", 10) || 300_000;

const testFn = shouldRun ? it : it.skip;

describe("modal example", () => {
testFn(
"starts sandbox-agent and responds to /v1/health",
async () => {
const { baseUrl, cleanup } = await setupModalSandboxAgent();
try {
const response = await fetch(`${baseUrl}/v1/health`, {
headers: buildHeaders({}),
});
expect(response.ok).toBe(true);
const data = await response.json();
expect(data.status).toBe("ok");
} finally {
await cleanup();
}
},
timeoutMs,
);
});
16 changes: 16 additions & 0 deletions examples/modal/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM"],
"module": "ESNext",
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"noEmit": true,
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "**/*.test.ts"]
}
Loading