Skip to content

REST API

Updated 4 min read

The apps-api is the programmatic authority: every write renders an App custom resource, and the operator does the actual work. The UI and the MCP server are both thin clients over this API.

Base path: /api/v1 — same-origin via the UI (https://apps.<cluster-domain>/api/v1/...).

All endpoints except /healthz, /readyz and /config require a Keycloak JWT bearer token:

Authorization: Bearer <access-token>

The API validates the signature against the realm JWKS, and stamps the caller’s preferred_username as the app’s owner on create. With api.auth.enabled=false (local development), requests are anonymous and treated as an admin.

Every response carries an X-Request-ID header. Send one (up to 128 characters from A-Z a-z 0-9 . _ : -) and it is kept; otherwise the API makes one. Behind the Nebari gateway, Envoy assigns the id at the edge; the UI’s nginx and the MCP server pass it on, so the id on a response is the one in the API’s logs for that call.

Every route that addresses one app (/apps/{ns}/{name} and everything under it) allows only the app’s owner or a member of an admin group (api.adminGroups, default ["admin"]); anyone else gets 403. GET /apps and the /analytics/* endpoints return only the apps the caller may see. Apps without an owner (created with kubectl) are admin-only. The path namespace must be a launchable namespace on every route, not only on create.

Method & pathDescription
GET /healthzLiveness (public). Always 200 while the process serves.
GET /readyzReadiness (public): 200 {"status": "ok", "checks": {...}} when the Kubernetes API answers a namespace list and, with auth on, the realm JWKS loads (each within 3s; a good JWKS fetch is reused for 60s). Otherwise 503, with the failing check marked "failed" and the reason in the API’s log.
GET /configUI bootstrap config (public): authEnabled, Keycloak url/realm/SPA client id, appsDomain, appsScheme.
GET /capabilities{appsDomain, sourceTypes, namespaces} — what this cluster supports and where the caller may launch. sourceTypes is ["git", "inline", "pvc"].
GET /auth/meThe caller’s username, email, groups, and admin (member of an admin group).
Method & pathDescription
GET /apps?namespace=List the apps the caller may see (all launchable namespaces, or one).
POST /appsCreate + launch. Body mirrors App.spec plus name/namespace (see below). 409 if the name exists in the namespace or the subdomain is used by any other app.
POST /apps/uploadCreate + launch an app from an upload. Multipart: manifest (JSON, source omitted) + file (.zip or .html). A manifest with runtime.pixiTask launches the zip as a Python/pixi app.
GET /apps/{ns}/{name}Full spec + status.
PATCH /apps/{ns}/{name}Update any of displayName, description, thumbnail, source, runtime, access. access.subdomain cannot change (422).
DELETE /apps/{ns}/{name}Delete (cascades to all children).
POST /apps/{ns}/{name}/stopScale to zero.
POST /apps/{ns}/{name}/startScale back to one.
POST /apps/{ns}/{name}/restartRoll the app’s pods (like kubectl rollout restart) without changing the spec. 409 if the app is stopped.
Method & pathDescription
GET /apps/{ns}/{name}/statusPhase, URL, replicas, conditions, message.
GET /apps/{ns}/{name}/logs?lines=200&container=Recent pod logs (lines 1–5000, default 200).
GET /apps/{ns}/{name}/eventsKubernetes events for the app’s resources.
GET /apps/{ns}/{name}/metricsInstantaneous per-pod CPU (millicores) and memory (Mi) from metrics.k8s.io. {available, pods:[{name, cpu, memory}]}; available:false when the cluster has no metrics server.
GET /analytics/summary?namespace=Over the apps the caller may see: totals and breakdowns by phase / source type / namespace, plus replica readiness.
GET /analytics/metricsResource usage of the apps the caller may see, aggregated per app and per namespace, plus restart counts. {usageAvailable, apps:[{namespace, name, cpu, memory, restarts}], byNamespace:[…]}. Restart counts always populate (pod status); CPU/memory require a metrics server.
{
"name": "team-site",
"namespace": "team-analytics",
"displayName": "Team Site",
"description": "The team's documentation site",
"source": {
"type": "git",
"git": { "url": "https://github.com/org/site", "ref": "main", "subdir": "public" }
},
"runtime": {
"replicas": 1,
"env": [{ "name": "LOG_LEVEL", "value": "info" }],
"resources": { "requests": { "cpu": "250m", "memory": "512Mi" } }
},
"access": { "public": false, "groups": ["analytics"], "subdomain": "team-site" }
}

To launch a Python/pixi app, add "pixiTask": "<task>" to runtime — the source (any type) must then contain a pixi.toml or pyproject.toml at its root, and the task must start a server on 0.0.0.0:8080. See Launching apps.

name must be a valid Kubernetes name — lowercase letters, digits, and hyphens (^[a-z0-9]([-a-z0-9]*[a-z0-9])?$), at most 53 characters.

The API validates the source before writing the CR, so impossible launches fail fast with a 422 and a human-readable detail.

POST /apps/upload, multipart/form-data:

PartContent
manifestThe create request JSON without source.
fileA .zip of the app, or a single .html file (static apps only).

Rules: static archives need a root index.html; pixi archives (runtime.pixiTask in the manifest) need a root pixi.toml or pyproject.toml instead (one top-level folder is flattened either way). Text files only; ~900KB total. Violations return 400/413 with the reason. The extracted files become the App’s inline source.

Terminal window
curl -X POST https://apps.example.ai/api/v1/apps/upload \
-H "Authorization: Bearer $TOKEN" \
-F 'manifest={"name":"docs-site","namespace":"apps","displayName":"Docs Site",
"access":{"public":true,"subdomain":"docs-site"}}' \
-F "file=@site.zip"
StatusMeaning
401Missing or invalid bearer token.
403Namespace not available (not a launchable namespace), or the caller is neither the app’s owner nor an admin.
404App (or its pods, for logs) not found.
409An app with that name already exists in the namespace, or the subdomain is already used by another app.
413Request body over 2 MiB, or an upload (or its extracted contents) over the ~900 KB inline cap. Zip members are checked against the cap before they are decompressed.
422Invalid request: unsupported source type, missing required fields, or a non-empty access.users (reserved, not enforced).

Error bodies are {"detail": "..."} with an actionable message.