Snapshotter Module
The Snapshotter interface lets your agent capture workspace state as a serializable handle, then restore or branch from that handle. Useful for "explore a hypothesis, roll back if it doesn't pan out" patterns and Jupyter-style checkpointing.
Interface
interface SnapshotRef {
readonly providerId: string;
readonly ref: unknown; // provider-specific opaque payload
}
interface SnapshotOptions {
readonly signal?: AbortSignal;
/**
* Round-5 (A5) — opt in to restoring a snapshot whose origin sandbox
* is NOT this session's. Default false: cross-session restores are
* REJECTED to prevent cross-tenant data-mixing when the snapshot
* store (e.g. R2 binding) is shared across tenants.
*/
readonly allowCrossSession?: boolean;
}
interface Snapshotter {
snapshot(opts?: SnapshotOptions): Promise<SnapshotRef>;
restore(ref: SnapshotRef, opts?: SnapshotOptions): Promise<WorkspaceRef>;
branch?(ref: SnapshotRef, opts?: SnapshotOptions): Promise<WorkspaceRef>;
}Abort-aware via AbortSignal
SnapshotOptions.signal flows from the auto-injected workspace tools (which forward the agent's ctx.abortSignal) down to the provider. Providers SHOULD honor signal cancellation: at minimum, pre-check signal.aborted before issuing the underlying SDK call so an already-aborted agent never starts expensive long-running work. Where the underlying SDK supports mid-flight cancellation, providers should thread the signal through. Where it does not, providers should document the gap in JSDoc and rely on the pre-check.
opts is optional throughout for backwards compatibility — existing callers that don't pass it continue to work unchanged.
"Restore returns a NEW WorkspaceRef" — read this carefully
The Snapshotter contract treats snapshots as forks, not mutations. Both restore(ref) and branch(ref):
- Do NOT mutate the current workspace.
- Create a NEW workspace identity (the provider's choice — typically a new id like
{originId}-restored-{shortId}). - Apply the snapshot to that new workspace.
- Return a fresh
WorkspaceRefpointing at the new workspace.
This means after a restore(ref):
- The current workspace is unchanged.
- You have a new ref. To start using the restored state, your code or your LLM must switch to operating against the new ref.
The framework's tool layer surfaces the new ref via the tool result — the LLM sees the ref it can pass to subsequent calls.
registry.swapRef — the auto-injected tools update the persisted ref
Because restore() and branch() return a NEW ref, the framework's auto-injected workspace__<name>__restore and workspace__<name>__branch tools also call registry.swapRef(name, newRef) (round-4 cluster A9). This:
- Updates the in-memory registry entry's stored ref to the new value.
- Triggers the framework's
persistRefcallback so the new ref is checkpointed alongside session state.
After a restore/branch tool call, subsequent workspace__<name>__* tool calls (fs/shell/code) resolve against the NEW workspace — not the original. On session resume, the persisted ref is the new one, so the resumed session continues to operate on the restored/branched workspace.
swapRef is registry-internal — custom tools calling Snapshotter.restore() directly do NOT trigger the swap automatically. If your custom tool wants the registry's stored ref updated, call ctx.workspaces!.swapRef(name, newRef) explicitly. Most flows should use the auto-injected tools and let the framework manage the swap.
branch? is optional
Some providers (CodeSandbox SDK, etc.) distinguish "branch" (intentional fork) from "restore" (recover prior state) at the API layer. The Snapshotter interface codifies this distinction at the interface level. For providers where the implementation is identical (like CloudflareSandboxSnapshotter), branch is implemented as a semantic alias of restore — same behavior, different id suffix.
The auto-injected workspace__<name>__branch tool is only present when the provider populates branch?.
Auto-injected tools
For a workspace named <name> with snapshot: true:
| Tool | Schema | Returns | When |
|---|---|---|---|
workspace__<name>__snapshot | {} | { ref: SnapshotRef } | Always (when snapshot declared) |
workspace__<name>__restore | { ref: SnapshotRef; allowCrossSession?: boolean } | { ref: WorkspaceRef } (new) | Always |
workspace__<name>__branch | { ref: SnapshotRef; allowCrossSession?: boolean } | { ref: WorkspaceRef } (new) | Only when provider implements branch? |
Cross-session ownership check (round-5 A5)
When the same snapshot store (e.g. an R2 binding for cloudflare-sandbox) is shared across tenants/sessions, a snapshot ref's originSandboxId is the only signal distinguishing this session's snapshots from another's. Pre-fix, an LLM in tenant A could pass tenant B's backup.id to its restore tool and silently restore B's data into A's workspace — cross-tenant data leakage with no audit trail.
Post-fix, restore() and branch() track the origin of every snapshot taken within the current session and REJECT any ref whose origin is not in the set. Operators with a legitimate cross-session use case (e.g. a templating workflow that seeds tenant B's workspace from a known-good template snapshot) opt in via the allowCrossSession: true field on SnapshotOptions (or the matching tool input field). Cross-session opt-ins are audit-logged at warn level so security reviewers see when the boundary is crossed.
The default-deny posture closes the cross-tenant data-mixing class. Sessions that don't share a snapshot store (the common case — each session gets its own R2 bucket prefix) are unaffected; the snapshotter records its own snapshots and trivially passes the check.
Implementation responsibility. Snapshotter providers MUST track snapshots created within the current session and reject unowned refs unless
allowCrossSession: trueis set. TheCloudflareSandboxSnapshotter(round-5 reference impl) maintains an in-memorySet<originSandboxId>per session — DO restart loses the set, so post-restart recovery requires the explicit opt-in (intentional: post-restart we cannot distinguish recovery-of-own-snapshot from cross-session restore).
Capability config
// boolean — no policy options in v1
snapshot: boolean;Snapshot has no fine-grained config in v1. Either you declare it or you don't.
Provider-specific snapshot configuration (R2 binding for Cloudflare Sandbox, etc.) lives on the PROVIDER config, not the capability config. See per-provider pages.
Provider-specific behavior
CloudflareSandboxSnapshotter
snapshot()callssandbox.createBackup({ dir: snapshotDir }). Backup is uploaded to R2 (provider'sbackupR2Bindingmust be configured).restore(ref)andbranch(ref)generate a new sandbox ID and callgetSandbox(namespace, newId)to obtain a stub for the new sandbox, then apply the backup viarestoreBackup(payload.backup).- The mount is FUSE overlay — ephemeral. After the new sandbox sleeps + wakes, you must re-restore from the same
SnapshotRefto recover. - Without
backupR2Bindingconfigured,snapshot()throws at call time with a clear error. - Snapshot ref payload schema (round-4 cluster D). The
payloadcarries{ backup, originSandboxId }. The OUTER wrapper is.strict()(extra fields fail loudly — they indicate a real bug or migration). The INNERbackupfield uses.strip()because it mirrors the@cloudflare/sandboxSDK's own backup-handle shape, which is owned by the SDK and may grow new fields as it evolves.originSandboxIdis constrained to the sameSANDBOX_ID_REGEXasBaseRefPayload.idto prevent tampering at the Zod boundary.
Provider support matrix
| Provider | snapshot supported |
|---|---|
| In-Memory | ❌ |
| Local Bash | ❌ |
| Cloudflare Filestore | ❌ (could be added — R2-backed sqlite dump — but not in v1) |
| Cloudflare Sandbox | ✅ (R2-backed; restore returns NEW sandbox; branch implemented) |
Snapshot pruning / retention (round-5 D11)
The Snapshotter interface has no delete(ref) method in v1. Snapshots accumulate in the provider's backing store (R2 for CloudflareSandboxSnapshotter) without framework-side cleanup. A long-running session that snapshots once per checkpoint will grow R2 namespace size linearly with checkpoint count.
Filed as known follow-up:
Snapshotter.delete(ref)for explicit pruning. Until it lands, operators must manage retention out-of-band.
Recommended retention pattern for CloudflareSandboxSnapshotter:
- Tag snapshots with a deterministic R2 prefix per session (e.g.
snapshots/<sessionId>/<timestamp>.tar). - Run a periodic worker job (Cron Trigger) that lists the R2 bucket and deletes snapshots older than your retention window via the R2 API:typescript
const list = await env.BACKUPS.list({ prefix: 'snapshots/' }); const cutoff = Date.now() - 7 * 24 * 60 * 60 * 1000; // 7 days for (const obj of list.objects) { if (obj.uploaded.getTime() < cutoff) { await env.BACKUPS.delete(obj.key); } } - For sessions that take many snapshots in quick succession (e.g. one per LLM step), keep only the last N snapshots per session-prefix. Pair with a session-end cleanup hook to delete the entire prefix when the session terminates.
The framework cannot do this automatically because retention policy is workload-specific (legal hold, debugging windows, cost budgets all vary). Treat snapshot storage as an operator responsibility until Snapshotter.delete() lands.
Source
- Interface:
packages/core/src/workspace/types/modules/snapshot.ts - Tool injection:
packages/core/src/workspace/tool-injection.ts(search formakeSnapshotTools) - Reference implementation:
CloudflareSandboxSnapshotter