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; or

  • Authorization: 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 /jobs

Submit 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 source is required. source is {"type": "git", "url": …, "ref": …} (a remote clone URL; ref defaults to the remote HEAD) or {"type": "tarball", "id": "src-…"} (an id from POST /sources). There is no path transport: a host path would let a network submission read the service’s filesystem.

project targets a namespace, a slug you own (created on first submit) or owner/slug (builder standing required); absent: your personal project. arch and arches are mutually exclusive (one names a single target, the other a fan-out list). mode is build (default) or test; test_cmd implies test mode. With a formats selector ("auto" or a list of deb, docker, snap, flatpak, rpm, appimage, native) the response is {"group": "g-…", "jobs": […]}, one job per claimant, × each arches entry. reuse_build: false disables the build-once hand-off (on by default for groups with one build-system claimant, single arch). cache is fresh (default), artifact (memoization) or incremental (warm trees). ephemeral: true excludes 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 403 by name before any build runs.

GET /jobs

List jobs, up to 100, only what you have standing on. ?group=<id> scopes to one fan-out group; ?project=slug (or owner/slug) to one project.

GET /jobs/:id

Job status and metadata, plus artifacts (the produced artifact ids; empty for build-check successes) and failure metadata on failure (phase, exit code, outcome, class, node, log tail).

DELETE /jobs/:id

Cancel a job (queued: outright; running: stops at the next command boundary). Needs builder. Returns the job.

GET /jobs/:id/logs

Streamed logs (SSE): the current buffer replays, then live lines follow until the job’s terminal end event, and the stream closes right after it. ?format=text (or Accept: text/plain) returns the whole log as plain text.

GET /jobs/:id/artifact

Download the whole artifact as <name>_<version>.tar.gz.

GET /jobs/:id/results

Structured test results of a test job: per-file suite/case counts and the failing case names with their failure messages. 404 when nothing was collected.

POST /sources

Upload a source tarball (the raw body: a gzipped tar) → {"id": "src-…"}. Capped at 256 MiB.

Artifacts

GET /artifacts

The registry listing (up to 500, visible ones). Filters: ?name=, ?channel= (staging/rc/release), ?arch=, ?ephemeral=true (ephemeral-only), ?project=slug|owner/slug.

GET /artifacts/:id

Artifact metadata: name, version, format, arch, channels, and the files with digests.

GET /artifacts/:id/files/:filename

Download one file of an artifact.

GET /artifacts/:id/shares

The 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/shares

Mint 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/:id

Revoke 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/promote

Promote to a channel: {"channel": "rc"} or "release". Needs promoter; audited (who, what, when). One-directional: a label, never a rebuild.

Operations

GET /ops

Long-lived operations (a job and the process handling it), up to 200.

GET /metrics

Prometheus-format counters (text/plain; version=0.0.4). The scraper authenticates like any other client.

GET /version

{"version": "...", "api": 3}.

GET /health

Liveness, mounted at the root and open: probes carry no token.

Accounts and auth

GET /auth/methods

Open to unauthenticated callers; also mounted at the root, for pre-login discovery. The configured login providers: {"providers": [{"name", "kind", "authorize_url"}]}. The authorize_url has the client id and scope baked in; the client appends redirect_uri and state. An empty list means no login is offered.

POST /auth/session

Open 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 until POST /auth/signup confirms the handle.

POST /auth/signup

Open 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/device

Open 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/device on the default instance), within expires_in (600 s); device_code is the poll capability. Rate-limited per address.

POST /auth/device/confirm

Confirm 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 → 204 like the first (idempotent). Guarded: under /api/v1 only.

POST /auth/device/token

Open 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; honor interval, 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/me

The authenticated subject, its projects (with roles), and the account’s storage standing (storage.used_bytes against storage.quota_bytes; null = unlimited).

GET /auth/tokens

Your tokens, never their secrets.

POST /auth/tokens

Mint one: {"name", "expires_in_days", "scope"}. The secret is in this one response ({"token": "sgt-…", ...}); only its hash is stored. scope absent or "full": the subject everywhere; "registry": pull-only (see Authentication).

DELETE /auth/tokens/:id

Revoke one of your tokens; it stops authenticating immediately.

GET /auth/identities

Your linked logins, oldest first (provider key + display name when still configured).

POST /auth/identities

Link 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 → 409 naming 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 /projects

Your projects (owned + member, with roles), newest first.

POST /projects

Create one: {"slug": "web"} (lowercase letters, digits, dashes). You own what you create.

GET /projects/:owner/:slug

One project.

PATCH /projects/:owner/:slug

Flip visibility: {"visibility": "private"|"public"} (owner). A public project’s artifacts are pullable by anyone (see Auth and visibility).

DELETE /projects/:owner/:slug

Delete it (owner; refused while jobs exist, because build history is referenced, not orphaned).

GET /projects/:owner/:slug/members

The members, with roles.

PUT /projects/:owner/:slug/members

Grant or change one: {"handle", "role": "viewer"|"builder"|"promoter"} (owner only; owner is the project record and cannot be granted).

DELETE /projects/:owner/:slug/members/:handle

Revoke a membership (owner only).

GET /projects/:owner/:slug/remotes

The project’s git remotes (its mirrors; derive-on-submit matches these).

POST /projects/:owner/:slug/remotes

Register 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 /watches

The watches you can see (with their project): unattributed watches, plus every watch of a project you have viewer standing on.

POST /watches

Register 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": "…"
}

settings carries the submit defaults of triggered builds (formats, arches, env, cache, ephemeral, test, test_cmd). forge is generic (the default; it reports nothing), gitea, forgejo or github; credential is 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, a cache: "artifact" trigger (memoization does not compose with groups), an empty pattern or arch list, a zero interval.

GET /watches/:id

One watch in full, including its webhook_secret, the HMAC key to paste into the forge’s webhook config.

PUT /watches/:id

Change 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": true mints a fresh webhook secret (the forge config must be updated; the new value rides the response). "credential" replaces the forge token.

DELETE /watches/:id

Remove it. The history stays; re-registering starts clean.

POST /watches/:id/trigger

Poll 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/repositories

The 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 a reason ("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 /nodes

The runners the caller owns or is shared with: node id, arch, engine version, state, owner handle, owned flag, declared conduct profile (network, docker_format), last seen.

POST /nodes/retire

Retire a runner you own: {"node_id": …} → the row stays listed retired. Not idempotent: unknown or already-retired answers 404.

GET /nodes/:id/shares

The accounts the runner is shared with (owner only).

PUT /nodes/:id/shares

Grant use to an account: {"handle": "…"} → the sharee list.

DELETE /nodes/:id/shares/:handle

Revoke 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).