Templates

What a computer can be built from, and the sizes it can be built at.

Base URL https://app.mandala.computer/api/v1. Every request carries an API key, and failures take the shape under Errors.

GET/templates

List templatesviewer+

The images a computer can be built from, with the CPU and memory each one defaults to and the disk it cannot be built below. For the templates we publish, name is what you pass as template to create; for one you published, pass its ref — a short name is not resolved to your templates and falls back to the default template, or, while this list is short and carries X-GC-Incomplete, is refused with a 503 (see template on POST /computers).

If a host cannot be reached the list may be short, and then carries X-GC-Incomplete; this listing answers with what it has rather than a 503.

A template is an image rather than an operating system — hermes, claude and openclaw are the base Linux desktop with an agent already installed, coding is the same desktop with ten coding-agent CLIs on it (Claude Code, Codex, Gemini CLI, Qwen Code, opencode, goose, Cline, Aider, Open Interpreter and a Cursor CLI installer), managed-agents runs Anthropic’s Claude Managed Agents self-hosted worker as a service once its environment key is bound as a secret, and omarchy is a different desktop entirely (Arch + Hyprland) — so read label rather than assuming this list is a choice of OS. Only the templates your plan may launch are returned.

omarchy is a Wayland desktop, and that is worth knowing before you drive one. Screenshots, input and exec behave exactly as they do everywhere else: they are taken at the hypervisor, below the guest, so nothing about them changes. The window, window-action and clipboard routes are answered through the compositor instead of through X, which shows up in two places — a window id is the compositor address rather than an X window id (still opaque, still handed back the same way), and moving or resizing a window the compositor is TILING is refused rather than silently ignored, because a tiled window’s geometry belongs to the layout. Float it first.

Set MANDALA_API_KEY to your bearer key. MANDALA_BASE_URL, when set, is the full API base including /api/v1. Replace the illustrative resource IDs with yours. These pinned 0.16.0 SDK and direct HTTP examples do not read saved device-login credentials.

curl

base_url="${MANDALA_BASE_URL:-}"
if [ -z "$base_url" ]; then base_url='https://app.mandala.computer/api/v1'; fi
curl --fail-with-body "${base_url%/}"'/templates' \
  -H "Authorization: Bearer $MANDALA_API_KEY"

Python — Mandala SDK 0.16.0

# Python 3.10+: pip install mandala-computer==0.16.0
import os
from mandala_computer import Client

with Client(api_key=os.environ["MANDALA_API_KEY"], base_url=os.environ.get("MANDALA_BASE_URL") or "https://app.mandala.computer/api/v1") as client:
    result = client.templates.list()
    print(result)

TypeScript — Mandala SDK 0.16.0

// Node.js 22+: npm install mandala-computer@0.16.0
import { Client } from "mandala-computer";

const apiKey = process.env.MANDALA_API_KEY;
if (!apiKey) throw new Error("Set MANDALA_API_KEY");
const client = new Client({apiKey, baseUrl: process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1"});
const result = await client.templates.listWithStatus();
console.log(JSON.stringify(result, null, 2));

200 Illustrative successful response.

HTTP 200
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17

[
  {
    "name": "base",
    "ref": "system/base@1.2.0",
    "label": "Base Desktop (Linux)",
    "os": "linux",
    "icon": "debian",
    "cpu": 2,
    "ram_mb": 2048,
    "disk_gb": 20
  },
  {
    "name": "research-desktop",
    "ref": "acc-d5e6f7a8b9c0d1e2/research-desktop@1.0.0",
    "label": "Research desktop",
    "os": "linux",
    "cpu": 2,
    "ram_mb": 2048,
    "disk_gb": 20
  }
]

401 Platform bearer credential missing.

HTTP 401
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
WWW-Authenticate: Bearer

{
  "error": "Authentication required",
  "reason": "missing",
  "request_id": "3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17"
}

401 Platform bearer credential invalid.

HTTP 401
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
WWW-Authenticate: Bearer error="invalid_token"

{
  "error": "invalid API key",
  "reason": "invalid",
  "request_id": "3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17"
}

401 Platform bearer credential revoked.

HTTP 401
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
WWW-Authenticate: Bearer error="invalid_token"

{
  "error": "API key revoked",
  "reason": "revoked",
  "request_id": "3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17"
}

405 Unsupported method on this public path; HEAD returns these headers without a body.

HTTP 405
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
Allow: GET, HEAD, POST, OPTIONS

{
  "error": "method not allowed",
  "request_id": "3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17"
}

Response

Every template available to your account.

Response fields

namestring

The short name of the template, and what a computer reports as its own template. Accepted as template on a create for the templates we publish; one you published is named by its ref.

refstring

The pinned namespace/name@version for this template. Also accepted as template on a create, and the form to use when it matters that you get exactly this template and this version — a short name resolves to whatever the host currently has under it.

labelstring

Human-readable name.

osstring

linux. (windows is not currently offered on any plan.)

desktopstring

The display protocol this template’s desktop speaks: wayland, x11, or absent (X11, or a host too old to say); branch on === "wayland". Screenshots, input and exec are identical either way — they are taken below the guest — but two things differ on a Wayland template. A window id is the compositor’s address rather than an X window id (opaque and handed back the same way, but not comparable across the two), and moving or resizing a window the compositor is TILING is refused rather than silently ignored, because a tiled window’s geometry belongs to the layout. Absent also on a host deployed before this field existed, which is why it is not defaulted to x11 here.

iconstring

A slug naming a mark this console has an asset for, e.g. claude or debian. Cosmetic — nothing here validates it against a list, and an unrecognised value or none just means the template has no particular icon.

cpuinteger

The default this template builds at.

ram_mbinteger
disk_gbinteger

The FLOOR this template builds at, not a default like the two above — a create asking for less is raised to this and charged at it.

GET/templates/schema

Get the template schemaviewer+

The JSON Schema for a mandala/v1 template document — the declarative form a template is written in. Like every route here it needs an API key, so an editor or a $ref resolver pointed at this URL with no Authorization header gets a 401. Fetch it with a key — client.templates.schema(), or curl -H "Authorization: Bearer $MANDALA_API_KEY" — save it to a file, and point your editor at that local copy for completion and validation while you write one.

The document describes what a template IS: its ref (namespace/name@version), the image family it resolves to, what it is layered onto, and the shape a computer gets when the create names no numbers. It describes how one is BUILT as well: spec.build is applied to the parent named by spec.from — apt installs, shell steps, files and directories, in the order written — and spec.env is baked into the image as a profile script, and spec.secrets names secrets a build step may read — by id, never by value. And it can give the computers created from it a default browser proxy, spec.browser_proxy — {server, bypass} under the rules of the create field of that name, which a create may override or clear with null. What it still does NOT describe is a build from nothing (every document layers over a parent; a base is built by a script outside this format) or lifecycle hooks, because nothing runs them: a field arrives here only once something executes it, which is why adding one is not a new apiVersion and a document written today keeps its meaning.

Set MANDALA_API_KEY to your bearer key. MANDALA_BASE_URL, when set, is the full API base including /api/v1. Replace the illustrative resource IDs with yours. These pinned 0.16.0 SDK and direct HTTP examples do not read saved device-login credentials.

curl

base_url="${MANDALA_BASE_URL:-}"
if [ -z "$base_url" ]; then base_url='https://app.mandala.computer/api/v1'; fi
curl --fail-with-body "${base_url%/}"'/templates/schema' \
  -H "Authorization: Bearer $MANDALA_API_KEY"

Python — Mandala SDK 0.16.0

# Python 3.10+: pip install mandala-computer==0.16.0
import os
from mandala_computer import Client

with Client(api_key=os.environ["MANDALA_API_KEY"], base_url=os.environ.get("MANDALA_BASE_URL") or "https://app.mandala.computer/api/v1") as client:
    result = client.templates.schema()
    print(result)

TypeScript — Mandala SDK 0.16.0

// Node.js 22+: npm install mandala-computer@0.16.0
import { Client } from "mandala-computer";

const apiKey = process.env.MANDALA_API_KEY;
if (!apiKey) throw new Error("Set MANDALA_API_KEY");
const client = new Client({apiKey, baseUrl: process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1"});
const result = await client.templates.schema();
console.log(JSON.stringify(result, null, 2));

200 Complete template JSON Schema response.

HTTP 200
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17

{
  "$id": "https://app.mandala.computer/api/v1/templates/schema",
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "additionalProperties": false,
  "description": "A launch template: what a computer is built from and the shape it gets when the create names no numbers. mandala/v1 describes how one is BUILT as well: `spec.build` is applied to the parent named by `spec.from`, step by step in the order written, and `spec.env` is written into the image's profile. What is still absent is a build from nothing — every document layers over a parent — and lifecycle hooks, because nothing runs them; a field arrives under this apiVersion only once something executes it, and adding one does not make a v2.",
  "properties": {
    "apiVersion": {
      "const": "mandala/v1",
      "description": "The document format. A new value here means a field that already existed has changed meaning; fields being added does not."
    },
    "kind": {
      "const": "Template",
      "description": "Always Template."
    },
    "metadata": {
      "additionalProperties": false,
      "description": "What the template is called. Nothing here reaches the image.",
      "properties": {
        "label": {
          "description": "What a person is shown in the catalogue. Optional; a template with no label is shown by its name.",
          "maxLength": 64,
          "type": "string"
        },
        "name": {
          "description": "The template's name within its namespace. This is what a create's `template` field carries today.",
          "maxLength": 63,
          "not": {
            "pattern": "\\s"
          },
          "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?$",
          "type": "string"
        },
        "namespace": {
          "description": "The publisher this template belongs to. `system` is the catalogue that ships with the product.",
          "maxLength": 63,
          "not": {
            "pattern": "\\s"
          },
          "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?$",
          "type": "string"
        },
        "version": {
          "description": "MAJOR.MINOR.PATCH. A published ref is immutable: republishing namespace/name@version with any change at all is refused, so a change means a new version. Prerelease and build metadata are not accepted — they would be two names for one document.",
          "not": {
            "pattern": "\\s"
          },
          "pattern": "^(0|[1-9][0-9]{0,8})\\.(0|[1-9][0-9]{0,8})\\.(0|[1-9][0-9]{0,8})$",
          "type": "string"
        }
      },
      "required": [
        "namespace",
        "name",
        "version"
      ],
      "type": "object"
    },
    "spec": {
      "additionalProperties": false,
      "description": "What the template is.",
      "properties": {
        "build": {
          "description": "What to do to the parent image, in order. Order is the semantics: the steps are applied in the sequence written. A template with no build steps is one whose image a script produced — see `family`.",
          "items": {
            "oneOf": [
              {
                "additionalProperties": false,
                "properties": {
                  "apt": {
                    "additionalProperties": false,
                    "description": "Install Debian packages.",
                    "properties": {
                      "packages": {
                        "items": {
                          "not": {
                            "pattern": "\\s"
                          },
                          "pattern": "^[a-z0-9][a-z0-9+.-]*[a-z0-9+](:[a-z0-9][a-z0-9-]*)?(=[A-Za-z0-9.+:~-]+|/[a-z0-9.-]+)?$",
                          "type": "string"
                        },
                        "minItems": 1,
                        "type": "array"
                      },
                      "recommends": {
                        "description": "Install recommended packages too. Defaults to false, which is what you almost always want — a toolchain that drags in a documentation browser is a bigger image and a bigger attack surface. A desktop is the case that wants true.",
                        "type": "boolean"
                      }
                    },
                    "required": [
                      "packages"
                    ],
                    "type": "object"
                  }
                },
                "required": [
                  "apt"
                ],
                "type": "object"
              },
              {
                "additionalProperties": false,
                "properties": {
                  "run": {
                    "additionalProperties": false,
                    "description": "Run a shell script inside the image.",
                    "properties": {
                      "as": {
                        "description": "Run as this user, through a login shell. Omit for root. Getting this wrong is the most consequential mistake in a build of this kind: an installer run as root puts the user's data in /root, where the desktop session — which logs in as someone else — will never see it, and the image looks installed and behaves uninstalled.",
                        "not": {
                          "pattern": "\\s"
                        },
                        "pattern": "^$|^[a-z_][a-z0-9_-]*$",
                        "type": "string"
                      },
                      "script": {
                        "not": {
                          "pattern": "\u0000"
                        },
                        "pattern": "[^\\s…]",
                        "type": "string"
                      }
                    },
                    "required": [
                      "script"
                    ],
                    "type": "object"
                  }
                },
                "required": [
                  "run"
                ],
                "type": "object"
              },
              {
                "additionalProperties": false,
                "properties": {
                  "file": {
                    "additionalProperties": false,
                    "description": "Write a file into the image.",
                    "properties": {
                      "content": {
                        "type": "string"
                      },
                      "mode": {
                        "description": "Octal, as a STRING — YAML reads a bare 0644 as the decimal 644, which is a mode nobody meant and chmod would accept.",
                        "not": {
                          "pattern": "\\s"
                        },
                        "pattern": "^$|^0[0-7]{3}$",
                        "type": "string"
                      },
                      "owner": {
                        "description": "`user:group`. Omit for root:root.",
                        "not": {
                          "pattern": "\\s"
                        },
                        "pattern": "^$|^[a-z_][a-z0-9_-]*:[a-z_][a-z0-9_-]*$",
                        "type": "string"
                      },
                      "path": {
                        "not": {
                          "pattern": "(^|/)\\.\\.?(/|$)"
                        },
                        "pattern": "^/([^/\\x00:]+/)*[^/\\x00:]+$",
                        "type": "string"
                      }
                    },
                    "required": [
                      "path"
                    ],
                    "type": "object"
                  }
                },
                "required": [
                  "file"
                ],
                "type": "object"
              },
              {
                "additionalProperties": false,
                "properties": {
                  "mkdir": {
                    "additionalProperties": false,
                    "description": "Create a directory, with parents.",
                    "properties": {
                      "path": {
                        "not": {
                          "pattern": "(^|/)\\.\\.?(/|$)"
                        },
                        "pattern": "^/([^/\\x00:]+/)*[^/\\x00:]+$",
                        "type": "string"
                      }
                    },
                    "required": [
                      "path"
                    ],
                    "type": "object"
                  }
                },
                "required": [
                  "mkdir"
                ],
                "type": "object"
              }
            ]
          },
          "type": [
            "array",
            "null"
          ]
        },
        "desktop": {
          "description": "The display protocol this image's desktop speaks. Omit it for X11, which is what every Linux image built before this field runs. It decides which guest scripts the window, window-action and clipboard routes use: an X11 image is read with xprop and xdotool, a Wayland one through its compositor. Setting it wrongly does not break the desktop — it makes those three routes refuse, because the tools they reach for are not the ones the guest has.",
          "enum": [
            "x11",
            "wayland"
          ]
        },
        "env": {
          "additionalProperties": {
            "not": {
              "pattern": "[\n\u0000]"
            },
            "pattern": "^[^\n\u0000]*$",
            "type": "string"
          },
          "description": "Environment variables baked into the image, written as a script in `/etc/profile.d` so every login shell in every session sees them. Rendered in key order, so the same document always produces the same bytes.",
          "propertyNames": {
            "not": {
              "pattern": "\\s"
            },
            "pattern": "^[A-Za-z_][A-Za-z0-9_]*$"
          },
          "type": [
            "object",
            "null"
          ]
        },
        "family": {
          "description": "The golden image family this template resolves to, without a version suffix — the version a computer is pinned to is chosen at launch from the host's `<family>.current` pointer, so `golden-xfce` and never `golden-xfce-v6`. This field is the seam between a document and an image a build script produced, and the compiler removes it: a content-addressed template is named by its build digest. A document that declares build steps has to name a family of your OWN — `golden-<your account id>`, or that and a `-` and a name of your choosing — because a build WRITES into the family it names; a document with no steps names a family to launch FROM and is under no such rule. That is not checked here: this daemon has no accounts, so the answer comes from the control plane on publish and on build.",
          "not": {
            "pattern": "-v[0-9]+$|\\s"
          },
          "pattern": "^golden-[a-z0-9]([a-z0-9-]*[a-z0-9])?$",
          "type": "string"
        },
        "from": {
          "description": "The template this one is layered onto, as `namespace/name`. Records lineage, and is deliberately unversioned: the build follows the base family's `.current` pointer rather than a pin, so a version here would assert a guarantee the build does not make.\n\nA document that also declares `spec.build` or `spec.env` may only name a parent in the `system` namespace, and is refused otherwise — at validate and at publish. A build layers onto the parent's IMAGE, and a hypervisor resolves a parent only in the catalogue compiled into it: a build request carries the child document alone, so there is nothing that could tell it what `acme/base` is. Layering onto a template of your own is not supported yet. A document with no build steps is under no such rule — it records lineage for an image something else produced, and may name any parent.",
          "not": {
            "pattern": "\\s"
          },
          "pattern": "^(?:$|[a-z0-9]([a-z0-9-]*[a-z0-9])?/[a-z0-9]([a-z0-9-]*[a-z0-9])?)$",
          "type": "string"
        },
        "hardware": {
          "additionalProperties": false,
          "description": "The shape a computer gets when the create names no numbers. Not a constraint — what a computer may actually be given is bounded by the plan, and any size the plan allows is reachable at create or by a later resize.",
          "properties": {
            "cpu": {
              "description": "Virtual CPUs.",
              "maximum": 2147483647,
              "minimum": 1,
              "type": "integer"
            },
            "disk_gb": {
              "description": "Disk, in GiB. Unlike cpu and ram_mb this is a FLOOR rather than a default: a computer's disk is an overlay over the family's image and cannot be smaller than it, so a create asking for less is raised to this and charged for it.",
              "maximum": 2147483647,
              "minimum": 1,
              "type": "integer"
            },
            "ram_mb": {
              "description": "Memory, in MiB.",
              "maximum": 2147483647,
              "minimum": 512,
              "type": "integer"
            }
          },
          "required": [
            "cpu",
            "ram_mb",
            "disk_gb"
          ],
          "type": "object"
        },
        "icon": {
          "description": "A slug naming a mark the console has an asset for (\"claude\", \"debian\"), shown on the templates grid in place of a generic desktop icon. Purely cosmetic and never validated against a list published here: an unrecognised value, or none, just falls back to the plain icon. Not a URL — a document points at one of a small set the console ships assets for, rather than handing it an image to fetch and render.",
          "maxLength": 32,
          "not": {
            "pattern": "\\s"
          },
          "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?$",
          "type": "string"
        },
        "os": {
          "description": "The guest's operating system. This is the field a plan is checked against: an account whose plan does not admit the OS cannot launch the template and is not shown it.",
          "enum": [
            "linux",
            "windows"
          ]
        },
        "readiness": {
          "additionalProperties": false,
          "description": "How this template says it has finished coming up: a command run in the guest, polled until it exits zero. Readiness is a property of what YOU installed — a desktop that has drawn, a server that is listening — and only you can state it, which is why there is a field for it rather than a heuristic. Omit it and the host will not capture a booted machine of this template, because nothing can tell it when to look; declare one and a launch resumes a running machine instead of cold-booting. What is checked HERE is the SHAPE — that there is a command, that `as` is a user name, that the timeout is in range. Whether the command can ever report your template ready is a question about your image, and nothing here runs it: `exec: false` is a valid document and a check that never passes.",
          "properties": {
            "as": {
              "description": "Run the check as this user, through a login shell. Omit for root. A desktop's readiness is almost never visible to root — `systemctl --user`, the session bus and the process list of a graphical session all belong to the account that logged in.",
              "not": {
                "pattern": "\\s"
              },
              "pattern": "^$|^[a-z_][a-z0-9_-]*$",
              "type": "string"
            },
            "exec": {
              "description": "The command, run in the guest with `bash`. Exit zero means ready. A port check and an HTTP probe are both this — `ss -ltn | grep -q :8080`, `curl -fsS localhost:8080/health` — which is why there is one field here and not three. Without `as` it runs as root and does NOT get a login shell, so it inherits the guest agent's environment rather than a profile's — name binaries by path, or set `as` and get a login shell for that user.",
              "not": {
                "pattern": "\u0000"
              },
              "pattern": "[^\\s…]",
              "type": "string"
            },
            "timeout_sec": {
              "description": "How long to keep asking before giving up, in seconds. Omit it, or give 0, for the default of 300 seconds; the most that can be asked for is 1800. It is a ceiling and not an expectation: the poll stops at the first exit zero, so a template ready in ten seconds waits ten seconds whatever this says.",
              "maximum": 1800,
              "minimum": 0,
              "type": "integer"
            }
          },
          "required": [
            "exec"
          ],
          "type": [
            "object",
            "null"
          ]
        }
      },
      "required": [
        "os",
        "family",
        "hardware"
      ],
      "type": "object"
    }
  },
  "required": [
    "apiVersion",
    "kind",
    "metadata",
    "spec"
  ],
  "title": "Mandala Computer template",
  "type": "object"
}

401 Platform bearer credential missing.

HTTP 401
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
WWW-Authenticate: Bearer

{
  "error": "Authentication required",
  "reason": "missing",
  "request_id": "3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17"
}

401 Platform bearer credential invalid.

HTTP 401
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
WWW-Authenticate: Bearer error="invalid_token"

{
  "error": "invalid API key",
  "reason": "invalid",
  "request_id": "3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17"
}

401 Platform bearer credential revoked.

HTTP 401
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
WWW-Authenticate: Bearer error="invalid_token"

{
  "error": "API key revoked",
  "reason": "revoked",
  "request_id": "3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17"
}

405 Unsupported method on this public path; HEAD returns these headers without a body.

HTTP 405
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
Allow: GET, HEAD, OPTIONS

{
  "error": "method not allowed",
  "request_id": "3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17"
}

Response

The schema, as JSON Schema 2020-12.

POST/templates/validate

Check a template documentmember+

Check a template document against the schema and the rules the publish path applies, without publishing anything. Nothing is stored and no ref is claimed, so this is safe to call on a draft and safe to call repeatedly.

The body is the document itself, as JSON or YAML — not a JSON wrapper around it. Send Content-Type: application/yaml or application/json; either is read.

A document that is wrong is reported with every problem one pass can reach rather than the first, so a file with four mistakes takes one call — see problems in the response for the one case where fixing a problem reveals another. On success the two digests come back: doc_digest identifies the document and changes with anything that changes what it MEANS, and build_digest covers only what decides the image — so a new label or a version bump leaves it alone. build_digest is present only for a document with no spec.from, and such a document builds nothing: spec.build and spec.env need a parent. A document that names a parent in spec.from — which is every document that builds — has no build_digest, because that cannot be computed without the parent image’s, so comparing it cannot tell you whether an edit to such a document means a rebuild.

Set MANDALA_API_KEY to your bearer key. MANDALA_BASE_URL, when set, is the full API base including /api/v1. Replace the illustrative resource IDs with yours. These pinned 0.16.0 SDK and direct HTTP examples do not read saved device-login credentials. Supply template.yaml containing your template document (JSON or YAML); publishing requires metadata.namespace to be your account ID. A build must also name your own family.

curl

base_url="${MANDALA_BASE_URL:-}"
if [ -z "$base_url" ]; then base_url='https://app.mandala.computer/api/v1'; fi
curl --fail-with-body -X POST "${base_url%/}"'/templates/validate' \
  -H "Authorization: Bearer $MANDALA_API_KEY" \
  -H "Content-Type: application/yaml" \
  --data-binary @template.yaml

Python — Mandala SDK 0.16.0

# Python 3.10+: pip install mandala-computer==0.16.0
import os
from pathlib import Path
from mandala_computer import Client

with Client(api_key=os.environ["MANDALA_API_KEY"], base_url=os.environ.get("MANDALA_BASE_URL") or "https://app.mandala.computer/api/v1") as client:
    document = Path("template.yaml").read_text(encoding="utf-8")
    result = client.templates.validate(document)
    print(result)

TypeScript — Mandala SDK 0.16.0

// Node.js 22+: npm install mandala-computer@0.16.0
import { Client } from "mandala-computer";
import { readFile } from "node:fs/promises";

const apiKey = process.env.MANDALA_API_KEY;
if (!apiKey) throw new Error("Set MANDALA_API_KEY");
const client = new Client({apiKey, baseUrl: process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1"});
const document = await readFile("template.yaml", "utf8");
const result = await client.templates.validate(document);
console.log(JSON.stringify(result, null, 2));

200 Illustrative successful response.

HTTP 200
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17

{
  "valid": true,
  "ref": "acc-d5e6f7a8b9c0d1e2/research-desktop@1.0.0",
  "doc_digest": "sha256:aa74e60a334f7128ec1fa919958c6a9d62f15b35bf46c0bb47b60e90a320413c",
  "template": {
    "name": "research-desktop",
    "ref": "acc-d5e6f7a8b9c0d1e2/research-desktop@1.0.0",
    "label": "Research desktop",
    "os": "linux",
    "cpu": 2,
    "ram_mb": 2048,
    "disk_gb": 20
  },
  "canonical": "{\"apiVersion\":\"mandala/v1\",\"kind\":\"Template\",\"metadata\":{\"namespace\":\"acc-d5e6f7a8b9c0d1e2\",\"name\":\"research-desktop\",\"version\":\"1.0.0\",\"label\":\"Research desktop\"},\"spec\":{\"os\":\"linux\",\"family\":\"golden-acc-d5e6f7a8b9c0d1e2-research-desktop\",\"from\":\"system/base\",\"build\":[{\"apt\":{\"packages\":[\"jq\"]}}],\"hardware\":{\"cpu\":2,\"ram_mb\":2048,\"disk_gb\":20}}}",
  "build_digest_needs": "the contents of system/base's image, which only a host holding that image can compute; the digest is computed when the build runs"
}

401 Platform bearer credential missing.

HTTP 401
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
WWW-Authenticate: Bearer

{
  "error": "Authentication required",
  "reason": "missing",
  "request_id": "3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17"
}

401 Platform bearer credential invalid.

HTTP 401
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
WWW-Authenticate: Bearer error="invalid_token"

{
  "error": "invalid API key",
  "reason": "invalid",
  "request_id": "3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17"
}

401 Platform bearer credential revoked.

HTTP 401
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
WWW-Authenticate: Bearer error="invalid_token"

{
  "error": "API key revoked",
  "reason": "revoked",
  "request_id": "3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17"
}

405 Unsupported method on this public path; HEAD returns these headers without a body.

HTTP 405
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
Allow: POST, OPTIONS

{
  "error": "method not allowed",
  "request_id": "3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17"
}

Response

valid is what you branch on, and both branches are 200: an invalid document is an answer to the question, not a failed request. false brings problems and nothing else; true brings the ref, the digests, the catalogue row and the canonical bytes.

Three of the fields below — template, canonical and build_digest_needs — were on this wire and in no reader’s copy of it until OPL-4179, which wrote them out here in prose. OPL-4190 gave the response a schema, so the table is now held against the code that produces it rather than kept in step by hand. In the same change template stopped carrying the on-host image family, which is ours rather than yours and was already projected away on every other route that answers a catalogue row.

Response fields

validalwaysboolean

Whether the DOCUMENT is well formed — the schema and the rules that read the file itself. It is not a publish preflight: a valid document is still refused by POST /templates for things only your account decides, and none of them are visible here — a namespace that is not yours, a plan that does not carry publishing, a ref already taken, the per-account ceiling, an os that contradicts the family the document names, a spec.hardware.disk_gb below the disk floor of the family or of the spec.from parent (400), and a document with spec.build steps or spec.env naming a family that is not yours (403).

problemsstring[]

Present when valid is false. A document that could not be PARSED has exactly one entry, because nothing after the syntax error was read. A document that parsed is reported with every problem one pass can reach rather than the first, so a file with four mistakes in four fields takes one call — with one exception worth knowing: a build step that is structurally wrong (it does nothing, or carries more than one operation) is reported as that, and the problems INSIDE it are not looked at until you have fixed it. Each names what is wrong in the author’s own vocabulary.

refstring

namespace/name@version, as the document’s metadata spells it.

doc_digeststring

Identifies the document, as sha256:…. Taken over canonical below — so it changes with anything that changes what the document MEANS, a label included, and NOT with comments, key order, indentation or YAML-versus-JSON, none of which survive parsing. Being over canonical is also what lets you check it yourself.

templateTemplate

The catalogue row this document describes — the same shape GET /templates lists.

canonicalstring

The document as the digests were taken over it, with key order and whitespace normalised. Two files differing only in comments and key order are the same document and hash the same.

build_digeststring

Covers only what decides the image, as sha256:… — so a new label or a version bump leaves it alone. Present only for a document with no spec.from, which builds nothing (spec.build and spec.env need a parent), so it cannot tell you whether an edit to a document that builds means a rebuild. Replaced by build_digest_needs on a document with a parent.

build_digest_needsstring

Replaces build_digest on a document that names a parent in spec.from: a sentence saying what could not be computed. A build digest needs an identity for the CONTENTS of the base image, which is a fact about a host holding it rather than anything in your document.

POST/templates

Publish a templatemember+

Store a template document under a ref of your own, so POST /computers can launch it by name.

ACCOUNT-WIDE KEYS ONLY. Templates are the account’s catalogue, not a workspace’s: an API key confined to a workspace can launch a computer from a template by its ref and list them with GET /templates, but publishing, reading one of the account’s documents back and retiring are a 403 for it.

THE NAMESPACE IS YOUR ACCOUNT. metadata.namespace has to be your account id, which is the namespace part of the ref (<account id>/name@version) on every template GET /templates shows you as yours; anything else is a 403, including system, which is reserved for the templates we publish. Your templates are private: no other account can see or launch them, and there is no registry.

A REF IS IMMUTABLE. namespace/name@version names one document for ever. Publishing the identical document again succeeds and changes nothing, so a pipeline that republishes on every commit is safe; publishing a DIFFERENT document under the same ref is a 409, and the fix is to bump metadata.version. What counts as different is the digest, so a changed label is a change.

FOR EVER SURVIVES RETIRING. A ref you have retired with DELETE /templates/{namespace}/{name} is still spoken for: publishing it again is a 409 that names the date it went, and that is true even of the identical bytes. Retiring frees the ROW, which is what the ceiling counts — it does not free the name.

There is a ceiling on how many templates one account holds, and a publish past it is a 409 that says where you stand. Retire a version you no longer launch and publish again.

There is a SECOND, much larger ceiling on how many refs one account may ever claim — live and retired together. Retiring does not clear that one, because a retired ref can never be published again and so still counts. A 409 naming it is the one refusal here you cannot fix from your side; get in touch and we will raise it. refs_claimed on a retire response is where you stand.

NONE OF THESE FOUR 409s IS WORTH SENDING AGAIN AS IT IS, and each says so in reason: a different document under a taken ref and a retired ref carry reason: "exists" (bump metadata.version), and both ceilings carry reason: "unsupported" (retire a template, or get in touch).

The body is the document itself, as JSON or YAML, exactly as POST /templates/validate takes it — and validating first is worth it while you are iterating, since that route reports every problem at once and claims no ref. A document that is not valid is a 400 here, carrying the same list: {error: "That document is not valid.", problems: [...]}, every problem the validator found.

A spec.hardware.disk_gb below the disk floor of the family, or of the spec.from parent, is a 400 naming the floor. The validator does not check this; publish a new version with at least that much.

PUBLISHING IS NOT BUILDING, and what you can launch today follows from that. A document naming a spec.family the fleet already has an image for is launchable the moment it is published — which is how you give yourself one of our templates with your own label, sizing and defaults on it, and it needs no build at all.

A DOCUMENT THAT DECLARES spec.build STEPS OR spec.env HAS TO NAME A FAMILY OF YOUR OWN, because a build WRITES into the family it names, and naming one that is not yours is a 403 — the same answer, and the same rule, as POST /builds. Yours are the ones named after your account: golden-<your account id>, or that and a - and a name of your choosing. A document with neither is under no such rule — it names a family to launch FROM, and naming one of ours is the point of it.

A DOCUMENT THAT DECLARES spec.build STEPS OR spec.env MAY ONLY LAYER ONTO ONE OF OURS. spec.from has to name a template in the system namespace, and anything else is a 400 — here and at POST /templates/validate, which reports it first. A build layers onto the parent’s IMAGE, and a hypervisor resolves a parent only in the catalogue compiled into it: a build request carries the child document alone, so nothing there could work out what your-account/base is. Layering onto a template of your own is not supported yet. Note the asymmetry with the family rule above, because the two read alike and point opposite ways: the family you WRITE into must be yours, and the template you BUILD ONTO must be ours. A document with neither spec.build steps nor spec.env is under neither rule, and may record any spec.from it likes.

After a successful POST /builds, create a computer using the published template ref. Each pinned template version selects the highest image version successfully built from that exact document; an unpinned ref first selects the newest published template version. Image versions are allocated fleet-wide and never reused after cleanup. A forced rebuild can advance the image selected for future creates; existing computers keep their original image. If no verified holder has capacity, a create prepares an on-demand replica and returns 409 with code: template_image_preparing, progress in preparation, and a template_transfer retry token. Repeat the create after Retry-After seconds with that token to keep the exact selected build. No computer is created while preparing. Placement uses only hosts with verified matching bytes and does not fall back to an older image if the selected build is unavailable. A missing, withdrawn or not-yet-verified image returns 409; an incomplete fleet build inventory returns 503. Builds made before custom-image launch support need the same document submitted once more to record its launch provenance; the normal reuse path can reuse matching image bytes.

Set MANDALA_API_KEY to your bearer key. MANDALA_BASE_URL, when set, is the full API base including /api/v1. Replace the illustrative resource IDs with yours. These pinned 0.16.0 SDK and direct HTTP examples do not read saved device-login credentials. Supply template.yaml containing your template document (JSON or YAML); publishing requires metadata.namespace to be your account ID. A build must also name your own family.

curl

base_url="${MANDALA_BASE_URL:-}"
if [ -z "$base_url" ]; then base_url='https://app.mandala.computer/api/v1'; fi
curl --fail-with-body -X POST "${base_url%/}"'/templates' \
  -H "Authorization: Bearer $MANDALA_API_KEY" \
  -H "Content-Type: application/yaml" \
  --data-binary @template.yaml

Python — Mandala SDK 0.16.0

# Python 3.10+: pip install mandala-computer==0.16.0
import os
from pathlib import Path
from mandala_computer import Client

with Client(api_key=os.environ["MANDALA_API_KEY"], base_url=os.environ.get("MANDALA_BASE_URL") or "https://app.mandala.computer/api/v1") as client:
    document = Path("template.yaml").read_text(encoding="utf-8")
    result = client.templates.publish(document)
    print(result)

TypeScript — Mandala SDK 0.16.0

// Node.js 22+: npm install mandala-computer@0.16.0
import { Client } from "mandala-computer";
import { readFile } from "node:fs/promises";

const apiKey = process.env.MANDALA_API_KEY;
if (!apiKey) throw new Error("Set MANDALA_API_KEY");
const client = new Client({apiKey, baseUrl: process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1"});
const document = await readFile("template.yaml", "utf8");
const result = await client.templates.publish(document);
console.log(JSON.stringify(result, null, 2));

201 Illustrative successful response.

HTTP 201
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17

{
  "ref": "acc-d5e6f7a8b9c0d1e2/research-desktop@1.0.0",
  "doc_digest": "sha256:aa74e60a334f7128ec1fa919958c6a9d62f15b35bf46c0bb47b60e90a320413c",
  "document": {
    "apiVersion": "mandala/v1",
    "kind": "Template",
    "metadata": {
      "namespace": "acc-d5e6f7a8b9c0d1e2",
      "name": "research-desktop",
      "version": "1.0.0",
      "label": "Research desktop"
    },
    "spec": {
      "os": "linux",
      "family": "golden-acc-d5e6f7a8b9c0d1e2-research-desktop",
      "from": "system/base",
      "build": [
        {
          "apt": {
            "packages": [
              "jq"
            ]
          }
        }
      ],
      "hardware": {
        "cpu": 2,
        "ram_mb": 2048,
        "disk_gb": 20
      }
    }
  },
  "template": {
    "name": "research-desktop",
    "ref": "acc-d5e6f7a8b9c0d1e2/research-desktop@1.0.0",
    "label": "Research desktop",
    "os": "linux",
    "cpu": 2,
    "ram_mb": 2048,
    "disk_gb": 20
  },
  "versions": [
    "1.0.0"
  ],
  "published_at": "2026-09-16T12:00:00.000Z"
}

401 Platform bearer credential missing.

HTTP 401
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
WWW-Authenticate: Bearer

{
  "error": "Authentication required",
  "reason": "missing",
  "request_id": "3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17"
}

401 Platform bearer credential invalid.

HTTP 401
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
WWW-Authenticate: Bearer error="invalid_token"

{
  "error": "invalid API key",
  "reason": "invalid",
  "request_id": "3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17"
}

401 Platform bearer credential revoked.

HTTP 401
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
WWW-Authenticate: Bearer error="invalid_token"

{
  "error": "API key revoked",
  "reason": "revoked",
  "request_id": "3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17"
}

405 Unsupported method on this public path; HEAD returns these headers without a body.

HTTP 405
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
Allow: GET, HEAD, POST, OPTIONS

{
  "error": "method not allowed",
  "request_id": "3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17"
}

Response

The template as stored. document is the canonical form — the bytes doc_digest is over — so it may differ from what you sent in key order and whitespace, and not in meaning.

Response fields

refstring

namespace/name@version. What you pass as template to create a computer.

doc_digeststring

Identifies the document, as sha256:…. Two publishes of the same digest are the same template, which is what makes republishing an unchanged document a no-op rather than a conflict.

documentobject

The document itself, in its canonical form — the bytes doc_digest is over. Key order and whitespace may differ from what you sent; nothing else does.

templateTemplate

The catalogue row this document describes — the same shape GET /templates lists.

versionsstring[]

Every version of this template, newest first. Read one with ?version=.

published_atstring

RFC 3339 timestamp. Absent on a template we publish.

Failures

403

Also the answer, with no reason, to any API key confined to a workspace: templates are the account’s.

GET/templates/{namespace}/{name}

Get a templatemember+

One template, as the document it was written as — which is the half GET /templates drops: the lineage in spec.from, the build steps, and the digest.

Works for your own namespace and for system, so you can read what you are layering onto — though note that a document with spec.build steps or spec.env may only layer onto a system template, so reading your own is for inspecting it rather than for building on it. See POST /templates. Another account’s namespace is a 404, the same answer a name that does not exist gets.

Your account’s own namespace needs an account-wide key: an API key confined to a workspace is a 403 there, because the document is the account’s and may carry its build steps and scripts. It can still read system templates.

Without version this is the newest published version of that name — which is also what a create naming the unpinned namespace/name resolves to. versions lists the rest, newest first.

A ref you have RETIRED is still a 404 — there is no document to return — but the message names the date it went rather than telling you the template never existed. Same for a name whose every version has been retired.

Set MANDALA_API_KEY to your bearer key. MANDALA_BASE_URL, when set, is the full API base including /api/v1. Replace the illustrative resource IDs with yours. These pinned 0.16.0 SDK and direct HTTP examples do not read saved device-login credentials.

curl

base_url="${MANDALA_BASE_URL:-}"
if [ -z "$base_url" ]; then base_url='https://app.mandala.computer/api/v1'; fi
curl --fail-with-body "${base_url%/}"'/templates/system/base' \
  -H "Authorization: Bearer $MANDALA_API_KEY"

Python — Mandala SDK 0.16.0

# Python 3.10+: pip install mandala-computer==0.16.0
import os
from mandala_computer import Client

with Client(api_key=os.environ["MANDALA_API_KEY"], base_url=os.environ.get("MANDALA_BASE_URL") or "https://app.mandala.computer/api/v1") as client:
    namespace = "system"
    name = "base"
    result = client.templates.get(namespace, name)
    print(result)

TypeScript — Mandala SDK 0.16.0

// Node.js 22+: npm install mandala-computer@0.16.0
import { Client } from "mandala-computer";

const apiKey = process.env.MANDALA_API_KEY;
if (!apiKey) throw new Error("Set MANDALA_API_KEY");
const client = new Client({apiKey, baseUrl: process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1"});
const namespace = "system";
const name = "base";
const result = await client.templates.get(namespace, name);
console.log(JSON.stringify(result, null, 2));

200 Illustrative successful response.

HTTP 200
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17

{
  "ref": "system/base@1.2.0",
  "doc_digest": "sha256:e751ce0d9fd496887370989733757bed5645560a0cbdb5c99aeac6d22b9d75a8",
  "document": {
    "apiVersion": "mandala/v1",
    "kind": "Template",
    "metadata": {
      "namespace": "system",
      "name": "base",
      "version": "1.2.0",
      "label": "Base Desktop (Linux)"
    },
    "spec": {
      "os": "linux",
      "family": "golden-xfce",
      "icon": "debian",
      "readiness": {
        "exec": "/usr/bin/pgrep -x xfdesktop >/dev/null && sleep 2 && /usr/bin/pgrep -x xfdesktop >/dev/null",
        "timeout_sec": 240
      },
      "hardware": {
        "cpu": 2,
        "ram_mb": 2048,
        "disk_gb": 20
      }
    }
  },
  "template": {
    "name": "base",
    "ref": "system/base@1.2.0",
    "label": "Base Desktop (Linux)",
    "os": "linux",
    "icon": "debian",
    "cpu": 2,
    "ram_mb": 2048,
    "disk_gb": 20
  },
  "versions": [
    "1.2.0"
  ]
}

401 Platform bearer credential missing.

HTTP 401
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
WWW-Authenticate: Bearer

{
  "error": "Authentication required",
  "reason": "missing",
  "request_id": "3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17"
}

401 Platform bearer credential invalid.

HTTP 401
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
WWW-Authenticate: Bearer error="invalid_token"

{
  "error": "invalid API key",
  "reason": "invalid",
  "request_id": "3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17"
}

401 Platform bearer credential revoked.

HTTP 401
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
WWW-Authenticate: Bearer error="invalid_token"

{
  "error": "API key revoked",
  "reason": "revoked",
  "request_id": "3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17"
}

405 Unsupported method on this public path; HEAD returns these headers without a body.

HTTP 405
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
Allow: GET, HEAD, DELETE, OPTIONS

{
  "error": "method not allowed",
  "request_id": "3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17"
}

Path parameters

namespacerequiredstring

Your account id, or system for the templates we publish.

namerequiredstring

The template’s metadata.name — the part of the ref between the slash and the @.

Query parameters

versionstring

A specific MAJOR.MINOR.PATCH. Omit it for the newest. Sending it EMPTY or malformed is a 400 rather than a silent default — the same rule DELETE applies, where it matters much more.

Response

The template.

Response fields

refstring

namespace/name@version. What you pass as template to create a computer.

doc_digeststring

Identifies the document, as sha256:…. Two publishes of the same digest are the same template, which is what makes republishing an unchanged document a no-op rather than a conflict.

documentobject

The document itself, in its canonical form — the bytes doc_digest is over. Key order and whitespace may differ from what you sent; nothing else does.

templateTemplate

The catalogue row this document describes — the same shape GET /templates lists.

versionsstring[]

Every version of this template, newest first. Read one with ?version=.

published_atstring

RFC 3339 timestamp. Absent on a template we publish.

Failures

403

Also the answer, with no reason, to an API key confined to a workspace asking for a template in your account’s own namespace.

DELETE/templates/{namespace}/{name}

Retire a templatemember+

Retire a template you published, so it stops resolving and stops counting against your ceiling.

WITH version this retires that one version. WITHOUT it, this retires EVERY version of the name — which is what “retire this template” means, and is deliberately not the read route’s “the newest”: a delete that quietly took the latest one would let a script walk backwards through a history it never asked about.

COMPUTERS ARE NOT AFFECTED. A computer is built from the IMAGE the ref resolved to and holds no reference to the document, so anything already running, stopped or suspended is untouched, before and after. What a retire breaks is resolution: a NEW create naming the ref is refused.

THE REF IS STILL SPOKEN FOR, AND STILL COUNTS ONCE. Retiring frees the row the templates ceiling counts; it does not free the name, and it does not reduce refs_claimed — the much larger ceiling on refs an account may ever claim. Publishing namespace/name@version again after retiring it is a 409, identical bytes included — a ref that resolves, then does not, then does again is worse than either. Publish the next version instead.

A ref that was never yours is a 403, including system. One you already retired is a 404 that says when it went. An API key confined to a workspace is a 403 whatever it names: retiring is for an account-wide key.

OMITTING version AND SENDING IT EMPTY ARE DIFFERENT. ?version= — which is what most clients send for an unset optional string — is a 400, not a request to retire the whole name. So is any value that is not a MAJOR.MINOR.PATCH. Leaving the parameter off the URL entirely is the only way to ask for every version.

Set MANDALA_API_KEY to your bearer key. MANDALA_BASE_URL, when set, is the full API base including /api/v1. Replace the illustrative resource IDs with yours. These pinned 0.16.0 SDK and direct HTTP examples do not read saved device-login credentials.

curl

base_url="${MANDALA_BASE_URL:-}"
if [ -z "$base_url" ]; then base_url='https://app.mandala.computer/api/v1'; fi
curl --fail-with-body -X DELETE "${base_url%/}"'/templates/acc-d5e6f7a8b9c0d1e2/research-desktop?version=1.0.0' \
  -H "Authorization: Bearer $MANDALA_API_KEY"

Python — Mandala SDK 0.16.0

# Python 3.10+: pip install mandala-computer==0.16.0
import os
from mandala_computer import Client

with Client(api_key=os.environ["MANDALA_API_KEY"], base_url=os.environ.get("MANDALA_BASE_URL") or "https://app.mandala.computer/api/v1") as client:
    namespace = "acc-d5e6f7a8b9c0d1e2"
    name = "research-desktop"
    query = {"version":"1.0.0"}
    result = client.templates.retire(namespace, name, version=query["version"])
    print(result)

TypeScript — Mandala SDK 0.16.0

// Node.js 22+: npm install mandala-computer@0.16.0
import { Client } from "mandala-computer";

const apiKey = process.env.MANDALA_API_KEY;
if (!apiKey) throw new Error("Set MANDALA_API_KEY");
const client = new Client({apiKey, baseUrl: process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1"});
const namespace = "acc-d5e6f7a8b9c0d1e2";
const name = "research-desktop";
const query = {"version":"1.0.0"};
const result = await client.templates.retire(namespace, name, {version: query.version});
console.log(JSON.stringify(result, null, 2));

200 Illustrative successful response.

HTTP 200
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17

{
  "retired": [
    "acc-d5e6f7a8b9c0d1e2/research-desktop@1.0.0"
  ],
  "retired_at": "2026-09-16T12:00:01.000Z",
  "versions": [],
  "templates": 0,
  "refs_claimed": 1
}

401 Platform bearer credential missing.

HTTP 401
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
WWW-Authenticate: Bearer

{
  "error": "Authentication required",
  "reason": "missing",
  "request_id": "3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17"
}

401 Platform bearer credential invalid.

HTTP 401
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
WWW-Authenticate: Bearer error="invalid_token"

{
  "error": "invalid API key",
  "reason": "invalid",
  "request_id": "3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17"
}

401 Platform bearer credential revoked.

HTTP 401
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
WWW-Authenticate: Bearer error="invalid_token"

{
  "error": "API key revoked",
  "reason": "revoked",
  "request_id": "3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17"
}

405 Unsupported method on this public path; HEAD returns these headers without a body.

HTTP 405
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
Allow: GET, HEAD, DELETE, OPTIONS

{
  "error": "method not allowed",
  "request_id": "3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17"
}

Path parameters

namespacerequiredstring

Your account id, or system for the templates we publish.

namerequiredstring

The template’s metadata.name — the part of the ref between the slash and the @.

Query parameters

versionstring

A specific MAJOR.MINOR.PATCH. Omit the parameter entirely to retire every version of this name; sending it empty or malformed is a 400.

Response

What went, what is left of the name, and how many templates the account now holds.

Response fields

retiredstring[]

The refs that were retired, newest version first. Never empty — an empty retire is a 404.

retired_atstring

RFC 3339 timestamp. One value: everything in retired went in the same write.

versionsstring[]

The versions of this name still published, newest first. Empty means the name is gone — nothing resolves namespace/name any more.

templatesinteger

How many templates the account holds now. This is the number the per-account ceiling is against.

refs_claimedinteger

How many refs this account has ever claimed, live and retired together. It does NOT go down when you retire — a retired ref still counts, because it can never be published again — and there is a much larger ceiling on it than on templates. Reported here because this is the only place the two numbers can be seen moving differently.

Failures

403

Also the answer, with no reason, to any API key confined to a workspace: templates are the account’s.

GET/sizes

List sizesviewer+

The named sizes a computer can be launched at — each is a template plus a CPU/RAM/disk shape. These are the shapes hosts keep pre-booted, so a create that names one is typically answered from the warm pool in about a second; a custom shape boots cold in ten or so. Pass one as size on POST /computers. The catalogue is not a constraint: sending explicit cpu/ram_mb/disk_gb instead remains allowed, and a resize can take a computer anywhere your plan permits afterwards.

Set MANDALA_API_KEY to your bearer key. MANDALA_BASE_URL, when set, is the full API base including /api/v1. Replace the illustrative resource IDs with yours. These pinned 0.16.0 SDK and direct HTTP examples do not read saved device-login credentials.

curl

base_url="${MANDALA_BASE_URL:-}"
if [ -z "$base_url" ]; then base_url='https://app.mandala.computer/api/v1'; fi
curl --fail-with-body "${base_url%/}"'/sizes' \
  -H "Authorization: Bearer $MANDALA_API_KEY"

Python — Mandala SDK 0.16.0

# Python 3.10+: pip install mandala-computer==0.16.0
import os
from mandala_computer import Client

with Client(api_key=os.environ["MANDALA_API_KEY"], base_url=os.environ.get("MANDALA_BASE_URL") or "https://app.mandala.computer/api/v1") as client:
    result = client.sizes.list()
    print(result)

TypeScript — Mandala SDK 0.16.0

// Node.js 22+: npm install mandala-computer@0.16.0
import { Client } from "mandala-computer";

const apiKey = process.env.MANDALA_API_KEY;
if (!apiKey) throw new Error("Set MANDALA_API_KEY");
const client = new Client({apiKey, baseUrl: process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1"});
const result = await client.sizes.list();
console.log(JSON.stringify(result, null, 2));

200 Illustrative successful response.

HTTP 200
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17

[
  {
    "id": "small",
    "label": "Small",
    "template": "base",
    "cpu": 2,
    "ram_mb": 2048,
    "disk_gb": 20,
    "allowed": true,
    "cheapest_plan": "solo"
  },
  {
    "id": "standard",
    "label": "Standard",
    "template": "base",
    "cpu": 2,
    "ram_mb": 4096,
    "disk_gb": 20,
    "allowed": true,
    "cheapest_plan": "solo"
  },
  {
    "id": "large",
    "label": "Large",
    "template": "base",
    "cpu": 4,
    "ram_mb": 8192,
    "disk_gb": 40,
    "allowed": true,
    "cheapest_plan": "solo"
  },
  {
    "id": "hermes-standard",
    "label": "Standard",
    "template": "hermes",
    "cpu": 2,
    "ram_mb": 4096,
    "disk_gb": 20,
    "allowed": true,
    "cheapest_plan": "solo"
  },
  {
    "id": "claude-standard",
    "label": "Standard",
    "template": "claude",
    "cpu": 2,
    "ram_mb": 4096,
    "disk_gb": 20,
    "allowed": true,
    "cheapest_plan": "solo"
  },
  {
    "id": "claude-large",
    "label": "Large",
    "template": "claude",
    "cpu": 4,
    "ram_mb": 8192,
    "disk_gb": 40,
    "allowed": true,
    "cheapest_plan": "solo"
  },
  {
    "id": "omarchy-standard",
    "label": "Standard",
    "template": "omarchy",
    "cpu": 2,
    "ram_mb": 4096,
    "disk_gb": 20,
    "allowed": true,
    "cheapest_plan": "solo"
  },
  {
    "id": "openclaw-standard",
    "label": "Standard",
    "template": "openclaw",
    "cpu": 2,
    "ram_mb": 4096,
    "disk_gb": 20,
    "allowed": true,
    "cheapest_plan": "solo"
  }
]

401 Platform bearer credential missing.

HTTP 401
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
WWW-Authenticate: Bearer

{
  "error": "Authentication required",
  "reason": "missing",
  "request_id": "3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17"
}

401 Platform bearer credential invalid.

HTTP 401
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
WWW-Authenticate: Bearer error="invalid_token"

{
  "error": "invalid API key",
  "reason": "invalid",
  "request_id": "3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17"
}

401 Platform bearer credential revoked.

HTTP 401
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
WWW-Authenticate: Bearer error="invalid_token"

{
  "error": "API key revoked",
  "reason": "revoked",
  "request_id": "3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17"
}

405 Unsupported method on this public path; HEAD returns these headers without a body.

HTTP 405
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
Allow: GET, HEAD, OPTIONS

{
  "error": "method not allowed",
  "request_id": "3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17"
}

Response

The catalogue, with what your plan admits.

Response fields

idstring

What to pass as size on POST /computers.

labelstring

Human-readable name.

templatestring

The template this row builds — it comes with the size, so do not send template alongside.

cpuinteger
ram_mbinteger
disk_gbinteger
allowedboolean

Whether your plan’s per-computer ceilings admit this size. What the account already holds is not counted here — a create can still be refused against the plan’s pools, with the refusal naming the pool.

cheapest_planstring or null

The id of the cheapest plan whose ceilings admit this size — the plan to name when allowed is false. Null if no purchasable plan admits it.

Failures

403

A suspended account may still make this read: a 403 here is about the credential or the person holding it, never the account’s suspension.