createVM
createVM starts a VM in the current browser tab and resolves once the VM is ready to use.
import { createVM } from "@oxidevm/sdk";Importing the package is safe during server rendering. Calling createVM needs a browser with module workers, so call it from client code only.
Signatures
Section titled “Signatures”function createVM(options: SystemOptions): Promise<SystemVM>; // mode: "system"function createVM(options: UserspaceOptions): Promise<UserspaceVM>; // mode: "userspace"function createVM(options: CreateVMOptions): Promise<VM>; // either modeThe literal mode selects the overload, so TypeScript checks options and VM members that exist in only one mode. CreateVMOptions is SystemOptions | UserspaceOptions, and VM is SystemVM | UserspaceVM. The members of the returned VM are listed in VM.
A VM always runs in the mode you asked for. If that mode cannot start, createVM rejects with a VMError. It never starts the other mode instead.
Options
Section titled “Options”Both modes
Section titled “Both modes”| Option | Type | Default | Meaning |
|---|---|---|---|
mode |
"userspace" | "system" |
required | Userspace ("userspace") or System ("system"). |
workspaceId |
string |
none (ephemeral) | Opens a durable workspace. See workspaceId. |
image |
ImageRef<"userspace"> or ImageRef<"system"> |
the SDK’s pinned default image for the mode | The signed image to run. See image. |
imagePreparation |
"on-demand" | "before-ready" |
"on-demand" |
When image data is downloaded. See imagePreparation. |
memoryMiB |
number |
the image’s recommendation | Guest memory in MiB, an integer from 64 to 3072. |
runtime |
RuntimeOptions |
{ hosting: "self-hosted", baseUrl: "/oxidevm/runtime/" } |
Where the engine runs: in the hosted runtime frame, or from files your site serves. See runtime. |
network |
NetworkOptions |
{ mode: "local" } |
Guest internet access. See network. |
preview |
PreviewOptions |
none | The origin that serves previews of guest web servers. See preview. |
signal |
AbortSignal |
none | Cancels creation only. See Cancellation. |
onProgress |
(progress: BootProgress) => void |
none | Receives startup progress. See BootProgress. |
restorePoint |
ManagedResume |
"none" |
Where the VM starts: cold, or from a restore point. See restorePoint. |
shippedSnapshot |
string or ShippedSnapshotRef |
none | A signed prepared start to open on a workspace’s first visit. See shippedSnapshot. |
Userspace only
Section titled “Userspace only”| Option | Type | Default | Meaning |
|---|---|---|---|
initialUser |
"user" | "root" |
"user" |
Credentials of guest processes. See initialUser. |
rootPersistence |
"ephemeral" | "persistent" |
"ephemeral" |
Whether writable system files are kept. See rootPersistence. |
limits.maxProcesses |
number |
the image’s recommendation, else 32 |
Most live guest processes, an integer from 1 to 64. See limits.maxProcesses. |
commandInterceptors |
"auto" | "off" or readonly CommandInterceptor[] |
"auto" |
How commands run: eligible JavaScript launches in browser workers, or every command in Linux. See commandInterceptors. |
System only
Section titled “System only”| Option | Type | Default | Meaning |
|---|---|---|---|
vcpus |
1 | 2 | 3 | 4 |
1 |
Virtual CPUs. Only 1 is accepted for now: 2, 3 and 4 fail with FEATURE_NOT_AVAILABLE and detail.reason: "release-gate-open". vm.capabilities.maximumVcpus reports 1. |
Unknown option names, and options that belong to the other mode, fail with INVALID_ARGUMENT.
Option details
Section titled “Option details”workspaceId
Section titled “workspaceId”- 1 to 128 characters: letters, digits,
_and-. Anything else fails withINVALID_ARGUMENT. - Omitted, the VM gets a new ephemeral workspace of its own, and
vm.workspace.persistenceis{ kind: "ephemeral" }. - Present, the workspace is stored durably in this origin’s browser storage and kept between visits. See persistence.
- A durable workspace is opened exclusively. While one VM holds a workspace ID, creating another VM with the same ID, in this tab or another, fails with
STORAGE_ERRORanddetail.reason: "workspace-in-use". Awaitdispose()before you reopen it, or move it between tabs withallowWorkspaceHandoff.
Pass an ImageRef whose mode matches mode; a reference of the other mode fails with INVALID_ARGUMENT. You get one from:
defineImage({ mode, manifestUrl, manifestSHA256, trust }), wheretrustis{ algorithm: "Ed25519", keyId, publicKey }with the image author’s public key;- the module that
oxidevm assets install --image <bundle> --reference <file>generates (see CLI); defaultImage(mode, manifestUrl?), the SDK’s pinned default image, optionally served from another address. Its digest and key stay pinned whatever the address.
Before any guest code runs, the SDK verifies the whole manifest, which pins every file of the image, with the reference’s key; each file is checked against it when it is read. A durable workspace that already ran another image keeps running that image and names the requested one in vm.start.update; start with restorePoint: "update-image" to move it. vm.image always reports the image actually running.
imagePreparation
Section titled “imagePreparation”| Value | Behavior |
|---|---|
"on-demand" |
Image files are downloaded and verified when first needed. |
"before-ready" |
The complete image is installed in durable browser storage before guest code runs. Later starts, saves and restores use local data only: missing or damaged data fails with STORAGE_ERROR and detail.reason: "image-not-cached" instead of downloading. Repair it with vm.prepareImage(). |
Installation reports { phase: "prepare-image" } progress.
runtime
Section titled “runtime”type RuntimeOptions = | { hosting: "frame"; origin?: string | URL; license?: string } | { hosting: "self-hosted"; baseUrl: string | URL; license?: string };const vm = await createVM({ mode: "userspace", runtime: { hosting: "frame" } });{ hosting: "frame" } runs the engine in a hidden frame on the runtime origin, by default https://run.oxidevm.dev, which also serves the default Userspace image. Your page talks to it over a MessagePort. See Deploying.
originis a barehttps:origin, orhttp:onlocalhostor a loopback address, with no path, query or fragment.- The page must be cross-origin isolated; otherwise creation fails with
FEATURE_NOT_AVAILABLE,detail.reason: "browser-unsupported"anddetail.requirement: "cross-origin-isolation". - With the default
originand nopreviewoption, the VM serves previews fromhttps://<session>.oxvm.dev, as if you had passedpreview: { origin: "https://oxvm.dev", isolation: "per-session" }. - Your own
commandInterceptorsworkers fail withINVALID_ARGUMENT: the frame runs only the packaged interceptor. - A System VM in the frame needs an
imagethat you host: the frame serves no System image. The frame downloads it, so serve it as Custom images describes.
{ hosting: "self-hosted", baseUrl } loads the engine release your site serves at baseUrl. See Self-hosting the engine.
baseUrlis an absolutehttps:URL, anhttp:URL onlocalhostor a loopback address, or an origin-relative path. It may not contain credentials, a query or a fragment. A trailing/is added when missing.- The SDK fetches each engine module from
baseUrland checks its size and SHA-256 against the engine release the SDK pins before any worker runs it, so moving the files cannot change which bytes are accepted. A missing or different file fails creation withCORE_LOAD_FAILED. - Without
runtime, the SDK loads a self-hosted engine from/oxidevm/runtime/, whereoxidevm assets installstages it for a project withassetBaseUrl: "/oxidevm/". See CLI.
license is a license token (oxl1. followed by two base64url parts) for your page’s origin, in either shape. A string of another form fails with INVALID_ARGUMENT.
- Self-hosted, the token must also admit self-hosting. Without a token, creation fails with
PERMISSION_DENIEDanddetail.reason: "license-required"; with one the engine does not accept, withdetail.reason: "license-invalid". A page onlocalhost, a name ending in.localhost, a127.0.0.0/8address or[::1]needs no token. See Licenses. - Public use in the frame also requires a token. Missing tokens fail with
PERMISSION_DENIEDanddetail.reason: "license-required". Rejected tokens fail withdetail.reason: "license-invalid". Register and renew through Licenses.
network
Section titled “network”type NetworkOptions = | { mode: "local" } | { mode: "relay"; connection: RelayConnectionOptions };
type RelayConnectionOptions = | { kind: "personal"; workspaceName?: string } | { kind: "server"; url: string | URL; sessionUrl?: string | URL; headers?: Record<string, string> };{ mode: "local" }: guest sockets stay inside the VM.{ mode: "relay", connection }: after the VM is ready and beforecreateVMresolves, the SDK callsvm.network.connect(connection)once. If that fails or is cancelled, the VM is disposed andcreateVMrejects with the relay’s error (RELAY_ERROR,PERMISSION_DENIED,QUOTA_EXCEEDEDorPROTOCOL_ERROR, withdetail.reason). Invalid connection options fail withINVALID_ARGUMENT.- A personal relay asks the user to approve a six-digit pairing code, which creation cannot show. Create the VM with
{ mode: "local" }, followvm.network.watch, and callvm.network.connectyourself. See Networking.
preview
Section titled “preview”type PreviewOptions = | { origin: string | URL; isolation: "per-session" } | { origin: string | URL; isolation: "shared"; developmentOnly: true };originmust be an origin only (no path, query or fragment), usinghttps:, orhttp:onlocalhostor a loopback address."per-session"gives each session its own host underorigin, for examplehttps://<session>.preview.example.com, so cookies and storage stay separate between sessions."shared"serves every session fromoriginitself and requiresdevelopmentOnly: true. Sessions then share cookies and storage, so use it for local development only.- Without
preview,vm.capabilities.previewsis{ available: false, reason: "not-configured" }andvm.exposePortfails withFEATURE_NOT_AVAILABLE. See Previews.
restorePoint
Section titled “restorePoint”| Value | Start |
|---|---|
"none" |
Cold boot from the workspace’s current files. This is the default. |
"latest" |
Resume the newest restore point that can resume. A damaged or unreadable point gives way to the next older one; any other problem stops the search. If nothing resumes, Linux boots cold from the current files and vm.start reports reason and skipped. Required for shippedSnapshot. |
"pre-resume" |
Cold boot from the files as they were before the last resume replaced them, which keeps files newer than that restore point. Without an earlier resume, the same as "none". |
"fresh-image" |
Cold boot from the configured image’s own files. This resets the workspace: its saved files are not carried over. |
"update-image" |
Move a durable workspace to the configured image, keeping the workspace’s own /workspace files. Use it after vm.start.update named a newer image. |
{ id, createdAt } |
A SnapshotRef from vm.snapshots: resume that restore point. A reference to a restore point that does not exist fails with STORAGE_NOT_FOUND; a point that cannot resume boots cold, and vm.start reports reason and skipped. |
Check vm.start.kind to learn which start happened. See Restore points.
shippedSnapshot
Section titled “shippedSnapshot”A prepared start is a restore point signed by its author, built with oxidevm snapshot build or snapshot pack. It opens only when restorePoint is "latest" and the workspace has no restore point of its own; existing saves always take precedence.
| Form | Behavior |
|---|---|
string |
The name of a prepared start that the image lists: at most 64 lowercase letters, digits, ., _ and -, starting with a letter or digit. If it cannot open, Linux boots cold and vm.start.shipped.reason says why. |
ShippedSnapshotRef |
A reference from defineShippedSnapshot, usually inside the options module that oxidevm assets install --snapshot generates. It pins the prepared start’s manifest digest and sizes, the image it belongs to and the author key. If it cannot open, createVM rejects. |
initialUser
Section titled “initialUser”"user" runs guest processes with uid and gid 1000. "root" starts them with Linux root credentials; it requires commandInterceptors: "off", otherwise creation fails with INVALID_ARGUMENT.
rootPersistence
Section titled “rootPersistence”"persistent" keeps the writable system paths /var, /etc, /srv and /usr/local between visits, separately from /workspace and the immutable image. They are scoped to the workspace and the exact image. /run, /tmp, sockets, devices and live kernel objects start fresh every time. "persistent" requires workspaceId (otherwise INVALID_ARGUMENT) and durable browser storage.
limits.maxProcesses
Section titled “limits.maxProcesses”The most guest processes that may be alive at once, an integer from 1 to 64. Processes that your processes start count too. Without it, the limit recommended in the signed image applies, or 32 when the image has none.
commandInterceptors
Section titled “commandInterceptors”"auto" runs eligible JavaScript launches in browser workers, for example the JavaScript program that bun run build starts in an image with an acceleration profile, and the packaged TypeScript interceptor runs tsc --noEmit in a browser worker when it matches the one installed TypeScript compiler it authenticates. The Linux side keeps their files, sockets, process identity, permissions and signals, and every other command runs as an ordinary Linux process.
| Value | Behavior |
|---|---|
"auto" (default) |
Automatic acceleration and the packaged TypeScript interceptor. |
"off" |
Every command runs as a Linux process: no acceleration and no interceptor. initialUser: "root" requires it. |
[{ command: "tsc", worker }] |
Automatic acceleration with your own interceptor worker instead of the packaged one: exactly one entry, whose worker URL must be on the page’s origin. typescriptInterceptor() returns the packaged one. Your own worker needs a self-hosted engine; in the frame it fails with INVALID_ARGUMENT. |
Acceleration, which "auto" and an interceptor array both turn on, needs all of the following, or creation fails:
- a cross-origin isolated page (otherwise
FEATURE_NOT_AVAILABLE); - with a self-hosted engine, a
native-node-api.jsondescriptor beside the engine files atruntime.baseUrl, naming a separate execution origin that serves the SDK’sassets/native-process/files (otherwiseFEATURE_NOT_AVAILABLE); the hosted frame provides its own; initialUser: "user"(otherwiseINVALID_ARGUMENT).
While an accelerated JavaScript process runs, vm.snapshots.capture and vm.suspend fail with FEATURE_NOT_AVAILABLE and detail.reason: "js-process-active".
BootProgress
Section titled “BootProgress”type BootProgress = | { phase: "download"; asset: "engine" | "image" | "restore-point"; completedBytes: number; totalBytes: number | null } | { phase: "validate" } | { phase: "prepare-image"; stage: "download" | "verify" | "commit"; completedBytes: number; totalBytes: number } | { phase: "boot"; mode: RuntimeMode } | { phase: "restore"; step: "open"; completedBytes?: number; totalBytes?: number } | { phase: "restore"; step: "wake" | "reconnect" } | { phase: "ready" };| Phase | Meaning |
|---|---|
download |
Bytes of the engine modules, the image, or a prepared start’s restore point are arriving. totalBytes is null when the server sends no length. |
validate |
The image manifest arrived and its signature is being checked. |
prepare-image |
Image data is downloaded, verified and committed to browser storage, with byte progress per stage. |
boot |
Linux starts cold. A cold start reports boot and no restore events. |
restore |
A restore point is resumed: open (reading it, with byte progress), then wake (Linux continues where it left off), then reconnect (the guest is reconnected and its files brought up to date). |
ready |
Sent once, right before createVM resolves. |
When createVM resolves
Section titled “When createVM resolves”The returned VM is ready to use:
- the image has been authenticated and Linux has booted or resumed;
- the workspace answers: Userspace has mounted
/workspace, System has heard from its guest agent; - a
network: { mode: "relay" }connection is established.
Workspace and process calls can follow immediately. vm.start reports whether the VM booted cold or resumed.
Cancellation
Section titled “Cancellation”signal cancels creation only. Aborting it while createVM runs rolls the start back and rejects with ABORTED. After createVM has resolved, the signal no longer affects the VM: you own the VM and must call dispose().
import { createVM, VMError, type UserspaceVM } from "@oxidevm/sdk";
export async function openVM(signal: AbortSignal): Promise<UserspaceVM | undefined> { try { return await createVM({ mode: "userspace", workspaceId: "my-project", runtime: { hosting: "frame" }, signal, onProgress: (progress) => { console.log(progress.phase === "download" ? `${progress.asset}: ${progress.completedBytes} of ${progress.totalBytes ?? "?"} bytes` : progress.phase); }, }); } catch (error: unknown) { if (error instanceof VMError && error.code === "ABORTED") return undefined; throw error; }}Failure and cleanup
Section titled “Failure and cleanup”When creation fails or is cancelled, the SDK stops what it started, releases the workspace lease and frees downloaded assets before it rejects, so you can retry with the same workspaceId. If that cleanup fails as well, the rejection keeps the original code and message and adds detail.cleanupFailed: true and detail.cleanupError ({ code, message, detail? }).
| Code | Typical cause at creation |
|---|---|
INVALID_ARGUMENT |
An unknown option, an option of the other mode, a value out of range, or an image reference of the other mode. |
FEATURE_NOT_AVAILABLE |
No module workers, or a page that is not cross-origin isolated with the frame (both browser-unsupported), vcpus above 1 (release-gate-open), or Userspace commandInterceptors: "auto" without its requirements. |
CORE_LOAD_FAILED |
Engine files at runtime.baseUrl are missing or differ from the pinned release, or the browser lacks a required WebAssembly feature. |
PROTOCOL_ERROR |
The image manifest could not be downloaded or verified, or it is signed by another key (untrusted-key, image-trust-changed). |
STORAGE_ERROR |
The workspace is open in another VM (workspace-in-use), or browser storage failed. |
STORAGE_NOT_FOUND |
restorePoint names a restore point that does not exist. |
PERMISSION_DENIED |
A public runtime without a license token that admits its origin (license-required, license-invalid). |
RELAY_ERROR, PERMISSION_DENIED, QUOTA_EXCEEDED |
A network: { mode: "relay" } connection failed. |
ABORTED |
signal was aborted. |
Every code and detail.reason value is listed in Errors.