Crux
GuidesWorkspaces

Versions, Snapshots, and Transactions

Choose file history, explicit subtree snapshots, or staged multi-file writes.

Workspaces keep a version history for local files, materialize explicit subtree snapshots, and support staged multi-file transactions.

Choose The Recovery Boundary

NeedUse
Revert one file to an earlier versionundo()
Publish several new file changes as one coherent updatetransaction()
Preserve and later restore an existing file or subtreews.snapshot

undo() is file history. A transaction protects a change you are about to make. A snapshot is an explicit, reusable checkpoint of state that already exists.

Versioning And History

Every content change (write, edit, append, undo) appends an immutable version. History is always recorded; there is no flag to enable.

await ws.write("/outputs/report.md", "draft one");
await ws.write("/outputs/report.md", "draft two");

const history = await ws.history("/outputs/report.md");
// [{ version: 2, operation: "write", ... }, { version: 1, ... }]

const old = await ws.read("/outputs/report.md", { version: 1 }); // "draft one"

const diff = await ws.diff("/outputs/report.md", { from: 1, to: 2 });
diff.unified; // git-style unified diff string
diff.hunks; // structured add/remove/context lines

await ws.undo("/outputs/report.md"); // restores v1 as a new v3

diff defaults to the most recent change and applies to text files; binary files throw. undo reverts the last content change without rewriting history. rename, move, and copy start fresh history at the destination path. delete purges a file's history.

Versioning is on by default with unlimited retention. Bound it per file with versioning.maxVersions, which garbage collects the oldest snapshots and their assets:

const ws = workspace({
  id: "research",
  namespace,
  storage: storage({ records, assets }),
  versioning: { maxVersions: 20 },
});

Quota (limits.maxNamespaceBytes) counts live files only, not historical snapshots. Use maxVersions to bound history storage.

Pinned Artifact Versions

finalize() pins the current version as the published artifact. Editing the file afterwards creates new draft versions, but artifacts() and the workspace manifest keep surfacing the pinned revision until you finalize again.

await ws.write("/outputs/report.md", "published copy", { kind: "report" });
const published = await ws.finalize("/outputs/report.md");

await ws.edit("/outputs/report.md", { find: "published", replace: "wip" });

await ws.read("/outputs/report.md"); // working copy
await ws.read("/outputs/report.md", { version: published.version }); // published copy

Use this when the user needs a stable final output while the agent continues iterating.

Snapshots

Capture a local file or subtree through the singular snapshot facet. path is required; namespace is optional when the Workspace already resolves one:

const checkpoint = await ws.snapshot.create({
  path: "/outputs",
  namespace: "thread:123",
});

Restore is an exact-tree replacement. It creates or replaces captured files and deletes every later live file at or below the captured path that is absent from the snapshot. Files outside the captured tree are untouched.

The returned reference is JSON-safe and reusable. Persist the complete value, not only its opaque id:

const stored = JSON.stringify(checkpoint);
const loaded = JSON.parse(stored) as typeof checkpoint;

const result = await ws.snapshot.restore(loaded);
// { restoredFiles, deletedFiles, unchangedFiles }

Restore appends fresh file versions with operation "restore"; it never rewinds history. It preserves artifact metadata plus both the working content and a distinct published version when one was pinned at capture time. The snapshot remains listable and reusable after restore.

List committed snapshots newest first. A path filter matches the exact normalized capture path, not descendants. limit defaults to 50 and accepts integers from 1 through 100; pass the returned opaque cursor to continue the same Workspace, namespace, and path-filtered listing:

let page = await ws.snapshot.list({ path: "/outputs", limit: 20 });

if (page.cursor) {
  page = await ws.snapshot.list({
    path: "/outputs",
    limit: 20,
    cursor: page.cursor,
  });
}

Snapshots own materialized payload copies and live until explicitly deleted:

await ws.snapshot.delete(checkpoint);

Capture and restore reject any tree that intersects a source-backed mount; copy provider-owned files into a local mount first. Within one process, mutations for the same Workspace namespace are serialized and observed restore failures roll back. Generic stores do not provide process-crash, cross-process, or distributed atomicity, and snapshots are not a backup system.

Snapshots are explicit in this release. Automatic Effects checkpoints and conflict policy belong to the later #258 integration and are not performed by workspace.snapshot.

In Devtools, the Project Index Catalog lists where application definitions author these snapshot operations. Workspace activity and Run detail show operations that actually executed, with snapshot-specific aggregate summaries. Runtime snapshot instances remain runtime data and do not appear as static Catalog definitions.

See the snapshot API reference and storage costs and lifetime.

Transactions

Use transaction() when one deliverable spans several files and partial output would be misleading. The callback writes to a staged workspace view first. If the callback throws, the live namespace is unchanged. When it returns, Crux commits the touched paths and returns the callback result.

const artifact = await ws.transaction(async (tx) => {
  await tx.write("/outputs/report.md", "# Report", { status: "draft" });
  await tx.write("/outputs/data.csv", "name,value\nalpha,1\n");

  return tx.finalize("/outputs/report.md", { kind: "report" });
});

Reads inside the callback see staged changes. Ordinary ws reads still see the live namespace until commit:

await ws.write("/workspace/notes.md", "draft");

await ws.transaction(async (tx) => {
  await tx.edit("/workspace/notes.md", {
    find: "draft",
    replace: "final",
  });

  await tx.read("/workspace/notes.md"); // "final"
  await ws.read("/workspace/notes.md"); // "draft"
});

await ws.read("/workspace/notes.md"); // "final"

The transaction surface is file-focused: list, read, write, edit, delete, exists, stat, append, rename, move, copy, grep, artifacts, and finalize. It intentionally does not include prompt adapters such as asContext(), asTools(), or inject().

Transactions are namespace-local. Pass { namespace } to target a specific namespace outside prompt resolution:

await ws.transaction(
  async (tx) => {
    await tx.write("/outputs/report.md", "# Report");
  },
  { namespace: "thread:123" },
);

Source-backed mount mutations are rejected before provider hooks run. Copy a provider-backed file into /workspace or /outputs first if you need it inside a transaction.

Transactions are implemented over the generic RecordStore contract, so the same API works with in-memory storage, Convex record storage, Upstash Redis, and custom conforming stores. The generic implementation rolls back touched live paths when it observes a commit failure. Crash-proof multi-key durability still depends on the underlying store or runtime; transaction() is not a distributed transaction across stores.

On this page