api.proto for the public gRPC API at boxd.sh:9443. It is proto3 with no external imports, so copy it into a local api.proto and stub generation with protoc, buf, grpc_tools, or @grpc/proto-loader is one command.
You don’t strictly need the file. Server reflection is on, so the schema can be inferred straight from the endpoint. grpcurl and buf curl work against it directly, and grpcurl boxd.sh:9443 describe boxd.api.v1.BoxdApi prints the schema on demand.
The walkthrough (endpoint, authentication, hello world) is on the gRPC API overview.
syntax = "proto3";
package boxd.api.v1;
service BoxdApi {
rpc CreateVm(CreateVmRequest) returns (CreateVmResponse);
rpc DestroyVm(DestroyVmRequest) returns (DestroyVmResponse);
rpc RenameVm(RenameVmRequest) returns (RenameVmResponse);
// Replaces a VM's whole network-tag set (BOX-41) — not add/remove, the
// caller sends the complete desired set. Networks drive east-west
// reachability within the caller's org; see CreateVmRequest.networks.
rpc SetVmNetworks(SetVmNetworksRequest) returns (SetVmNetworksResponse);
rpc StartVm(StartVmRequest) returns (StartVmResponse);
rpc StopVm(StopVmRequest) returns (StopVmResponse);
rpc RebootVm(RebootVmRequest) returns (RebootVmResponse);
rpc GetVm(GetVmRequest) returns (GetVmResponse);
rpc ListVms(ListVmsRequest) returns (ListVmsResponse);
// A fresh, available auto-generated VM name — used by the interactive CLI to
// pre-fill the "name" prompt for `machine new`.
rpc SuggestVmName(SuggestVmNameRequest) returns (SuggestVmNameResponse);
rpc ExposePort(ExposePortRequest) returns (ExposePortResponse);
rpc UnexposePort(UnexposePortRequest) returns (UnexposePortResponse);
rpc ListExposedPorts(ListExposedPortsRequest) returns (ListExposedPortsResponse);
// Env vars & secrets (scoped to an org or, when org is empty, personal).
rpc SetVar(SetVarRequest) returns (VarReply);
rpc ListVars(ListVarsRequest) returns (VarListReply);
rpc RemoveVar(RemoveVarRequest) returns (VarReply);
rpc SetSecretScope(SetSecretScopeRequest) returns (VarReply);
// (network/domain scoping is parked — no SetSecretDomains RPC for now)
rpc StreamLogs(StreamLogsRequest) returns (stream LogChunk);
rpc Exec(stream ExecChunk) returns (stream ExecChunk);
rpc CreateNetwork(CreateNetworkRequest) returns (CreateNetworkResponse);
rpc ListNetworks(ListNetworksRequest) returns (ListNetworksResponse);
// Per-VM custom domains (one domain -> one VM). AddCustomDomain creates a
// `pending` row and a leader-gated control loop immediately starts
// verifying DNS and issuing certs, then flips it `active`; `last_error`
// carries the reason while pending. Callers are expected to have already
// pointed DNS at the machine before calling this — checking too early
// risks a public resolver negative-caching the apex before the records
// exist. Caller must own (or be a member of the org of) the target VM.
rpc AddCustomDomain(AddCustomDomainRequest) returns (AddCustomDomainResponse);
rpc ListCustomDomains(ListCustomDomainsRequest) returns (ListCustomDomainsResponse);
rpc RemoveCustomDomain(RemoveCustomDomainRequest) returns (RemoveCustomDomainResponse);
// Per-org wildcard domain (one apex per org, e.g. preview.mysaas.com,
// covering <vm>.preview.mysaas.com for every org-billed VM). Same
// already-pointed-DNS expectation as AddCustomDomain, this time NS
// delegation. Set/Clear require org admin; Get is readable by any org member.
rpc SetOrgDomain(SetOrgDomainRequest) returns (SetOrgDomainResponse);
rpc GetOrgDomain(GetOrgDomainRequest) returns (GetOrgDomainResponse);
rpc ClearOrgDomain(ClearOrgDomainRequest) returns (ClearOrgDomainResponse);
rpc Whoami(WhoamiRequest) returns (WhoamiResponse);
rpc CreateToken(CreateTokenRequest) returns (CreateTokenResponse);
rpc ListTokens(ListTokensRequest) returns (ListTokensResponse);
rpc RevokeToken(RevokeTokenRequest) returns (RevokeTokenResponse);
// Link an SSH public key to the caller's account so `ssh <vm>.boxd`
// authenticates by that key's fingerprint. Idempotent for the caller.
rpc LinkSshKey(LinkSshKeyRequest) returns (LinkSshKeyResponse);
rpc GetConfig(GetConfigRequest) returns (GetConfigResponse);
rpc ForkVm(ForkVmRequest) returns (ForkVmResponse);
rpc ListProxies(ListProxiesRequest) returns (ListProxiesResponse);
// The cluster's PROXY NODES — not to be confused with ListProxies above,
// which returns a VM's HTTP port-forwards. Used by the client-utilities
// daemon to open a DeviceBridge to every proxy: the device registry is
// per-process and in-memory, so registering with only one proxy leaves the
// device invisible to the others.
rpc ListProxyNodes(ListProxyNodesRequest) returns (ListProxyNodesResponse);
rpc CreateProxy(CreateProxyRequest) returns (CreateProxyResponse);
rpc DeleteProxy(DeleteProxyRequest) returns (DeleteProxyResponse);
rpc SetProxyPort(SetProxyPortRequest) returns (SetProxyPortResponse);
rpc UploadFile(UploadFileRequest) returns (UploadFileResponse);
// Streaming upload: lifts the 4 MiB single-message gRPC cap of UploadFile.
// The first chunk carries vm_id/path/total_size; subsequent chunks carry
// bytes only. Server side reads exactly total_size bytes via `head -c N`,
// verifies the file size, and returns the confirmed byte count.
rpc UploadFileStream(stream UploadFileChunk) returns (UploadFileResponse);
rpc DownloadFile(DownloadFileRequest) returns (DownloadFileResponse);
rpc SuspendVm(SuspendVmRequest) returns (SuspendVmResponse);
rpc ResumeVm(ResumeVmRequest) returns (ResumeVmResponse);
rpc HibernateVm(HibernateVmRequest) returns (HibernateVmResponse);
rpc WakeVm(WakeVmRequest) returns (WakeVmResponse);
// Templates are operator-managed (boxd-ctl) and used only as the fast default
// path of `new` when a prepped template matches the image + size — there is no
// public API to create/list/delete templates or create a VM from a specific one.
// Snapshots: user/org point-in-time VM captures (memory + disk), replicated
// across a few workers and versioned (latest-only).
rpc CreateSnapshot(CreateSnapshotRequest) returns (CreateSnapshotResponse);
rpc ListSnapshots(ListSnapshotsRequest) returns (ListSnapshotsResponse);
rpc GetSnapshot(GetSnapshotRequest) returns (GetSnapshotResponse);
rpc DeleteSnapshot(DeleteSnapshotRequest) returns (DeleteSnapshotResponse);
rpc CreateVmFromSnapshot(CreateVmFromSnapshotRequest) returns (CreateVmResponse);
// Checkpoints: per-VM, worker-local full captures (state+memory+disk) with
// warm restore in place. Same artifacts as a snapshot, but never replicated
// and never an account-level image — they live and die with the VM.
rpc CreateCheckpoint(CreateCheckpointRequest) returns (CreateCheckpointResponse);
rpc ListCheckpoints(ListCheckpointsRequest) returns (ListCheckpointsResponse);
rpc DeleteCheckpoint(DeleteCheckpointRequest) returns (DeleteCheckpointResponse);
rpc RestoreCheckpoint(RestoreCheckpointRequest) returns (RestoreCheckpointResponse);
rpc SetAutoSuspendTimeout(SetAutoSuspendTimeoutRequest) returns (SetAutoSuspendTimeoutResponse);
rpc SetAutoHibernateTimeout(SetAutoHibernateTimeoutRequest) returns (SetAutoHibernateTimeoutResponse);
// Disks
rpc CreateDisk(CreateDiskRequest) returns (CreateDiskResponse);
rpc ListDisks(ListDisksRequest) returns (ListDisksResponse);
rpc AttachDisk(AttachDiskRequest) returns (AttachDiskResponse);
rpc DetachDisk(DetachDiskRequest) returns (DetachDiskResponse);
rpc DestroyDisk(DestroyDiskRequest) returns (DestroyDiskResponse);
// API Keys
rpc CreateApiKey(CreateApiKeyRequest) returns (CreateApiKeyResponse);
rpc ListApiKeys(ListApiKeysRequest) returns (ListApiKeysResponse);
rpc DeleteApiKey(DeleteApiKeyRequest) returns (DeleteApiKeyResponse);
// Billing — individual subscriptions. ListPlans is unauth-friendly
// (returns the single self-serve plan), the rest require JWT.
// RPC ChangeShape removed (shape retired, rc/0.2) — it only ever returned
// UNIMPLEMENTED in prod. Do not reuse the method name with new semantics.
rpc ListPlans(ListPlansRequest) returns (ListPlansResponse);
rpc GetBilling(GetBillingRequest) returns (GetBillingResponse);
rpc CreateCheckoutSession(CreateCheckoutSessionRequest) returns (CreateCheckoutSessionResponse);
rpc CreateBillingPortalSession(CreateBillingPortalSessionRequest) returns (CreateBillingPortalSessionResponse);
// Organizations (v2 org model). Owner-only visibility toggle: share your VM
// with the whole org it belongs to, or make it private again. A VM never
// moves between orgs (it's born in the owner's org).
rpc ShareVm(ShareVmRequest) returns (ShareVmResponse);
rpc UnshareVm(UnshareVmRequest) returns (UnshareVmResponse);
// List the orgs the caller is a member of (backs the CLI/console org picker).
rpc ListOrgs(ListOrgsRequest) returns (ListOrgsResponse);
// The org's shared plan: aggregate quota + current usage. Any member may
// view it (read-only; the operator sets the quota via boxd-ctl).
rpc GetOrgBilling(GetOrgBillingRequest) returns (GetOrgBillingResponse);
}
// VM configuration — all fields use 0/empty as "use default"
message VmConfig {
uint32 vcpu = 1; // 0 = user quota or 2
uint64 memory_bytes = 2; // 0 = user quota or 8 GiB
uint64 disk_bytes = 3; // 0 = 100 GiB
SrfConfig srf = 4;
NetworkConfig network = 5;
repeated VolumeMount volumes = 6;
}
message SrfConfig {
optional uint32 auto_suspend_timeout_secs = 1; // unset = server default (30s for new, inherit for fork)
uint32 auto_destroy_timeout_secs = 2; // 0 = no auto-destroy
}
message NetworkConfig {
bool ssh = 1; // default: true
repeated ProxyEntry proxies = 2;
}
message ProxyEntry {
string name = 1;
uint32 port = 2; // 0 = auto-detect
}
message VolumeMount {
string disk_id = 1;
string mount_path = 2;
bool read_only = 3;
}
// VM management
message CreateVmRequest {
string name = 1;
string image_ref = 2;
reserved 3; // network_id removed — auto-created per user
reserved 4; // disk_bytes removed — fixed at 100 GB
repeated EnvVar env = 5;
repeated string cmd = 6;
string restart_policy = 7;
VmConfig config = 8; // omit for defaults (all zeros)
// Org to create the VM in (name or id). Empty = personal. Set from the
// caller's active context. Non-empty routes through the processor (skips the
// fast path) so membership is validated and the org context is set at birth.
// The org pays either way; the VM is PRIVATE to the creator unless `shared`
// is set.
string org = 9;
// Share the new VM with the whole org (only meaningful with `org`). Default
// false = private to the creator, billed to the org.
bool shared = 10;
// Network tags (BOX-41): east-west reachability within the org. Empty =
// this VM joins the org's implicit DEFAULT network, whose members are the
// org's other networkless, non-isolated VMs — where every pre-BOX-41 VM
// lands, so nothing had to be migrated. Naming ≥1 network OPTS OUT of the
// default network: the VM then reaches only VMs it shares a network with,
// and untagged VMs no longer reach it. Editable after creation via
// SetVmNetworks.
repeated string networks = 11;
// Isolated (BOX-41): a sandbox. Never in the default network and never a
// peer of another isolated VM — so an isolated VM with no `networks` has
// no peers at all, and one with `networks` reaches exactly the NON-isolated
// VMs it shares a network with (east-west, internal DNS and host-mediated
// exec/cp all follow the same rule). Also: no metadata adapter, and the
// guest is stripped of the in-VM boxd CLI, the git credential helper, the
// GitHub/Linear/Slack/Figma integrations, the agent skills/credentials, and
// `boxd local`. Internet, inbound SSH/HTTPS, and operator support access
// are unaffected. Set at creation, IMMUTABLE after — there is deliberately
// no setter RPC. A fork or restore-from-snapshot always inherits the
// source's/snapshot's value.
bool isolated = 12;
}
message EnvVar {
string key = 1;
string value = 2;
}
message CreateVmResponse {
string vm_id = 1;
string name = 2;
string public_ip = 3;
string url = 4;
string image = 5;
string status = 6;
uint64 boot_time_ms = 7;
}
message DestroyVmRequest { string vm_id = 1; }
message DestroyVmResponse {}
message SuggestVmNameRequest {}
message SuggestVmNameResponse { string name = 1; }
// vm_id accepts a VM name or ID (owner-resolved). new_name must be free and not
// reserved. Routing/DNS follow the new name immediately; the in-VM hostname
// updates on the VM's next reboot.
message RenameVmRequest { string vm_id = 1; string new_name = 2; }
message RenameVmResponse { string name = 1; }
// Tag-based networks (BOX-41). `networks` is the COMPLETE desired set
// (replaces, not merges). Clearing it to empty does NOT mean "no peers": it
// moves the VM into the org's default network (its other networkless,
// non-isolated VMs). On an ISOLATED VM this is the only reachability lever
// there is — an isolated VM peers exclusively through explicit shared
// networks, so clearing them leaves it with no peers at all.
message SetVmNetworksRequest { string vm_id = 1; repeated string networks = 2; }
message SetVmNetworksResponse { repeated string networks = 1; }
// vm_id accepts a VM name or ID (owner-resolved). protocol is "tcp" | "udp" |
// "both" (both share one allocated public port). Re-exposing the same vm_port
// keeps its public port and updates the protocol set. Max 3 per VM.
message ExposePortRequest {
string vm_id = 1 [deprecated = true]; // use `vm`
uint32 vm_port = 2;
string protocol = 3;
string vm = 4; // machine id OR name
}
// dns is `<vm>.<zone>` and resolves to the VM's proxy IP — connect on public_port.
message ExposePortResponse {
string vm_name = 1;
string dns = 2;
uint32 public_port = 3;
uint32 vm_port = 4;
string protocol = 5;
// The machine this forward belongs to, beside `vm_name` — lets a caller
// round-trip to any vm_id-addressed call without a name lookup.
string vm_id = 6;
}
// Remove the forward for (vm_id, vm_port), freeing its public port. Errors if
// nothing is exposed on that port. The response echoes what was removed.
message UnexposePortRequest {
string vm_id = 1 [deprecated = true]; // use `vm`
uint32 vm_port = 2;
string vm = 3; // machine id OR name
}
message UnexposePortResponse { ExposePortResponse forward = 1; }
// `vm` (id OR name) narrows to one machine; empty = every forward the
// caller can see, which is what the CLI's fleet view relies on.
message ListExposedPortsRequest { string vm = 1; }
message ListExposedPortsResponse { repeated ExposePortResponse forwards = 1; }
// Env vars & secrets. `org` empty = personal scope. `kind` = "env" | "secret".
// `scope` = "all" | "shared" | "private" (empty = default "shared"; ignored for
// personal). Secret values are write-only: never returned in any response.
// Field 6 (network-scope `domains`) is parked — reserved, not surfaced.
message SetVarRequest {
string org = 1;
string kind = 2;
string name = 3;
string value = 4;
string scope = 5;
reserved 6; // parked: secret network-scope allowlist
}
message VarReply { string message = 1; }
message ListVarsRequest { string org = 1; string kind = 2; }
message VarItem {
string name = 1;
string kind = 2;
string scope = 3;
string value = 4; // env only; always empty for secrets
reserved 5, 6; // parked: networked / domains
}
message VarListReply { repeated VarItem vars = 1; }
message RemoveVarRequest { string org = 1; string kind = 2; string name = 3; string scope = 4; }
message SetSecretScopeRequest { string org = 1; string name = 2; string from = 3; string to = 4; }
message StartVmRequest { string vm_id = 1; }
message StartVmResponse {}
message StopVmRequest { string vm_id = 1; }
message StopVmResponse {}
message RebootVmRequest { string vm_id = 1; }
message RebootVmResponse {}
message SuspendVmRequest { string vm_id = 1; }
message SuspendVmResponse {
uint64 suspend_us = 1;
}
message ResumeVmRequest { string vm_id = 1; }
message ResumeVmResponse {
uint64 resume_us = 1;
}
message HibernateVmRequest { string vm_id = 1; }
message HibernateVmResponse {}
message WakeVmRequest { string vm_id = 1; }
message WakeVmResponse {}
message GetVmRequest { string vm_id = 1; }
message GetVmResponse {
string vm_id = 1;
string name = 2;
string image_ref = 3;
string public_ip = 4;
string status = 5;
string restart_policy = 6;
uint64 disk_bytes = 7;
uint32 auto_suspend_timeout_secs = 8; // 0 = disabled/unset; always concrete on the wire
uint32 ssh_port = 9; // per-VM SSH port on the proxy IP (0 = not yet allocated)
// Org the VM is SHARED with (org name; empty = not shared) and the org
// paying for it (org name; empty = the owner's personal quota). A private
// org-billed VM has `org` empty and `billing_org` set.
string org = 10;
string billing_org = 11;
// The domain the VM is addressed under: the VM's billing org's active custom
// domain (e.g. `preview.mysaas.com`) if it has one, else the cluster zone
// (e.g. `boxd.sh`). Clients build `<name>.<access_domain>` for SSH/HTTPS
// display. The default `<name>.<cluster-zone>` always continues to resolve.
string access_domain = 12;
uint32 auto_hibernate_timeout_secs = 13; // 0 = disabled/unset
// How the VM was created, for DISPLAY: "snapshot/<name>:<version>",
// "fork/<source-vm-name>", or "standalone". Kept verbatim for old clients —
// new ones read the structured `source_kind`/`source_name`/`source_version`
// trio below instead of string-splitting this.
string source = 14;
// --- Sizing. The machine's EFFECTIVE vCPU/RAM, resolved exactly like the
// spawn path (`Repository::vm_resources`): the VM's own override if it has
// one, else its billing org's quota, else the platform default. Never the
// raw nullable column, which reads 0 for a VM sized purely by its org.
uint32 vcpu = 15;
uint64 memory_bytes = 16;
// Wall-clock unix SECONDS the VM was created. 0 = unknown: the row predates
// migration 0076 and was deliberately not backfilled with a fabricated time.
int64 created_at = 17;
// Wall-clock unix MILLISECONDS the VM entered `hibernated`. 0 = not
// hibernated (or hibernated before the column was recorded).
int64 hibernated_at_ms = 18;
// Idle seconds before the VM is auto-destroyed. 0 = disabled/unset.
uint32 auto_destroy_timeout_secs = 19;
// Last boot duration in MILLISECONDS (0 = never booted / not recorded) and
// the wall-clock unix MILLISECONDS of the last client connection to the VM
// (0 = never connected).
int64 boot_time_ms = 20;
int64 last_connected_ms = 21;
// Ids for the orgs named by `org` / `billing_org` above, so a caller can
// round-trip to an org-addressed call without a name→id lookup. Same
// emptiness rules as the names they sit beside.
string org_id = 22;
string billing_org_id = 23;
// Structured form of `source`, so clients never parse that string.
// UNSET for a standalone machine — see VmSource below.
VmSource source_info = 24;
// Tag-based networks (BOX-41): east-west reachability within the org. See
// CreateVmRequest.networks for the rule. Empty = this VM is in the org's
// implicit default network (its other networkless, non-isolated VMs);
// non-empty means it reaches only VMs sharing a network.
repeated string networks = 25;
// Isolated (BOX-41): a sandbox — peers only through an explicit shared
// `networks` entry, never through the default network and never with
// another isolated VM. No metadata adapter. Immutable after creation.
bool isolated = 26;
// Per-VM custom domains bound to this machine (BOX-30) — `domain` is
// globally unique but not `vm_id`, so more than one can point here.
// Distinct from `access_domain` above, which is the org's wildcard
// domain or the cluster zone, not a per-VM custom domain.
repeated CustomDomainInfo custom_domains = 27;
}
// Where a machine CAME FROM: a fork of another VM, or a restore of a
// snapshot. A standalone machine leaves this message UNSET rather than
// setting a kind that names its own absence, so a caller writes
// `if (source_info)` instead of `if (source_info.kind != "standalone")`.
// (The legacy `GetVmResponse.source` display string still reads
// "standalone" for those machines — it is unchanged.)
message VmSource {
// "fork" | "snapshot". Never "standalone": that is the unset message.
string kind = 1;
// The source VM's name (fork) or the snapshot's name (snapshot).
string name = 2;
// The snapshot version. 0 for a fork.
uint64 version = 3;
// The source VM's id (fork) or the snapshot's id (snapshot).
//
// PROVENANCE, NOT A LIVE REFERENCE. A fork's parent may since have been
// destroyed and a snapshot may since have been deleted, so this id is not
// guaranteed to resolve — treat a lookup miss as normal, not as an error.
//
// Empty when the id is genuinely unrecoverable: a machine restored from a
// snapshot before the id was persisted (migration 0077) reports its
// name and version with an empty id, because `source_snapshot`'s
// "<name>:<version>" string cannot be resolved back to a row safely
// (snapshot names are unique per owner and can be re-created).
string id = 4;
}
message ListVmsRequest {
// Scope: empty = the caller's personal VMs; an org name = that org's VMs
// (caller must be a member). Picker-driven; defaults to personal.
string org = 1;
// When true, ignore `org` and return every VM the caller can reach: their
// personal VMs plus, for each org they belong to, that org's shared VMs and
// their own private org-billed VMs (the `can_access_vm` set), deduped. Used
// by `ssh-config`, which must index all reachable VMs regardless of the
// active context.
bool all_contexts = 2;
}
message ListVmsResponse {
repeated GetVmResponse vms = 1;
// Cluster-info: the OpenSSH public-key line of the proxy's SSH host key
// (`ssh-ed25519 AAAA...`). All VM SSH connections terminate at the proxy and
// present this key, so the CLI writes one `known_hosts` line per VM with it.
string ssh_host_key = 2;
}
// Logs and exec
message StreamLogsRequest {
string vm_id = 1;
bool follow = 2;
}
message LogChunk {
bytes data = 1;
}
message ExecChunk {
bytes data = 1;
bool stdin = 2;
bool tty = 3;
string command = 4;
string vm_id = 5;
int32 exit_code = 6;
// PTY size. On the first chunk (with `tty = true`) these set the initial
// PTY geometry. On a subsequent chunk with `window_change = true` they
// signal a terminal resize. Zero falls back to 80x24.
uint32 cols = 7;
uint32 rows = 8;
bool window_change = 9;
// Server→client: the bytes in `data` came from the subprocess's stderr.
// Defaults to false (stdout). Old servers never set this — old clients
// ignore it. PTY-mode execs merge stderr into stdout at the kernel
// level (slave_fd), so this field is only meaningful for non-PTY execs.
bool is_stderr = 10;
// Client-utilities device id of the connecting machine; the proxy injects it
// into the VM session env as BOXD_DEVICE_ID so in-VM tools reach this device.
string device_id = 11;
}
// Networks
message CreateNetworkRequest {
string name = 1;
}
message CreateNetworkResponse {
string network_id = 1;
}
message ListNetworksRequest {}
message ListNetworksResponse {
repeated NetworkInfo networks = 1;
}
message NetworkInfo {
string network_id = 1;
string subnet = 2;
string status = 3;
}
// Domains
//
// Per-VM custom domains (custom_domains table) and the per-org wildcard
// domain (org_domains table) are two independent mechanisms — see
// docs/custom-domains.md and docs/org-custom-domains.md. Both start
// `status: "pending"` the moment Add/Set is called, and a leader-gated
// control loop verifies DNS and issues certs, then flips it `"active"`;
// `last_error` carries the reason while pending. Callers are expected to
// show DNS setup instructions and get the user's confirmation that records
// are in place *before* calling Add/Set — this API does not have a
// not-yet-confirmed intermediate state.
message AddCustomDomainRequest {
string vm_id = 1; // VM id or name
string domain = 2; // full hostname, e.g. "example.com"
}
message AddCustomDomainResponse {
CustomDomainInfo domain = 1;
}
message ListCustomDomainsRequest {}
message ListCustomDomainsResponse {
repeated CustomDomainInfo domains = 1;
}
message RemoveCustomDomainRequest {
string domain = 1;
}
message RemoveCustomDomainResponse {}
message CustomDomainInfo {
string domain = 1;
string vm_id = 2;
string vm_name = 3;
string status = 4; // "pending" | "active"
string last_error = 5;
}
message SetOrgDomainRequest {
string org_id = 1;
string domain = 2; // bare apex, e.g. "preview.mysaas.com" (wildcard implied)
}
message SetOrgDomainResponse {
OrgDomainInfo domain = 1;
}
message GetOrgDomainRequest {
string org_id = 1;
}
message GetOrgDomainResponse {
// Message-typed fields already have presence in proto3 (no `optional`
// label needed) — unset (`HasField`/`has_domain()` false) if the org has
// no domain.
OrgDomainInfo domain = 1;
}
message ClearOrgDomainRequest {
string org_id = 1;
}
message ClearOrgDomainResponse {}
message OrgDomainInfo {
string org_id = 1;
string domain = 2;
string status = 3; // "pending" | "active"
string last_error = 4;
}
// Config
message GetConfigRequest {}
message GetConfigResponse {
string default_image = 1;
string zone = 2;
}
// Identity
message WhoamiRequest {}
message WhoamiResponse {
string user_id = 1;
repeated string pubkey_fingerprints = 2;
// Field 3 removed (default_network_id) — no RPC accepts a network id, so it
// was readable and unusable. Old clients decode it as "".
reserved 3;
reserved "default_network_id";
BillingInfo billing = 4;
// Human-friendly label (identity handle / display name) to show instead of
// the opaque user_id. Provider-agnostic; empty falls back to user_id.
string display_name = 5;
}
// Current billing state for the caller, mirrored from users.{stripe_*,
// subscription_status, past_due_since}. Empty stripe_*/past_due_since
// when the user has never upgraded.
message BillingInfo {
// Field 1 removed (tier — the retired 'free'|'individual' display label;
// entitlement is subscription_status + the account state). Old clients
// decode it as "" — benign.
reserved 1;
reserved "tier";
// Field 2 removed (shape). Old clients decode it as "" — benign.
reserved 2;
reserved "shape";
string subscription_status = 3; // 'active' | 'trialing' | 'past_due' | 'canceled'
// Fields 4/5 removed (stripe_customer_id, stripe_subscription_id) — payment
// processor identifiers, inert without our secret key and useless to a
// client: CreateBillingPortalSession is the sanctioned way to reach Stripe.
// Operators still see them via boxd-admin, which reads the replica directly.
// Old clients decode them as "" — benign.
reserved 4, 5;
reserved "stripe_customer_id", "stripe_subscription_id";
int64 past_due_since = 6; // 0 = not past due
uint32 max_vms = 7; // effective quota
// Effective per-VM sizing from the caller's quota columns (operator
// set-quota; platform defaults when unset). Replaces the removed shape
// label for scripts that keyed on it.
uint32 vcpu = 8;
uint64 memory_bytes = 9;
// Credit-billing balance in micro-euros (1e-6 €; e.g. 5_000_000 = €5.00).
// 0 for stripe orgs (not meaningful there).
int64 balance_micro_eur = 10;
// 'stripe' (Stripe subscription, the pre-credits default) | 'credits'
// (pay-as-you-go balance, no subscription).
string billing_mode = 11;
}
// Catalog of plans the website + console render — the single self-serve
// plan (lookup_key 'boxd-monthly'). The server is the authority on the
// catalog so the public site and the API never drift on prices.
message ListPlansRequest {}
message ListPlansResponse {
repeated PlanInfo plans = 1;
}
message PlanInfo {
// Field 1 removed (tier). Plan identity is lookup_key; size is vcpu +
// memory_bytes.
reserved 1;
reserved "tier";
// Field 2 removed (shape).
reserved 2;
reserved "shape";
uint32 monthly_eur = 3; // price in EUR cents, e.g. 2000 for €20
string lookup_key = 4; // 'boxd-monthly'
string description = 5; // human-readable
uint32 vcpu = 6;
uint64 memory_bytes = 7;
}
// Detailed billing view (same as Whoami.billing today; kept as its own
// RPC because Phase 3 will extend it with invoice history / next-renewal).
message GetBillingRequest {}
message GetBillingResponse {
BillingInfo billing = 1;
}
// Free → paid upgrade. Server: creates Stripe Customer if missing, creates
// a Checkout Session for the single plan with automatic VAT, returns the
// URL. Client redirects browser to the URL. Webhook activates the
// subscription on completion.
message CreateCheckoutSessionRequest {
// Field 1 removed (shape). There is one self-serve plan; old clients
// still sending it are ignored (unknown fields are skipped).
reserved 1;
reserved "shape";
string success_url = 2; // optional override; server has a default
string cancel_url = 3; // optional override
}
message CreateCheckoutSessionResponse {
string checkout_url = 1;
}
// Paying user → manage payment/cancel. Server creates a Stripe Customer
// Portal session bound to user.stripe_customer_id; client redirects.
message CreateBillingPortalSessionRequest {
string return_url = 1; // optional override
}
message CreateBillingPortalSessionResponse {
string portal_url = 1;
}
// Tokens
message CreateTokenRequest {
uint64 expires_in_secs = 1;
}
message CreateTokenResponse {
string token = 1;
int64 expires_at = 2;
}
message ListTokensRequest {}
message ListTokensResponse {
repeated TokenInfo tokens = 1;
}
message TokenInfo {
string jti = 1;
int64 created_at = 2;
int64 expires_at = 3;
}
message RevokeTokenRequest {
string jti = 1;
}
message RevokeTokenResponse {}
// Link an SSH public key to the caller's account. `pubkey` is the OpenSSH
// authorized_keys line (`ssh-ed25519 AAAA... [comment]`) — the verbatim
// contents of a `.pub` file. The comment is ignored.
message LinkSshKeyRequest {
string pubkey = 1;
// Optional device this key belongs to (the CLI's per-install `device_id`).
// When set, the server keeps ONE key per (user, device): re-linking from the
// same device replaces its previous key instead of accumulating stale ones.
string device_id = 2;
// Optional human label for the device (e.g. hostname), for display.
string label = 3;
}
message LinkSshKeyResponse {}
// Fork
message ForkVmRequest {
string source_vm_id = 1;
string name = 2;
VmConfig config = 3; // omit = inherit from source (all zeros)
// Forking a shared org VM yields a PRIVATE org VM of the forker by default
// (visibility dropped, org billing kept). Set true to keep the fork shared
// with the source's org. No effect when the source is personal.
bool shared = 4;
// Network tags override (BOX-41): empty = inherit the source's current
// networks (the default — matches how image_ref/org/etc. carry over).
// Non-empty replaces them outright for the fork.
repeated string networks = 5;
// Isolation override, one-way: true forces the fork isolated even when the
// source is not. False (the proto3 default, indistinguishable from unset)
// means "inherit", so the server computes `source.isolated || isolated` —
// a fork of an isolated machine can never be de-isolated by a client.
//
// Upgrading is safe because `boxd-takeoff` REMOVES the isolated surface
// rather than skipping its install: the fork mailbox carries this flag to
// the guest, which then deletes the in-VM CLI, the git credential helper,
// the agent skills and any coding-agent credentials it inherited on the
// source's disk. That is the same path `isolated` already takes on a normal
// create (which is itself a fork of a NON-isolated warm template).
bool isolated = 6;
}
message ForkVmResponse {
string vm_id = 1;
string name = 2;
string public_ip = 3;
string url = 4;
string image = 5;
string status = 6;
string forked_from = 7;
uint64 boot_time_ms = 8;
}
// Proxies
message ListProxiesRequest {
string vm_name = 1 [deprecated = true]; // use `vm`
// Machine id OR name. Empty = every proxy the caller can see.
string vm = 2;
}
message ListProxiesResponse {
repeated ProxyInfo proxies = 1;
}
message ProxyInfo {
string name = 1;
string vm_name = 2;
string domain = 3;
// The stored port: the locked port, else the auto-detected one, else 0.
// `effective_port` below is what traffic actually lands on.
uint32 port = 4;
bool is_default = 5;
// Rendered English for a terminal ("8000", "auto (8000)"). Kept for old
// clients; new ones read `port_mode` + `effective_port`.
string port_display = 6;
// The VM this route belongs to, beside `vm_name`, so a caller can address
// the machine without a name→id lookup.
string vm_id = 7;
// "locked" (the port was pinned by the user) or "auto" (discovered from the
// guest). The machine-readable half of `port_display`.
string port_mode = 8;
// The port traffic is actually forwarded to — identical to the number
// rendered inside `port_display`, and to what the proxy's
// `routing::resolve_proxy_port` picks: the locked port when
// port_mode="locked", else the detected port, falling back to 8000.
uint32 effective_port = 9;
}
// Proxy NODES (cluster infrastructure), distinct from ProxyInfo above.
message ListProxyNodesRequest {}
message ListProxyNodesResponse {
repeated ProxyNodeInfo proxies = 1;
}
message ProxyNodeInfo {
// Raft node name, e.g. "proxy-0".
string id = 1;
// Primary public IPv4. The daemon dials this directly and overrides the TLS
// server name, since proxy nodes have no per-node DNS name of their own.
string ipv4 = 2;
}
message CreateProxyRequest {
string name = 1;
string vm_name = 2 [deprecated = true]; // use `vm`
uint32 port = 3;
// Machine id OR name. Preferred over `vm_name`.
string vm = 4;
}
message CreateProxyResponse {
string name = 1;
string vm_name = 2;
string domain = 3;
uint32 port = 4;
}
message DeleteProxyRequest {
string name = 1;
string vm_name = 2 [deprecated = true]; // use `vm`
string vm = 3; // machine id OR name
}
message DeleteProxyResponse {}
message SetProxyPortRequest {
string name = 1;
string vm_name = 2 [deprecated = true]; // use `vm`
string port = 3;
string vm = 4; // machine id OR name
}
message SetProxyPortResponse {}
// File transfer
message UploadFileRequest {
string vm_id = 1;
string path = 2;
bytes data = 3;
}
message UploadFileResponse {
// Bytes the server confirmed it wrote (verified via stat after upload).
// Older servers that don't perform verification leave this as 0; CLIs
// should fall back to the local file size in that case.
uint64 bytes_written = 1;
}
// Streaming chunk for `UploadFileStream`. The first chunk on the stream
// carries `vm_id`, `path`, and `total_size` (set once); `data` may be empty
// or carry the first slice of file bytes. Subsequent chunks set `data` only
// — vm_id/path/total_size on later chunks are ignored. The stream end
// signals completion; the server has already exited its writer once
// `total_size` bytes have arrived.
message UploadFileChunk {
string vm_id = 1;
string path = 2;
uint64 total_size = 3;
bytes data = 4;
}
message DownloadFileRequest {
string vm_id = 1;
string path = 2;
}
message DownloadFileResponse {
bytes data = 1;
}
// --- Snapshots ---
message CreateSnapshotRequest {
string vm = 1; // source VM name or id (owned by the caller, must be running)
string name = 2; // snapshot name (unique per owner; re-save bumps the version)
}
message CreateSnapshotResponse {
string snapshot_id = 1;
string name = 2;
uint64 version = 3; // the version being captured
string status = 4; // "pending" until the artifact lands on the origin
}
message ListSnapshotsRequest {
// Org context (name or id); empty = the caller's active/anchor context.
// Snapshots never cross an org boundary; key-fenced callers are pinned.
string org = 1;
}
message ListSnapshotsResponse {
repeated SnapshotInfo snapshots = 1;
}
message GetSnapshotRequest {
string name = 1; // snapshot name or id
string org = 2; // org context (name or id); empty = active/anchor
}
message GetSnapshotResponse {
SnapshotInfo snapshot = 1;
}
message DeleteSnapshotRequest {
string name = 1; // snapshot name or id
string org = 2; // org context (name or id); empty = active/anchor
}
message DeleteSnapshotResponse {}
message CreateVmFromSnapshotRequest {
string snapshot = 1; // snapshot name or id
string name = 2; // new VM name (optional; auto-generated if empty)
VmConfig config = 3; // optional overrides
string org = 4; // org context (name or id); empty = active/anchor
// Isolation override, one-way — same semantics as ForkVmRequest.isolated.
// The snapshot carries the `isolated` captured off its source VM at save
// time; true here forces the restore isolated on top of that, and the
// server computes `snapshot.isolated || isolated`. Never de-isolates.
bool isolated = 5;
}
// Returns a CreateVmResponse — identical shape to `new`, so the CLI renders it
// the same way.
message SnapshotInfo {
string snapshot_id = 1;
string name = 2;
uint64 version = 3; // current (latest ready) version; 0 = none yet
string status = 4; // "pending" | "ready" | "failed"
// Fields 5/6 removed (ready_replicas, desired_replicas) — internal
// artifact-replication state. `status` already answers the only question a
// caller has: is it usable yet. Old clients decode them as 0.
reserved 5, 6;
reserved "ready_replicas", "desired_replicas";
uint64 size_bytes = 7;
int64 updated_at = 8;
uint32 vcpu = 9;
uint64 memory_bytes = 10;
uint64 use_count = 11;
// Wall-clock unix SECONDS the snapshot was first captured (`updated_at`
// above moves with every new version; this one does not).
int64 created_at = 12;
}
// --- Checkpoints ---
message CreateCheckpointRequest {
string vm = 1; // VM name or id (owned by the caller, must be running)
string name = 2; // checkpoint name (unique per VM)
}
message CreateCheckpointResponse {
string checkpoint_id = 1;
string name = 2;
string status = 3; // "pending" until the artifact lands
}
message ListCheckpointsRequest {
string vm = 1; // VM name or id (owned by the caller)
}
message ListCheckpointsResponse {
repeated CheckpointInfo checkpoints = 1;
}
message DeleteCheckpointRequest {
string vm = 1; // VM name or id
string name = 2; // checkpoint name or id
}
message DeleteCheckpointResponse {}
message RestoreCheckpointRequest {
string vm = 1; // VM name or id
string name = 2; // checkpoint name or id (must be ready and available)
}
message RestoreCheckpointResponse {}
message CheckpointInfo {
string checkpoint_id = 1;
string name = 2;
string status = 3; // "pending" | "ready" | "failed"
uint64 size_bytes = 4;
int64 created_at = 5;
string created_by = 6;
// Restorable right now: the checkpoint's worker is still the VM's worker.
// A checkpoint on a since-migrated/dead worker lists as unavailable.
bool available = 7;
}
// --- Disks ---
message CreateDiskRequest {
string name = 1;
uint64 size_bytes = 2; // disk size in bytes
// Field 3 removed (read_only) — a disk is always created writable; only
// the attachment (AttachDiskRequest.read_only) can be read-only.
reserved 3;
}
message CreateDiskResponse {
string disk_id = 1;
string name = 2;
uint64 size_bytes = 3;
string status = 4;
// Field 5 removed (read_only) — see CreateDiskRequest.
reserved 5;
}
message ListDisksRequest {}
message ListDisksResponse {
repeated DiskInfo disks = 1;
}
message DiskInfo {
string disk_id = 1;
string name = 2;
uint64 size_bytes = 3;
string status = 4;
// Field 5 removed (worker_id) — which host holds the disk is placement
// detail; exposing it leaked fleet size, the node naming scheme, and which
// of a customer's machines are co-located. Old clients decode it as "".
reserved 5;
reserved "worker_id";
repeated DiskAttachment attachments = 6;
// Field 7 removed (read_only) — a disk has no single mode; check
// DiskAttachment.mount_mode for a specific attachment's mode instead.
reserved 7;
// Wall-clock unix SECONDS the disk was created.
int64 created_at = 8;
}
message DiskAttachment {
string vm_id = 1;
string vm_name = 2;
string mount_path = 3;
string mount_mode = 4; // "ro" or "rw"
}
message AttachDiskRequest {
string disk_id = 1;
string vm_id = 2;
string mount_path = 3;
// Mount this attachment read-only. A disk is always created writable;
// this is the only place read-only is chosen. For now a disk can only be
// attached to one VM at a time (regardless of mode) — its backing file
// lives on one specific worker, not shared/networked storage, so this
// request is rejected if the disk already has any attachment.
bool read_only = 4;
}
message AttachDiskResponse {}
message DetachDiskRequest {
string disk_id = 1;
string vm_id = 2;
}
message DetachDiskResponse {}
message DestroyDiskRequest {
string disk_id = 1;
}
message DestroyDiskResponse {}
message SetAutoSuspendTimeoutRequest {
string vm_id = 1; // accepts VM name or id; resolved server-side
uint32 timeout_secs = 2; // 0 = disable
}
message SetAutoSuspendTimeoutResponse {}
message SetAutoHibernateTimeoutRequest {
string vm_id = 1; // accepts VM name or id; resolved server-side
uint32 timeout_secs = 2; // 0 = disable
}
message SetAutoHibernateTimeoutResponse {}
// --- API Keys ---
message CreateApiKeyRequest {
string name = 1;
uint64 expires_in_secs = 2; // 0 = no expiry
// Org (by name) the key is fenced to; "" = your own org. Every key is
// scoped to exactly one org — a key never grants account-wide access.
string org = 3;
// "member" (default): acts as you within the org. "org": userless service
// credential — the org's SHARED fleet only; requires org admin.
string kind = 4;
}
message CreateApiKeyResponse {
string id = 1;
string api_key = 2; // raw key, shown once — never stored
int64 expires_at = 3; // 0 = no expiry
}
message ListApiKeysRequest {}
message ListApiKeysResponse {
repeated ApiKeyInfo keys = 1;
}
message ApiKeyInfo {
string id = 1;
string name = 2;
string key_prefix = 3;
int64 created_at = 4;
int64 last_used_at = 5; // 0 = never
int64 expires_at = 6; // 0 = no expiry
string org = 7; // org name the key is fenced to
string kind = 8; // "member" | "org"
}
message DeleteApiKeyRequest {
string id = 1;
}
message DeleteApiKeyResponse {}
// --- Organizations (v2 org model) ---
message ShareVmRequest {
string vm_id = 1; // VM name or id (must be owned by the caller)
}
message ShareVmResponse {}
message ListOrgsRequest {}
message ListOrgsResponse {
repeated OrgSummary orgs = 1;
// Field 2 (active_org_id) removed — the active org context is now a
// per-device client concept (console localStorage / CLI local file).
reserved 2;
// The caller's default org (`users.default_org_id`) — no longer derivable
// client-side (default orgs have uuid ids and can change on ownership
// transfer), so the server resolves it.
string default_org_id = 3;
}
message OrgSummary {
string id = 1;
string name = 2; // display label (may be shared across orgs)
bool is_admin = 3; // caller is the org admin
string slug = 4; // unique machine key (used for resolution / `auth switch`)
// NB: no is_personal flag. There is ONE org type — a user's own org is a
// normal org, selected by name like any other (see migration 0057).
}
message GetOrgBillingRequest {
string org = 1; // org name (caller must be a member)
}
message GetOrgBillingResponse {
string id = 1;
string name = 2;
bool is_admin = 3; // caller is the org admin
// Aggregate shared quota the org's VMs draw from (NULL columns fall back
// to the platform default).
uint32 max_vms = 4;
uint32 vcpu = 5;
uint64 memory_bytes = 6;
// Non-destroyed VMs billed to the org — shared + private org-billed (the
// count enforced against max_vms).
uint32 vm_count = 7;
// Per-VM billing line items (admin only; empty for plain members). This is
// management/billing visibility, never access: an admin sees that a
// private org-billed VM exists and who owns it, but cannot reach it.
repeated OrgBilledVm vms = 8;
// Credit balance in micro-euros (1e-6 €). 0 for stripe orgs (credit
// billing never touched them) — render only when billing_mode='credits'.
// Visible to every org member: the balance is operating state, unlike the
// per-VM lines above, which stay admin-only.
int64 balance_micro_eur = 9;
// 'stripe' (subscription) | 'credits' (usage-based).
string billing_mode = 10;
// Usage accrued but not yet charged, micro-euros — priced with the
// sweeper's own integrals and rates over hours after its watermark, so the
// balance and the usage numbers can never look like they disagree.
// Display-only approximation; absent on stripe orgs and when the pricing
// read fails (render nothing rather than a wrong number).
optional int64 accruing_micro_eur = 11;
}
message OrgBilledVm {
string name = 1;
string owner = 2; // owner user_id (stable — scripts key on this)
string status = 3;
// Field 4 removed (shape). Sizing lives in the org quota columns
// (GetOrgBillingResponse.vcpu/memory_bytes).
reserved 4;
reserved "shape";
string billing = 5; // "shared" | "private"
// Human label for the owner (display name, else GitHub handle), resolved
// server-side. Empty when nothing better than the user_id is known —
// renderers fall back to `owner`. Opaque user ids (usr_…) made the raw
// id column unreadable; legacy gh-<login> ids only looked like handles.
string owner_name = 6;
}
message UnshareVmRequest {
string vm_id = 1; // VM name or id (must be owned by the caller)
}
message UnshareVmResponse {}
// NOTE: the client-utilities DeviceBridge service lives in `device.proto`
// (same package) — deliberately out of this file so the SDK regen scripts,
// which compile only api.proto, never ship it in the TS/Python stubs.