# FastVM API Public REST API for managing FastVM VMs, snapshots, firewalls, and command execution. > This document concatenates the FastVM Python SDK, TypeScript SDK, and REST API reference for direct LLM ingestion. It is regenerated from `api/openapi.yaml` + `api/helpers.yaml` on every build (see `frontend/scripts/gen-sdk-docs.ts`); do not edit by hand. ## Python SDK ```shell pip install fastvm ``` ### Top-level helpers #### `health` *GET /healthz* ```python client.health() -> HealthResponse ``` Health check **Returns:** `HealthResponse` #### `upload` ```python client.upload( vm_id: str, local_path: str, remote_path: str, *, fetch_timeout_sec: int = 600, exec_timeout_sec: int = 600, ) -> None ``` Copy a local file or directory into the VM. Uses `vms.files.presign` and `vms.files.fetch` under the hood. Directories are tarred on the fly before upload and extracted VM-side after fetch. Streams end-to-end with no intermediate copy to `/tmp` on the client, so multi-GB transfers are bounded by VM disk, not RAM. Directory mode needs the `tar` binary on the client's `PATH` (standard on macOS and Linux; available on modern Windows via bsdtar). **Parameters** | Name | Type | Default | Description | | --- | --- | --- | --- | | `vm_id` | `str` | required | Target VM id. | | `local_path` | `str` | required | Local file or directory path. | | `remote_path` | `str` | required | Destination path inside the VM. | | `fetch_timeout_sec` | `int` | 600 | Timeout on the VM-side /files/fetch call. | | `exec_timeout_sec` | `int` | 600 | Timeout on VM-side tar extraction (dir mode only). | **Returns:** `None` **Example** ```python client.upload(vm.id, "./config.toml", "/etc/app.toml") # file client.upload(vm.id, "./src", "/root/src") # directory (tar-streamed) ``` #### `download` ```python client.download( vm_id: str, remote_path: str, local_path: str, *, exec_timeout_sec: int = 600, ) -> None ``` Copy a file or directory from the VM to the client. Uses `vms.files.presign` plus a VM-side exec to classify the path and stream its contents out. Directories are tarred VM-side and un-tarred on the client, rooted at `./` so upload and download are symmetric. Streams end-to-end with no intermediate copy. Missing paths raise `FileNotFoundError` (Python) or `FileTransferError` with `code: 'ENOENT'` (TypeScript). **Parameters** | Name | Type | Default | Description | | --- | --- | --- | --- | | `vm_id` | `str` | required | Target VM id. | | `remote_path` | `str` | required | Source path inside the VM. | | `local_path` | `str` | required | Destination path on the client. | | `exec_timeout_sec` | `int` | 600 | Timeout on VM-side exec (classify + stream). | **Returns:** `None` **Example** ```python client.download(vm.id, "/root/out.log", "./out.log") # file client.download(vm.id, "/var/log", "./log-backup") # directory ``` #### `wait_for_vm_ready` ```python client.wait_for_vm_ready( vm_id: str, *, poll_interval: float = 2.0, timeout: float = 300.0, ) -> VM ``` Poll `GET /v1/vms/{id}` until the VM reaches `status == "running"` or a terminal failure status. Same polling logic as `vms.launch`; use this when you already have a VM id from `vms.list()` or another flow. **Parameters** | Name | Type | Default | Description | | --- | --- | --- | --- | | `vm_id` | `str` | required | Target VM id. | | `poll_interval` | `float` | 2.0 | Seconds between polls. | | `timeout` | `float` | 300.0 | Total wait deadline in seconds. | **Returns:** `VM` **Example** ```python vm = client.vms.retrieve(some_id) vm = client.wait_for_vm_ready(vm.id, timeout=120) ``` ### VMs #### `list` *GET /v1/vms* ```python client.vms.list( status: VMStatus, ) -> VM[] ``` List VMs **Parameters** | Name | Type | Default | Description | | --- | --- | --- | --- | | `status` | `VMStatus` | — | Restrict to VMs with this status. Accepts any value of `VMStatus`; unknown values return an empty list. | **Returns:** `VM[]` #### `launch` *POST /v1/vms* ```python client.vms.launch( *, machine_type: MachineType | None = None, snapshot_id: str | None = None, name: str | None = None, metadata: dict[str, str] | None = None, firewall: FirewallPolicy | None = None, wait: bool = True, poll_interval: float = 2.0, wait_timeout: float = 300.0, timeout: float | httpx.Timeout | None = None, max_retries: int = 0, ) -> VM ``` Launch a VM and (by default) block until it reaches `status == "running"`. `POST /v1/vms` returns 201 for immediately-running VMs and 202 for queued VMs; the override handles both paths transparently by polling `GET /v1/vms/{id}`. Pass `wait=false` (TS) / `wait=False` (Python) to skip polling and return the raw 201/202 body. Pass `snapshot_id` / `snapshotId` to restore from a snapshot instead of cold-booting. Terminal failure statuses (`error`, `stopped`, `deleting`) raise `VMLaunchError`. Polling-deadline exceeded raises `VMNotReadyError`. **Parameters** | Name | Type | Default | Description | | --- | --- | --- | --- | | `machine_type` | `MachineType | None` | None | VM flavor (c1m2, c2m4, ...). Required unless snapshot_id is set. | | `snapshot_id` | `str | None` | None | Restore from snapshot instead of cold-booting. | | `name` | `str | None` | None | Human-readable VM name. | | `metadata` | `dict[str, str] | None` | None | Free-form key/value labels. | | `firewall` | `FirewallPolicy | None` | None | Initial firewall policy. | | `wait` | `bool` | True | Block until RUNNING. Set False for raw 201/202 behavior. | | `poll_interval` | `float` | 2.0 | Seconds between polls when wait=True. | | `wait_timeout` | `float` | 300.0 | Max seconds to wait for RUNNING. Raises VMNotReadyError on exceed. | | `timeout` | `float | httpx.Timeout | None` | None | Per-request HTTP timeout (forwarded to generated launch verbatim). | | `max_retries` | `int` | 0 | Auto-retry on 5xx/connect errors. POST is non-idempotent, default 0. | **Returns:** `VM` **Example** ```python from fastvm import FastvmClient client = FastvmClient() vm = client.vms.launch(machine_type="c1m2", name="dev") print(vm.id, vm.status) # "running" # Restore from snapshot vm = client.vms.launch(snapshot_id="snp_...") # Skip polling — get the raw 201/202 body vm = client.vms.launch(machine_type="c1m2", wait=False) ``` #### `retrieve` *GET /v1/vms/{id}* ```python client.vms.retrieve( id: str, ) -> VM ``` Get a VM **Parameters** | Name | Type | Default | Description | | --- | --- | --- | --- | | `id` | `str` | required | VM ID (UUID). | **Returns:** `VM` #### `update` *PATCH /v1/vms/{id}* ```python client.vms.update( id: str, name: str, metadata: Metadata, ttl: unknown, ) -> VM ``` Update a VM **Parameters** | Name | Type | Default | Description | | --- | --- | --- | --- | | `id` | `str` | required | VM ID (UUID). | | `name` | `str` | — | | | `metadata` | `Metadata` | — | | | `ttl` | `unknown` | — | | **Returns:** `VM` #### `delete` *DELETE /v1/vms/{id}* ```python client.vms.delete( id: str, ) -> DeleteResponse ``` Delete a VM **Parameters** | Name | Type | Default | Description | | --- | --- | --- | --- | | `id` | `str` | required | VM ID (UUID). | **Returns:** `DeleteResponse` #### `pause` *POST /v1/vms/{id}/pause* ```python client.vms.pause( id: str, ) -> VM ``` Pause a VM **Parameters** | Name | Type | Default | Description | | --- | --- | --- | --- | | `id` | `str` | required | VM ID (UUID). | **Returns:** `VM` #### `resume` *POST /v1/vms/{id}/resume* ```python client.vms.resume( id: str, ) -> VM ``` Resume a paused VM **Parameters** | Name | Type | Default | Description | | --- | --- | --- | --- | | `id` | `str` | required | VM ID (UUID). | **Returns:** `VM` #### `refresh_ttl` *POST /v1/vms/{id}/ttl/refresh* ```python client.vms.refresh_ttl( id: str, ) -> VM ``` Reset the VM's TTL cycle **Parameters** | Name | Type | Default | Description | | --- | --- | --- | --- | | `id` | `str` | required | VM ID (UUID). | **Returns:** `VM` #### `set_firewall` *PUT /v1/vms/{id}/firewall* ```python client.vms.set_firewall( id: str, ingress: IngressPolicy, egress: EgressPolicy, dns: DNSPolicy, ) -> VM ``` Replace firewall policy **Parameters** | Name | Type | Default | Description | | --- | --- | --- | --- | | `id` | `str` | required | VM ID (UUID). | | `ingress` | `IngressPolicy` | — | | | `egress` | `EgressPolicy` | — | | | `dns` | `DNSPolicy` | — | | **Returns:** `VM` #### `patch_firewall` *PATCH /v1/vms/{id}/firewall* ```python client.vms.patch_firewall( id: str, ingress: IngressPolicy, egress: EgressPolicy, dns: DNSPolicy, ) -> VM ``` Patch firewall policy **Parameters** | Name | Type | Default | Description | | --- | --- | --- | --- | | `id` | `str` | required | VM ID (UUID). | | `ingress` | `IngressPolicy` | — | | | `egress` | `EgressPolicy` | — | | | `dns` | `DNSPolicy` | — | | **Returns:** `VM` #### `console_token` *POST /v1/vms/{id}/console-token* ```python client.vms.console_token( id: str, ) -> ConsoleTokenResponse ``` Mint a console token **Parameters** | Name | Type | Default | Description | | --- | --- | --- | --- | | `id` | `str` | required | VM ID (UUID). | **Returns:** `ConsoleTokenResponse` #### `run` *POST /v1/vms/{id}/exec* ```python client.vms.run( id: str, *, command: str | Sequence[str], timeout_sec: int | None = None, max_retries: int = 0, ) -> ExecVMResponse ``` Execute a command inside a VM. The override accepts `str` in addition to `Sequence[str]`: plain shell strings are auto-wrapped into `["sh", "-c", ""]` before hitting the API. Argv-style calls pass through unchanged. The wrap guards against Python's silent string-to-chars iteration when a `Sequence[str]` parameter is passed a bare string, which would otherwise produce a nonsensical argv like `["l","s"," ","-","l","a"]`. **Parameters** | Name | Type | Default | Description | | --- | --- | --- | --- | | `id` | `str` | required | Target VM id. | | `command` | `str | Sequence[str]` | required | Shell string (auto-wrapped) or argv. | | `timeout_sec` | `int | None` | None | Server-side execution timeout. | | `max_retries` | `int` | 0 | Auto-retry on 5xx. Non-idempotent, default 0. | **Returns:** `ExecVMResponse` **Example** ```python # Shell strings work — auto-wrapped into ["sh", "-c", ...] result = client.vms.run(vm.id, command="ls -la /root") # Argv lists pass through unchanged result = client.vms.run(vm.id, command=["python3", "main.py", "--flag"]) print(result.exit_code, result.stdout) ``` #### `stream` ```python client.vms.stream( id: str, *, command: str | Sequence[str], timeout_sec: int | None = None, ) -> Iterator[ExecEvent] ``` Stream exec output as typed events via `Accept: application/x-ndjson`. Same endpoint as `vms.run` (`POST /v1/vms/{id}/exec`), but the server emits a newline-delimited stream of `ExecEvent` objects instead of a single buffered JSON response. Events are: - `"o"` — stdout chunk (decoded bytes in `data`) - `"e"` — stderr chunk (decoded bytes in `data`) - `"x"` — terminal exit event (`exit_code`, `timed_out`, `duration_ms`) There is no 4 MiB per-stream cap on output. The HTTP connection stays open until the command exits or `timeout_sec` fires server-side. Use this for long-running processes (builds, test runners, live logs) where you need incremental output without buffering the entire result. Shell strings (Python only) are auto-wrapped into `["sh", "-c", ...]` exactly like `vms.run`. **Parameters** | Name | Type | Default | Description | | --- | --- | --- | --- | | `id` | `str` | required | Target VM id. | | `command` | `str | Sequence[str]` | required | Shell string (auto-wrapped) or argv list. | | `timeout_sec` | `int | None` | None | Server-side execution timeout in seconds. | **Returns:** `Iterator[ExecEvent]` **Example** ```python from fastvm import FastvmClient, ExecEvent client = FastvmClient() for event in client.vms.stream(vm.id, command="make -j8"): if event.type == "o": sys.stdout.buffer.write(event.data) elif event.type == "e": sys.stderr.buffer.write(event.data) elif event.type == "x": print(f"exit {event.exit_code} in {event.duration_ms} ms") ``` ### VMs.Services #### `list` *GET /v1/vms/{id}/services* ```python client.vms.services.list( id: str, ) -> Service[] ``` List service registrations **Parameters** | Name | Type | Default | Description | | --- | --- | --- | --- | | `id` | `str` | required | VM ID (UUID). | **Returns:** `Service[]` #### `register` *POST /v1/vms/{id}/services* ```python client.vms.services.register( id: str, name: str, port: int, h2c: bool, ) -> Service ``` Register a service on a VM **Parameters** | Name | Type | Default | Description | | --- | --- | --- | --- | | `id` | `str` | required | VM ID (UUID). | | `name` | `str` | required | | | `port` | `int` | required | | | `h2c` | `bool` | false | Optional. When true, the proxy uses HTTP/2 cleartext to the backend (required for gRPC). Defaults to false (HTTP/1.1). | **Returns:** `Service` #### `update` *PUT /v1/vms/{id}/services/{serviceName}* ```python client.vms.services.update( id: str, service_name: str, port: int, h2c: bool, ) -> Service ``` Register or update a service on a VM **Parameters** | Name | Type | Default | Description | | --- | --- | --- | --- | | `id` | `str` | required | VM ID (UUID). | | `service_name` | `str` | required | Service registration name. 1–29 chars, lowercase letters and digits with optional single internal hyphens (no leading, trailing, or consecutive hyphens). Embedded in the public URL as the leftmost label. | | `port` | `int` | required | New TCP port. Same value as the existing entry is a no-op. | | `h2c` | `bool` | false | Optional. When true, the proxy uses HTTP/2 cleartext to the backend. Same value as the existing entry is a no-op; a different value updates the registered transport. | **Returns:** `Service` #### `delete` *DELETE /v1/vms/{id}/services/{serviceName}* ```python client.vms.services.delete( id: str, service_name: str, ) ``` Deregister a service from a VM **Parameters** | Name | Type | Default | Description | | --- | --- | --- | --- | | `id` | `str` | required | VM ID (UUID). | | `service_name` | `str` | required | Service registration name. 1–29 chars, lowercase letters and digits with optional single internal hyphens (no leading, trailing, or consecutive hyphens). Embedded in the public URL as the leftmost label. | ### VMs.Ssh_keys #### `list` *GET /v1/vms/{id}/ssh-keys* ```python client.vms.ssh_keys.list( id: str, ) -> SshKeyListResponse ``` List authorized SSH keys for a VM **Parameters** | Name | Type | Default | Description | | --- | --- | --- | --- | | `id` | `str` | required | VM ID (UUID). | **Returns:** `SshKeyListResponse` #### `add` *POST /v1/vms/{id}/ssh-keys* ```python client.vms.ssh_keys.add( id: str, name: str, public_key: str, ) -> SshKey ``` Register an SSH public key **Parameters** | Name | Type | Default | Description | | --- | --- | --- | --- | | `id` | `str` | required | VM ID (UUID). | | `name` | `str` | — | Optional human label. | | `public_key` | `str` | required | OpenSSH-format public key (`ssh-ed25519 AAA...`). Comments are stripped. Newlines are rejected. | **Returns:** `SshKey` **Example** ```python with open(os.path.expanduser("~/.ssh/id_ed25519.pub")) as f: client.vms.ssh_keys.add(vm.id, public_key=f.read(), name="laptop") # then: ssh root@ ``` #### `delete` *DELETE /v1/vms/{id}/ssh-keys/{fingerprint}* ```python client.vms.ssh_keys.delete( id: str, fingerprint: str, ) -> DeleteResponse ``` Remove an authorized SSH key **Parameters** | Name | Type | Default | Description | | --- | --- | --- | --- | | `id` | `str` | required | VM ID (UUID). | | `fingerprint` | `str` | required | OpenSSH SHA256 fingerprint of the key to delete (e.g. `SHA256:abc...`). The base64 hash includes `+` and `/` and the prefix has `:`, so callers MUST URL-encode the value into the path segment. SDKs do this automatically. | **Returns:** `DeleteResponse` ### VMs.Files #### `presign` *POST /v1/vms/{id}/files/presign* ```python client.vms.files.presign( id: str, path: str, ) -> FilePresignResponse ``` Mint signed URLs for uploading a file to a VM **Parameters** | Name | Type | Default | Description | | --- | --- | --- | --- | | `id` | `str` | required | VM ID (UUID). | | `path` | `str` | required | Absolute destination path inside the guest filesystem (where the file will land after `fetchFileToVm`). Used only to scope the staging object key; any value server-side is accepted here. | **Returns:** `FilePresignResponse` **Example** ```python # High-level helpers — handle presign + PUT/GET + fetch + (for dirs) tar # for both file and directory transfers automatically. client.upload(vm.id, "./local/file.txt", "/root/file.txt") client.upload(vm.id, "./local-dir", "/root/remote-dir") client.download(vm.id, "/root/out.log", "./out.log") client.download(vm.id, "/var/log", "./log-backup") # Raw call if you need manual control over the signed-URL flow: presign = client.vms.files.presign(vm.id, path="/root/file.txt") ``` #### `fetch` *POST /v1/vms/{id}/files/fetch* ```python client.vms.files.fetch( id: str, url: str, path: str, timeout_sec: int, ) -> ExecVMResponse ``` Fetch a file into a VM from a presigned URL **Parameters** | Name | Type | Default | Description | | --- | --- | --- | --- | | `id` | `str` | required | VM ID (UUID). | | `url` | `str` | required | Must be the `downloadUrl` previously returned by `POST /v1/vms/{id}/files/presign` (URLs from other sources are rejected). | | `path` | `str` | required | Absolute destination path inside the guest filesystem. | | `timeout_sec` | `int` | — | Per-fetch timeout in seconds. | **Returns:** `ExecVMResponse` **Example** ```python # You usually don't call this directly — client.upload() composes # presign + PUT + fetch in a single call. Use it when you need to # pipe an already-hosted URL (still from /files/presign) into the VM. client.vms.files.fetch(vm.id, url=presign.download_url, path="/root/file.txt") ``` ### Snapshots #### `list` *GET /v1/snapshots* ```python client.snapshots.list() -> Snapshot[] ``` List snapshots **Returns:** `Snapshot[]` #### `create` *POST /v1/snapshots* ```python client.snapshots.create( vm_id: str, name: str, ) -> Snapshot ``` Create a snapshot from a VM **Parameters** | Name | Type | Default | Description | | --- | --- | --- | --- | | `vm_id` | `str` | required | | | `name` | `str` | — | Snapshot name (trimmed + whitespace-collapsed, max 64 runes; longer values are truncated server-side). Auto-generated as `snapshot-<8-char-vmId-prefix>` if empty. | **Returns:** `Snapshot` #### `retrieve` *GET /v1/snapshots/{id}* ```python client.snapshots.retrieve( id: str, ) -> Snapshot ``` Get a snapshot **Parameters** | Name | Type | Default | Description | | --- | --- | --- | --- | | `id` | `str` | required | Snapshot ID (UUID). | **Returns:** `Snapshot` #### `update` *PATCH /v1/snapshots/{id}* ```python client.snapshots.update( id: str, name: str, ) -> Snapshot ``` Rename a snapshot **Parameters** | Name | Type | Default | Description | | --- | --- | --- | --- | | `id` | `str` | required | Snapshot ID (UUID). | | `name` | `str` | — | | **Returns:** `Snapshot` #### `delete` *DELETE /v1/snapshots/{id}* ```python client.snapshots.delete( id: str, ) -> DeleteResponse ``` Delete a snapshot **Parameters** | Name | Type | Default | Description | | --- | --- | --- | --- | | `id` | `str` | required | Snapshot ID (UUID). | **Returns:** `DeleteResponse` ### Builds #### `create` *POST /v1/builds* ```python client.builds.create( name: str, image_ref: str, dockerfile_content: str, machine_type: MachineType, disk_gi_b: int, context_download_url: str, ) -> BuildResponse ``` Build a snapshot from an image ref or Dockerfile **Parameters** | Name | Type | Default | Description | | --- | --- | --- | --- | | `name` | `str` | — | Optional human-readable name for the resulting snapshot. If omitted, the build ID is used. | | `image_ref` | `str` | — | Docker image reference (e.g. `python:3.13-slim`, `ghcr.io/user/repo:tag`). Used directly on the no-Dockerfile path, and as a fallback `FROM` source otherwise. | | `dockerfile_content` | `str` | — | Raw Dockerfile content to feed to `buildah bud` inside the build VM. Multi-stage, `SHELL`, `RUN --mount`, and every standard Dockerfile feature is supported (handled natively by buildah). Container-runtime metadata (`CMD`, `ENTRYPOINT`, `EXPOSE`, `LABEL`, `HEALTHCHECK`) is consumed by buildah but does not surface on the resulting FastVM snapshot — when the snapshot boots, systemd takes over, not the container's CMD. | | `machine_type` | `MachineType` | — | | | `disk_gi_b` | `int` | — | Disk size for the build VM. Defaults to 10 GiB if omitted. | | `context_download_url` | `str` | — | Presigned GET URL for a `tar.gz` of the build context. The worker downloads and extracts this into `/tmp/buildctx` before invoking buildah, so `COPY` instructions resolve against the user's files. Obtain via `POST /v1/build-contexts/presign`. | **Returns:** `BuildResponse` **Example** ```python # High-level: builds, polls, returns the completed Snapshot. snapshot = await client.build( image_ref="python:3.13-slim", dockerfile="FROM python:3.13-slim\nRUN pip install flask\n", ) # With a build context for COPY instructions: snapshot = await client.build( image_ref="ubuntu:24.04", dockerfile=Path("./Dockerfile").read_text(), context_dir="./my-app", ) ``` #### `retrieve` *GET /v1/builds/{id}* ```python client.builds.retrieve( id: str, ) -> BuildResponse ``` Get build status **Parameters** | Name | Type | Default | Description | | --- | --- | --- | --- | | `id` | `str` | required | Build ID (UUID). | **Returns:** `BuildResponse` ### Build_contexts #### `presign` *POST /v1/build-contexts/presign* ```python client.build_contexts.presign() -> FilePresignResponse ``` Mint signed URLs for uploading a build context tarball **Returns:** `FilePresignResponse` ### Quotas #### `retrieve` *GET /v1/org/quotas* ```python client.quotas.retrieve() -> OrgQuotaUsage ``` Get org quotas and usage **Returns:** `OrgQuotaUsage` ## TypeScript SDK ```shell npm install fastvm ``` ### Top-level helpers #### `health` *GET /healthz* ```typescript client.health(): APIPromise ``` Health check **Returns:** `HealthResponse` #### `upload` ```typescript client.upload( vmId: string, localPath: string, remotePath: string, opts?: { fetchTimeoutSec?: number; execTimeoutSec?: number }, ): Promise ``` Copy a local file or directory into the VM. Uses `vms.files.presign` and `vms.files.fetch` under the hood. Directories are tarred on the fly before upload and extracted VM-side after fetch. Streams end-to-end with no intermediate copy to `/tmp` on the client, so multi-GB transfers are bounded by VM disk, not RAM. Directory mode needs the `tar` binary on the client's `PATH` (standard on macOS and Linux; available on modern Windows via bsdtar). **Parameters** | Name | Type | Default | Description | | --- | --- | --- | --- | | `vmId` | `string` | required | Target VM id. | | `localPath` | `string` | required | Local file or directory path. | | `remotePath` | `string` | required | Destination path inside the VM. | | `opts.fetchTimeoutSec` | `number` | 600 | Timeout on the VM-side /files/fetch call. | | `opts.execTimeoutSec` | `number` | 600 | Timeout on VM-side tar extraction. | **Returns:** `Promise` **Example** ```typescript await client.upload(vm.id, './config.toml', '/etc/app.toml'); await client.upload(vm.id, './src', '/root/src'); ``` #### `download` ```typescript client.download( vmId: string, remotePath: string, localPath: string, opts?: { fetchTimeoutSec?: number; execTimeoutSec?: number }, ): Promise ``` Copy a file or directory from the VM to the client. Uses `vms.files.presign` plus a VM-side exec to classify the path and stream its contents out. Directories are tarred VM-side and un-tarred on the client, rooted at `./` so upload and download are symmetric. Streams end-to-end with no intermediate copy. Missing paths raise `FileNotFoundError` (Python) or `FileTransferError` with `code: 'ENOENT'` (TypeScript). **Parameters** | Name | Type | Default | Description | | --- | --- | --- | --- | | `vmId` | `string` | required | Target VM id. | | `remotePath` | `string` | required | Source path inside the VM. | | `localPath` | `string` | required | Destination path on the client. | | `opts.fetchTimeoutSec` | `number` | 600 | Timeout on VM-side /files/fetch. | | `opts.execTimeoutSec` | `number` | 600 | Timeout on VM-side exec. | **Returns:** `Promise` **Example** ```typescript await client.download(vm.id, '/root/out.log', './out.log'); await client.download(vm.id, '/var/log', './log-backup'); ``` #### `waitForVmReady` ```typescript client.waitForVmReady( vmId: string, opts?: { pollIntervalMs?: number; timeoutMs?: number }, ): Promise ``` Poll `GET /v1/vms/{id}` until the VM reaches `status == "running"` or a terminal failure status. Same polling logic as `vms.launch`; use this when you already have a VM id from `vms.list()` or another flow. **Parameters** | Name | Type | Default | Description | | --- | --- | --- | --- | | `vmId` | `string` | required | Target VM id. | | `opts.pollIntervalMs` | `number` | 2000 | Milliseconds between polls. | | `opts.timeoutMs` | `number` | 300000 | Total wait deadline in ms. | **Returns:** `Promise` **Example** ```typescript let vm = await client.vms.retrieve(someId); vm = await client.waitForVmReady(vm.id, { timeoutMs: 120_000 }); ``` ### VMs #### `list` *GET /v1/vms* ```typescript client.vms.list( status: VMStatus, ): APIPromise ``` List VMs **Parameters** | Name | Type | Default | Description | | --- | --- | --- | --- | | `status` | `VMStatus` | — | Restrict to VMs with this status. Accepts any value of `VMStatus`; unknown values return an empty list. | **Returns:** `VM[]` #### `launch` *POST /v1/vms* ```typescript client.vms.launch( params: VmLaunchParams, options?: RequestOptions, launchOpts?: { wait?: boolean; pollIntervalMs?: number; timeoutMs?: number }, ): APIPromise ``` Launch a VM and (by default) block until it reaches `status == "running"`. `POST /v1/vms` returns 201 for immediately-running VMs and 202 for queued VMs; the override handles both paths transparently by polling `GET /v1/vms/{id}`. Pass `wait=false` (TS) / `wait=False` (Python) to skip polling and return the raw 201/202 body. Pass `snapshot_id` / `snapshotId` to restore from a snapshot instead of cold-booting. Terminal failure statuses (`error`, `stopped`, `deleting`) raise `VMLaunchError`. Polling-deadline exceeded raises `VMNotReadyError`. **Parameters** | Name | Type | Default | Description | | --- | --- | --- | --- | | `params` | `VmLaunchParams` | required | Generated launch params (machineType, snapshotId, name, metadata, firewall). | | `options` | `RequestOptions | undefined` | undefined | Generated per-request options (headers, signal, timeout, etc.). | | `launchOpts.wait` | `boolean` | true | Block until RUNNING. Set false for raw 201/202 behavior. | | `launchOpts.pollIntervalMs` | `number` | 2000 | Milliseconds between polls (±10% jitter applied). | | `launchOpts.timeoutMs` | `number` | 300000 | Total polling deadline in ms. Throws VMNotReadyError on exceed. | **Returns:** `APIPromise` **Example** ```typescript import { FastvmClient } from 'fastvm'; const client = new FastvmClient(); const vm = await client.vms.launch({ machineType: 'c1m2', name: 'dev' }); console.log(vm.id, vm.status); // "running" // Restore from snapshot const fromSnap = await client.vms.launch({ snapshotId: 'snp_...' }); // Skip polling — returns the raw 201/202 body const queued = await client.vms.launch( { machineType: 'c1m2' }, undefined, { wait: false }, ); ``` #### `retrieve` *GET /v1/vms/{id}* ```typescript client.vms.retrieve( id: string, ): APIPromise ``` Get a VM **Parameters** | Name | Type | Default | Description | | --- | --- | --- | --- | | `id` | `string` | required | VM ID (UUID). | **Returns:** `VM` #### `update` *PATCH /v1/vms/{id}* ```typescript client.vms.update( id: string, name: string, metadata: Metadata, ttl: unknown, ): APIPromise ``` Update a VM **Parameters** | Name | Type | Default | Description | | --- | --- | --- | --- | | `id` | `string` | required | VM ID (UUID). | | `name` | `string` | — | | | `metadata` | `Metadata` | — | | | `ttl` | `unknown` | — | | **Returns:** `VM` #### `delete` *DELETE /v1/vms/{id}* ```typescript client.vms.delete( id: string, ): APIPromise ``` Delete a VM **Parameters** | Name | Type | Default | Description | | --- | --- | --- | --- | | `id` | `string` | required | VM ID (UUID). | **Returns:** `DeleteResponse` #### `pause` *POST /v1/vms/{id}/pause* ```typescript client.vms.pause( id: string, ): APIPromise ``` Pause a VM **Parameters** | Name | Type | Default | Description | | --- | --- | --- | --- | | `id` | `string` | required | VM ID (UUID). | **Returns:** `VM` #### `resume` *POST /v1/vms/{id}/resume* ```typescript client.vms.resume( id: string, ): APIPromise ``` Resume a paused VM **Parameters** | Name | Type | Default | Description | | --- | --- | --- | --- | | `id` | `string` | required | VM ID (UUID). | **Returns:** `VM` #### `refreshTtl` *POST /v1/vms/{id}/ttl/refresh* ```typescript client.vms.refreshTtl( id: string, ): APIPromise ``` Reset the VM's TTL cycle **Parameters** | Name | Type | Default | Description | | --- | --- | --- | --- | | `id` | `string` | required | VM ID (UUID). | **Returns:** `VM` #### `setFirewall` *PUT /v1/vms/{id}/firewall* ```typescript client.vms.setFirewall( id: string, ingress: IngressPolicy, egress: EgressPolicy, dns: DNSPolicy, ): APIPromise ``` Replace firewall policy **Parameters** | Name | Type | Default | Description | | --- | --- | --- | --- | | `id` | `string` | required | VM ID (UUID). | | `ingress` | `IngressPolicy` | — | | | `egress` | `EgressPolicy` | — | | | `dns` | `DNSPolicy` | — | | **Returns:** `VM` #### `patchFirewall` *PATCH /v1/vms/{id}/firewall* ```typescript client.vms.patchFirewall( id: string, ingress: IngressPolicy, egress: EgressPolicy, dns: DNSPolicy, ): APIPromise ``` Patch firewall policy **Parameters** | Name | Type | Default | Description | | --- | --- | --- | --- | | `id` | `string` | required | VM ID (UUID). | | `ingress` | `IngressPolicy` | — | | | `egress` | `EgressPolicy` | — | | | `dns` | `DNSPolicy` | — | | **Returns:** `VM` #### `consoleToken` *POST /v1/vms/{id}/console-token* ```typescript client.vms.consoleToken( id: string, ): APIPromise ``` Mint a console token **Parameters** | Name | Type | Default | Description | | --- | --- | --- | --- | | `id` | `string` | required | VM ID (UUID). | **Returns:** `ConsoleTokenResponse` #### `run` *POST /v1/vms/{id}/exec* ```typescript client.vms.run( id: string, params: VmRunParams, options?: RequestOptions, ): APIPromise ``` Execute a command inside a VM. Same generated method as upstream. TypeScript doesn't silently iterate strings into characters, so no shell-string auto-wrap helper is needed. Pass an argv array directly. **Parameters** | Name | Type | Default | Description | | --- | --- | --- | --- | | `id` | `string` | required | Target VM id. | | `params` | `VmRunParams` | required | Request body (command: string[], timeoutSec?: number). | | `options` | `RequestOptions | undefined` | undefined | Generated per-request options. | **Returns:** `APIPromise` **Example** ```typescript const result = await client.vms.run(vm.id, { command: ['python3', 'main.py', '--flag'], }); console.log(result.exitCode, result.stdout); ``` #### `stream` ```typescript client.vms.stream( id: string, body: VmRunParams, opts?: StreamOptions, ): AsyncIterable ``` Stream exec output as typed events via `Accept: application/x-ndjson`. Same endpoint as `vms.run` (`POST /v1/vms/{id}/exec`), but the server emits a newline-delimited stream of `ExecEvent` objects instead of a single buffered JSON response. Events are: - `"o"` — stdout chunk (decoded bytes in `data`) - `"e"` — stderr chunk (decoded bytes in `data`) - `"x"` — terminal exit event (`exit_code`, `timed_out`, `duration_ms`) There is no 4 MiB per-stream cap on output. The HTTP connection stays open until the command exits or `timeout_sec` fires server-side. Use this for long-running processes (builds, test runners, live logs) where you need incremental output without buffering the entire result. Shell strings (Python only) are auto-wrapped into `["sh", "-c", ...]` exactly like `vms.run`. **Parameters** | Name | Type | Default | Description | | --- | --- | --- | --- | | `id` | `string` | required | Target VM id. | | `body` | `VmRunParams` | required | Request body (command: string[], timeoutSec?: number, stdin?: string). | | `opts.timeoutMs` | `number` | undefined (no client-side timeout) | Client-side HTTP abort deadline in milliseconds. | **Returns:** `AsyncIterable` **Example** ```typescript import { FastvmClient, type ExecEvent } from 'fastvm'; const client = new FastvmClient(); for await (const event of client.vms.stream(vm.id, { command: ['make', '-j8'] })) { if (event.type === 'o') process.stdout.write(event.data); else if (event.type === 'e') process.stderr.write(event.data); else if (event.type === 'x') console.log(`exit ${event.exitCode} in ${event.durationMs}ms`); } ``` ### VMs.Services #### `list` *GET /v1/vms/{id}/services* ```typescript client.vms.services.list( id: string, ): APIPromise ``` List service registrations **Parameters** | Name | Type | Default | Description | | --- | --- | --- | --- | | `id` | `string` | required | VM ID (UUID). | **Returns:** `Service[]` #### `register` *POST /v1/vms/{id}/services* ```typescript client.vms.services.register( id: string, name: string, port: number, h2c: boolean, ): APIPromise ``` Register a service on a VM **Parameters** | Name | Type | Default | Description | | --- | --- | --- | --- | | `id` | `string` | required | VM ID (UUID). | | `name` | `string` | required | | | `port` | `number` | required | | | `h2c` | `boolean` | false | Optional. When true, the proxy uses HTTP/2 cleartext to the backend (required for gRPC). Defaults to false (HTTP/1.1). | **Returns:** `Service` #### `update` *PUT /v1/vms/{id}/services/{serviceName}* ```typescript client.vms.services.update( id: string, serviceName: string, port: number, h2c: boolean, ): APIPromise ``` Register or update a service on a VM **Parameters** | Name | Type | Default | Description | | --- | --- | --- | --- | | `id` | `string` | required | VM ID (UUID). | | `serviceName` | `string` | required | Service registration name. 1–29 chars, lowercase letters and digits with optional single internal hyphens (no leading, trailing, or consecutive hyphens). Embedded in the public URL as the leftmost label. | | `port` | `number` | required | New TCP port. Same value as the existing entry is a no-op. | | `h2c` | `boolean` | false | Optional. When true, the proxy uses HTTP/2 cleartext to the backend. Same value as the existing entry is a no-op; a different value updates the registered transport. | **Returns:** `Service` #### `delete` *DELETE /v1/vms/{id}/services/{serviceName}* ```typescript client.vms.services.delete( id: string, serviceName: string, ) ``` Deregister a service from a VM **Parameters** | Name | Type | Default | Description | | --- | --- | --- | --- | | `id` | `string` | required | VM ID (UUID). | | `serviceName` | `string` | required | Service registration name. 1–29 chars, lowercase letters and digits with optional single internal hyphens (no leading, trailing, or consecutive hyphens). Embedded in the public URL as the leftmost label. | ### VMs.Ssh_keys #### `list` *GET /v1/vms/{id}/ssh-keys* ```typescript client.vms.ssh_keys.list( id: string, ): APIPromise ``` List authorized SSH keys for a VM **Parameters** | Name | Type | Default | Description | | --- | --- | --- | --- | | `id` | `string` | required | VM ID (UUID). | **Returns:** `SshKeyListResponse` #### `add` *POST /v1/vms/{id}/ssh-keys* ```typescript client.vms.ssh_keys.add( id: string, name: string, publicKey: string, ): APIPromise ``` Register an SSH public key **Parameters** | Name | Type | Default | Description | | --- | --- | --- | --- | | `id` | `string` | required | VM ID (UUID). | | `name` | `string` | — | Optional human label. | | `publicKey` | `string` | required | OpenSSH-format public key (`ssh-ed25519 AAA...`). Comments are stripped. Newlines are rejected. | **Returns:** `SshKey` **Example** ```typescript import { readFileSync } from "node:fs"; await client.vms.sshKeys.add(vm.id, { publicKey: readFileSync(`${process.env.HOME}/.ssh/id_ed25519.pub`, "utf8"), name: "laptop", }); // then: ssh root@ ``` #### `delete` *DELETE /v1/vms/{id}/ssh-keys/{fingerprint}* ```typescript client.vms.ssh_keys.delete( id: string, fingerprint: string, ): APIPromise ``` Remove an authorized SSH key **Parameters** | Name | Type | Default | Description | | --- | --- | --- | --- | | `id` | `string` | required | VM ID (UUID). | | `fingerprint` | `string` | required | OpenSSH SHA256 fingerprint of the key to delete (e.g. `SHA256:abc...`). The base64 hash includes `+` and `/` and the prefix has `:`, so callers MUST URL-encode the value into the path segment. SDKs do this automatically. | **Returns:** `DeleteResponse` ### VMs.Files #### `presign` *POST /v1/vms/{id}/files/presign* ```typescript client.vms.files.presign( id: string, path: string, ): APIPromise ``` Mint signed URLs for uploading a file to a VM **Parameters** | Name | Type | Default | Description | | --- | --- | --- | --- | | `id` | `string` | required | VM ID (UUID). | | `path` | `string` | required | Absolute destination path inside the guest filesystem (where the file will land after `fetchFileToVm`). Used only to scope the staging object key; any value server-side is accepted here. | **Returns:** `FilePresignResponse` **Example** ```typescript // High-level helpers — handle presign + PUT/GET + fetch + (for dirs) tar // for both file and directory transfers automatically. await client.upload(vm.id, './local/file.txt', '/root/file.txt'); await client.upload(vm.id, './local-dir', '/root/remote-dir'); await client.download(vm.id, '/root/out.log', './out.log'); await client.download(vm.id, '/var/log', './log-backup'); // Raw call if you need manual control over the signed-URL flow: const presign = await client.vms.files.presign(vm.id, { path: '/root/file.txt' }); ``` #### `fetch` *POST /v1/vms/{id}/files/fetch* ```typescript client.vms.files.fetch( id: string, url: string, path: string, timeoutSec: number, ): APIPromise ``` Fetch a file into a VM from a presigned URL **Parameters** | Name | Type | Default | Description | | --- | --- | --- | --- | | `id` | `string` | required | VM ID (UUID). | | `url` | `string` | required | Must be the `downloadUrl` previously returned by `POST /v1/vms/{id}/files/presign` (URLs from other sources are rejected). | | `path` | `string` | required | Absolute destination path inside the guest filesystem. | | `timeoutSec` | `number` | — | Per-fetch timeout in seconds. | **Returns:** `ExecVMResponse` **Example** ```typescript // You usually don't call this directly — client.upload() composes // presign + PUT + fetch in a single call. Use it when piping an // already-hosted URL (still from /files/presign) into the VM. await client.vms.files.fetch(vm.id, { url: presign.downloadUrl, path: '/root/file.txt', }); ``` ### Snapshots #### `list` *GET /v1/snapshots* ```typescript client.snapshots.list(): APIPromise ``` List snapshots **Returns:** `Snapshot[]` #### `create` *POST /v1/snapshots* ```typescript client.snapshots.create( vmId: string, name: string, ): APIPromise ``` Create a snapshot from a VM **Parameters** | Name | Type | Default | Description | | --- | --- | --- | --- | | `vmId` | `string` | required | | | `name` | `string` | — | Snapshot name (trimmed + whitespace-collapsed, max 64 runes; longer values are truncated server-side). Auto-generated as `snapshot-<8-char-vmId-prefix>` if empty. | **Returns:** `Snapshot` #### `retrieve` *GET /v1/snapshots/{id}* ```typescript client.snapshots.retrieve( id: string, ): APIPromise ``` Get a snapshot **Parameters** | Name | Type | Default | Description | | --- | --- | --- | --- | | `id` | `string` | required | Snapshot ID (UUID). | **Returns:** `Snapshot` #### `update` *PATCH /v1/snapshots/{id}* ```typescript client.snapshots.update( id: string, name: string, ): APIPromise ``` Rename a snapshot **Parameters** | Name | Type | Default | Description | | --- | --- | --- | --- | | `id` | `string` | required | Snapshot ID (UUID). | | `name` | `string` | — | | **Returns:** `Snapshot` #### `delete` *DELETE /v1/snapshots/{id}* ```typescript client.snapshots.delete( id: string, ): APIPromise ``` Delete a snapshot **Parameters** | Name | Type | Default | Description | | --- | --- | --- | --- | | `id` | `string` | required | Snapshot ID (UUID). | **Returns:** `DeleteResponse` ### Builds #### `create` *POST /v1/builds* ```typescript client.builds.create( name: string, imageRef: string, dockerfileContent: string, machineType: MachineType, diskGiB: number, contextDownloadUrl: string, ): APIPromise ``` Build a snapshot from an image ref or Dockerfile **Parameters** | Name | Type | Default | Description | | --- | --- | --- | --- | | `name` | `string` | — | Optional human-readable name for the resulting snapshot. If omitted, the build ID is used. | | `imageRef` | `string` | — | Docker image reference (e.g. `python:3.13-slim`, `ghcr.io/user/repo:tag`). Used directly on the no-Dockerfile path, and as a fallback `FROM` source otherwise. | | `dockerfileContent` | `string` | — | Raw Dockerfile content to feed to `buildah bud` inside the build VM. Multi-stage, `SHELL`, `RUN --mount`, and every standard Dockerfile feature is supported (handled natively by buildah). Container-runtime metadata (`CMD`, `ENTRYPOINT`, `EXPOSE`, `LABEL`, `HEALTHCHECK`) is consumed by buildah but does not surface on the resulting FastVM snapshot — when the snapshot boots, systemd takes over, not the container's CMD. | | `machineType` | `MachineType` | — | | | `diskGiB` | `number` | — | Disk size for the build VM. Defaults to 10 GiB if omitted. | | `contextDownloadUrl` | `string` | — | Presigned GET URL for a `tar.gz` of the build context. The worker downloads and extracts this into `/tmp/buildctx` before invoking buildah, so `COPY` instructions resolve against the user's files. Obtain via `POST /v1/build-contexts/presign`. | **Returns:** `BuildResponse` **Example** ```typescript // Raw call (returns 202 immediately; poll for completion). const build = await client.builds.create({ imageRef: 'python:3.13-slim', dockerfileContent: 'FROM python:3.13-slim\nRUN pip install flask\n', }); ``` #### `retrieve` *GET /v1/builds/{id}* ```typescript client.builds.retrieve( id: string, ): APIPromise ``` Get build status **Parameters** | Name | Type | Default | Description | | --- | --- | --- | --- | | `id` | `string` | required | Build ID (UUID). | **Returns:** `BuildResponse` ### Build_contexts #### `presign` *POST /v1/build-contexts/presign* ```typescript client.build_contexts.presign(): APIPromise ``` Mint signed URLs for uploading a build context tarball **Returns:** `FilePresignResponse` ### Quotas #### `retrieve` *GET /v1/org/quotas* ```typescript client.quotas.retrieve(): APIPromise ``` Get org quotas and usage **Returns:** `OrgQuotaUsage` ## REST API Endpoint: `https://api.fastvm.org` ### VMs VM lifecycle #### GET /v1/vms *List VMs* Lists all non-deleted VMs for the authenticated org. Supports metadata-equality filtering; callers pass repeated query parameters of the form `metadata.=` (e.g. `metadata.env=prod&metadata.role=api`). The optional `status` query filter narrows by lifecycle status (e.g. `?status=paused`). Auth: required (X-API-Key). **Parameters** | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `status` | query | `VMStatus` | no | Restrict to VMs with this status. Accepts any value of `VMStatus`; unknown values return an empty list. | **Responses** - `200` — `VM[]`: List of VMs - `401` — `Error`: Missing or invalid credentials - `500` — `Error`: Internal server error #### POST /v1/vms *Launch a VM* Creates a new VM, either from a machineType (fresh boot) or a snapshotId (restore from snapshot). - Returns **201** when the VM is already running in the response. - Returns **202** when the VM is queued; clients must poll `GET /v1/vms/{id}` until status transitions to `running`. Terminal failure statuses are `error` and `stopped`. The SDK's `launch()` helper handles the 201/202 branching and polling automatically. Auth: required (X-API-Key). **Request body:** `CreateVMRequest` **Responses** - `201` — `VMCreateResponse`: VM is already running. The response is a VM object, and on snapshot restores it may include an optional `snapshotRestoreWarnings` field if pre-registered services from the snapshot failed to land on the new VM (the VM itself is good; the user can re-register the listed services manually). - `202` — `VM`: VM is queued; poll for readiness - `400` — `Error`: Invalid request - `401` — `Error`: Missing or invalid credentials - `403` — `Error`: Org quota exceeded - `404` — `Error`: Snapshot or base image not found - `500` — `Error`: Internal server error - `502` — `Error`: Upstream service error - `503` — `Error`: Service temporarily unavailable #### GET /v1/vms/{id} *Get a VM* Auth: required (X-API-Key). **Parameters** | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | path | `string` | yes | VM ID (UUID). | **Responses** - `200` — `VM`: VM - `401` — `Error`: Missing or invalid credentials - `404` — `Error`: Resource not found - `500` — `Error`: Internal server error #### PATCH /v1/vms/{id} *Update a VM* Renames a VM and/or replaces its metadata map. At least one of `name` or `metadata` must be provided. Sending `metadata: {}` clears all metadata; omitting `metadata` leaves it unchanged. Auth: required (X-API-Key). **Parameters** | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | path | `string` | yes | VM ID (UUID). | **Request body:** `UpdateVMRequest` **Responses** - `200` — `VM`: Updated VM - `400` — `Error`: Invalid request - `401` — `Error`: Missing or invalid credentials - `404` — `Error`: Resource not found - `500` — `Error`: Internal server error #### DELETE /v1/vms/{id} *Delete a VM* Auth: required (X-API-Key). **Parameters** | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | path | `string` | yes | VM ID (UUID). | **Responses** - `200` — `DeleteResponse`: VM deletion result - `401` — `Error`: Missing or invalid credentials - `404` — `Error`: Resource not found - `500` — `Error`: Internal server error - `502` — `Error`: Upstream service error #### POST /v1/vms/{id}/pause *Pause a VM* Captures the VM state, frees the worker and all customer-facing quotas, and transitions the VM to `paused`. Idempotent on already-paused VMs (returns 200 with the current state). Synchronous; ~3 s end-to-end. Auth: required (X-API-Key). **Parameters** | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | path | `string` | yes | VM ID (UUID). | **Responses** - `200` — `VM`: Paused VM (or already paused — idempotent). - `401` — `Error`: Missing or invalid credentials - `404` — `Error`: Resource not found - `409` — `Error`: VM in a state that cannot be paused. - `500` — `Error`: Internal server error - `502` — `Error`: Upstream service error #### POST /v1/vms/{id}/resume *Resume a paused VM* Restores the VM's prior state, re-acquires quota, and transitions to `running`. Sync-when-fast / async-when-queued: returns 200 if the VM is running inline, or 202 if queued for cluster capacity. Idempotent on already-running. Auth: required (X-API-Key). **Parameters** | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | path | `string` | yes | VM ID (UUID). | **Responses** - `200` — `VM`: VM running (resumed inline). - `202` — `VM`: Resume queued for capacity. VM is in `resuming`; poll `GET /v1/vms/{id}` or wait for the `vm.resumed` webhook. - `401` — `Error`: Missing or invalid credentials - `404` — `Error`: Resource not found - `409` — `Error`: VM not in a state that can be resumed. - `429` — `QuotaExceeded`: Org quota exceeded; body indicates the dimension. - `500` — `Error`: Internal server error - `502` — `Error`: Upstream service error #### POST /v1/vms/{id}/ttl/refresh *Reset the VM's TTL cycle* Resets the TTL countdown to a fresh `seconds` budget. From `running`, the deadline moves to `now + seconds*1000`. From `paused`, the remaining-budget is reset to `seconds*1000` and takes effect on next resume. 409 if no TTL is configured. Auth: required (X-API-Key). **Parameters** | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | path | `string` | yes | VM ID (UUID). | **Responses** - `200` — `VM`: Updated VM with reset TTL cycle. - `401` — `Error`: Missing or invalid credentials - `404` — `Error`: Resource not found - `409` — `Error`: VM has no TTL configured, OR is in a transitional state. - `500` — `Error`: Internal server error ### Snapshots Snapshot lifecycle #### GET /v1/snapshots *List snapshots* Lists all snapshots for the authenticated org. Supports metadata-equality filtering; callers pass repeated query parameters of the form `metadata.=` (e.g. `metadata.env=prod&metadata.role=api`). Auth: required (X-API-Key). **Responses** - `200` — `Snapshot[]`: List of snapshots - `401` — `Error`: Missing or invalid credentials - `500` — `Error`: Internal server error #### POST /v1/snapshots *Create a snapshot from a VM* Captures a VM's state into a customer-visible snapshot. Supported on `running` and `paused` VMs; returns 201 Created with the new snapshot in both cases. On a paused VM, repeated calls within the same pause cycle are idempotent: the second call returns the same snapshot record without modification. Auth: required (X-API-Key). **Request body:** `CreateSnapshotRequest` **Responses** - `201` — `Snapshot`: Snapshot created - `400` — `Error`: Invalid request - `401` — `Error`: Missing or invalid credentials - `403` — `Error`: Snapshot quota exceeded - `404` — `Error`: Source VM not found - `409` — `Error`: Source VM is in a non-snapshottable state (provisioning, pausing, resuming, error, deleting), or the paused VM already has a snapshot with a different name. - `500` — `Error`: Internal server error - `502` — `Error`: Upstream service error #### GET /v1/snapshots/{id} *Get a snapshot* Returns the full Snapshot record for the given ID, scoped to the authenticated org. Used by the SDK's `build()` flow to fetch the completed snapshot after polling reports `completed`. Auth: required (X-API-Key). **Parameters** | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | path | `string` | yes | Snapshot ID (UUID). | **Responses** - `200` — `Snapshot`: Snapshot record - `401` — `Error`: Missing or invalid credentials - `404` — `Error`: Resource not found - `500` — `Error`: Internal server error #### PATCH /v1/snapshots/{id} *Rename a snapshot* Auth: required (X-API-Key). **Parameters** | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | path | `string` | yes | Snapshot ID (UUID). | **Request body:** `UpdateSnapshotRequest` **Responses** - `200` — `Snapshot`: Updated snapshot - `400` — `Error`: Invalid request - `401` — `Error`: Missing or invalid credentials - `404` — `Error`: Resource not found - `500` — `Error`: Internal server error #### DELETE /v1/snapshots/{id} *Delete a snapshot* Auth: required (X-API-Key). **Parameters** | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | path | `string` | yes | Snapshot ID (UUID). | **Responses** - `200` — `DeleteResponse`: Snapshot deletion result - `401` — `Error`: Missing or invalid credentials - `404` — `Error`: Resource not found - `500` — `Error`: Internal server error - `502` — `Error`: Upstream service error ### Firewall VM firewall policy #### PUT /v1/vms/{id}/firewall *Replace firewall policy* Replaces the full firewall policy on a VM. Auth: required (X-API-Key). **Parameters** | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | path | `string` | yes | VM ID (UUID). | **Request body:** `FirewallPolicy` **Responses** - `200` — `VM`: Updated VM - `400` — `Error`: Invalid request - `401` — `Error`: Missing or invalid credentials - `404` — `Error`: Resource not found - `500` — `Error`: Internal server error - `502` — `Error`: Upstream service error #### PATCH /v1/vms/{id}/firewall *Patch firewall policy* Updates one or more blocks of the firewall policy. Each top-level block (`ingress`, `egress`, `dns`) is optional; when present, the supplied object **replaces that block wholesale**. Per-rule diffing is not supported — to change a single rule, send the full block with the desired rule list. An empty body (`{}`) is a no-op. Examples: - `{"ingress": {"default": "deny", "rules": []}}` clears all ingress rules and sets the default action. - `{"dns": {"mode": "allow", "domains": ["api.example.com"], "blockBypass": true}}` updates only the DNS block. Auth: required (X-API-Key). **Parameters** | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | path | `string` | yes | VM ID (UUID). | **Request body:** `PatchFirewallRequest` **Responses** - `200` — `VM`: Updated VM - `400` — `Error`: Invalid request - `401` — `Error`: Missing or invalid credentials - `404` — `Error`: Resource not found - `500` — `Error`: Internal server error - `502` — `Error`: Upstream service error ### Exec In-VM command execution #### POST /v1/vms/{id}/exec *Execute a command inside a VM* Runs `command` inside the VM. Response shape is determined by the client's `Accept` header: - **`Accept: application/json`** (default, omitted, or `*/*`): buffered `ExecVMResponse` — the server collects all output and returns a single JSON object once the command exits. Per-stream output is capped at 4 MiB; overflow bytes are dropped and signalled via `stdoutTruncated` / `stderrTruncated`. - **`Accept: application/x-ndjson`**: newline-delimited stream of `ExecEvent`s — zero or more `stdout`/`stderr` chunks followed by exactly one terminal `exit` event. Use this for incremental output (long builds, test runners, live logs). No server-side cap. Both modes share the same request body. `timeoutSec` bounds server-side execution; clients should set their own HTTP timeout in addition. 502 responses are transient (the upstream VM host is unreachable or returned an error). The SDK's `run()` helper does NOT auto-retry these by default: exec is **not idempotent**, so if a 502 hides a successful exec a retry may run the command twice. Callers opt in with `max_retries=N` per call. Auth: required (X-API-Key). **Parameters** | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | path | `string` | yes | VM ID (UUID). | **Request body:** `ExecVMRequest` **Responses** - `200` — `ExecVMResponse`: Command completed. `application/json` (default) returns a single `ExecVMResponse`; `application/x-ndjson` returns an event stream terminated by one `exit` event. - `400` — `Error`: Invalid request - `401` — `Error`: Missing or invalid credentials - `404` — `Error`: Resource not found - `409` — `Error`: VM is not running - `500` — `Error`: Internal server error - `502` — `Error`: Upstream VM host unreachable or returned an error. Not retried by default (non-idempotent). ### Console Interactive serial console access #### POST /v1/vms/{id}/console-token *Mint a console token* Returns a short-lived token and WebSocket path. Open a WebSocket to `wss://?session=` to attach to the VM's serial console. The WebSocket endpoint itself is intentionally not modeled in this spec because it uses a capability-URL flow (no API key on upgrade) and a custom binary/text protocol. See `src/fastvm/lib/console.py` in the Python SDK for a reference client. Auth: required (X-API-Key). **Parameters** | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | path | `string` | yes | VM ID (UUID). | **Responses** - `200` — `ConsoleTokenResponse`: Console token - `401` — `Error`: Missing or invalid credentials - `404` — `Error`: Resource not found - `409` — `Error`: VM is not running - `500` — `Error`: Internal server error ### ssh Per-VM authorized SSH key management. Register pubkeys here, then `ssh root@` directly — no proxy or wrapper needed. Snapshot images carry zero customer keys; the per-VM authorized set is pushed to the running guest on every add/remove. #### GET /v1/vms/{id}/ssh-keys *List authorized SSH keys for a VM* Returns every SSH public key registered for this VM. This is the canonical "who can ssh in" set — pushed to the guest agent on every add/remove and rewritten verbatim into `/root/.ssh/authorized_keys_fastvm` inside the guest. SSH itself is plain `ssh root@` against the VM's public IPv6 address (visible on the VM resource); make sure port 22 is open in the VM's firewall. Auth: required (X-API-Key). **Parameters** | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | path | `string` | yes | VM ID (UUID). | **Responses** - `200` — `SshKeyListResponse`: Authorized keys list - `401` — `Error`: Missing or invalid credentials - `404` — `Error`: Resource not found - `500` — `Error`: Internal server error #### POST /v1/vms/{id}/ssh-keys *Register an SSH public key* Adds one authorized SSH public key to this VM. The fingerprint is derived server-side and returned. Duplicate fingerprints return 409. Up to 32 keys per VM. The new set is pushed to the running guest synchronously (best-effort if the VM isn't running yet). After this call, `ssh root@` works from any host with IPv6 connectivity to the VM (port 22 must be open in the VM firewall). Auth: required (X-API-Key). **Parameters** | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | path | `string` | yes | VM ID (UUID). | **Request body:** `AddSshKeyRequest` **Responses** - `201` — `SshKey`: Key registered - `400` — `Error`: Invalid request - `401` — `Error`: Missing or invalid credentials - `404` — `Error`: Resource not found - `409` — `Error`: Key with this fingerprint is already registered - `500` — `Error`: Internal server error #### DELETE /v1/vms/{id}/ssh-keys/{fingerprint} *Remove an authorized SSH key* Deletes one key by fingerprint. The new set is pushed to the running guest synchronously. Existing SSH sessions on the VM are NOT terminated — the key simply won't authorize new connections. Auth: required (X-API-Key). **Parameters** | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | path | `string` | yes | VM ID (UUID). | | `fingerprint` | path | `string` | yes | OpenSSH SHA256 fingerprint of the key to delete (e.g. `SHA256:abc...`). The base64 hash includes `+` and `/` and the prefix has `:`, so callers MUST URL-encode the value into the path segment. SDKs do this automatically. | **Responses** - `200` — `DeleteResponse`: Key removed - `401` — `Error`: Missing or invalid credentials - `404` — `Error`: Resource not found - `500` — `Error`: Internal server error ### Files File upload/download to/from a running VM #### POST /v1/vms/{id}/files/presign *Mint signed URLs for uploading a file to a VM* Returns a pair of short-lived signed URLs targeting a per-VM staging location. Upload to `uploadUrl` with PUT (`Content-Type: application/octet-stream`), then pass `downloadUrl` to `POST /v1/vms/{id}/files/fetch` to have the server pull it into the guest filesystem. Auth: required (X-API-Key). **Parameters** | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | path | `string` | yes | VM ID (UUID). | **Request body:** `FilePresignRequest` **Responses** - `200` — `FilePresignResponse`: Signed URLs + upload size ceiling - `400` — `Error`: Invalid request - `401` — `Error`: Missing or invalid credentials - `404` — `Error`: Resource not found - `409` — `Error`: VM is not running - `500` — `Error`: Internal server error - `501` — `Error`: File transfer not configured on this deployment #### POST /v1/vms/{id}/files/fetch *Fetch a file into a VM from a presigned URL* Pulls `url` into the guest at `path`. `url` must be a presigned storage URL previously minted by `POST /v1/vms/{id}/files/presign` (URLs from other sources are rejected). Response mirrors `/v1/vms/{id}/exec`: reports stdout/stderr/exit code of the underlying download+unpack operation. Not idempotent; not retried by default. Auth: required (X-API-Key). **Parameters** | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | path | `string` | yes | VM ID (UUID). | **Request body:** `FileFetchRequest` **Responses** - `200` — `ExecVMResponse`: Fetch completed (command result) - `400` — `Error`: Invalid request - `401` — `Error`: Missing or invalid credentials - `404` — `Error`: Resource not found - `409` — `Error`: VM is not running - `413` — `Error`: Object too large for VM disk / insufficient guest disk space - `500` — `Error`: Internal server error - `502` — `Error`: Failed to HEAD the presigned URL ### Quotas Org quotas and usage #### GET /v1/org/quotas *Get org quotas and usage* Auth: required (X-API-Key). **Responses** - `200` — `OrgQuotaUsage`: Quota limits and current usage - `401` — `Error`: Missing or invalid credentials - `500` — `Error`: Internal server error ### Builds Build snapshots from a Docker image ref or Dockerfile #### POST /v1/builds *Build a snapshot from an image ref or Dockerfile* Submits an asynchronous build. The scheduler creates a build VM, runs `buildah pull` (image-only path) or `buildah bud` (Dockerfile path) inside it, snapshots the result, and tears the VM down. At least one of `imageRef` or `dockerfileContent` must be provided. If `dockerfileContent` is set, the worker writes it verbatim into `/tmp/buildctx/Dockerfile` — buildah handles multi-stage, `SHELL`, `RUN --mount`, etc. natively. For `COPY` instructions that need files, upload the build context first via `POST /v1/build-contexts/presign` and pass the returned download URL as `contextDownloadUrl`. Response is `202 Accepted` with a build ID; poll `GET /v1/builds/{id}` until `status` is `completed` or `failed`. Auth: required (X-API-Key). **Request body:** `CreateBuildRequest` **Responses** - `202` — `BuildResponse`: Build accepted; poll `GET /v1/builds/{id}` for status - `400` — `Error`: Invalid request - `401` — `Error`: Missing or invalid credentials - `403` — `Error`: Org quota exceeded - `413` — `Error`: `contextDownloadUrl` references an object larger than the server-side cap (1 GiB). The cap is also returned as `maxUploadBytes` from `POST /v1/build-contexts/presign`, so the SDK can advise users before upload — this 413 is the authoritative server-side enforcement. - `500` — `Error`: Internal server error - `501` — `Error`: File staging is not configured on this cluster (no `FILE_STAGING_BUCKET`). Returned only when `contextDownloadUrl` is supplied — builds without a context work fine on staging-disabled clusters. #### GET /v1/builds/{id} *Get build status* Returns the current state of a build. While the build is in progress, `status` is `pending` or `running` and `progress` contains a human-readable string describing the current phase (e.g. `Pulling image`, `Building (3 steps)`, `Settling VM`). On success, `status` is `completed` and `snapshotId` references a `ready` snapshot — fetch it via `GET /v1/snapshots/{id}`. On failure, `status` is `failed` and `error` carries the worker's diagnostic. Auth: required (X-API-Key). **Parameters** | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | path | `string` | yes | Build ID (UUID). | **Responses** - `200` — `BuildResponse`: Build status - `401` — `Error`: Missing or invalid credentials - `404` — `Error`: Resource not found - `500` — `Error`: Internal server error #### POST /v1/build-contexts/presign *Mint signed URLs for uploading a build context tarball* Returns a pair of short-lived signed URLs targeting a per-org staging location. Tar+gzip your build-context directory, PUT it to `uploadUrl` with `Content-Type: application/gzip`, then pass `downloadUrl` as `contextDownloadUrl` on `POST /v1/builds`. Unlike `/v1/vms/{id}/files/presign`, this endpoint isn't keyed to a specific VM — context uploads happen *before* the build VM exists. Auth: required (X-API-Key). **Responses** - `200` — `FilePresignResponse`: Signed URLs + upload size ceiling - `401` — `Error`: Missing or invalid credentials - `500` — `Error`: Internal server error - `501` — `Error`: File staging is not configured on this deployment ### VM Services Per-VM service registrations exposed via the public 4to6 HTTP proxy #### GET /v1/vms/{id}/services *List service registrations* Returns the services currently registered on this VM, sorted by name. Each service is exposed at `https://--.proxy.` over HTTPS. Auth: required (X-API-Key). **Parameters** | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | path | `string` | yes | VM ID (UUID). | **Responses** - `200` — `Service[]`: Services - `401` — `Error`: Missing or invalid credentials - `404` — `Error`: Resource not found - `500` — `Error`: Internal server error #### POST /v1/vms/{id}/services *Register a service on a VM* Registers an HTTP service on the VM under `name`, listening on `port`. The service immediately becomes addressable at `https://--.proxy.` once the firewall is applied (synchronous). Idempotent: a POST with a name that already exists at the same `(port, h2c)` returns 201 with the existing entry. POST with a name that already exists at a different port OR different `h2c` returns 409 — use PUT to update an existing service. Per-VM cap: currently 16 services per VM (configurable via `MAX_SERVICES_PER_VM` on the scheduler). Auth: required (X-API-Key). **Parameters** | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | path | `string` | yes | VM ID (UUID). | **Request body:** `RegisterServiceRequest` **Responses** - `201` — `Service`: Service registered (or idempotent same-`(port, h2c)` re-register) - `400` — `any`: Invalid name, invalid port, or per-VM cap exceeded. The body is a `QuotaExceededError` for the cap case (carries the structured `vm_service_quota_exceeded` reason + numeric count) and an `Error` otherwise. - `401` — `Error`: Missing or invalid credentials - `404` — `Error`: Resource not found - `409` — `Error`: The service name is already registered at a different port or different `h2c` (use PUT to update), or the VM is in `error` state and cannot be modified. - `500` — `Error`: Internal server error #### PUT /v1/vms/{id}/services/{serviceName} *Register or update a service on a VM* Idempotent register-or-update: same name + new port updates the port; same name + same port is a no-op. Returns the resulting entry. Used to change the upstream port for an existing service registration without dropping and re-creating it. Auth: required (X-API-Key). **Parameters** | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | path | `string` | yes | VM ID (UUID). | | `serviceName` | path | `string` | yes | Service registration name. 1–29 chars, lowercase letters and digits with optional single internal hyphens (no leading, trailing, or consecutive hyphens). Embedded in the public URL as the leftmost label. | **Request body:** `UpdateServiceRequest` **Responses** - `200` — `Service`: Service updated (or no-op same-port re-issue) - `400` — `any`: Invalid name or port, or per-VM cap exceeded. The body is a `QuotaExceededError` for the cap case and an `Error` otherwise. - `401` — `Error`: Missing or invalid credentials - `404` — `Error`: Resource not found - `409` — `Error`: VM is in `error` state and cannot be modified - `500` — `Error`: Internal server error #### DELETE /v1/vms/{id}/services/{serviceName} *Deregister a service from a VM* Idempotent: deleting a service that doesn't exist returns 204. Removes the firewall auto-rule synchronously; the proxy stops routing to the service within seconds (cache invalidation broadcast; 30s TTL is the safety net). Auth: required (X-API-Key). **Parameters** | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | path | `string` | yes | VM ID (UUID). | | `serviceName` | path | `string` | yes | Service registration name. 1–29 chars, lowercase letters and digits with optional single internal hyphens (no leading, trailing, or consecutive hyphens). Embedded in the public URL as the leftmost label. | **Responses** - `204`: Service deregistered (or already absent) - `400` — `Error`: Invalid service name - `401` — `Error`: Missing or invalid credentials - `404` — `Error`: Resource not found - `409` — `Error`: VM is in `error` state and cannot be modified - `500` — `Error`: Internal server error ### Health Service health #### GET /healthz *Health check* Returns 200 when the API is reachable. SDK clients call this on startup to warm HTTP/2 connections before the first real request. **Responses** - `200` — `object`: Service is healthy - `500` — `Error`: Internal server error ### Schemas #### `Error` | Field | Type | Required | Description | | --- | --- | --- | --- | | `error` | `string` | yes | Human-readable error message. | #### `QuotaExceededError` Per-VM service quota exceeded. The `error` token is a stable machine-readable code so SDKs can branch on it; `count` is the configured cap at denial time. | Field | Type | Required | Description | | --- | --- | --- | --- | | `error` | `"vm_service_quota_exceeded"` | yes | | | `count` | `integer` | yes | | #### `DeleteResponse` | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | `string` | yes | | | `deleted` | `boolean` | yes | | #### `VMStatus` Lifecycle status. Known values: `provisioning`, `running`, `stopped`, `pausing`, `paused`, `resuming`, `deleting`, `error`. Terminal failure statuses are `error` and `stopped`; transitional values (`provisioning`, `pausing`, `resuming`, `deleting`) indicate the VM is in flight. Additional values may be introduced in future server versions; clients should treat unknown values as "in transition" rather than as hard errors. #### `SnapshotStatus` Snapshot lifecycle status. Known values: `creating`, `ready`, `error`. Additional values may be introduced in future server versions. #### `TTL` Per-VM auto-action timer. The cycle ticks down while the VM is `running` and freezes on pause. `seconds` is the original cycle duration; refresh and PATCH-time updates reset to this value. | Field | Type | Required | Description | | --- | --- | --- | --- | | `seconds` | `integer` | yes | Cycle duration. Refresh resets to this value. Capped at 1 year (31536000s); larger values are rejected with 400. | | `action` | `"pause" | "delete"` | yes | Action taken on expiry. `pause` re-arms the cycle for the next running session; `delete` is terminal. | #### `QuotaExceeded` 429 body returned by `/v1/vms/{id}/resume` when the org's quota for one of the listed dimensions would be exceeded. | Field | Type | Required | Description | | --- | --- | --- | --- | | `error` | `string` | yes | | | `dimension` | `"vcpu" | "memory_mib" | "disk_gib" | "snapshot_count"` | yes | | #### `MachineType` Machine size identifier (e.g. `c1m2`, `c2m4`). Controls CPU and memory allocation. Must be supplied on launch unless restoring from a snapshot. #### `VM` | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | `string` | yes | | | `name` | `string` | yes | | | `orgId` | `string` | yes | | | `machineName` | `string` | no | | | `sourceName` | `string` | no | Source snapshot or image name (empty on fresh boot). | | `firewall` | `FirewallPolicy` | no | | | `effectiveFirewall` | `any` | no | Read-only composed view: `firewall` (the user policy) unioned with per-service auto-rules from this VM's registered services. Each auto-rule has source CIDR `::/0` and a `description` of the form `auto: proxy service `. The same policy is what the worker firewall actually enforces. Set `firewall` to mutate; this field is computed per-response from `firewall` and the current service registry, never persisted. | | `metadata` | `Metadata` | no | | | `envVars` | `EnvVars` | no | | | `publicIpv6` | `string` | no | | | `cpu` | `integer` | yes | | | `memoryMiB` | `integer` | yes | | | `diskGiB` | `integer` | yes | | | `status` | `VMStatus` | yes | | | `createdAt` | `string` | yes | | | `deletedAt` | `string` | no | | | `ttl` | `any` | no | Optional auto-action timer. Null when no TTL is configured. See `TTL` for semantics. | | `expiresAtMs` | `integer` | no | Absolute timestamp in ms when the TTL fires. Set only while the VM is `running` (the countdown freezes on pause). | | `ttlRemainingMs` | `integer` | no | Remaining cycle budget in ms. Set only while the VM is paused; restored to `expiresAtMs` on resume. | | `pausedAt` | `string` | no | When the VM became paused; null otherwise. | #### `Snapshot` | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | `string` | yes | | | `name` | `string` | yes | | | `orgId` | `string` | yes | | | `vmId` | `string` | yes | | | `firewall` | `FirewallPolicy` | no | | | `metadata` | `Metadata` | no | | | `envVars` | `EnvVars` | no | | | `services` | `SnapshotService[]` | no | Captured service registrations from the source VM at snapshot time. | | `status` | `SnapshotStatus` | yes | | | `createdAt` | `string` | yes | | #### `PolicyAction` Allow/deny verb. Used both as the per-direction default posture and as each rule's action. #### `IngressRuleKind` Ingress rule kind. Only `cidr` is supported — inbound packets don't carry a domain the worker could match on without TLS interception. #### `EgressRuleKind` Egress rule kind. - `cidr`: match by destination IP/CIDR + port/proto. - `fqdn`: match by destination domain (resolved through the in-process DNS resolver) + port/proto. Resolved IPs land in a per-rule dynamic nft set; the chain emits one rule per fqdn rule keyed on (set, proto, port). Port/proto enforcement on fqdn rules is honest — the prior `kind: domain` shape with a shared allow-set silently ignored them. Fqdn values accept an optional leading `*.` wildcard (e.g. `*.example.com`). Bare wildcards and non-leading wildcards are rejected. Wildcards match one-or-more labels left of the suffix and do not match the apex (matches DNS wildcard semantics). #### `DNSMode` Toggles the meaning of `dns.domains`. - `allow`: allowlist — only listed domains can resolve; any other query returns NXDOMAIN. - `deny`: blocklist — listed domains return NXDOMAIN; all other queries resolve through the upstream resolver. Default is `deny` with an empty list, which means "resolve everything" — the safe default that preserves existing behavior when callers omit the `dns` block. #### `IngressRule` | Field | Type | Required | Description | | --- | --- | --- | --- | | `action` | `PolicyAction` | yes | | | `kind` | `IngressRuleKind` | yes | | | `value` | `string` | yes | CIDR (e.g. `::/0`, `10.0.0.0/8`). IPv4 and IPv6 CIDRs are both accepted in the schema; L3 enforcement coverage per family is a worker-side concern. | | `protocol` | `"tcp" | "udp" | "any"` | yes | | | `ports` | `string` | yes | Single port (`443`), inclusive range (`8080-8090`), or `any`. When `protocol` is `any`, `ports` MUST be `any`. | | `description` | `string` | no | | #### `IngressPolicy` | Field | Type | Required | Description | | --- | --- | --- | --- | | `default` | `PolicyAction` | yes | | | `rules` | `IngressRule[]` | no | | #### `EgressRule` | Field | Type | Required | Description | | --- | --- | --- | --- | | `action` | `PolicyAction` | yes | | | `kind` | `EgressRuleKind` | yes | | | `value` | `string` | yes | For `kind: cidr`, an IPv4 or IPv6 CIDR. For `kind: fqdn`, a domain name with optional leading `*.` wildcard. Must be reachable through the `dns` gate — a fqdn value blocked by `dns.mode`/`dns.domains` is rejected at PUT time as a dead rule. | | `protocol` | `"tcp" | "udp" | "any"` | yes | | | `ports` | `string` | yes | Single port (`443`), inclusive range (`8080-8090`), or `any`. When `protocol` is `any`, `ports` MUST be `any`. | | `description` | `string` | no | | #### `EgressPolicy` | Field | Type | Required | Description | | --- | --- | --- | --- | | `default` | `PolicyAction` | yes | | | `rules` | `EgressRule[]` | no | | #### `DNSPolicy` DNS-layer filtering, independent of egress L4 rules. The resolver applies the DNS gate BEFORE L4 enforcement; a domain blocked here returns NXDOMAIN regardless of what egress.rules says about its IPs. All fields are optional — the server defaults `mode` to `deny` when missing, `domains` to `[]`, and `blockBypass` to false (see `normalizeDNSPolicy` in `scheduler/internal/httpapi/firewall.go`). | Field | Type | Required | Description | | --- | --- | --- | --- | | `mode` | `DNSMode` | no | | | `domains` | `string[]` | no | | | `blockBypass` | `boolean` | no | When true, the worker denies DoT (TCP 853) and the known public DoH endpoint IPs at the nft layer so guests cannot sidestep the in-process resolver. Default `false` — turning this on breaks workloads that legitimately reach `1.1.1.1` / `8.8.8.8` / etc. on TCP/443 for non-DoH reasons (e.g. services whose data plane lives on a Cloudflare anycast IP). Operators who enable DNS allowlist mode typically also flip this on explicitly. | #### `FirewallPolicy` Top-level firewall policy with three independent axes. All sub-blocks are optional — the server substitutes the safe default (ingress deny / egress allow / dns mode=deny + empty) for missing blocks. Sending `firewall: null` on VM create is also valid. | Field | Type | Required | Description | | --- | --- | --- | --- | | `ingress` | `IngressPolicy` | no | | | `egress` | `EgressPolicy` | no | | | `dns` | `DNSPolicy` | no | | #### `PatchFirewallRequest` Partial firewall update. Each block (`ingress`, `egress`, `dns`) is optional; when present, the supplied object replaces that block wholesale. To change a single rule, send the full block with the desired rule list. An empty body (`{}`) is a no-op. | Field | Type | Required | Description | | --- | --- | --- | --- | | `ingress` | `IngressPolicy` | no | | | `egress` | `EgressPolicy` | no | | | `dns` | `DNSPolicy` | no | | #### `SnapshotService` Captured (name, port, h2c) tuple for a single service registration on a snapshotted VM. Carried across snapshot/ restore by `POST /v1/vms` (snapshot-restore branch) so the new VM gets the same service registrations the source VM had at snapshot time. | Field | Type | Required | Description | | --- | --- | --- | --- | | `name` | `string` | yes | | | `port` | `integer` | yes | | | `h2c` | `boolean` | no | | #### `SnapshotRestoreWarnings` Reports best-effort failures during the snapshot-restore service-replay step. Only present when restoring from a snapshot AND the post-create bulk service registration failed. The VM is created successfully and usable; the user can manually re-register the listed services with one `POST /v1/vms/{id}/services` per service. Bulk service registration is atomic at Redis (one Lua call either writes all-N entries or zero), so partial state ("5 of 8 registered") is impossible — the response is always either a VM with all services registered or a VM with zero services and the full list returned here. | Field | Type | Required | Description | | --- | --- | --- | --- | | `servicesRegistrationFailed` | `boolean` | yes | Always `true` when this object is present. | | `unregisteredServices` | `SnapshotService[]` | no | Services from the snapshot that did not land on the new VM. Caller can re-register each via `POST /v1/vms/{id}/services`. | | `reason` | `string` | no | Operator-facing diagnostic for the failure. | #### `VMCreateResponse` VM object as returned by `POST /v1/vms`. On snapshot restore, an optional `snapshotRestoreWarnings` field may be present if the captured services failed to re-register on the new VM. Existing SDK callers that don't know about the field see the unchanged VM wire shape (`omitempty` keeps the field absent on cold boots and on warning-free restores). #### `CreateVMRequest` Boot behavior depends on which fields are set: - `snapshotId` set → restore from snapshot (takes precedence over `machineType` if both are sent). - Otherwise → fresh boot. `machineType` selects the size; if omitted or empty, defaults to `c1m2`. | Field | Type | Required | Description | | --- | --- | --- | --- | | `name` | `string` | no | User-facing name (trimmed + whitespace-collapsed, max 64 runes after normalization; longer values are truncated server-side). Auto-generated as `vm-<8-char-id-prefix>` if empty. | | `machineType` | `MachineType` | no | | | `snapshotId` | `string` | no | Snapshot ID to restore from. | | `diskGiB` | `integer` | no | Override the default disk size (GiB). | | `firewall` | `FirewallPolicy` | no | | | `metadata` | `Metadata` | no | | | `envVars` | `EnvVars` | no | | | `ttl` | `TTL` | no | | #### `UpdateVMRequest` At least one of `name`, `metadata`, or `ttl` must be provided. Sending `metadata: {}` clears all metadata; omitting it leaves existing metadata unchanged. Sending `ttl: null` explicitly clears the TTL; sending a `TTL` object replaces it; omitting the field leaves the current TTL unchanged. | Field | Type | Required | Description | | --- | --- | --- | --- | | `name` | `string` | no | | | `metadata` | `Metadata` | no | | | `ttl` | `any` | no | | #### `CreateSnapshotRequest` | Field | Type | Required | Description | | --- | --- | --- | --- | | `vmId` | `string` | yes | | | `name` | `string` | no | Snapshot name (trimmed + whitespace-collapsed, max 64 runes; longer values are truncated server-side). Auto-generated as `snapshot-<8-char-vmId-prefix>` if empty. | #### `UpdateSnapshotRequest` Rename a snapshot. `name` is optional; if omitted or empty, the server regenerates the auto-name (`snapshot-<8-char-vmId-prefix>`). | Field | Type | Required | Description | | --- | --- | --- | --- | | `name` | `string` | no | | #### `CreateBuildRequest` At least one of `imageRef` or `dockerfileContent` must be set. If only `imageRef` is provided, the build VM pulls that image and rsyncs its rootfs over the VM's `/`. If `dockerfileContent` is provided, the build VM writes it verbatim to `/tmp/buildctx/Dockerfile` and runs `buildah bud`. | Field | Type | Required | Description | | --- | --- | --- | --- | | `name` | `string` | no | Optional human-readable name for the resulting snapshot. If omitted, the build ID is used. | | `imageRef` | `string` | no | Docker image reference (e.g. `python:3.13-slim`, `ghcr.io/user/repo:tag`). Used directly on the no-Dockerfile path, and as a fallback `FROM` source otherwise. | | `dockerfileContent` | `string` | no | Raw Dockerfile content to feed to `buildah bud` inside the build VM. Multi-stage, `SHELL`, `RUN --mount`, and every standard Dockerfile feature is supported (handled natively by buildah). Container-runtime metadata (`CMD`, `ENTRYPOINT`, `EXPOSE`, `LABEL`, `HEALTHCHECK`) is consumed by buildah but does not surface on the resulting FastVM snapshot — when the snapshot boots, systemd takes over, not the container's CMD. | | `machineType` | `MachineType` | no | | | `diskGiB` | `integer` | no | Disk size for the build VM. Defaults to 10 GiB if omitted. | | `contextDownloadUrl` | `string` | no | Presigned GET URL for a `tar.gz` of the build context. The worker downloads and extracts this into `/tmp/buildctx` before invoking buildah, so `COPY` instructions resolve against the user's files. Obtain via `POST /v1/build-contexts/presign`. | #### `BuildResponse` Build state snapshot. Returned by `POST /v1/builds` (initial `pending` state) and `GET /v1/builds/{id}` (current state on each poll). | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | `string` | yes | Build ID (UUID). Use this to poll status. | | `name` | `string` | no | | | `status` | `string` | yes | Current state. Known values: `pending` (accepted, not yet started), `running` (worker is executing), `completed` (snapshot is ready), `failed` (build did not produce a snapshot). Additional values may be introduced in future server versions; clients should treat unknown values as "in progress" rather than as hard errors. | | `snapshotId` | `string` | no | Set when `status` is `completed`. Fetch the corresponding Snapshot record via `GET /v1/snapshots/{id}`. | | `imageRef` | `string` | yes | | | `progress` | `string` | no | Human-readable phase string while the build runs (e.g. `creating build VM`, `buildah pull`, `buildah bud`, `applying image`, `settling VM`, `creating snapshot`). Not present after a terminal status. | | `error` | `string` | no | Set when `status` is `failed`. Diagnostic from the worker (truncated to ~4 KiB). | | `createdAt` | `string` | yes | | #### `Metadata` Free-form string→string map. Server-enforced limits: up to 256 keys, key length 1–256 bytes, value length ≤4096 bytes, total JSON encoding ≤65536 bytes. #### `EnvVars` Environment variable string→string map injected into the VM at boot. Keys must be 1–256 bytes and match shell-variable name (`[A-Za-z_][A-Za-z0-9_]*`); values may not contain newline, carriage return, or null bytes. Total JSON encoding ≤65536 bytes. #### `ExecVMRequest` | Field | Type | Required | Description | | --- | --- | --- | --- | | `command` | `string[]` | yes | Argv-style command. First element must be non-empty. For shell strings, wrap as `["sh", "-c", ""]`. | | `timeoutSec` | `integer` | no | Server-side execution timeout in seconds. Must be positive when provided; omit to use the server default. | | `stdin` | `string` | no | Optional base64-encoded stdin blob, written to the child's stdin before the process starts reading much and then closed. Streaming stdin is not supported — pipe from a file inside the guest if you need that shape. | #### `ExecEvent` One event in the NDJSON exec stream returned by `POST /v1/vms/{id}/exec` under `Accept: application/x-ndjson`. Short field names (`t`, `d`, `c`, `to`, `ms`) keep per-chunk overhead small since high-output commands can produce thousands of events per exec. | Field | Type | Required | Description | | --- | --- | --- | --- | | `t` | `"o" | "e" | "x"` | yes | Event type: `o` = stdout chunk, `e` = stderr chunk, `x` = terminal exit event. | | `d` | `string` | no | For `o`/`e`: base64-encoded raw bytes of the chunk. For `x`: optional diagnostic string (e.g. spawn failure) when non-empty. | | `c` | `integer` | no | Exit code. Present on `x` events only. | | `to` | `boolean` | no | True if the command was killed by the timeout. `x` events only. | | `ms` | `integer` | no | Guest-reported duration in milliseconds. `x` events only. | #### `ExecVMResponse` Buffered response shape for `POST /v1/vms/{id}/exec` under `Accept: application/json`. The server collects the streamed events and returns this aggregate once the command exits. Per-stream output is capped at 4 MiB; overflow bytes are dropped and signalled via `stdoutTruncated` / `stderrTruncated`. Streaming clients (`Accept: application/x-ndjson`) receive every byte without a cap. | Field | Type | Required | Description | | --- | --- | --- | --- | | `exitCode` | `integer` | yes | | | `stdout` | `string` | yes | | | `stderr` | `string` | yes | | | `timedOut` | `boolean` | yes | | | `stdoutTruncated` | `boolean` | yes | True if the collector dropped stdout bytes past the 4 MiB cap. | | `stderrTruncated` | `boolean` | yes | True if the collector dropped stderr bytes past the 4 MiB cap. | | `durationMs` | `integer` | yes | | #### `FilePresignRequest` | Field | Type | Required | Description | | --- | --- | --- | --- | | `path` | `string` | yes | Absolute destination path inside the guest filesystem (where the file will land after `fetchFileToVm`). Used only to scope the staging object key; any value server-side is accepted here. | #### `FilePresignResponse` Pair of signed URLs scoped to the same per-VM staging object. Usable in either direction: either side (client or VM) PUTs bytes to `uploadUrl`, and either side GETs them back via `downloadUrl`. URLs expire after `expiresInSec` seconds and the staging object is auto-deleted after about a day. | Field | Type | Required | Description | | --- | --- | --- | --- | | `uploadUrl` | `string` | yes | Presigned PUT URL for the staging object. Accepts `Content-Type: application/octet-stream`. Used by the client on upload, or by the VM (via an exec'd `curl -T -`) on download. | | `downloadUrl` | `string` | yes | Presigned GET URL for the same staging object. Used by the VM (via `POST /v1/vms/{id}/files/fetch`) on upload, or by the client (via `httpx.stream` / `curl`) on download. | | `expiresInSec` | `integer` | yes | Lifetime of both URLs in seconds. | | `maxUploadBytes` | `integer` | yes | Upper bound on upload size (equals the VM's disk size in bytes). | #### `FileFetchRequest` | Field | Type | Required | Description | | --- | --- | --- | --- | | `url` | `string` | yes | Must be the `downloadUrl` previously returned by `POST /v1/vms/{id}/files/presign` (URLs from other sources are rejected). | | `path` | `string` | yes | Absolute destination path inside the guest filesystem. | | `timeoutSec` | `integer` | no | Per-fetch timeout in seconds. | #### `ConsoleTokenResponse` | Field | Type | Required | Description | | --- | --- | --- | --- | | `token` | `string` | yes | | | `expiresInSec` | `integer` | yes | | | `websocketPath` | `string` | yes | Relative WebSocket path; combine with your API host as `wss://?session=`. | #### `SshKey` | Field | Type | Required | Description | | --- | --- | --- | --- | | `name` | `string` | no | Optional human label. | | `publicKey` | `string` | yes | OpenSSH-format public key, of the form ` ` — the optional comment is stripped server-side. Supported types: `ssh-ed25519`, `ssh-rsa`, `ecdsa-sha2-nistp{256,384,521}`, plus FIDO2 hardware-backed variants (`sk-...@openssh.com`). | | `fingerprint` | `string` | yes | OpenSSH SHA256 fingerprint, e.g. `SHA256:abc...`. This is the **identifier** — matches what `ssh-keygen -lf` prints and what your ssh client shows on first connect; pass it back as the `{fingerprint}` path segment to `deleteSshKey`. | | `createdAt` | `string` | yes | | #### `SshKeyListResponse` | Field | Type | Required | Description | | --- | --- | --- | --- | | `keys` | `SshKey[]` | yes | | #### `AddSshKeyRequest` | Field | Type | Required | Description | | --- | --- | --- | --- | | `name` | `string` | no | Optional human label. | | `publicKey` | `string` | yes | OpenSSH-format public key (`ssh-ed25519 AAA...`). Comments are stripped. Newlines are rejected. | #### `OrgQuotaValues` | Field | Type | Required | Description | | --- | --- | --- | --- | | `vcpu` | `integer` | yes | | | `memoryMiB` | `integer` | yes | | | `diskGiB` | `integer` | yes | | | `snapshotCount` | `integer` | yes | | #### `OrgQuotaUsage` | Field | Type | Required | Description | | --- | --- | --- | --- | | `orgId` | `string` | yes | | | `limits` | `OrgQuotaValues` | yes | | | `usage` | `OrgQuotaValues` | yes | | #### `Service` | Field | Type | Required | Description | | --- | --- | --- | --- | | `name` | `string` | yes | Service name (1–29 chars). Embedded in the public URL as `--.proxy.`. | | `port` | `integer` | yes | TCP port the service listens on inside the VM. Privileged ports (<1024) are rejected. | | `h2c` | `boolean` | yes | When true, the proxy speaks HTTP/2 cleartext (h2c) to the backend. Required for gRPC and h2c-only apps. When false (default), the proxy uses HTTP/1.1 — covers HTTP/1.1 apps, Server-Sent Events, and WebSocket pass-through. | #### `RegisterServiceRequest` | Field | Type | Required | Description | | --- | --- | --- | --- | | `name` | `string` | yes | | | `port` | `integer` | yes | | | `h2c` | `boolean` | no | Optional. When true, the proxy uses HTTP/2 cleartext to the backend (required for gRPC). Defaults to false (HTTP/1.1). | #### `UpdateServiceRequest` | Field | Type | Required | Description | | --- | --- | --- | --- | | `port` | `integer` | yes | New TCP port. Same value as the existing entry is a no-op. | | `h2c` | `boolean` | no | Optional. When true, the proxy uses HTTP/2 cleartext to the backend. Same value as the existing entry is a no-op; a different value updates the registered transport. |