---
title: "Build a template"
method: POST
path: "/builds"
tags: ["Builds"]
---

# Build a template

`POST /builds`

## Request

- Base URL: `https://app.mandala.computer/api/v1`
- URL: `https://app.mandala.computer/api/v1/builds`
- Auth: HTTP bearer

Compile a template document into a golden image, and return immediately with a job to watch. A build takes minutes — an agent image is roughly fifteen — so this never blocks: the answer is `202` with an `id`, and `GET /builds/{id}` says what became of it.

ACCOUNT-WIDE KEYS ONLY. Builds are the account’s, like the templates they build: an API key confined to a workspace is a `403` on this route and on every other `/builds` route, and can launch a computer from a published template by its ref instead.

The body is the document itself, as JSON or YAML, exactly as `POST /templates/validate` takes it. Validate first if you are iterating; a document that is wrong is refused here too, but the validator tells you every problem at once.

THE NAMESPACE AND THE FAMILY BOTH HAVE TO BE YOURS, and either one is a `403`. `metadata.namespace` has to be your account id, the same rule `POST /templates` states. `spec.family` is what the built image is CALLED on a hypervisor, in a directory shared with every computer on that machine — so a build may only write into a family named after your account: `golden-<your account id>`, or that and a `-` and a name of your choosing. The message names the family it refused.

THE PARENT HAS TO BE ONE OF OURS. `spec.from` on a document with `spec.build` steps or `spec.env` must name a `system` template, and anything else is a `400` rather than a `403`: it is not a permission, it is that no hypervisor can resolve it. A build layers onto the parent’s image and the only catalogue a hypervisor searches is the one compiled into it, while this request carries your document alone — so there is nothing that could tell it what `your-account/base` is. Layering onto a template of your own is not supported yet.

SECRETS ARE NAMED, NEVER WRITTEN. `spec.secrets` lists the secrets a build step may read: each entry is the `id` of one of yours and the `as` it appears under as an environment variable. The value is not in the document and never travels in it — a template document is text you publish, copy and review, and a value written there is a value in a file. The values are delivered to the build over a channel of their own and a step sees them only as those variables: we put them in no argument, no staged file and no line of the build log. What a step does with them afterwards is yours — a step that prints its own environment prints the value, and the build log is readable through `GET /builds/{id}/progress`.

A document may name at most 32 secrets; more is a `400` here. `POST /templates/validate` does not check that limit, so a document it passes can still be refused for it.

A reference is resolved in YOUR scope at the moment you submit, and the revision current then is the one the build uses: your account-wide secrets. A reference that does not resolve in that scope — a workspace’s, another account’s, one that has been deleted — is a `400` carrying ONE sentence, the same sentence for all three, because which of them it was is not something an answer should tell you. That is the only refusal that is deliberately indistinct: a malformed reference gets a `400` saying what is wrong with it, a secret whose value cannot be delivered gets a `409`, and a deployment with no keyring configured gets a `503`. Retry logic wants those apart — the `503` is worth retrying, and the two `400`s and that `409` are not. The secret `409` carries `reason: "unsupported"` and is final until the secret is replaced: its value cannot be read under this control plane’s keyring, or is not text an environment variable can carry, or the secret was deleted or left the build’s scope while the request was being admitted. Replace the secret (or name another) and build again.

Freezing the revision is what makes a build reproducible and what makes a rotation land. Two builds of the same document against the same revisions are the same build and the second may reuse the first’s image; rotate a secret and the next build is a different build, and runs. An image built from your secrets is yours alone — another account writing the same document never reaches it, and could not name your secret to begin with.

One build runs per hypervisor at a time. A `409` with `reason: "contention"` means a host is busy, or has no room on its disk for another build right now, rather than that anything is wrong with your document, and is worth retrying. Your plan sets how many builds you may start in a rolling day; a `429` names the number you have used.

A BUILD YOU KEEP COUNTS AGAINST YOUR STORAGE POOL. A build produces a whole disk image that stays on a hypervisor, so each one your account keeps counts against your plan’s storage pool — the same pool your computers’ disks use — at its template’s `disk_gb`. A build that would take the pool past its size is a `402` that names the pool, your computers and your kept builds, and costs nothing; creating or growing a computer counts kept builds the same way. A build counts from the moment it is accepted, and stops counting if it fails or reuses an image already built. Each template family keeps its three newest builds on each hypervisor; an older one is removed automatically once no computer or snapshot uses it, and then stops counting — so launching a template whose build was removed is refused until you build it again. Builds made before this rule are kept, never removed to make room: an account over its pool because of them has new builds and computers that need room refused, and its owners are told once by email which families hold what.

A BUILD THAT REUSED AN IMAGE DOES NOT SPEND THAT ALLOWANCE. An identical document, built before, is answered from the image already on disk in a fraction of a second rather than the minutes a real build takes, so resubmitting bytes you have already built does not spend a unit sized against the long case. It is not counted once the fleet reports what it was, which follows the `202` by a moment rather than arriving with it. Passing `no_reuse` opts out: it is a request for the work, and the work is charged.

THERE IS A SECOND, MUCH LARGER CEILING ON BUILD REQUESTS, ten times the first. Every request that reaches a hypervisor counts against it, whatever comes of it there — a reuse hit is cheap and it is not free: it takes that machine’s one build slot while it runs, and every other build on it is answered `409` for the length of that. Requests refused before any hypervisor is asked — a plan that does not include building, a namespace or a family that is not yours — count against neither ceiling, because nothing was asked of any machine. A document that will not PARSE is not one of those: it is sent on, because the hypervisor that would build it is the tier that reads it, so it costs a request and no build. Both ceilings answer `429`, and the message says which one you met. Iterating on a document will not come near the second; a loop resubmitting one will.

A REQUEST A HYPERVISOR REFUSES COSTS NO BUILD — `409` busy or out of room, `400` for a document it will not build, `501` — and it does count as a request: the host started nothing, but it was asked. A build that was admitted (`202`) counts against the build allowance whatever becomes of it, a build that FAILED included — what that one bounds is a shared machine, and charging only for the builds that worked would make the cheapest way to exhaust a hypervisor a document that always fails. So does a build this API could not get an answer about, since a hypervisor may well be running it.

Requires the `member` role or higher.

## Query parameters

- `no_reuse` 'true'

## Response `202`

The job. `status` is `running` until it is `succeeded` or `failed`. A succeeded build has produced an image. Publish that same document, then create a computer using its `ref`. Launches select the newest successful image for that exact document and verify it on its host; see `POST /templates` for version selection and unavailable-image behavior.

- TemplateBuild
  - `id` string: Stable identifier, e.g. `bld-a1b2c3d4e5f6`.
  - `ref` string: The document this was built from, as `namespace/name@version`.
  - `status` string: `running`, `succeeded` or `failed`.
  - `error` string: Why it failed, when it did. For a failing `run:` step this is the end of that step’s own output.
  - `started_at` string: RFC 3339 timestamp.
  - `finished_at` string: RFC 3339 timestamp. Absent while it is still running.

## Other responses

- `400`: The request was malformed — a bad resolution string, a missing `command`, a `{pid}` or `{window}` that is not one. It is also what a well-formed request gets when the thing it names cannot be acted on and never will be — a restore onto a computer that is gone, for instance. Either way it is final: the message says what is wrong, and sending it again unchanged answers the same. That is the whole difference from a `503`, which means ask again shortly.
- `401`: Platform authentication required or refused: reason is missing, invalid, or revoked, with WWW-Authenticate: Bearer (invalid_token for invalid/revoked).
- `402`: Your plan refused this: a quota, or a payment that has not gone through. On an agent run it can instead be `Insufficient AI credits` with `top_up_url` (a run funded by the account’s AI credits), or the model provider’s own billing refusal of your `X-Model-Key`, relayed.
- `403`: The credential is recognized but its holder lacks the required role or membership, or the holder/account is suspended, or the holder has not verified their email address. An ordinary denial for too low a role may carry no `reason`. The email one carries none either, and its message begins `Verify your email before`: it answers a create, a clone of a computer or a snapshot, a new API key and a new SSH key, and is cleared by verifying the address in the dashboard. `reason: "revoked"` marks authority that is gone: a suspended account or a lost membership (refused as the request arrives, or if it happens while the request is in flight), or a role that was lowered while the request was in flight. The message identifies the refusal; replacing a valid key alone does not restore authority, and sending the same call again does not help. Also the answer, with no `reason`, to any API key confined to a workspace: builds are the account’s.
- `405`: This public path exists but does not accept that method. Allow lists only its public methods, with HEAD for GET and metadata OPTIONS.
- `409`: Refused for the state something is in — resizing a computer that is running, running a command on one that is stopped, taking a snapshot while another capture of it is running. WHETHER RETRYING HELPS IS IN THE BODY, NOT THE STATUS, and this is the one thing to take from this entry. Some of these describe a state that is passing: a guest agent still inside its boot window, a guest agent busy with another call, a move already running on the account. Those clear on their own and the same request works a moment later. The rest describe a DECISION about the request you sent — the size does not fit, the computer is the wrong one for this, the saved session cannot travel — and no amount of retrying turns one of those into a yes. A client that treats 409 as uniformly transient loops forever on half of them. Where the platform can say which it is, it sends `reason` beside `error` — one word, meant to be switched on rather than read: `contention` and `starting` clear on their own, `unavailable` means the computer is not running and only starting it helps, `running` means the computer is running and only stopping it helps (a resize), `unsupported` means waiting will not change the answer — this computer cannot do it at all, or it needs another step first, which the message names (a stop, or removing its secret bindings, for example) — `exists` means a create-only upload found its path taken, `name_taken` means a secret of that name is already in that scope, `stale_revision` means a secret changed since the `revision_id` you sent was read (or a computer’s bound list since the `version` you sent), and `stale_capture` means the screenshot capture you named is no longer held: a newer one replaced it, or its host’s daemon restarted or the computer moved. `starting` covers the two minutes after a start or a restart, and after a reboot from inside the guest on a computer last cold-started recently enough to report one (a stop and a start gives an older one that); a guest agent that is still silent after that is a `502`. Match on that and never on the sentence, which is prose and is rewritten. **Absent means no classification was given** — treat it, and any word you do not recognise, as no answer, because not every refusal here has one yet. It is what the guest routes answer: the clipboard on a stopped computer used to be a 409 indistinguishable from a clipboard whose selection was claimed for an instant, and a blanket retry loop spun against the first until its deadline. Every 409 that clears on its own carries `contention` or `starting`: a host busy with another build or with no room for one, an operation already running on the same computer or snapshot (a capture, a copy, a delete, a restore or clone reading it, another agent run), a lock another request holds, a move or a delivery settling. So a 409 with no word, or with any other word, is not one that waiting fixes: read the sentence for the step it names. The ones worth knowing by name, because each has a next step that is not "wait": A resize past what the computer’s host can run carries `move`, an object rather than a message: `{"required":true,"possible":true}` means another host in this region could run it and the computer has to be moved there first — `POST /computers/{id}/move` with the same sizing fields is how you agree to that, and it is a separate call because relocating a computer is not something a resize should do to you quietly. `possible:false` means nowhere in the region can, and the size is the thing to change. The move itself then refuses for the things it re-decides at the moment it runs, and only one of those is worth waiting on: a computer that is running or suspended has to be stopped or resumed-and-stopped by you, a size that fits where it already is wants an ordinary resize instead, and a region with nowhere to put it wants a smaller size. `Another computer on this account is being moved right now` is the one that clears by itself, and it carries `reason: "contention"` — one move runs per account, and `GET /moves` says which. And the terminal socket refuses twice over: `reason: "unavailable"` with `resume_required: true` on a suspended computer, which clears when you start it and not before, and `reason: "unsupported"` on a running computer whose hardware carries no terminal channel — one last started before interactive terminals existed — which needs a stop and a start. Not a restart, which resets the same machine, and not a retry, which never comes good. Two kinds here, and `reason` tells them apart. `reason: "unsupported"`: a secret the document names cannot be delivered (its value cannot be read under this control plane’s keyring, or is not text an environment variable can carry, or the secret was deleted or left the build’s scope); nothing was built, and sending it again never helps until the secret is replaced. `reason: "contention"`: every host that can build is busy with another build, or has no room in its memory or on its disk for one; nothing was built, and the same request can succeed shortly. `unsupported` too, when the only builders that answered have disk bookkeeping that needs operator recovery, which waiting does not clear.
- `413`: What you sent, or asked for, is past a size limit. A JSON request body is capped at 1 MiB unless the route states its own limit: `POST /computers/{id}/exec` takes up to 4 MiB, and some routes take less; guest file transfers, template documents and the clipboard have limits of their own. The message names the limit that applied.
- `429`: You are asking faster than your plan allows. `Retry-After` says how many seconds to wait, and every metered response — anything past authentication and the re-check that your credential’s authority still holds — carries `RateLimit-Remaining`, so you do not have to be refused to find out. A `401`, a `403` from that re-check, an `OPTIONS` answer and an unexpected `500` carry none. The budget is spent in proportion to what a call costs rather than per request. What a call costs, in budget units, by kind (each operation’s exact figure is its `x-rate-cost` in the OpenAPI document): 1 to read one thing; 5 for a listing that spans the fleet, and 5 for a call into a running guest such as a screenshot or an exec; 10 for a power change and 10 to move file bytes; 25 for `GET /usage`, `GET /credits`, creating a public URL, and creating, rotating or testing a webhook; 50 to capture a snapshot; 100 to create, clone or restore a computer, and 100 to start an agent run, whose actions are then charged as they happen. The account-wide budget, as what an idle caller may spend at once and what refills each minute: No plan 1200 at once and 1200 a minute; Solo 4800 at once and 4800 a minute; Studio 12000 at once and 12000 a minute; Swarm 21600 at once and 21600 a minute. A single API key gets half of either. A `401` for a missing or unusable credential costs nothing: it is refused before anything is metered. A few refusals at the door cost one unit: a `403` because the role is too low or the route refuses an API key confined to a workspace, a `404` for a path that matches nothing, a `405`, and an `Idempotency-Key` answered from its stored result or still in progress. Everything else is charged in full when it is admitted, before it is carried out, so a refusal after that has spent the whole cost: a `400`, `402` or `409` from the route, a `403` its handler sends (on most routes, a suspended account included), and a model provider’s `401` on an agent run. The budget belongs to the account and is shared across everything on it, and any single API key may spend at most half of it. `RateLimit-Limit` is whichever of those two is binding on the request that carried it — usually the per-key half, and the account-wide figure when other keys have already spent more of it than this one has. All three `RateLimit-*` headers describe the same one, so `limit` minus `remaining` is what has been spent against it. A few `429`s are not about this budget, say so in their message, and can carry no `Retry-After`: a model provider’s rate limit relayed on an agent run. The cap on forks of one snapshot waiting for their secrets sends its own bounded expiry-based `Retry-After` with `reason: "contention"` (see `POST /snapshots/{id}/clone`).
- `500`: The application could not finish this: an unexpected exception, a build that broke, or a storage fault. Application refusals carry the request correlation ID; unexpected internal details are not returned.
- `501`: This feature is not available: no hypervisor in the fleet can build a template, or the platform or the host holding this computer does not offer it. Unlike a `409`, this does not clear on its own — retrying will not help, so treat it as an outage of the feature rather than of the moment.
- `503`: Something this depends on — a hypervisor, a store, or a host’s capacity — could not answer or take this right now. A read can be retried shortly. A change answered `503` may or may not have happened — a failure after the request was sent is answered the same way, with or without a warning in the message — so read the current state before retrying anything that is not safe to repeat, such as a create or a command. On the collection reads, `allow_partial=1` accepts a knowingly short answer instead.

---

[API](https://skmtc.dev/mandala/apis/mandala-computer-api.md) · [All operations](https://skmtc.dev/mandala/apis/mandala-computer-api/llms.txt) · [OpenAPI document](https://skmtc.dev/mandala/apis/mandala-computer-api/revisions/2afbb87fa703?raw)
