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.