Snapshotter Module
The Snapshotter interface lets your agent capture workspace state as a serializable handle, then restore, branch from, list, or delete that handle. Useful for "explore a hypothesis, roll back if it doesn't pan out" patterns and Jupyter-style checkpointing — and for keeping snapshot storage bounded under sustained agent use.
Interface
interface SnapshotRef {
readonly providerId: string;
readonly ref: unknown; // provider-specific opaque payload
}
interface SnapshotOptions {
readonly signal?: AbortSignal;
/**
* Opt in to operating on a snapshot whose origin sandbox is NOT this
* session's. Default false: cross-session restore/branch/list/delete
* are REJECTED to prevent cross-session data-mixing when the snapshot
* store (e.g. R2 binding) is shared across sessions.
*/
readonly allowCrossSession?: boolean;
}
interface SnapshotListOptions extends SnapshotOptions {
/** Maximum number of refs to return. Provider-default when omitted. */
readonly limit?: number;
}
interface Snapshotter {
snapshot(opts?: SnapshotOptions): Promise<SnapshotRef>;
restore(ref: SnapshotRef, opts?: SnapshotOptions): Promise<WorkspaceRef>;
branch?(ref: SnapshotRef, opts?: SnapshotOptions): Promise<WorkspaceRef>;
list?(opts?: SnapshotListOptions): Promise<SnapshotRef[]>;
delete?(ref: SnapshotRef, opts?: SnapshotOptions): Promise<void>;
}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_restore and workspace_branch tools also call registry.swapRef(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_* 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(newRef) explicitly. Most flows should use the auto-injected tools and let the framework manage the swap.
branch?, list?, delete? are 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.
list and delete are also optional — some providers may not have a way to enumerate or remove snapshots through their SDK. The corresponding auto-injected tools (workspace_list_snapshots, workspace_delete_snapshot) error at runtime with a clear message when invoked against an implementation that didn't populate the method (matches the branch? gating pattern).
CloudflareSandboxSnapshotter (the round-7 reference implementation) populates all four optional methods. The @cloudflare/sandbox SDK does NOT expose native list/delete on ISandbox, so the snapshotter goes directly to the configured R2 backup binding using the SDK's documented backup-key layout (backups/<id>/data.sqsh + backups/<id>/meta.json).
Auto-injected tools
For a workspace with snapshot: true:
| Tool | Schema | Returns | When |
|---|---|---|---|
workspace_snapshot | {} | { ref: SnapshotRef } | Always (when snapshot declared) |
workspace_restore | { ref: SnapshotRef; allowCrossSession?: boolean } | { ref: WorkspaceRef } (new) | Always |
workspace_branch | { ref: SnapshotRef; allowCrossSession?: boolean } | { ref: WorkspaceRef } (new) | Always (errors at runtime when provider lacks branch?) |
workspace_list_snapshots | { allowCrossSession?: boolean; limit?: number } | { snapshots: SnapshotRef[] } | Always (errors at runtime when provider lacks list?) |
workspace_delete_snapshot | { ref: SnapshotRef; allowCrossSession?: boolean } | { deleted: true } | Always (errors at runtime when provider lacks delete?) |
Cross-session ownership check (round-5 A5)
When the same snapshot store (e.g. an R2 binding for cloudflare-sandbox) is shared across sessions, a snapshot ref's originSandboxId is the only signal distinguishing this session's snapshots from another's. Pre-fix, an LLM with access to one session's restore tool could pass another session's backup.id and silently restore that session's data into its own workspace — cross-session data-mixing 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 a 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 reviewers see when the boundary is crossed.
The default-deny posture closes the cross-session 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 — enable with default config
snapshot: true;
// or object form — round-7 added the SnapshotCapConfig surface
snapshot: {
// Default cap on the auto-injected list_snapshots tool's response
// count. Default: 100. Bound the LLM-supplied limit so a session
// with tens of thousands of accumulated snapshots cannot dump the
// whole pool into a single tool result.
maxListResults: number;
}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).restoreBackupresult check (0.10.x). The 0.10.3 SDK signals restore failures (not found, expired, archive missing, container failure) by throwing, and otherwise returns{ success: true }. The snapshotter additionally treats an explicit{ success: false }as a failure and throws a clear error — a defensive guard against the documented{ success: boolean }contract even though the current SDK never returnsfalse.- 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.passthrough()because it mirrors the@cloudflare/sandboxSDK's ownDirectoryBackuphandle, which is owned by the SDK and may grow new fields as it evolves..passthrough()(not the previous.strip()) preserves any SDK-added fields so they round-trip intact: the parsedbackupis handed straight back torestoreBackup(payload.backup), so dropping unknown fields would discard SDK-added restore hints.originSandboxIdis constrained to the sameSANDBOX_ID_REGEXasBaseRefPayload.idto prevent tampering at the Zod boundary.
Provider support matrix
| Provider | snapshot supported |
|---|---|
| In-Memory | ❌ |
| Local Bash | ❌ |
| Local Sandbox | ❌ |
| Docker | ❌ |
| Cloudflare Filestore | ❌ (could be added — R2-backed sqlite dump — but not in v1) |
| Cloudflare Sandbox | ✅ (R2-backed; restore returns NEW sandbox; branch implemented) |
| Cloudflare Dynamic Worker | ❌ (script-only — ephemeral isolate, no durable state) |
Pruning snapshots (round-7)
Pre-round-7 the Snapshotter interface had no delete(ref) method. An LLM could call snapshot() 10/sec; each write produced a multi-MB squashfs archive in R2; the only mitigation operators had was an out-of-band R2 lifecycle policy. Sustained adversarial use would inflate R2 bills materially — flagged in the round-7 audit as cost-amplification gap #R7-03.
Round 7 promoted list? and delete? to first-class (optional) methods on the Snapshotter interface, with auto-injected tools the LLM can call: workspace_list_snapshots and workspace_delete_snapshot. Agents that snapshot heavily can now self-prune; operators querying the registry can audit and prune cross-session leftovers via the allowCrossSession: true opt-in.
Pruning patterns:
// Agent prompt fragment — instruct the LLM to keep only the most
// recent N snapshots:
//
// "Before taking a new snapshot, call list_snapshots; if the result
// has 5+ entries, call delete_snapshot on the oldest one before
// proceeding."// Operator-side cron prune (tighter than R2 lifecycle, owned by the
// framework): for each long-running session, periodically open the
// workspace, call list_snapshots({ allowCrossSession: true }), filter
// by your retention rule, and delete each match.
//
// Cross-session opt-ins emit an audit warn at the registry's logger
// so operators can review every cross-session prune.Defense-in-depth: keep your R2 lifecycle policy as a backstop (round-5 D11's recommended pattern is still valid) — the framework's pruning is a reactive surface, the lifecycle policy is the time-bound floor. Together they bound R2 storage on every dimension: agent-driven pruning + max-age policy.
maxListResults cap. The auto-injected list_snapshots tool clamps an LLM-supplied limit to the configured ceiling (default: 100; configurable via capabilities.snapshot.maxListResults). Without this clamp, list_snapshots itself would become a sibling cost-amplification vector — an LLM with a 10k-snapshot pool could request limit: 10000 and dump the whole pool into the tool result. The clamp is the round-7 follow-on protection.
Cross-session ownership applies to all four operations. list() returns only the current session's snapshots by default; delete() rejects refs whose origin sandbox/session isn't this session's. Cross-session opt-ins go through allowCrossSession: true on the tool input and audit-log at warn.
Idempotent delete. delete() on an already-removed ref resolves successfully (matches POSIX rm -f). Agents and operators can call it without tracking deletion state.
In-memory tracking caveat. The round-5 A5 ownership tracking (and round-7's ownedBackupIds extension) lives in process memory. A DO restart loses the set; post-restart, default list() returns only snapshots taken since the restart, and delete() requires allowCrossSession: true to remove pre-restart snapshots. This is intentional: post-restart we cannot distinguish own-vs-foreign snapshots without consulting the SDK's meta.json payloads, which do NOT stamp a session origin field. Operators rebuilding a session should use the explicit opt-in; the audit log surfaces every crossing.
Source
- Interface:
packages/core/src/workspace/types/modules/snapshot.ts - Tool injection:
packages/core/src/workspace/tool-injection.ts(search formakeSnapshotTools) - Reference implementation:
CloudflareSandboxSnapshotter