> ## 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.

# gRPC API

> Raw gRPC access to the boxd public API — for any language with a protoc plugin.

The boxd CLI, SSH server, and SDKs are all clients of one thing: the public gRPC API at `boxd.sh:9443`. If your language doesn't have a boxd SDK yet, generate one from the proto file and talk to it directly.

## Endpoint

```
boxd.sh:9443
```

* **Transport:** HTTP/2 cleartext (h2c). No TLS on this port — auth is short-lived JWTs.
* **Reflection:** [gRPC server reflection](https://grpc.io/docs/guides/reflection/) is enabled, so tools like `grpcurl` work without the proto file.
* **Service:** `boxd.api.v1.BoxdApi`

## The proto

Copy the full service definition into a local `api.proto`, then generate stubs with `protoc`, [`buf`](https://buf.build), [`grpc_tools`](https://pypi.org/project/grpcio-tools/), or `@grpc/proto-loader`. The proto is `proto3` with no external imports — generation is one command.

You don't strictly need the file: since [server reflection](https://grpc.io/docs/guides/reflection/) is on, `grpcurl` and `buf curl` work directly against the endpoint, and `grpcurl -plaintext boxd.sh:9443 describe boxd.api.v1.BoxdApi` dumps the schema on demand.

<Expandable title="api.proto — full service definition">
  ```protobuf theme={"theme":"github-dark"}
  syntax = "proto3";
  package boxd.api.v1;

  service BoxdApi {
    rpc CreateVm(CreateVmRequest)             returns (CreateVmResponse);
    rpc DestroyVm(DestroyVmRequest)           returns (DestroyVmResponse);
    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);
    rpc ExposePort(ExposePortRequest)             returns (ExposePortResponse);
    rpc UnexposePort(UnexposePortRequest)         returns (UnexposePortResponse);
    rpc ListExposedPorts(ListExposedPortsRequest) returns (ListExposedPortsResponse);
    rpc StreamLogs(StreamLogsRequest)         returns (stream LogChunk);
    rpc Exec(stream ExecChunk)                returns (stream ExecChunk);
    rpc CreateNetwork(CreateNetworkRequest)   returns (CreateNetworkResponse);
    rpc ListNetworks(ListNetworksRequest)     returns (ListNetworksResponse);
    rpc BindDomain(BindDomainRequest)         returns (BindDomainResponse);
    rpc UnbindDomain(UnbindDomainRequest)     returns (UnbindDomainResponse);
    rpc ListDomains(ListDomainsRequest)       returns (ListDomainsResponse);
    rpc Whoami(WhoamiRequest)                 returns (WhoamiResponse);
    rpc CreateToken(CreateTokenRequest)       returns (CreateTokenResponse);
    rpc ListTokens(ListTokensRequest)         returns (ListTokensResponse);
    rpc RevokeToken(RevokeTokenRequest)       returns (RevokeTokenResponse);
    rpc GetConfig(GetConfigRequest)            returns (GetConfigResponse);
    rpc ForkVm(ForkVmRequest)                 returns (ForkVmResponse);
    rpc ListProxies(ListProxiesRequest)       returns (ListProxiesResponse);
    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 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 static four-shape catalog), the rest require JWT.
    rpc ListPlans(ListPlansRequest)                                   returns (ListPlansResponse);
    rpc GetBilling(GetBillingRequest)                                 returns (GetBillingResponse);
    rpc CreateCheckoutSession(CreateCheckoutSessionRequest)           returns (CreateCheckoutSessionResponse);
    rpc CreateBillingPortalSession(CreateBillingPortalSessionRequest) returns (CreateBillingPortalSessionResponse);
    rpc ChangeShape(ChangeShapeRequest)                               returns (ChangeShapeResponse);
  }

  // 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)
  }

  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 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 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)
  }

  message ListVmsRequest {}
  message ListVmsResponse {
    repeated GetVmResponse vms = 1;
  }

  // Raw TCP/UDP port forwards. Allocates a public port (40000–60000) on the VM's
  // proxy, DNAT-forwarded to vm_port inside the VM. Up to 3 forwards per VM.
  message ExposePortRequest  { string vm_id = 1; uint32 vm_port = 2; string protocol = 3; }  // protocol: "tcp" | "udp" | "both"
  // 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;
  }
  // 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; uint32 vm_port = 2; }
  message UnexposePortResponse { ExposePortResponse forward = 1; }
  message ListExposedPortsRequest  {}
  message ListExposedPortsResponse { repeated ExposePortResponse forwards = 1; }

  // 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
  message BindDomainRequest {
    string domain = 1;
    string vm_id  = 2;
  }

  message BindDomainResponse {}

  message UnbindDomainRequest {
    string domain = 1;
  }

  message UnbindDomainResponse {}

  message ListDomainsRequest {}
  message ListDomainsResponse {
    repeated DomainInfo domains = 1;
  }

  message DomainInfo {
    string domain = 1;
    string vm_id  = 2;
  }

  // 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;
    string          default_network_id  = 3;
    BillingInfo     billing             = 4;
  }

  // Current billing state for the caller, mirrored from users.{tier, shape,
  // stripe_*, subscription_status, past_due_since}. Empty stripe_*/past_due_since
  // when the user has never upgraded; trial_end_unix is set during the 14-day
  // Stripe-native trial.
  message BillingInfo {
    string tier                   = 1;  // 'free' | 'individual'
    string shape                  = 2;  // '2x8' | '4x16' | '8x32' | '16x64'
    string subscription_status    = 3;  // 'active' | 'trialing' | 'past_due' | 'canceled'
    string stripe_customer_id     = 4;  // empty when never upgraded
    string stripe_subscription_id = 5;  // empty when on free tier
    int64  past_due_since         = 6;  // 0 = not past due
    uint32 max_vms                = 7;  // effective quota
  }

  // Static catalog of plans the website + console render. Mirrors the
  // Stripe lookup_key set (individual-2x8 … individual-16x64). 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 {
    string tier          = 1;  // 'free' | 'individual'
    string shape         = 2;  // '2x8' | '4x16' | '8x32' | '16x64'
    uint32 monthly_eur   = 3;  // price in EUR cents, e.g. 4000 for €40
    string lookup_key    = 4;  // 'individual-2x8' …
    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
  // Checkout Session for the requested shape with 14-day trial + automatic
  // VAT, returns the URL. Client redirects browser to the URL. Webhook
  // activates the subscription on completion (Phase 4).
  message CreateCheckoutSessionRequest {
    string shape       = 1;  // '4x16' | '8x32' | '16x64' (2x8 is free, not chargeable)
    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;
  }

  // In-place shape swap (e.g. 4x16 → 8x32). Server calls
  // Subscriptions.update on Stripe with the new price + proration. Webhook
  // commits the new shape to Raft (Phase 4).
  message ChangeShapeRequest {
    string shape = 1;  // '2x8' | '4x16' | '8x32' | '16x64'
  }
  message ChangeShapeResponse {
    string shape  = 1;  // echoed back from Stripe response
    string status = 2;  // 'trialing' | 'active' | ...
  }

  // 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 {}

  // Fork
  message ForkVmRequest {
    string   source_vm_id = 1;
    string   name         = 2;
    VmConfig config       = 3;  // omit = inherit from source (all zeros)
  }

  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;
  }

  message ListProxiesResponse {
    repeated ProxyInfo proxies = 1;
  }

  message ProxyInfo {
    string name         = 1;
    string vm_name      = 2;
    string domain       = 3;
    uint32 port         = 4;
    bool   is_default   = 5;
    string port_display = 6;
  }

  message CreateProxyRequest {
    string name    = 1;
    string vm_name = 2;
    uint32 port    = 3;
  }

  message CreateProxyResponse {
    string name    = 1;
    string vm_name = 2;
    string domain  = 3;
    uint32 port    = 4;
  }

  message DeleteProxyRequest {
    string name    = 1;
    string vm_name = 2;
  }

  message DeleteProxyResponse {}

  message SetProxyPortRequest {
    string name    = 1;
    string vm_name = 2;
    string port    = 3;
  }

  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;
  }

  // --- Disks ---

  message CreateDiskRequest {
    string name       = 1;
    uint64 size_bytes = 2;  // disk size in bytes
  }

  message CreateDiskResponse {
    string disk_id    = 1;
    string name       = 2;
    uint64 size_bytes = 3;
    string status     = 4;
  }

  message ListDisksRequest {}

  message ListDisksResponse {
    repeated DiskInfo disks = 1;
  }

  message DiskInfo {
    string disk_id    = 1;
    string name       = 2;
    uint64 size_bytes = 3;
    string status     = 4;
    string worker_id  = 5;
    repeated DiskAttachment attachments = 6;
  }

  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;
    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
  }

  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
  }

  message DeleteApiKeyRequest {
    string id = 1;
  }

  message DeleteApiKeyResponse {}

  // 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.
  ```
</Expandable>

## Authentication

Two-step: long-lived API key → short-lived JWT → bearer token on every gRPC call.

### 1. Create an API key

API keys are issued from the boxd console. Sign in at [boxd.sh](https://boxd.sh), open the API keys page, and create one. The raw key is shown **once** — copy it immediately. Format: `bxd_` followed by \~40 base62 characters.

You can also create them via the CLI once you're logged in:

```bash theme={"theme":"github-dark"}
boxd auth login                         # GitHub OAuth
boxd auth keys create my-app
```

### 2. Exchange for a JWT

Send the API key to the exchange endpoint over HTTPS:

```bash theme={"theme":"github-dark"}
curl -X POST https://boxd.sh/api/v1/auth/token \
  -H "Content-Type: application/json" \
  -d '{"api_key":"bxd_..."}'
```

```json theme={"theme":"github-dark"}
{
  "token": "eyJhbGciOi...",
  "expires_at": 1735689600,
  "user_id": "gh-username"
}
```

The JWT is short-lived (default \~1 hour). Re-exchange before expiry. Rate-limited per source IP.

### 3. Send it on every gRPC call

```
authorization: Bearer eyJhbGciOi...
```

Standard gRPC metadata. Most generated clients expose this as a per-call interceptor or call option.

## Hello world

<Tabs>
  <Tab title="grpcurl">
    Easiest way to verify auth and reachability — no proto file needed (server reflection is on).

    ```bash theme={"theme":"github-dark"}
    # Set your JWT
    export BOXD_JWT="$(curl -s -X POST https://boxd.sh/api/v1/auth/token \
      -H 'Content-Type: application/json' \
      -d '{"api_key":"bxd_..."}' | jq -r .token)"

    # Whoami
    grpcurl -plaintext \
      -H "authorization: Bearer $BOXD_JWT" \
      boxd.sh:9443 boxd.api.v1.BoxdApi/Whoami

    # Create a VM
    grpcurl -plaintext \
      -H "authorization: Bearer $BOXD_JWT" \
      -d '{"name": "hello-grpc"}' \
      boxd.sh:9443 boxd.api.v1.BoxdApi/CreateVm

    # List VMs
    grpcurl -plaintext \
      -H "authorization: Bearer $BOXD_JWT" \
      boxd.sh:9443 boxd.api.v1.BoxdApi/ListVms
    ```

    Install: `brew install grpcurl` or [github.com/fullstorydev/grpcurl](https://github.com/fullstorydev/grpcurl).
  </Tab>

  <Tab title="Go">
    Save the proto from above as `gen/api.proto`, then generate stubs:

    ```bash theme={"theme":"github-dark"}
    cd gen
    protoc --go_out=. --go_opt=paths=source_relative \
           --go-grpc_out=. --go-grpc_opt=paths=source_relative \
           api.proto
    ```

    Hello world (`main.go`):

    ```go theme={"theme":"github-dark"}
    package main

    import (
        "context"
        "encoding/json"
        "fmt"
        "log"
        "net/http"
        "strings"

        boxdv1 "yourmodule/gen"
        "google.golang.org/grpc"
        "google.golang.org/grpc/credentials/insecure"
        "google.golang.org/grpc/metadata"
    )

    func exchange(apiKey string) string {
        body := strings.NewReader(`{"api_key":"` + apiKey + `"}`)
        resp, err := http.Post("https://boxd.sh/api/v1/auth/token",
            "application/json", body)
        if err != nil { log.Fatal(err) }
        defer resp.Body.Close()
        var out struct{ Token string }
        json.NewDecoder(resp.Body).Decode(&out)
        return out.Token
    }

    func main() {
        jwt := exchange("bxd_...")

        conn, err := grpc.NewClient("boxd.sh:9443",
            grpc.WithTransportCredentials(insecure.NewCredentials()))
        if err != nil { log.Fatal(err) }
        defer conn.Close()

        client := boxdv1.NewBoxdApiClient(conn)
        ctx := metadata.AppendToOutgoingContext(context.Background(),
            "authorization", "Bearer "+jwt)

        me, err := client.Whoami(ctx, &boxdv1.WhoamiRequest{})
        if err != nil { log.Fatal(err) }
        fmt.Println("user:", me.UserId)

        vm, err := client.CreateVm(ctx, &boxdv1.CreateVmRequest{
            Name: "hello-grpc",
        })
        if err != nil { log.Fatal(err) }
        fmt.Printf("created %s at %s (%dms)\n",
            vm.Name, vm.Url, vm.BootTimeMs)
    }
    ```
  </Tab>

  <Tab title="Node / TypeScript">
    Save the proto from above as `./api.proto`, then install runtime deps:

    ```bash theme={"theme":"github-dark"}
    npm install @grpc/grpc-js @grpc/proto-loader
    ```

    Hello world (`hello.ts`) — uses dynamic loading, no codegen step:

    ```typescript theme={"theme":"github-dark"}
    import * as grpc from '@grpc/grpc-js';
    import * as protoLoader from '@grpc/proto-loader';

    async function exchange(apiKey: string): Promise<string> {
      const r = await fetch('https://boxd.sh/api/v1/auth/token', {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify({ api_key: apiKey }),
      });
      const { token } = await r.json();
      return token;
    }

    async function main() {
      const jwt = await exchange('bxd_...');

      const def = protoLoader.loadSync('./api.proto', {
        keepCase: true, longs: String, enums: String, defaults: true,
      });
      const proto = grpc.loadPackageDefinition(def) as any;

      const meta = new grpc.Metadata();
      meta.set('authorization', `Bearer ${jwt}`);

      const client = new proto.boxd.api.v1.BoxdApi(
        'boxd.sh:9443',
        grpc.credentials.createInsecure(),
      );

      client.Whoami({}, meta, (err: any, me: any) => {
        if (err) throw err;
        console.log('user:', me.user_id);

        client.CreateVm({ name: 'hello-grpc' }, meta, (err: any, vm: any) => {
          if (err) throw err;
          console.log(`created ${vm.name} at ${vm.url} (${vm.boot_time_ms}ms)`);
        });
      });
    }

    main();
    ```
  </Tab>

  <Tab title="Python">
    Save the proto from above as `./api.proto`, then:

    ```bash theme={"theme":"github-dark"}
    pip install grpcio grpcio-tools requests
    python -m grpc_tools.protoc -I. --python_out=. --grpc_python_out=. api.proto
    ```

    Hello world (`hello.py`):

    ```python theme={"theme":"github-dark"}
    import grpc
    import requests
    import api_pb2 as pb
    import api_pb2_grpc as rpc

    def exchange(api_key: str) -> str:
        r = requests.post(
            "https://boxd.sh/api/v1/auth/token",
            json={"api_key": api_key},
        )
        r.raise_for_status()
        return r.json()["token"]

    def main():
        jwt = exchange("bxd_...")
        meta = (("authorization", f"Bearer {jwt}"),)

        with grpc.insecure_channel("boxd.sh:9443") as ch:
            client = rpc.BoxdApiStub(ch)

            me = client.Whoami(pb.WhoamiRequest(), metadata=meta)
            print("user:", me.user_id)

            vm = client.CreateVm(pb.CreateVmRequest(name="hello-grpc"), metadata=meta)
            print(f"created {vm.name} at {vm.url} ({vm.boot_time_ms}ms)")

    if __name__ == "__main__":
        main()
    ```
  </Tab>
</Tabs>

## Errors

Standard gRPC status codes. The most common ones you'll hit:

| Code                 | When                                                               |
| -------------------- | ------------------------------------------------------------------ |
| `UNAUTHENTICATED`    | Missing/malformed/expired JWT, or `authorization` metadata not set |
| `NOT_FOUND`          | VM, disk, template, or domain doesn't exist                        |
| `RESOURCE_EXHAUSTED` | Per-user VM quota reached                                          |
| `INVALID_ARGUMENT`   | Bad request shape (e.g. invalid VM name, conflicting fields)       |
| `INTERNAL`           | Server-side error                                                  |

The error message in `Status.message()` is human-readable and safe to surface to users.

## Streaming RPCs

Three RPCs use streams:

* `StreamLogs` — server-streaming, emits `LogChunk` messages as the VM produces output. Set `follow=true` to keep the stream open after current logs flush.
* `UploadFileStream` — client-streaming, lifts the 4 MiB single-message gRPC cap of `UploadFile`. The first `UploadFileChunk` carries `vm_id`, `path`, and `total_size`; subsequent chunks carry bytes only. The server reads exactly `total_size` bytes and returns the confirmed byte count.
* `Exec` — bidirectional. First message must contain `vm_id` and `command`; subsequent client messages with `stdin=true` pipe stdin, or `window_change=true` with `cols`/`rows` resize a PTY. Server messages carry `data` with `is_stderr` set to `true` for stderr chunks and `false` (default) for stdout. PTY-mode execs merge stderr into stdout at the kernel, so `is_stderr` is only set for non-PTY execs. Final server message has `exit_code` set. Clients close their send half of the bidi stream to signal stdin EOF to the subprocess (the proxy translates this to `CHANNEL_EOF` on the underlying SSH channel).

## What's next

<Columns cols={2}>
  <Card title="CLI" icon="https://mintcdn.com/azin/Ax1V0serIwQf0x_2/images/icons/command.svg?fit=max&auto=format&n=Ax1V0serIwQf0x_2&q=85&s=6c33d9e29e4e937c0950311233ec5659" href="/reference/external-cli" width="16" height="16" data-path="images/icons/command.svg">
    Same API, no codegen — useful for shell scripting and one-offs.
  </Card>

  <Card title="Primitives: Machines" icon="https://mintcdn.com/azin/Ax1V0serIwQf0x_2/images/icons/server-square.svg?fit=max&auto=format&n=Ax1V0serIwQf0x_2&q=85&s=1bbbc4c3c59a2e4c73bca3d24a58fb2c" href="/how-it-works/vms" width="16" height="16" data-path="images/icons/server-square.svg">
    Concepts behind `CreateVm` / `ForkVm` / `SuspendVm`.
  </Card>
</Columns>
