> ## Documentation Index
> Fetch the complete documentation index at: https://docs.boxd.sh/llms.txt
> Use this file to discover all available pages before exploring further.

# Proto

> The full api.proto service definition, ready to copy and generate stubs from.

The complete `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`](https://buf.build), [`grpc_tools`](https://pypi.org/project/grpcio-tools/), or `@grpc/proto-loader` is one command.

You don't strictly need the file. [Server reflection](https://grpc.io/docs/guides/reflection/) is on, so the schema can be inferred straight from the endpoint. `grpcurl` and `buf curl` work against it directly, and `grpcurl -plaintext 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](/reference/grpc-api).

```protobuf theme={"theme":"github-dark"}
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.
```
