Skip to content
Merged
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
32 changes: 32 additions & 0 deletions EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Runnable examples live in [`examples/`](./examples).
- [Blueprint with Build Context](#blueprint-with-build-context)
- [Devbox From Blueprint (Run Command, Shutdown)](#devbox-from-blueprint-lifecycle)
- [Devbox Mounts (Agent, Code, Object)](#devbox-mounts)
- [Devbox Snapshots (Suspend, Resume, Restore, Delete)](#devbox-snapshots)
- [Devbox Tunnel (HTTP Server Access)](#devbox-tunnel)
- [MCP Hub + Claude Code + GitHub](#mcp-github-tools)
- [Secrets with Devbox and Agent Gateway](#secrets-with-devbox)
Expand Down Expand Up @@ -107,6 +108,37 @@ yarn test:examples

**Source:** [`examples/devbox-mounts.ts`](./examples/devbox-mounts.ts)

<a id="devbox-snapshots"></a>
## Devbox Snapshots (Suspend, Resume, Restore, Delete)

**Use case:** Upload a file to a devbox, preserve it across suspend and resume, create a disk snapshot, restore multiple devboxes from that snapshot, mutate each copy independently, and delete the snapshot when finished.

**Tags:** `devbox`, `snapshot`, `suspend`, `resume`, `files`, `cleanup`

### Workflow
- Create a source devbox
- Upload a file and mutate it into a shared baseline
- Suspend and resume the source devbox
- Create a disk snapshot from the resumed devbox
- Restore two additional devboxes from the same snapshot baseline
- Mutate the same file differently in each devbox to prove isolation
- Shutdown the devboxes and delete the snapshot

### Prerequisites
- `RUNLOOP_API_KEY`

### Run
```sh
yarn tsn -T examples/devbox-snapshots.ts
```

### Test
```sh
yarn test:examples
```

**Source:** [`examples/devbox-snapshots.ts`](./examples/devbox-snapshots.ts)

<a id="devbox-tunnel"></a>
## Devbox Tunnel (HTTP Server Access)

Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ Use direct `secrets` injection when code inside the devbox legitimately needs th

For mount patterns, see [`examples/devbox-mounts.ts`](./examples/devbox-mounts.ts). It shows when to use `agent_mount` for reusable agents like Claude Code, `code_mount` for Git repositories such as [`runloopai/rl-cli`](https://github.com/runloopai/rl-cli.git), and `object_mount` for blobs that should appear on the devbox at startup. The example also demonstrates agent gateway wiring for Anthropic credentials plus object TTL, `.tgz` compression, and extraction-on-mount behavior.

For snapshot workflows, see [`examples/devbox-snapshots.ts`](./examples/devbox-snapshots.ts). It uploads a file to a source devbox, shows `suspend()` plus `resume()`, takes a disk snapshot, restores multiple devboxes from the same snapshot baseline, mutates the file independently in each devbox, and deletes the snapshot after the demo completes.

## Agent Guidance

Detailed agent-specific instructions live in [`llms.txt`](./llms.txt). Consolidated recipes for frequent tasks are in [`EXAMPLES.md`](./EXAMPLES.md).
Expand Down
221 changes: 221 additions & 0 deletions examples/devbox-snapshots.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
#!/usr/bin/env -S npm run tsn -T

/**
---
title: Devbox Snapshots (Suspend, Resume, Restore, Delete)
slug: devbox-snapshots
use_case: Upload a file to a devbox, preserve it across suspend and resume, create a disk snapshot, restore multiple devboxes from that snapshot, mutate each copy independently, and delete the snapshot when finished.
workflow:
- Create a source devbox
- Upload a file and mutate it into a shared baseline
- Suspend and resume the source devbox
- Create a disk snapshot from the resumed devbox
- Restore two additional devboxes from the same snapshot baseline
- Mutate the same file differently in each devbox to prove isolation
- Shutdown the devboxes and delete the snapshot
tags:
- devbox
- snapshot
- suspend
- resume
- files
- cleanup
prerequisites:
- RUNLOOP_API_KEY
run: yarn tsn -T examples/devbox-snapshots.ts
test: yarn test:examples
---
*/

import { RunloopSDK, toFile } from '@runloop/api-client';
import { wrapRecipe, runAsCli } from './_harness';
import type { Devbox, Snapshot } from '@runloop/api-client';
import type { RecipeContext, RecipeOutput } from './types';

function uniqueName(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}

const FILE_PATH = '/tmp/snapshot-demo.txt';

type FileReadableDevbox = {
file: {
read(params: { file_path: string }): Promise<string>;
};
};

async function readFileContents(devbox: FileReadableDevbox): Promise<string> {
return devbox.file.read({ file_path: FILE_PATH });
}

export async function recipe(ctx: RecipeContext): Promise<RecipeOutput> {
const { cleanup } = ctx;

const sdk = new RunloopSDK({
bearerToken: process.env['RUNLOOP_API_KEY'],
});

const resourcesCreated: string[] = [];

let sourceDevbox: Devbox | undefined;
let cloneA: Devbox | undefined;
let cloneB: Devbox | undefined;
let snapshot: Snapshot | undefined;

let sourceNeedsCleanup = false;
let cloneANeedsCleanup = false;
let cloneBNeedsCleanup = false;
let snapshotNeedsCleanup = false;

// The harness runs cleanup in LIFO order, so register snapshot cleanup first
// to ensure it runs after any live devboxes have been shut down.
cleanup.add('snapshot:baseline', async () => {
if (snapshotNeedsCleanup && snapshot) {
await snapshot.delete();
}
});
cleanup.add('devbox:source', async () => {
if (sourceNeedsCleanup && sourceDevbox) {
await sourceDevbox.shutdown();
}
});
cleanup.add('devbox:clone-a', async () => {
if (cloneANeedsCleanup && cloneA) {
await cloneA.shutdown();
}
});
cleanup.add('devbox:clone-b', async () => {
if (cloneBNeedsCleanup && cloneB) {
await cloneB.shutdown();
}
});

// Start from a single source devbox.
sourceDevbox = await sdk.devbox.create({
name: uniqueName('snapshot-source'),
launch_parameters: {
resource_size_request: 'X_SMALL',
},
});
sourceNeedsCleanup = true;
resourcesCreated.push(`devbox:${sourceDevbox.id}`);

const uploadedContents = 'uploaded-from-local-file';
const baselineContents = 'baseline-after-upload-and-mutation';
const sourceContents = 'source-devbox-after-isolated-mutation';
const cloneAContents = 'clone-a-after-isolated-mutation';
const cloneBContents = 'clone-b-after-isolated-mutation';

await sourceDevbox.file.upload({
path: FILE_PATH,
file: await toFile(Buffer.from(uploadedContents, 'utf8'), 'snapshot-demo.txt'),
});
const uploadedReadback = await readFileContents(sourceDevbox);

await sourceDevbox.file.write({
file_path: FILE_PATH,
contents: baselineContents,
});

// suspend & resume:
await sourceDevbox.suspend();
const suspendedInfo = await sourceDevbox.awaitSuspended();
const resumedInfo = await sourceDevbox.resume();
const resumedReadback = await readFileContents(sourceDevbox);

snapshot = await sourceDevbox.snapshotDisk({
name: uniqueName('snapshot-baseline'),
commit_message: 'Capture the shared baseline after suspend and resume.',
});
snapshotNeedsCleanup = true;
resourcesCreated.push(`snapshot:${snapshot.id}`);

// Restore two separate devboxes from the same baseline snapshot.
cloneA = await snapshot.createDevbox({
name: uniqueName('snapshot-clone-a'),
launch_parameters: {
resource_size_request: 'X_SMALL',
},
});
cloneANeedsCleanup = true;
resourcesCreated.push(`devbox:${cloneA.id}`);

cloneB = await sdk.devbox.createFromSnapshot(snapshot.id, {
name: uniqueName('snapshot-clone-b'),
launch_parameters: {
resource_size_request: 'X_SMALL',
},
});
cloneBNeedsCleanup = true;
resourcesCreated.push(`devbox:${cloneB.id}`);

const cloneABaselineReadback = await readFileContents(cloneA);
const cloneBBaselineReadback = await readFileContents(cloneB);

await sourceDevbox.file.write({ file_path: FILE_PATH, contents: sourceContents });
await cloneA.file.write({ file_path: FILE_PATH, contents: cloneAContents });
await cloneB.file.write({ file_path: FILE_PATH, contents: cloneBContents });

const sourceIsolatedReadback = await readFileContents(sourceDevbox);
const cloneAIsolatedReadback = await readFileContents(cloneA);
const cloneBIsolatedReadback = await readFileContents(cloneB);

await cloneB.shutdown();
cloneBNeedsCleanup = false;
await cloneA.shutdown();
cloneANeedsCleanup = false;
await sourceDevbox.shutdown();
sourceNeedsCleanup = false;
await snapshot.delete();
snapshotNeedsCleanup = false;

return {
resourcesCreated,
checks: [
{
name: 'uploaded file is readable on the source devbox',
passed: uploadedReadback === uploadedContents,
details: uploadedReadback,
},
{
name: 'suspend reaches the suspended state',
passed: suspendedInfo.status === 'suspended',
details: `status=${suspendedInfo.status}`,
},
{
name: 'resume preserves the baseline file contents',
passed: resumedInfo.status === 'running' && resumedReadback === baselineContents,
details: `status=${resumedInfo.status}, contents=${resumedReadback}`,
},
{
name: 'multiple devboxes can use the same snapshot baseline',
passed: cloneABaselineReadback === baselineContents && cloneBBaselineReadback === baselineContents,
details: `cloneA=${cloneABaselineReadback}, cloneB=${cloneBBaselineReadback}`,
},
{
name: 'devboxes diverge after isolated mutations',
passed:
sourceIsolatedReadback === sourceContents &&
cloneAIsolatedReadback === cloneAContents &&
cloneBIsolatedReadback === cloneBContents,
details: `source=${sourceIsolatedReadback}, cloneA=${cloneAIsolatedReadback}, cloneB=${cloneBIsolatedReadback}`,
},
{
name: 'snapshot-backed devboxes stay isolated from one another',
passed: new Set([sourceIsolatedReadback, cloneAIsolatedReadback, cloneBIsolatedReadback]).size === 3,
details: `values=${JSON.stringify([sourceIsolatedReadback, cloneAIsolatedReadback, cloneBIsolatedReadback])}`,
},
{
name: 'snapshot can be deleted after the demo finishes',
passed: !snapshotNeedsCleanup,
details: `deleted=${String(!snapshotNeedsCleanup)}`,
},
],
};
}

export const runDevboxSnapshotsExample = wrapRecipe({ recipe });

if (require.main === module) {
void runAsCli(runDevboxSnapshotsExample);
}
8 changes: 8 additions & 0 deletions examples/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { ExampleResult } from './types';
import { runBlueprintWithBuildContextExample } from './blueprint-with-build-context';
import { runDevboxFromBlueprintLifecycleExample } from './devbox-from-blueprint-lifecycle';
import { runDevboxMountsExample } from './devbox-mounts';
import { runDevboxSnapshotsExample } from './devbox-snapshots';
import { runDevboxTunnelExample } from './devbox-tunnel';
import { runMcpGithubToolsExample } from './mcp-github-tools';
import { runSecretsWithDevboxExample } from './secrets-with-devbox';
Expand Down Expand Up @@ -40,6 +41,13 @@ export const exampleRegistry: ExampleRegistryEntry[] = [
requiredEnv: ['RUNLOOP_API_KEY', 'ANTHROPIC_API_KEY'],
run: runDevboxMountsExample,
},
{
slug: 'devbox-snapshots',
title: 'Devbox Snapshots (Suspend, Resume, Restore, Delete)',
fileName: 'devbox-snapshots.ts',
requiredEnv: ['RUNLOOP_API_KEY'],
run: runDevboxSnapshotsExample,
},
{
slug: 'devbox-tunnel',
title: 'Devbox Tunnel (HTTP Server Access)',
Expand Down
3 changes: 3 additions & 0 deletions llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

- [Devbox lifecycle example](examples/devbox-from-blueprint-lifecycle.ts): Create blueprint, launch devbox, run commands, cleanup
- [Devbox mounts example](examples/devbox-mounts.ts): Show `agent_mount`, `code_mount`, and `object_mount` together, including Claude Code via Anthropic agent gateway, the `runloopai/rl-cli` repo, and object TTL/compression/extraction behavior
- [Devbox snapshots example](examples/devbox-snapshots.ts): Upload a file, suspend and resume the source devbox, snapshot the shared baseline, restore multiple devboxes from it, mutate each copy independently, and delete the snapshot
- [MCP GitHub example](examples/mcp-github-tools.ts): MCP Hub integration with Claude Code
- [Secrets with Devbox example](examples/secrets-with-devbox.ts): Show direct secret injection for app data alongside agent gateway for upstream credentials that should stay off the devbox

Expand All @@ -25,6 +26,8 @@
- Prefer SDK methods (`sdk.devbox`, `sdk.blueprint`, etc.) for object-oriented patterns and convenience
- For resources without SDK coverage (e.g., benchmarks), use `sdk.api.*` as a fallback
- Use `sdk.devbox.create()` to create devboxes; they auto-await readiness
- Use `devbox.suspend()` plus `devbox.awaitSuspended()` when you want to park a devbox and preserve its disk state, then use `devbox.resume()` to bring the same devbox back to `running`
- Use `devbox.snapshotDisk()` to capture a reusable disk baseline and `snapshot.createDevbox()` or `sdk.devbox.createFromSnapshot(snapshot.id, ...)` to branch multiple devboxes from that same state
- Use `devbox.cmd.exec('command')` for commands expected to return immediately (e.g., `echo`, `pwd`, `cat`)—blocks until completion, returns `ExecutionResult` with stdout/stderr
- Use `devbox.cmd.execAsync('command')` for long-running or background processes (servers, watchers, builds)—returns immediately with `Execution` handle to check status, get result, or kill
- Both `exec` and `execAsync` support streaming callbacks (`stdout`, `stderr`, `output`) for real-time output
Expand Down