Compiling a template document into an image of your own.
Base URL https://app.mandala.computer/api/v1. Every request carries an API key, and failures take the shape under Errors.
POST/builds
Build a templatemember+
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 400s 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.
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%/}"'/builds' \
-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.builds.start(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.builds.start(document);
console.log(JSON.stringify(result, null, 2));
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"
}
Query parameters
no_reuse
string
Build even when an image already carries this document’s build digest. Identical documents normally share an image, which is what makes a repeated build cheap; pass this when you want the work done again anyway. This one does count against your daily allowance whatever it finds — the work is the thing being asked for.
Response
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.
Response fields
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.
Failures
403
Also the answer, with no reason, to any API key confined to a workspace: builds are the account’s.
409
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.
GET/builds
List buildsviewer+
Every build this account has started that the fleet still holds a record of, newest first. Needs an account-wide key: one confined to a workspace is a 403.
This is a fan-out across the fleet, so it fails closed the way GET /computers and GET /snapshots do: a hypervisor that cannot be reached makes the answer 503 rather than short. allow_partial is the way through.
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%/}"'/builds' \
-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.builds.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.builds.listWithStatus();
console.log(JSON.stringify(result, null, 2));
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"
}
Query parameters
allow_partial
string
Accept a listing known to be short. Without it this endpoint answers 503 when a hypervisor holding some of your things cannot be reached, because a short list is not a smaller truth — it reads exactly like the missing ones were deleted, and the obvious next thing a script does with something that has disappeared is tidy it up. A short build listing has no rows marking what is gone: the builds a silent host was holding are simply not there. Nothing on this tier records which hypervisor ran which build — a build is not a thing you act on afterwards, so there was never anything to route to it — so there is no cache to name what is missing here, and no count to give you either. Read the X-GC-Incomplete response header rather than the rows: it is present only when the listing is short, and its value here is always 0, because nothing can count what is missing. Any other value of this parameter is read as absent rather than refused.
Response
Your builds.
Response fields
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.
Failures
403
Also the answer, with no reason, to any API key confined to a workspace: builds are the account’s.
GET/builds/{id}
Get a buildviewer+
What became of one build. The golden’s own filename is ours and is accepted nowhere here; what names the result is the ref you submitted: publish that same document with POST /templates and create a computer by its ref — see there for how the image is chosen and the template_image_preparing 409. error says why a failed one failed, which for a run: step is the end of that step’s own output.
Needs an account-wide key: one confined to a workspace is a 403.
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%/}"'/builds/bld-b1c2d3e4f5a6' \
-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:
id = "bld-b1c2d3e4f5a6"
result = client.builds.get(id)
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 id = "bld-b1c2d3e4f5a6";
const result = await client.builds.get(id);
console.log(JSON.stringify(result, null, 2));
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.
Failures
403
Also the answer, with no reason, to any API key confined to a workspace: builds are the account’s.
GET/builds/{id}/progress
Get build progressviewer+
What a build is DOING, as against what became of it. A build is minutes long — most of them spent copying a multi-gigabyte base image and then running your document’s steps — so this says which step of how many is running, and which one failed.
This is the polling half. GET builds/{id}/events is the same thing as an event stream; use that for a terminal and this for anything that reconnects, restarts, or cannot hold a socket open.
It stays readable after the build has finished, so a program that was not attached at the time can still see which step failed.
Needs an account-wide key: one confined to a workspace is a 403.
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%/}"'/builds/bld-b1c2d3e4f5a6/progress' \
-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:
id = "bld-b1c2d3e4f5a6"
result = client.builds.progress(id)
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 id = "bld-b1c2d3e4f5a6";
const result = await client.builds.progress(id);
console.log(JSON.stringify(result, null, 2));
running, succeeded or failed — the job’s own status, restated so one poll answers both questions.
done
boolean
Whether to stop polling. Derived from status and not from phase: a phase is read out of the build’s log, which your own run: steps write into, and only the job decides whether a build worked.
phase
string
Where the build is in itself: planning, staging, copying, building, publishing, and then published, reused or failed. The long ones are copying — the base image is several gigabytes — and building, which is the one with steps in it.
unknown is the one remaining value, and it means the build finished without keeping a step-by-step record — every build from before this endpoint existed is one. It is not reported as published because a build that reused an existing image succeeds too, and that distinction lived in the record that is missing. status is still the answer.
step
integer
Which step is running, 1-based, or the one that failed. 0 before the build reaches the first.
Every step, in order, whatever its status — so the whole list renders from the first read.
note
string
One line about the phase, or why a failed build failed.
error
string
Why it failed, when it did. The same value GET builds/{id} gives.
updated_at
string
RFC 3339. When the build last MOVED, and not when this was last read — a build whose steps have stopped advancing is a build whose updated_at stops advancing.
unmatched
boolean
Present and true only when this fleet could not recognise its own build tool’s output, so the per-step position is unavailable. The build itself is unaffected and status is still the answer.
Failures
403
Also the answer, with no reason, to any API key confined to a workspace: builds are the account’s.
GET/builds/{id}/events
Stream build progressviewer+
The same record as GET builds/{id}/progress, as text/event-stream, for as long as the build runs, up to 50 minutes. A build cannot run that long, so a stream still open then ends with an error event rather than closing quietly.
Three event types. progress carries a BuildProgress and is sent only when something actually moved, so every one of them is news. done carries the final BuildProgress and is the last event of a build that finished — including one that FAILED, which is a done whose status says failed rather than an error. error is the last event of a stream that could not go on, and says nothing about the build. Its data is {request_id, error} when the hypervisor running the build stopped answering or the stream reached its 50 minutes; poll for the outcome. When your authority went while the stream was open — the key deleted, the membership removed, the account suspended — it also carries status (401 or 403) and reason: "revoked", the refusal the same request would get now; polling is refused the same way, so do not poll with that credential.
Attaching to a build that has already finished is not an error: you get one progress and one done immediately. Lines beginning : are keepalives and can be ignored.
An account may hold eight of these open at once; the ninth is refused with a 429 naming the limit. It is a bound on concurrency rather than on price, because a stream is charged once and then polls for as long as the build runs. There is one build per hypervisor, so this is more streams than a fleet has builds to watch.
Needs an account-wide key: one confined to a workspace is a 403.
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. The SDK consumes fragmented SSE frames and comments, stops on done, and raises on stream errors or premature EOF. A terminal failed build is a done event with status failed; inspect its status.
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%/}"'/builds/bld-b1c2d3e4f5a6/events' \
-H "Authorization: Bearer $MANDALA_API_KEY" \
--no-buffer
Python — Mandala SDK 0.16.0
# Python 3.10+: pip install mandala-computer==0.16.0
import os
from contextlib import closing
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:
id = "bld-b1c2d3e4f5a6"
with closing(client.builds.events(id)) as events:
for event in events:
print(event)
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 id = "bld-b1c2d3e4f5a6";
for await (const event of client.builds.events(id)) console.log(event);
200 Illustrative SSE progress and terminal done frames.
200 Illustrative error frame after the HTTP stream has opened; work may be incomplete.
HTTP 200
Content-Type: text/event-stream
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
event: error
data: {"request_id":"3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17","error":"the hypervisor running this build stopped answering; poll the build for its outcome"}
Path parameters
idrequired
string
The build id.
Response
The payload of each progress and done event. The response body is the event stream.
Response fields
id
string
The build this describes.
status
string
running, succeeded or failed — the job’s own status, restated so one poll answers both questions.
done
boolean
Whether to stop polling. Derived from status and not from phase: a phase is read out of the build’s log, which your own run: steps write into, and only the job decides whether a build worked.
phase
string
Where the build is in itself: planning, staging, copying, building, publishing, and then published, reused or failed. The long ones are copying — the base image is several gigabytes — and building, which is the one with steps in it.
unknown is the one remaining value, and it means the build finished without keeping a step-by-step record — every build from before this endpoint existed is one. It is not reported as published because a build that reused an existing image succeeds too, and that distinction lived in the record that is missing. status is still the answer.
step
integer
Which step is running, 1-based, or the one that failed. 0 before the build reaches the first.
Every step, in order, whatever its status — so the whole list renders from the first read.
note
string
One line about the phase, or why a failed build failed.
error
string
Why it failed, when it did. The same value GET builds/{id} gives.
updated_at
string
RFC 3339. When the build last MOVED, and not when this was last read — a build whose steps have stopped advancing is a build whose updated_at stops advancing.
unmatched
boolean
Present and true only when this fleet could not recognise its own build tool’s output, so the per-step position is unavailable. The build itself is unaffected and status is still the answer.
Failures
403
Also the answer, with no reason, to any API key confined to a workspace: builds are the account’s.
429
ON THIS ROUTE a 429 is usually the eight-streams-at-once cap rather than your rate budget, and the message names which limit you met. Both carry Retry-After, but it means something weaker here: the meter computes when your budget refills, while the stream cap can only offer a poll interval — a slot frees when somebody else’s build ends, and nothing can say when that is. So waiting out Retry-After on this one is not a promise of a slot. The durable answers are to close a stream you already hold, or to poll GET /builds/{id}/progress, which has no cap.