---
title: "Get build progress"
method: GET
path: "/builds/{id}/progress"
tags: ["Builds"]
---

# Get build progress

`GET /builds/{id}/progress`

## Request

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

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

Requires the `viewer` role or higher.

## Path parameters

- `id` string, required

## Response `200`

Where the build has got to.

- BuildProgress
  - `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.
  - `of` integer: How many steps there are.
  - `steps` BuildStep[]: Every step, in order, whatever its status — so the whole list renders from the first read.
    - `n` integer: Its position, 1-based.
    - `kind` string: `apt`, `run`, `file`, `mkdir`, `env` for the environment block, or `finish` for the cleanup every build ends with.
    - `label` string: What the step does, from your own document — the packages, the path, or the first real line of the script.
    - `status` string: `pending`, `running`, `done`, `failed`, or `skipped` for one an earlier failure meant we never reached. `unknown` is the rare one: this build’s step record was lost mid-build — a host that lost power between the write and the flush — and rebuilt from your document, so these steps ran and what became of them is not recoverable. It is not reported as `done`, which would claim they succeeded, or as `pending`, which would claim they never ran.
    - `started_at` string: RFC 3339. Absent until it starts.
    - `finished_at` string: RFC 3339. Absent until it ends.
  - `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.

## Other responses

- `401`: Platform authentication required or refused: reason is missing, invalid, or revoked, with WWW-Authenticate: Bearer (invalid_token for invalid/revoked).
- `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.
- `404`: No such resource in your scope — a computer, snapshot, secret, webhook, SSH key, API key, workspace, operation and so on — or no such endpoint; a confirmed missing guest file is no such file in the guest.
- `405`: This public path exists but does not accept that method. Allow lists only its public methods, with HEAD for GET and metadata OPTIONS.
- `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.
- `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)
