The management API¶
Everything a CLI or UI client addresses: jobs, artifacts, sources,
accounts, projects, watches. Management routes mount under
/api/v1: https://saggar.dev/api/v1/jobs,
…/api/v1/projects, …/api/v1/auth/me. The root namespace stays
free for the paths foreign clients hardcode: /v2 (the OCI registry
handshake), /apt, /worker (the runner protocol), /hooks
(forge webhooks), /s (share links) and /health. The CLI adds
the prefix itself; point --server https://saggar.dev and go.
GET /api/v1/version answers {"version": "...", "api": 3}. The
API generation only moves on incompatible changes; additive ones stay
within the generation.
Authentication¶
The management API authenticates subjects (see Sign the CLI in for how to get a token):
X-Auth-Token: <sgt-…>, what the CLI sends; orAuthorization: Bearer <sgt-…>, the standard spelling, equally accepted.
Token scopes: a full token (the default) acts as its subject
everywhere; a registry-scoped token (POST /auth/tokens with
{"scope": "registry"}) authenticates on the registry front-ends
only. The management API answers 403 by name, so a leaked pull
credential cannot read jobs or artifacts.
Authorization follows the project ladder (see Auth and visibility for the read rules): reading jobs, logs, artifacts and members needs viewer; submitting and cancelling needs builder; promoting needs promoter; memberships and deletion are the owner’s. Rows predating accounts carry no project and are public history. Listings show only what the caller has standing on.
Management routes¶
Paths relative to /api/v1.
Jobs and sources¶
POST /jobsSubmit a job. Body:
{ "source": {"type": "git", "url": "https://…", "ref": "main"}, "project": "slug" , "format": "docker", "name": "my-project", "arch": "arm64", "arches": ["amd64", "riscv64"], "env": "debian:12@amd64/glibc-2.36", "mode": "build", "test_cmd": "make check", "formats": "auto", "reuse_build": true, "cache": "fresh", "ephemeral": false }
Only
sourceis required.sourceis{"type": "git", "url": …, "ref": …}(a remote clone URL;refdefaults to the remote HEAD) or{"type": "tarball", "id": "src-…"}(an id fromPOST /sources). There is no path transport: a host path would let a network submission read the service’s filesystem.projecttargets a namespace, a slug you own (created on first submit) orowner/slug(builder standing required); absent: yourpersonalproject.archandarchesare mutually exclusive (one names a single target, the other a fan-out list).modeisbuild(default) ortest;test_cmdimplies test mode. With aformatsselector ("auto"or a list ofdeb,docker,snap,flatpak,rpm,appimage,native) the response is{"group": "g-…", "jobs": […]}, one job per claimant, × eacharchesentry.reuse_build: falsedisables the build-once hand-off (on by default for groups with one build-system claimant, single arch).cacheisfresh(default),artifact(memoization) orincremental(warm trees).ephemeral: trueexcludes the artifact from the registries and collects it after the instance’s ephemeral TTL (24 h by default); the job record, failure metadata and logs always survive.A submission from an already-over-quota account answers
403by name before any build runs.GET /jobsList jobs, up to 100, only what you have standing on.
?group=<id>scopes to one fan-out group;?project=slug(orowner/slug) to one project.GET /jobs/:idJob status and metadata, plus
artifacts(the produced artifact ids; empty for build-check successes) andfailuremetadata on failure (phase, exit code, outcome, class, node, log tail).DELETE /jobs/:idCancel a job (queued: outright; running: stops at the next command boundary). Needs builder. Returns the job.
GET /jobs/:id/logsStreamed logs (SSE): the current buffer replays, then live lines follow until the job’s terminal
endevent, and the stream closes right after it.?format=text(orAccept: text/plain) returns the whole log as plain text.GET /jobs/:id/artifactDownload the whole artifact as
<name>_<version>.tar.gz.GET /jobs/:id/resultsStructured test results of a test job: per-file suite/case counts and the failing case names with their failure messages.
404when nothing was collected.POST /sourcesUpload a source tarball (the raw body: a gzipped tar) →
{"id": "src-…"}. Capped at 256 MiB.
Artifacts¶
GET /artifactsThe registry listing (up to 500, visible ones). Filters:
?name=,?channel=(staging/rc/release),?arch=,?ephemeral=true(ephemeral-only),?project=slug|owner/slug.GET /artifacts/:idArtifact metadata: name, version, format, arch, channels, and the files with digests.
GET /artifacts/:id/files/:filenameDownload one file of an artifact.
GET /artifacts/:id/sharesThe artifact’s share links: who minted them, until when, which are revoked. The tokens are not in the listing (shown once at mint, stored hashed), so it can audit but never resurrect a link. Viewer standing.
POST /artifacts/:id/sharesMint a share link:
{"expires_in_secs": 86400}(absent: the link never expires on its own) → the plaintext token (sgs-…), shown once, the ready-to-paste path/s/<token>, and the stored row. Viewer standing is enough: reading the artifact is the most a link can ever grant. Audited.DELETE /shares/:idRevoke a share link, immediately: every holder of the plaintext loses access at once. The link’s creator or the project’s owner may revoke. Revoked and unknown share one
404, the same answer the serve path gives, for the same reason. Audited.POST /artifacts/:id/promotePromote to a channel:
{"channel": "rc"}or"release". Needs promoter; audited (who, what, when). One-directional: a label, never a rebuild.
Operations¶
GET /opsLong-lived operations (a job and the process handling it), up to 200.
GET /metricsPrometheus-format counters (
text/plain; version=0.0.4). The scraper authenticates like any other client.GET /version{"version": "...", "api": 3}.GET /healthLiveness, mounted at the root and open: probes carry no token.
Accounts and auth¶
GET /auth/methodsOpen to unauthenticated callers; also mounted at the root, for pre-login discovery. The configured login providers:
{"providers": [{"name", "kind", "authorize_url"}]}. Theauthorize_urlhas the client id and scope baked in; the client appendsredirect_uriandstate. An empty list means no login is offered.POST /auth/sessionOpen to unauthenticated callers; also mounted at the root. Exchange one provider’s web-flow code:
{"provider", "code", "redirect_uri"}. The service is the confidential client of every kind and runs the exchange server-side, so no client secret ever reaches a browser. Known identity →{"status": "login", "token", "expires_at", "subject"}; first sight →{"status": "signup", "signup_token", "handle"}, and nothing is created untilPOST /auth/signupconfirms the handle.POST /auth/signupOpen to unauthenticated callers; also mounted at the root. Confirm a sign-up:
{"signup_token", "handle"}→ the login reply. The proposed handle is a suggestion; any free, non-reserved handle works. A taken handle →409, a reserved one →400; both leave the grant alive for a retry. An expired or unknown grant →410/404, and the login starts over (grants expire in 10 minutes).POST /auth/deviceOpen to unauthenticated callers; also mounted at the root. Start a device login (what
saggar login’s browser mode runs): →{"user_code", "device_code", "verification_uri", "expires_in", "interval"}: the 8-character code (no look-alike glyphs) a signed-in user confirms at the verification page (https://saggar.dev/deviceon the default instance), withinexpires_in(600 s);device_codeis the poll capability. Rate-limited per address.POST /auth/device/confirmConfirm a device login as the signed-in caller:
{"user_code"}→204, what the app’s device page does behind its authenticated proxy. The typed code is normalized: case, dashes and spaces are ignored. Unknown →404, expired →410, a repeat confirm →204like the first (idempotent). Guarded: under/api/v1only.POST /auth/device/tokenOpen to unauthenticated callers; also mounted at the root. Poll a device login:
{"device_code"}, long-polling up to 25 s (set the client timeout above it; honorinterval, 2 s).{"status": "pending"}until a signed-in user confirms, then{"status": "approved", "token", "expires_at", "subject"}, the exact reply a web-flow login returns. Unknown →404, expired →410; the poll never consumes the login, so a lost response answers again.GET /auth/meThe authenticated subject, its projects (with roles), and the account’s storage standing (
storage.used_bytesagainststorage.quota_bytes;null= unlimited).GET /auth/tokensYour tokens, never their secrets.
POST /auth/tokensMint one:
{"name", "expires_in_days", "scope"}. The secret is in this one response ({"token": "sgt-…", ...}); only its hash is stored.scopeabsent or"full": the subject everywhere;"registry": pull-only (see Authentication).DELETE /auth/tokens/:idRevoke one of your tokens; it stops authenticating immediately.
GET /auth/identitiesYour linked logins, oldest first (provider key + display name when still configured).
POST /auth/identitiesLink another provider login to your account:
{"provider", "code", "redirect_uri"}, the same OAuth round-trip as a sign-in, attaching instead of entering. An identity already linked to another account →409naming its handle.DELETE /auth/identities?provider=…Unlink one login, refused while it is the account’s last (lock-out guard). After unlinking, that provider’s next login opens a fresh sign-up.
Projects¶
GET /projectsYour projects (owned + member, with roles), newest first.
POST /projectsCreate one:
{"slug": "web"}(lowercase letters, digits, dashes). You own what you create.GET /projects/:owner/:slugOne project.
PATCH /projects/:owner/:slugFlip visibility:
{"visibility": "private"|"public"}(owner). A public project’s artifacts are pullable by anyone (see Auth and visibility).DELETE /projects/:owner/:slugDelete it (owner; refused while jobs exist, because build history is referenced, not orphaned).
GET /projects/:owner/:slug/membersThe members, with roles.
PUT /projects/:owner/:slug/membersGrant or change one:
{"handle", "role": "viewer"|"builder"|"promoter"}(owner only;owneris the project record and cannot be granted).DELETE /projects/:owner/:slug/members/:handleRevoke a membership (owner only).
GET /projects/:owner/:slug/remotesThe project’s git remotes (its mirrors; derive-on-submit matches these).
POST /projects/:owner/:slug/remotesRegister one:
{"url": …}(owner; idempotent; watch registration adds remotes too).DELETE /projects/:owner/:slug/remotes?url=…Forget one remote (owner; refused while a watch of the project polls that URL).
Watches¶
GET /watchesThe watches you can see (with their project): unattributed watches, plus every watch of a project you have viewer standing on.
POST /watchesRegister one, one per repo, keyed by the normalized URL (duplicate → conflict):
{ "url": "https://git.example.com/me/web", "project": "web", "branches": ["main", "release/*"], "tags": [], "settings": {"formats": "auto", "test": null}, "interval_secs": 300, "enabled": true, "forge": "gitea", "credential": "…" }
settingscarries the submit defaults of triggered builds (formats,arches,env,cache,ephemeral,test,test_cmd).forgeisgeneric(the default; it reports nothing),gitea,forgejoorgithub;credentialis the forge API token for commit-status reporting, stored write-only, never served back. Save-time validation refuses the incoherent: a tests-only watch with a formats list, acache: "artifact"trigger (memoization does not compose with groups), an empty pattern or arch list, a zero interval.GET /watches/:idOne watch in full, including its
webhook_secret, the HMAC key to paste into the forge’s webhook config.PUT /watches/:idChange what you passed at creation; a patch, so omitted fields keep the watch’s current value. The URL and the id are immutable.
"rotate_webhook_secret": truemints a fresh webhook secret (the forge config must be updated; the new value rides the response)."credential"replaces the forge token.DELETE /watches/:idRemove it. The history stays; re-registering starts clean.
POST /watches/:id/triggerPoll right now; returns the groups each unseen head produced (empty when nothing new).
GitHub App¶
The GitHub App is the service’s repo-import and private-clone registration, separate from login (see Import from GitHub: the GitHub App):
GET /forges/github/repositoriesThe repositories the caller’s own GitHub App installations reach, the import page’s GitHub listing:
{"repositories": [{"url" (clone URL), "full_name", "description", "updated_at"}]}. Nothing to list: an empty list plus areason("github app not configured"/"no github app installed").
Runner fleet¶
Mounted under /api/v1 beside the rest (see The runner protocol for
the protocol side):
GET /nodesThe runners the caller owns or is shared with: node id, arch, engine version, state, owner handle,
ownedflag, declared conduct profile (network,docker_format), last seen.POST /nodes/retireRetire a runner you own:
{"node_id": …}→ the row stays listedretired. Not idempotent: unknown or already-retired answers404.GET /nodes/:id/sharesThe accounts the runner is shared with (owner only).
PUT /nodes/:id/sharesGrant use to an account:
{"handle": "…"}→ the sharee list.DELETE /nodes/:id/shares/:handleRevoke a share → the sharee list; future claims stop immediately, a running job finishes its lease.
Errors¶
Errors are JSON: {"error": "<human-readable message>"} with a
precise status. 400 for a malformed body, 401 for a missing
or unknown credential, 403 for a scope or standing refusal (named:
which role was required, or that the token is registry-scoped),
404 for unknown rows, 409 for conflicts (a taken watch URL, a
linked identity, a membership), 410 where a grant, device login or
share link has expired; it existed and ran out, unlike the 404 a
revoked or unknown name answers. Denials on the registries are
deliberately indistinguishable from unknown names (see
The registries).