Skip to content

Workspace

vm.workspace reads and writes the files under /workspace inside the VM. Guest programs see the same files: what you write with writeText("notes.txt", ...) is /workspace/notes.txt to a shell in the VM, and what a guest program writes there you can read back. The workspace has the same methods and types in Userspace and System.

  • Every path addresses /workspace. notes.txt and /notes.txt name the same file, /workspace/notes.txt in the guest; / names the workspace root.
  • Paths in returned entries are workspace paths with a leading /, for example /src/main.ts.
  • A path may not contain NUL, \, or . and .. segments, and stays within Linux limits: 4,095 bytes in total and 255 bytes per segment. Other paths fail with INVALID_ARGUMENT.
  • Symbolic links cannot lead outside /workspace.

Process cwd and command paths are guest paths, validated separately (see VM).

Member Returns Meaning
id string The workspace ID: your workspaceId, or a generated ID for an ephemeral workspace.
persistence WorkspacePersistence How the workspace is stored. See Persistence.
readFile(path, signal?) Promise<Uint8Array> Reads a file’s bytes.
readText(path, signal?) Promise<string> Reads a file as UTF-8 text.
writeFile(path, data, signal?) Promise<void> Creates or replaces a file with data (Uint8Array).
writeText(path, text, signal?) Promise<void> Creates or replaces a file with UTF-8 text.
list(path?, signal?) Promise<readonly WorkspaceEntry[]> Lists a directory, / by default.
remove(path, options?) Promise<void> Removes a file, a link or an empty directory. options: recursive?: boolean removes a directory and everything in it; signal?.
entries(path?, signal?) Promise<WorkspaceFileEntry[]> Lists a directory with sizes, link targets and versions, / by default.
stat(path, signal?) Promise<WorkspaceFileEntry> Describes one entry.
read(path, options?) Promise<WorkspaceFile> Reads a file together with its entry. options: maxBytes?, signal?.
write(path, data, options?) Promise<WorkspaceFileEntry> Writes a file, optionally only over a known version, and returns its new entry. options: ifVersion?, signal?.
createDirectory(path, options?) Promise<void> Creates a directory and any missing parents. An existing directory is not an error. options: signal?.
rename(from, to, options?) Promise<void> Moves an entry. It never replaces an existing entry at to. options: signal?.
watch(onChange, options) WorkspaceWatch Reports changes in the directories you choose. See Watching.
  • Every method except watch returns a promise. An aborted signal cancels the request with ABORTED.
  • Writes copy your bytes before they return, so you can reuse the buffer, and resolve after the change is committed.
  • Text helpers use UTF-8; readFile, writeFile, read and write work with bytes.
  • After vm.dispose(), every method fails with PROTOCOL_ERROR.

The option types are exported as WorkspaceReadOptions ({ maxBytes?: number; signal?: AbortSignal }), WorkspaceWriteOptions ({ ifVersion?: string | null; signal?: AbortSignal }), WorkspaceOperationOptions ({ signal?: AbortSignal }) and WorkspaceWatchOptions ({ signal: AbortSignal }).

list returns WorkspaceEntry values:

Field Type Meaning
kind "file" | "directory" | "symlink" What the entry is.
name string The last path segment.
path string The workspace path, with a leading /.

entries and stat return WorkspaceFileEntry values, which add size, link and version:

Field Type Meaning
kind "file" | "directory" | "symlink" The entry itself; a symbolic link is reported as "symlink".
link { kind: "file" | "directory" | "missing"; path: string | null } | null For a symbolic link, what its target is and the target’s workspace path (null when the target is missing). null for every other entry.
name string The last path segment.
path string The workspace path, with a leading /.
size number Size in bytes; 0 for a directory.
version string A 48-hex-digit content version. Pass it to write as ifVersion.

entries lists names in code-point order. read returns a WorkspaceFile: { entry: WorkspaceFileEntry; data: Uint8Array }.

write takes an optional ifVersion so that an editor never overwrites changes it has not seen.

ifVersion write succeeds when Otherwise
omitted always; it creates or replaces the file
a version string the file exists and its version is still that version STORAGE_ERROR, detail.reason: "version-mismatch"
null nothing exists at the path yet STORAGE_ERROR, detail.reason: "exists"
  • The parent directory must already exist; create it with createDirectory first.
  • A directory at the path fails with STORAGE_ERROR and detail.reason: "not-a-file".
  • An ifVersion that is not 48 lowercase hex digits fails with INVALID_ARGUMENT.
save.ts
import { VMError, type VM } from "@oxidevm/sdk";
/** Saves only if nobody changed the file since `version` was read. */
export async function save(vm: VM, path: string, text: string, version: string): Promise<string | "conflict"> {
try {
const entry = await vm.workspace.write(path, new TextEncoder().encode(text), { ifVersion: version });
return entry.version;
} catch (error: unknown) {
if (error instanceof VMError && error.code === "STORAGE_ERROR" && error.detail?.["reason"] === "version-mismatch") {
return "conflict";
}
throw error;
}
}
seed.ts
import { VMError, type VM } from "@oxidevm/sdk";
/** Creates a starter file on the first visit and keeps the user's edits afterwards. */
export async function seed(vm: VM, path: string, text: string): Promise<void> {
try {
await vm.workspace.write(path, new TextEncoder().encode(text), { ifVersion: null });
} catch (error: unknown) {
if (error instanceof VMError && error.code === "STORAGE_ERROR" && error.detail?.["reason"] === "exists") return;
throw error;
}
}
Limit Value When exceeded
read maxBytes, which defaults to and may not exceed 16 MiB (16,777,216 bytes) STORAGE_ERROR, detail.reason: "too-large", with the file’s size in detail.size
maxBytes an integer from 0 to 16 MiB INVALID_ARGUMENT
write 16 MiB of data STORAGE_ERROR, detail.reason: "too-large"
Userspace writeFile and writeText 16 MiB of data, as for write INVALID_ARGUMENT
Path 4,095 bytes in total, 255 bytes per segment INVALID_ARGUMENT
Watched directories 256 distinct directories per VM, across all its watches INVALID_ARGUMENT
Code detail.reason When
INVALID_ARGUMENT none An invalid path, maxBytes or ifVersion, or too many watched directories.
STORAGE_NOT_FOUND none The path, or a parent directory on it, does not exist.
PERMISSION_DENIED none The entry is not accessible.
STORAGE_ERROR "too-large" A file or a write is above the size limits.
STORAGE_ERROR "version-mismatch" write with an ifVersion string found another version, or no file.
STORAGE_ERROR "exists" write with ifVersion: null found an entry, or rename found an entry at to.
STORAGE_ERROR "not-a-file" read or write met a directory or another non-file.
STORAGE_ERROR "not-a-directory" A path component is not a directory, for example createDirectory("a/b") when a is a file, or a watched path is not a directory.
STORAGE_ERROR "busy" The entry stayed busy in the guest after the SDK’s own retries.
STORAGE_ERROR none Another storage failure. In Userspace, detail.errno then holds the Linux error number, for example after remove of a non-empty directory without recursive.
FEATURE_NOT_AVAILABLE "image-update-required" System: the image’s guest agent has no workspace file operations. This affects entries, stat, read, write, createDirectory, rename, watch and list.
FEATURE_NOT_AVAILABLE "no-root-filesystem" Userspace: the VM has no root filesystem.
ABORTED none The request’s signal aborted, or the VM was disposed while a watch ran.
PROTOCOL_ERROR none The VM was disposed.

Branch on code and detail.reason, never on message. See Errors.

watch
watch(
onChange: (changes: readonly WorkspaceChange[]) => void,
options: { signal: AbortSignal },
): WorkspaceWatch;
interface WorkspaceWatch {
setDirectories(paths: readonly string[]): Promise<void>;
readonly done: Promise<void>;
}
type WorkspaceChange =
| { kind: "changed"; directory: string; name: string }
| { kind: "rescan" };
  • watch returns at once. A new watch watches nothing until setDirectories resolves.
  • setDirectories(paths) replaces the watch’s directories. Paths follow the path rules and are reported back in normalized form (src becomes /src); duplicates collapse. Each path must be a directory or a link to one. A VM watches at most 256 distinct directories across all its watches. If the call fails, the watch keeps its previous directories.
  • onChange receives batches of changes:
    • { kind: "changed", directory, name }: the entry name in the watched directory changed. Call stat or entries to see its new state.
    • { kind: "rescan" }: read every watched directory again. It is sent, for example, when more changes arrived than a batch reports, when a watched directory was moved or removed, and after a restore.
  • Each watch receives the changes of its own directories and every rescan.
  • done resolves when options.signal aborts and rejects when the watch fails. When the VM is disposed, done rejects with ABORTED.

Read the directories after setDirectories resolves, then apply the changes that follow:

watch-source.ts
import type { VM } from "@oxidevm/sdk";
export async function watchSource(vm: VM, signal: AbortSignal, refresh: (directory: string) => void): Promise<void> {
const watch = vm.workspace.watch((changes) => {
for (const change of changes) refresh(change.kind === "rescan" ? "/src" : change.directory);
}, { signal });
await watch.setDirectories(["src"]);
refresh("/src");
await watch.done;
}
WorkspacePersistence
type WorkspacePersistence =
| { kind: "ephemeral" }
| { kind: "persistent"; backend: "opfs" | "indexeddb"; browserRetention: "best-effort" | "granted" };
kind Meaning
"ephemeral" Created without workspaceId. The files belong to this VM alone and are not kept for a later visit.
"persistent" Created with workspaceId. The files are stored in this origin’s browser storage, in OPFS ("opfs") or IndexedDB ("indexeddb"), and the same ID opens them again on a later visit.
  • browserRetention is "granted" when the browser reported persistent storage for the origin (navigator.storage.persisted()) as the VM was created, otherwise "best-effort". Even "granted" storage is not safe from quota limits, users clearing site data, or hardware failure.
  • A workspace created with workspaceId is never reported as durable while being kept in memory: if durable storage is refused, the operation fails instead.
  • Files that guest programs write under /workspace belong to the same workspace. For a durable workspace, vm.dispose() saves them before it resolves.
  • Files survive a normal close and reopen; running processes do not. To bring processes back, use restore points.
  • Only one VM at a time can hold a durable workspace. See workspaceId.
  • When a durable Userspace workspace opens on a different image than the one it was saved on, for example with restorePoint: "update-image", your /workspace changes are carried onto the new image’s files, and vm.start.workspaceDetails reports the outcome.