Skip to Content
DocsAPI ReferenceCloud OS API

Cloud OS API

Complete reference for the Quazzar Cloud OS REST API. All endpoints use the /api/ prefix and require authentication (session cookie, Authorization: Bearer, or X-API-Key) unless marked public; anonymous requests get 401. System-level changes — app install/uninstall/start/stop, domains, system service actions, updates, Docker cleanup, backups, security scans, the audit log, terminals, the host File Manager (/api/files/*, reads included), AI runtime / model / provider administration, workflow changes and runs, Server Mode changes (containers, disks, storage, network, proxy routes, tunnels, object storage, Plex), the sysctl actuators, crontab and the Database Manager — additionally need the admin role and return 403 for other users. API keys act with their owner’s current role and are limited to their scopes; the paired mobile app acts with its owner’s role. Installs from every path (API, setup, Molly, Control Panel) count against the plan’s app limit (402 upgrade_required when reached). The full list of public endpoints is in Security Hardening. The base URL is your Cloud OS instance address (for example, http://localhost:8080/api/).

See Authentication for details on obtaining and using tokens.


Error responses

Most errors are JSON with four fields:

{ "error": "Invalid name.", "code": "invalid_name", "message": "Invalid name.", "params": {} }

error and message carry the English text (unchanged for existing clients such as the CLI and the mobile app). code is a stable identifier and params fills its placeholders; the web app uses them to show the message in the user’s language. A few endpoints, such as the component and upgrade guards, keep a machine code in error (for example component_not_installed); their message is still the English text.


Authentication

Endpoints for user setup, login, token management, TOTP two-factor authentication, session management, and Control Panel OAuth integration.

MethodPathDescription
POST/api/auth/setupCreate the initial admin account (first boot only)
POST/api/auth/loginAuthenticate and receive JWT tokens
POST/api/auth/refreshRefresh an expired access token (public; authenticated by the refresh_token cookie)
POST/api/auth/logoutRevoke the current session and clear the auth cookies (public; identifies the session by the refresh cookie, a {"refresh_token"} body, or the access token)
GET/api/auth/meGet the current authenticated user
POST/api/auth/change-passwordChange the admin password
POST/api/auth/totp/enableEnable TOTP two-factor authentication
POST/api/auth/totp/verifyVerify a TOTP code
GET/api/auth/sessionsList active sessions
DELETE/api/auth/sessions/{id}Revoke a specific session
GET/api/auth/cp-statusCheck whether this node is connected to a Control Panel (used by login page to show/hide the “Sign in with Quazzar Cloud” button)

Control Panel Session (Impersonate)

MethodPathDescription
GET/api/cp/sessionValidate a CP-issued impersonate token and create a local OS session

This endpoint is called by the browser redirect from the CP “Open in node” button. The token query parameter must be a valid HMAC-signed JWT issued by the CP for this specific node. On success, a session cookie is set and the response redirects to the OS dashboard.

OAuth Callback (Sign in with Quazzar Cloud)

MethodPathDescription
GET/api/oauth/callbackHandle the OAuth 2.0 authorization code callback from the CP IdP

The OS redirects here after the user approves on the CP. The endpoint exchanges the code with the CP, validates the identity assertion, and creates a local OS session.

Query parameters: code (required), state (required, must match the value generated at authorization start).

Lock screen, passkeys & approve-on-device

Every endpoint below that signs someone in (/unlock, /passkeys/login/finish, the approved /approve/{id} poll) answers with exactly the same body and cookies as POST /api/auth/login: {"access_token": "…", "user": {…}} plus the access_token and refresh_token cookies. See the Sign-in guide for the user-facing side.

MethodPathAuthDescription
GET/api/auth/lock-infopublicNode facts for the Login/Lock screen (no user data, cached 10 s)
POST/api/auth/locksessionLock the current session → 204
POST/api/auth/unlocksession (allowed while locked){"password":"…"} or {"passkey":{"session_id","credential"}} → fresh session
POST/api/auth/passkeys/register/beginsession{session_id, options:{publicKey:…}} for navigator.credentials.create()
POST/api/auth/passkeys/register/finishsession{session_id, name, credential} → {id, name, created_at}
GET/api/auth/passkeyssession{"passkeys":[{id, name, created_at, last_used_at}]}
PATCH/api/auth/passkeys/{id}session{"name":"…"} → the updated passkey
DELETE/api/auth/passkeys/{id}sessionRemove a passkey → 204
POST/api/auth/passkeys/login/beginpublicDiscoverable sign-in: no username, empty allowCredentials
POST/api/auth/passkeys/login/finishpublic{session_id, credential} → login response
POST/api/auth/approve/requestpublic{"email"} → {request_id, expires_at, code} (always, even for unknown emails)
GET/api/auth/approve/{id}publicstatus is pending, approved, denied or expired; the first poll after approval also returns the session
GET/api/auth/approve/{id}/streampublicServer-Sent Events, event: status with the same JSON
GET/api/auth/approve/pendingsession{"requests":[{id, device, ip, city, created_at, expires_at}]} for the current user
POST/api/auth/approve/{id}/decidesession (target user){"approve":true,"code":"47"} → 204; a wrong code → 400 and the request is denied

Lock-info response

{ "hostname": "quazzar-01", "online": true, "uptime_seconds": 1234567, "apps_in_orbit": 12, "version": "0.10.4", "setup_required": false, "passkeys_enabled": true, "approve_enabled": true }

passkeys_enabled is false when the page is served over plain HTTP from anything other than localhost, or from a bare IP address — browsers only offer passkeys in a secure context on a real hostname.

Locked sessions. While a session is locked, every authenticated request it makes returns 423 {"error":"locked"} except /api/auth/unlock, /api/auth/logout, /api/auth/lock-info and /api/auth/me (which reports "locked": true). Refreshing the access token does not unlock. Wrong unlock passwords return 401 and count toward the same per-IP limit as /api/auth/login (5 attempts / 15 min). Tokens issued before this release carry no session id; locking them returns 409 session_not_lockable until the next refresh (≤ 15 min).

Passkeys. The relying-party ID is auth.webauthn_rp_id (env QUAZZAR_WEBAUTHN_RP_ID, default proxy.base_domain) when the browser is on that host or one of its subdomains; otherwise it is the request host. Challenges expire after 5 minutes and can be used once. A passkey sign-in with user verification (Face ID, fingerprint, PIN) satisfies two-factor authentication; without user verification, accounts with TOTP get the usual {"totp_required": true, "user_id": "…"} step. A signature counter that goes backwards (a possibly cloned key) is refused and logged.

Approve on another device. Requests expire after 2 minutes and are rate-limited per IP (10 / 10 min) and per email (5 / 10 min). The code is shown only on the requesting screen, never in the notification, so the approver has to type what they see. The SSE stream carries only the status; when it reports approved, make one GET /api/auth/approve/{id} to collect the session (cookies cannot be set mid-stream).

QR-code sign-in

MethodPathAuthDescription
POST/api/auth/qr/generatepublic (rate-limited)→ {"session_id", "secret", "expires_at"}. Put only session_id in the QR code; keep secret on this device
GET/api/auth/qr/status/{session_id}public→ {"status"}: pending, approved, consumed or expired. Never returns a token
POST/api/auth/qr/approvesession or paired mobile app{"session_id"} → {"status":"approved","message":"session approved"}; 404 unknown, 410 expired, 409 already approved or used. /api/auth/qr/authenticate is an alias for older mobile builds
POST/api/auth/qr/exchangepublic (the secret is the credential; rate-limited){"session_id","secret"} → the login response (same body and cookies as POST /api/auth/login)

/qr/exchange errors: 400 missing field, 401 {"error":"invalid qr session"} for an unknown id, a wrong secret or a session already exchanged (these cases are indistinguishable), 409 not approved yet (the session stays usable — keep polling), 410 expired. A code lives 5 minutes; approving it leaves at most 60 seconds to exchange, and it can be exchanged once. API keys cannot approve.

Profile: avatar & preferences

MethodPathAuthDescription
PUT/api/profile/avatarsessionmultipart field file, ≤ 2 MB, PNG/JPEG/WebP → {"avatar_url":"/api/avatars/<sha256>.png"}
DELETE/api/profile/avatarsessionRemove the avatar → 204
GET/api/avatars/{sha256}.pngpublicThe 256×256 PNG, served with immutable cache headers
GET/api/profile/preferencessessionShell preferences with defaults applied: accent, custom_accent, theme, motion, sounds, mode, spaces (hero blocks per space, e.g. {"home":{"player":false}}; only changed blocks are stored)
PUT/api/profile/preferencessessionPartial update, returns the full object. Unknown fields → 400; spaces: null resets the blocks

The image type is detected from the file contents, not the extension. Uploads are center-cropped and re-encoded, which also strips EXIF/GPS metadata. avatar_url (string or null) also appears on the user object of /api/auth/me, the login response and /api/users.

{ "accent": "nebula", "custom_accent": null, "theme": "dark", "motion": "max", "sounds": true, "mode": "server" }

accent is one of nebula|plasma|ion|quasar|custom, custom_accent is #RRGGBB or null, theme is dark|light, motion is calm|cinematic|max, mode is server|personal|cluster. Unknown fields and invalid values return 400. If you never set mode, it follows the node default chosen at first-run setup, or server for admins and personal for everyone else when none was chosen.

First-run setup

MethodPathAuthDescription
POST/api/setup/nodeadminName the node, pick the default mode, install starter apps
// request { "name": "quazzar-01", "mode": "personal", "starter_apps": ["immich", "jellyfin"] } // response { "hostname": "quazzar-01", "mode": "personal", "apps": [ { "template": "immich", "app_id": "8f1c…", "status": "installing" }, { "template": "jellyfin", "error": "upgrade_required" } ] }

name is lowercased and must be a valid hostname label (letters, digits and -, 1–63 characters, not starting or ending with -). Starter apps install the same way as POST /api/apps, including the plan’s app limit. If one app fails, its error is listed and the rest continue.


Apps

Manage applications installed on the Cloud OS instance. Apps are deployed from templates as Docker containers and can be started, stopped, restarted, and monitored.

MethodPathDescription
GET/api/appsList all installed apps
POST/api/appsInstall a new app from a template
GET/api/apps/{appId}Get app details
DELETE/api/apps/{appId}Uninstall an app
POST/api/apps/{appId}/startStart a stopped app
POST/api/apps/{appId}/stopStop a running app
POST/api/apps/{appId}/restartRestart an app
GET/api/apps/{appId}/logsGet app container logs (admin)
GET/api/apps/{appId}/statsGet app resource usage statistics

Container logs routinely contain secrets, so every way of reading them is admin only: GET /api/apps/{appId}/logs, GET /api/apps/{appId}/logs/search, GET /api/containers/{id}/logs, the log aggregation surface (/api/logs, /api/logs/stream, /api/logs/system, /api/logs/journal, /api/logs/docker/{id}, /api/log-forwarders) and the public-tunnel log WebSocket GET /ws/tunnels/{id}. Non-admins get 403.

Containers (Server Mode)

MethodPathAuthDescription
GET/api/containerssessionList containers: name, image, state, ports, labels (no environment)
GET/api/containers/{id}adminInspect: environment variables, command, mounts, networks
GET/api/containers/{id}/logsadminContainer logs
POST / DELETE/api/containers/…adminRun, compose deploy, start / stop / restart / remove, images, registries

Inspect is admin only rather than redacted: the environment usually holds database passwords and API keys, and the command line and logs carry the same secrets, so hiding env values alone would not be enough.

Example: Install an App

curl -X POST http://localhost:8080/api/apps \ -H "Authorization: Bearer <token>" \ -H "Content-Type: application/json" \ -d '{ "template": "nextcloud", "name": "my-nextcloud", "config": { "admin_user": "admin", "admin_password": "SecurePassword123" } }'
{ "id": "app_abc123", "name": "my-nextcloud", "template": "nextcloud", "status": "installing", "created_at": "2025-01-15T10:30:00Z" }

Example: Get App Logs

curl "http://localhost:8080/api/apps/app_abc123/logs?lines=100&follow=false" \ -H "Authorization: Bearer <token>"

Example: Get App Stats

curl http://localhost:8080/api/apps/app_abc123/stats \ -H "Authorization: Bearer <token>"
{ "app_id": "app_abc123", "cpu_percent": 2.5, "memory_usage_mb": 256, "memory_limit_mb": 1024, "network_rx_bytes": 1048576, "network_tx_bytes": 524288, "disk_usage_mb": 512 }

Templates

Browse the application template catalog. Templates define how apps are installed and configured.

MethodPathDescription
GET/api/templatesList all available templates
GET/api/templates/categoriesList template categories
GET/api/templates/{name}Get details for a specific template
GET/api/templates/featuredGet the template shown in the Launchpad Featured banner (404 JSON error when none is flagged)

Each template may carry three optional fields used by the Launchpad cards: stars (approximate GitHub stars of the upstream project, e.g. 48000), featured (true for the Featured banner) and tagline (a short banner line). They are omitted when not set. Template authors can add them to the template YAML:

name: immich website: https://immich.app stars: 48000 featured: true tagline: "Your photo library, off the cloud."

Example: List Templates

curl http://localhost:8080/api/templates \ -H "Authorization: Bearer <token>"
[ { "name": "nextcloud", "display_name": "Nextcloud", "description": "Self-hosted productivity platform", "category": "productivity", "version": "28.0", "icon": "nextcloud.svg" }, { "name": "gitea", "display_name": "Gitea", "description": "Lightweight self-hosted Git service", "category": "development", "version": "1.21", "icon": "gitea.svg" } ]

Dashboard Layout API

Per-user, per-kind dashboard layout stored and versioned server-side. Two independent layouts exist: server (Server Home) and personal (Personal Home). Changes are written with optimistic concurrency — a stale write returns 409 Conflict and the client must refetch before retrying.

See Per-User Layout Sync for the UI-level description.

MethodPathDescription
GET/api/profile/dashboard?type=server|personalFetch the current layout for the given dashboard kind
PUT/api/profile/dashboard?type=server|personalSave a new layout. Body: { layout, version }

GET /api/profile/dashboard

Query parameters

ParamRequiredValues
typeyesserver or personal
curl "http://localhost:8080/api/profile/dashboard?type=server" \ -H "Authorization: Bearer <token>"
{ "type": "server", "version": 7, "layout": [ { "id": "cpu", "x": 0, "y": 0, "w": 1, "h": 1 }, { "id": "ram", "x": 1, "y": 0, "w": 1, "h": 1 }, { "id": "network", "x": 2, "y": 0, "w": 2, "h": 2 } ] }

version is a monotonically increasing integer. Always pass the version you received back when writing.

PUT /api/profile/dashboard

Query parameters

ParamRequiredValues
typeyesserver or personal

Request body

{ "version": 7, "layout": [ { "id": "cpu", "x": 0, "y": 0, "w": 2, "h": 1 }, { "id": "ram", "x": 2, "y": 0, "w": 2, "h": 1 }, { "id": "network", "x": 0, "y": 1, "w": 4, "h": 2 } ] }
curl -X PUT "http://localhost:8080/api/profile/dashboard?type=server" \ -H "Authorization: Bearer <token>" \ -H "Content-Type: application/json" \ -d '{ "version": 7, "layout": [...] }'

Response (success)

{ "type": "server", "version": 8 }

Status codes

StatusMeaning
200Layout saved; response includes the new version
400Missing or invalid type parameter, or malformed body
401Not authenticated
403Insufficient permissions
409Stale version — another device has written a newer layout. Fetch the latest and retry

When a 409 is returned the body includes the current server version so the client knows what to fetch:

{ "error": "stale_version", "current_version": 9, "message": "Layout was updated from another device. Reload and retry." }

Components

Read and change which optional parts of Cloud OS are installed. See Components.

MethodPathAccessDescription
GET/api/componentsAny signed-in userList components and their status
POST/api/components/applyAdminStart a change; returns an operation id
GET/api/components/ops/{id}/events?from=NAdminServer-sent events for an operation, starting at event N
POST/api/components/objstore/purge-minioAdminDelete the old MinIO data after the move to Garage; returns {"freed_bytes": N, "binary_removal_started": true}

Example: List components

{ "components": [ { "id": "objstore", "title": "S3 Object Storage", "summary": "...", "ui": "...", "requires": [], "enabled": true, "status": "installed" } ], "docker": { "mode": "keep", "flavor": "docker.io", "version": "24.0.7", "too_old": false }, "tuning": { "zram": true, "earlyoom": true, "irqbalance": true, "cpupower": true, "preload": true }, "busy": false }

status is installed, disabled, failed or pending-apt. config_error is added when components.yaml cannot be read; in that case every feature stays visible.

On a node that has MinIO data the response also has a top-level objstore field:

"objstore": { "phase": "copying", "copied": 1240, "total": 3100, "bytes": 5242880 }

phase is pending (Garage not installed yet), waiting_space (with error_code: "objstore_not_enough_space" and error_params: {"needed", "free"}), copying, catching_up, verifying, switching, failed (with error) or done (with legacy_bytes until the old data is deleted, then purged: true). drive_paused: true means Orbit Drive is off until the node can tell which store holds its data or quazzard restarts; error_code then says why (objstore_migration_state_unknown, objstore_migration_state_unknown_next_start when Garage is down too and nothing is retried before the next start, objstore_minio_unavailable, objstore_drive_next_start). With objstore_minio_unavailable, error_params.error is the start failure and error_params.missing is binary or env when the minio program or minio.env is gone.

POST /api/components/objstore/purge-minio is refused with 409 and one of objstore_minio_purge_unavailable (the move has not finished), objstore_minio_purge_too_soon (within 6 minutes of the switch; error_params.minutes), objstore_garage_unavailable (Garage is not healthy), objstore_garage_node_changed (the running Garage node is not the one the data was moved to), objstore_garage_data_mismatch (a bucket that received objects in the move is now gone or empty; error_params.bucket, moved, now), objstore_minio_changed_after_switch (files in the MinIO buckets changed after the switch; error_params.count) or components_busy (a components apply is running, or systemd cannot say).

Example: Apply a change

set uses the same syntax as quazzar setup apply --set. Set confirm_snap to true when replacing the snap version of Docker.

curl -X POST http://localhost:8080/api/components/apply \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"set": "vpn,-mail,docker=replace", "confirm_snap": false}'
{ "op_id": "op-1790000000000000000" }

Errors: 400 (bad_spec, confirm_snap_required), 409 (busy, another operation is running).

Example: Follow progress

GET /api/components/ops/{op_id}/events?from=0 streams events of type plan, step, log, restart, done and error. The stream ends after done or error. The operation may restart Quazzar; reconnect with from set to the last event number plus one to continue without repeats.

id: 3 event: step data: {"seq":3,"type":"step","component":"vpn","step":"install","title":"Installing WireGuard","status":"ok","time":"2026-09-28T10:00:00Z"}

Requests to components that are off

Routes that belong to a component that is not installed answer 404:

{ "error": "component_not_installed", "component": "objstore", "message": "This component is not installed. Install it in Settings → Components." }

Changing network settings while network management is off answers 409 with the same error and component set to network. Reading network state still works.


Object Storage

The node’s Garage store. Reads for any signed-in user; changes need the admin role. When S3 Object Storage is off the routes answer 404 component_not_installed; when Garage is not running, 503 objstore_garage_unavailable.

MethodPathDescription
GET/api/objects/buckets{"buckets":[{"name","created_at","size_bytes","object_count","visibility","public_url","public_host","reserved"}]} — public_* only for public buckets; reserved: true for Orbit Drive’s quazzar-drive
POST/api/objects/buckets{"name":"cdn","visibility":"public"} → 201
DELETE/api/objects/buckets/{name}204; 409 objstore_bucket_not_empty; 409 objstore_bucket_reserved for quazzar-drive
GET/api/objects/access-keys{"access_keys":[{"access_key","name","status","grants":[{"bucket","read","write","owner"}]}]}
POST/api/objects/access-keysBody below → 201 with the secret, once
DELETE/api/objects/access-keys/{key}204
GET/api/objects/endpoint{"internal":"http://127.0.0.1:3900","web":"http://127.0.0.1:3902","region":"us-east-1","secure":false} — internal is a full URL; there is no console
curl -X POST http://localhost:8080/api/objects/access-keys \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{"name":"backup-agent","grants":[{"bucket":"backups","read":true,"write":true,"owner":false}]}'
{ "access_key": "GK31c2f218a2e44f485b94239e", "secret_key": "…", "name": "backup-agent", "grants": [{ "bucket": "backups", "read": true, "write": true, "owner": false }], "secret_is_one_shot": true }

Errors: 400 objstore_policy_unsupported (a non-empty policy field — IAM policies are gone), 404 objstore_bucket_not_found (a grant names an unknown bucket; nothing is created), 409 objstore_bucket_exists, 409 objstore_bucket_reserved (a grant on quazzar-drive, Orbit Drive’s bucket; nothing is created), 409 objstore_migration_in_progress (bucket and key changes wait until the move from MinIO finishes).

Reserved settings keys

Settings keys that start with objstore. belong to Quazzar (the MinIO to Garage migration record). The settings API does not list them, GET /api/settings/{key} answers for one exactly as for a key that does not exist (200 with an empty value), and writing one (PUT /api/settings/{key} or inside a bulk PUT /api/settings/) answers 400 settings_key_reserved.


System

Monitor system resources, hardware health, and performance metrics for the Cloud OS instance.

MethodPathDescription
GET/api/system/metrics/currentGet current system resource usage (CPU, memory, disk)
GET/api/system/metricsGet historical system metrics
GET/api/system/infoGet system information (OS, kernel, uptime)
GET/api/system/apps/metricsGet aggregated metrics for all apps
GET/api/system/disk/healthGet disk health status (S.M.A.R.T.)
GET/api/system/disksList real filesystems (ext4, xfs, btrfs, zfs, f2fs, vfat)
GET/api/system/network/interfacesList network interfaces
GET/api/system/overviewOne-call summary for the Overview and Login screens (cached ~5 s)
GET/api/system/metrics/liveFast live sample for the Monitoring screen (poll ~1 s)
GET/api/disks/overviewPhysical disks with usage and S.M.A.R.T. health (cached 60 s)

Example: Get Current Metrics

curl http://localhost:8080/api/system/metrics/current \ -H "Authorization: Bearer <token>"
{ "cpu": { "usage_percent": 12.5, "cores": 4, "model": "Intel Xeon E-2236" }, "memory": { "total_mb": 16384, "used_mb": 8192, "usage_percent": 50.0 }, "disk": { "total_gb": 500, "used_gb": 120, "usage_percent": 24.0 }, "uptime_seconds": 864000 }

Example: Get System Info

curl http://localhost:8080/api/system/info \ -H "Authorization: Bearer <token>"
{ "hostname": "cloud-os-01", "os": "Ubuntu 22.04 LTS", "kernel": "5.15.0-91-generic", "architecture": "x86_64", "quazzar_version": "0.9.7", "uptime_seconds": 864000, "node_id": "node_abc123" }

Example: List Disks

Returns every real filesystem on the host. Pseudo-filesystems (tmpfs, devtmpfs, squashfs, overlay mounts) are excluded. The Storage Overview dashboard widget reads this endpoint.

curl http://localhost:8080/api/system/disks \ -H "Authorization: Bearer <token>"
[ { "device": "/dev/sda1", "mount_point": "/", "fs_type": "ext4", "total_bytes": 214748364800, "used_bytes": 52428800000, "free_bytes": 162319564800, "usage_percent": 24.4 }, { "device": "/dev/sda2", "mount_point": "/boot/efi", "fs_type": "vfat", "total_bytes": 536870912, "used_bytes": 5242880, "free_bytes": 531628032, "usage_percent": 1.0 } ]

Status codes

StatusMeaning
200List of filesystems
401Not authenticated

Example: System Overview

Host facts plus app, container, reverse-proxy, backup, alert and security summaries. Every block is best-effort: when a source is unavailable (no Docker, no scan yet) its counters are 0 and its timestamps null instead of failing the request.

curl http://localhost:8080/api/system/overview \ -H "Authorization: Bearer <token>"
{ "hostname": "quazzar-01", "os": "Ubuntu 24.04.1 LTS", "kernel": "6.8.0-45-generic", "arch": "amd64", "cpu_model": "AMD Ryzen 9 7950X 16-Core Processor", "cpu_cores": 16, "cpu_threads": 32, "ram_total_bytes": 68719476736, "uptime_seconds": 1234567, "version": "0.10.4", "apps": { "installed": 12, "running": 11 }, "containers": { "running": 14, "total": 16 }, "proxy_routes": 14, "backup": { "status": "ok", "last_success_at": "2026-09-24T02:00:00Z", "last_verified_at": "2026-09-24T02:05:00Z", "last_size_bytes": 442381631488 }, "alerts": { "open": 0, "critical": 0 }, "security": { "score": 92, "max": 100, "last_scan_at": "2026-09-24T09:15:00Z" } }

backup.status is ok, stale (last success older than 48 h), failed (the most recent run failed) or none (no finished backup yet). last_verified_at is the latest passing backup verification — verification results are now stored, so it survives restarts.

Example: Live Metrics

A cheap sample meant to be polled about once a second while the Monitoring screen is visible. Samples younger than 500 ms are shared between callers, so many open tabs cost the same as one. Rates are derived from counter deltas between samples and are never negative (counter resets read as 0). net.iface is the default-route interface (falling back to the busiest non-virtual one); ifaces lists physical interfaces only. cpu_temp_c is null when no sensor is readable (VMs, containers).

curl http://localhost:8080/api/system/metrics/live \ -H "Authorization: Bearer <token>"
{ "at": "2026-09-24T15:02:03Z", "cpu_percent": 23.4, "per_core": [12.1, 40.2, 8.0, 33.3], "cpu_temp_c": 52.0, "load": [1.2, 0.9, 0.7], "ram_used_bytes": 21233664000, "ram_total_bytes": 68719476736, "swap_used_bytes": 0, "net": { "iface": "eth0", "rx_bytes_per_sec": 4200000, "tx_bytes_per_sec": 1200000 }, "ifaces": [ { "name": "eth0", "rx_bytes_per_sec": 4200000, "tx_bytes_per_sec": 1200000 } ], "disk_io": { "read_bytes_per_sec": 1000, "write_bytes_per_sec": 2000 } }

Example: Disks Overview (S.M.A.R.T.)

Physical disks only (loop, RAM and zram devices are skipped). used_bytes / used_percent are aggregated over every mounted partition, LUKS or LVM volume on the disk. S.M.A.R.T. is read with smartctl -j -a for every disk (install smartmontools; the service retries through sudo -n when it lacks permission). smart.status is one of Healthy, Failing, <n> reallocated, <n> pending, <n> media errors, Worn <n>%, or Unknown with smart.available: false when S.M.A.R.T. can’t be read.

curl http://localhost:8080/api/disks/overview \ -H "Authorization: Bearer <token>"
[ { "device": "nvme0n1", "model": "Samsung SSD 990 PRO 1TB", "serial": "S6Z...", "size_bytes": 1000204886016, "used_bytes": 580000000000, "used_percent": 58.0, "mounts": ["/", "/data"], "rotational": false, "transport": "nvme", "smart": { "available": true, "healthy": true, "status": "Healthy", "temperature_c": 38, "power_on_hours": 8123, "reallocated_sectors": 0, "pending_sectors": 0, "media_errors": 0, "percentage_used": 3 } } ]

GET /api/disks/{name} now also returns the full S.M.A.R.T. attribute map (smart.attributes, e.g. "Reallocated_Sector_Ct": "4" for SATA drives or "percentage_used": "3" for NVMe).


Release Notes & Feature Tour

The What’s new page reads release notes the node caches from the Control Center (GET {CC}/api/v1/release-notes?product=os). The node pulls the full list once an hour and on Sync; the cache is replaced only after a successful fetch, so a Control Center outage never empties the page. Enrolled nodes send their instance id, so canary notes for a staged rollout they take part in show up too.

MethodPathDescription
GET/api/release-notesCached release notes, newest version first: [{id, version, title, summary, published_at, items[], fetched_at}]
GET/api/release-notes/{version}One cached release note
POST/api/release-notes/syncFetch the notes from the Control Center now (admin); 502 and the old cache kept on failure
GET/api/feature-tourFeature tour catalogue: {version, slides[]}. The tour opens once when version is newer than /api/profile/last-seen-release

Network History

Per-interface throughput history used by the Network — Per Interface dashboard widget.

MethodPathDescription
GET/api/network/historyGet bucketed RX/TX history for a network interface

GET /api/network/history

Query parameters

ParamRequiredValuesDefault
interfaceyesInterface name, e.g. eth0—
rangeno5m, 15m, 1h, 6h, 24h1h

Each range value maps to a fixed bucket size:

RangeBucket
5m10 s
15m30 s
1h1 min
6h5 min
24h15 min
curl "http://localhost:8080/api/network/history?interface=eth0&range=1h" \ -H "Authorization: Bearer <token>"
{ "interface": "eth0", "range": "1h", "bucket_seconds": 60, "points": [ { "ts": "2026-05-25T10:00:00Z", "rx_bytes_per_sec": 125000, "tx_bytes_per_sec": 48000 }, { "ts": "2026-05-25T10:01:00Z", "rx_bytes_per_sec": 132000, "tx_bytes_per_sec": 51000 } ] }

ts values are bucket start times in UTC ISO 8601. The array is sorted ascending by ts. Points with no observed traffic report 0 for both fields rather than being omitted.

Status codes

StatusMeaning
200History array
400Missing interface param or invalid range value
401Not authenticated

Backups

Create and manage backups of app data and system configuration. Supports local and remote backup targets.

MethodPathDescription
GET/api/backupsList all backups
POST/api/backupsCreate a new backup
POST/api/backups/{id}/restoreRestore from a backup
GET/api/backup/targetsList configured backup targets
POST/api/backup/targetsAdd a backup target (local, S3, etc.)
GET/api/backup/scheduleGet the backup schedule

Example: Create a Backup

curl -X POST http://localhost:8080/api/backups \ -H "Authorization: Bearer <token>" \ -H "Content-Type: application/json" \ -d '{ "target": "s3-primary", "apps": ["app_abc123", "app_def456"], "include_config": true }'
{ "id": "bak_abc123", "status": "in_progress", "target": "s3-primary", "apps": ["app_abc123", "app_def456"], "include_config": true, "started_at": "2025-01-15T10:30:00Z" }

Example: Configure an S3 Backup Target

curl -X POST http://localhost:8080/api/backup/targets \ -H "Authorization: Bearer <token>" \ -H "Content-Type: application/json" \ -d '{ "name": "s3-primary", "type": "s3", "config": { "bucket": "my-backups", "region": "us-east-1", "access_key": "AKIAIOSFODNN7EXAMPLE", "secret_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", "encryption": true } }'

Domains

Manage custom domains and SSL certificates for apps running on the Cloud OS instance.

MethodPathDescription
GET/api/domainsList all configured domains
POST/api/domainsAdd a new domain
PUT/api/domains/{id}Update a domain configuration
DELETE/api/domains/{id}Remove a domain
GET/api/domains/{id}/sslGet SSL certificate status for a domain

Example: Add a Domain

curl -X POST http://localhost:8080/api/domains \ -H "Authorization: Bearer <token>" \ -H "Content-Type: application/json" \ -d '{ "domain": "cloud.example.com", "app_id": "app_abc123", "auto_ssl": true }'
{ "id": "dom_abc123", "domain": "cloud.example.com", "app_id": "app_abc123", "ssl": { "status": "provisioning", "provider": "letsencrypt" }, "created_at": "2025-01-15T10:30:00Z" }

SSL certificates are automatically provisioned through the built-in Caddy reverse proxy when auto_ssl is enabled. Ensure your domain’s DNS A record points to the Cloud OS instance before adding it.


Reverse Proxy — Domains & Routes

Manage routing rules for the embedded Caddy reverse proxy. Routes map hostnames (subdomains or full domains) to installed apps, the Cloud OS UI, or arbitrary host:port upstreams. Changes are staged and applied atomically via POST /api/proxy/apply.

See Domains & Routes Manager for the UI walkthrough, including port-conflict resolution and SSO gating.

Routes

MethodPathDescription
GET/api/proxy/routesList all configured routes
POST/api/proxy/routesCreate a new route
GET/api/proxy/routes/{id}Get a single route
PUT/api/proxy/routes/{id}Update a route. Append ?confirm=true when re-pointing the apex domain
DELETE/api/proxy/routes/{id}Delete a custom route (returns 409 for apex or auto-created app routes)
POST/api/proxy/applyApply all staged route changes to Caddy
GET/api/proxy/upstreamsList all known upstreams (apps, OS UI, manual entries)

Settings (admin)

MethodPathDescription
POST/api/settings/proxy/takeoverStop and disable the conflicting web server so Caddy can own ports 80/443
GET/api/settings/proxy/coexist-snippetGenerate a nginx or Apache front-proxy snippet. Query param: proxy=nginx|apache

Forward Auth

MethodPathDescription
GET/api/auth/validateCaddy forward_auth target. Returns 200 with user headers on a valid session, 401 otherwise

Example: Create a Subdomain Route

curl -X POST http://localhost:8080/api/proxy/routes \ -H "Authorization: Bearer <token>" \ -H "Content-Type: application/json" \ -d '{ "hostname": "git.example.com", "upstream_type": "app", "upstream_id": "app_abc123", "require_auth": true }'
{ "id": "route_abc123", "hostname": "git.example.com", "upstream_type": "app", "upstream_id": "app_abc123", "require_auth": true, "status": "pending", "created_at": "2025-06-01T10:00:00Z" }

status is pending until POST /api/proxy/apply is called.

Example: Create a Manual Upstream Route

curl -X POST http://localhost:8080/api/proxy/routes \ -H "Authorization: Bearer <token>" \ -H "Content-Type: application/json" \ -d '{ "hostname": "metrics.example.com", "upstream_type": "manual", "upstream_addr": "localhost:9090", "require_auth": false }'

Example: Re-point the Apex Domain (requires confirm)

curl -X PUT "http://localhost:8080/api/proxy/routes/route_apex?confirm=true" \ -H "Authorization: Bearer <token>" \ -H "Content-Type: application/json" \ -d '{ "upstream_type": "app", "upstream_id": "app_abc123" }'

Omitting ?confirm=true returns 409 Conflict:

{ "error": "apex_repoint_requires_confirm", "message": "Re-pointing the apex domain will move the OS UI away from this address. Resubmit with ?confirm=true to proceed." }

Example: Apply Routes

curl -X POST http://localhost:8080/api/proxy/apply \ -H "Authorization: Bearer <token>"
{ "applied": 3, "unchanged": 7, "errors": [] }

Example: List Upstreams

curl http://localhost:8080/api/proxy/upstreams \ -H "Authorization: Bearer <token>"
[ { "id": "app_abc123", "type": "app", "name": "gitea", "addr": "127.0.0.1:3000" }, { "id": "quazzar-ui", "type": "ui", "name": "OS UI", "addr": "127.0.0.1:8080" } ]

Example: Get Coexist Snippet

curl "http://localhost:8080/api/settings/proxy/coexist-snippet?proxy=nginx" \ -H "Authorization: Bearer <token>"
# Paste inside your existing nginx http {} block server { listen 80; listen 443 ssl; server_name _; # Forward everything to Quazzar on the alternate port location / { proxy_pass http://127.0.0.1:8443; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } }

Status Codes

StatusMeaning
200OK
201Route created
204Route deleted
400Invalid request body or hostname
401Not authenticated
403Insufficient permissions (admin-only endpoints)
409Apex re-point without ?confirm=true, or attempt to delete a protected route

VPN

Manage the built-in WireGuard VPN server for secure remote access to the Cloud OS instance and its apps.

MethodPathDescription
GET/api/vpn/providerGet VPN server configuration
POST/api/vpn/providerConfigure the VPN server
GET/api/vpn/peersList VPN peers (clients)
POST/api/vpn/peersCreate a new VPN peer
DELETE/api/vpn/peers/{id}Remove a VPN peer
GET/api/vpn/peers/{id}/qrcodeGet a QR code for peer configuration

Example: Create a VPN Peer

curl -X POST http://localhost:8080/api/vpn/peers \ -H "Authorization: Bearer <token>" \ -H "Content-Type: application/json" \ -d '{ "name": "my-laptop", "dns": "1.1.1.1" }'
{ "id": "peer_abc123", "name": "my-laptop", "public_key": "aB1cD2eF3gH4...", "allowed_ips": "10.0.0.2/32", "config": "[Interface]\nPrivateKey = ...\nAddress = 10.0.0.2/32\nDNS = 1.1.1.1\n\n[Peer]\n..." }

The config field contains the full WireGuard configuration file content for the peer. You can also retrieve a QR code for easy mobile setup:

curl http://localhost:8080/api/vpn/peers/peer_abc123/qrcode \ -H "Authorization: Bearer <token>"

Cluster

Cluster mode (see Clustering). Admin endpoints need an admin session. Node-to-primary endpoints use per-node credentials; join endpoints are public but token-authenticated and rate-limited.

MethodPathAuthDescription
GET/api/cluster/overviewadminRole, Raft term / leader, mesh RTT, every node with metrics
POST/api/cluster/initadminMake this node the primary; returns the overview
POST/api/cluster/tokensadminCreate a one-time join token (ttl_minutes, labels) + install command
GET/api/cluster/tokensadminActive tokens (no secrets)
DELETE/api/cluster/tokens/{id}adminRevoke a token (204)
GET/api/cluster/join.shpublicJoin script (installs Cloud OS if missing)
POST/api/cluster/joinadmin or local keyWorker side: send a join request to a primary
POST/api/cluster/join-requestsjoin tokenWorker → primary: register as pending → {request_id}
GET/api/cluster/join-requests/{id}X-Join-TokenPoll: pending / approved / rejected; credentials exactly once
POST/api/cluster/heartbeatAuthorization: Node <id>:<secret>Metrics + apps in, directives out
POST/api/internal/workflow/execute-stepcluster network + X-Quazzar-Node-SignaturePrimary → node: run one delegated workflow step
POST/api/cluster/nodes/{id}/approve · /rejectadminDecide a pending node (204)
POST/api/cluster/nodes/{id}/cordon · /uncordon · /wakeadminNode actions (204; wake → 409 without a MAC)
POST/api/cluster/nodes/{id}/drainadminCordon + move apps → {moves, manual}
PUT/api/cluster/nodes/{id}/labelsadmin{"labels":{"gpu":"rtx4090"}} (object or legacy string)
DELETE/api/cluster/nodes/{id}adminRemove node, revoke its credential
GET/api/cluster/servicesadmin{services:[{id, app_id, app, template, node_id, node_host, port, protocol, state, moved_at}]}
GET / POST/api/cluster/placementadminList (array) / create placement rules
PUT / DELETE/api/cluster/placement/{id}adminUpdate / delete a rule
POST/api/cluster/rebalance[?dry_run=1]admin{moves:[{app, from, to}]}
POST/api/cluster/migrateadminMove one stateless app (app_id, to_node / to_node_id, optional from_node / from_node_id)
GET/api/cluster/events?limit=20admin{events:[{id, at, kind, severity, node_id, text}]}
GET/api/ha/statususerRaft state, leader_id, term, peers
POST/api/ha/drilladminFailover drill → {ok, seconds}

Service state is one of running, rescheduling, pending, failed, manual. Event kind is one of join_requested, approved, rejected, joined, heartbeat_lost, recovered, apps_moved, rescheduling_manual, cordoned, uncordoned, drained, removed, woken, rebalanced, rolling_update, failover_drill, leader_changed.

Example: Create a join token

curl -X POST https://quazzar-01.local/api/cluster/tokens \ -H "Authorization: Bearer <token>" -H "Content-Type: application/json" \ -d '{"ttl_minutes":15,"labels":{"zone":"home"}}'
{ "id": "6f1c…", "token": "qz_Vh3…", "expires_at": "2026-09-24T12:15:00Z", "labels": {"zone": "home"}, "command": "curl -sfL https://quazzar-01.local/api/cluster/join.sh | sudo sh -s -- --token qz_Vh3… --primary https://quazzar-01.local" }

Example: Placement rule

curl -X POST https://quazzar-01.local/api/cluster/placement \ -H "Authorization: Bearer <token>" -H "Content-Type: application/json" \ -d '{"priority":10,"apps":["ollama","localai"],"selector":"gpu=rtx4090"}'

Selector grammar: comma-separated key=value, key (exists), !key (absent). The legacy {"app_template","label_selector"} body is still accepted.

Delegated workflow steps (primary → node)

A workflow step with a target node is sent to that node’s POST /api/internal/workflow/execute-step. The node accepts it only from the cluster network (10.42.0.0/16, TCP peer — forwarded-for headers are ignored) and with a valid signature made with its own node credential. The primary stores only the SHA-256 of each node secret, so it signs with that hash:

POST /api/internal/workflow/execute-step X-Quazzar-Node-Signature: <node_id>:<unix_seconds>:<hex HMAC-SHA256> {"step":{"id":"s1","type":"transform","config":{…}},"input":{…}}

The HMAC key is hex(sha256(node_secret)) and the message is node_id + "\n" + unix_seconds + "\n" + hex(sha256(body)). The node rejects (401) a signature for another node id, one more than 2 minutes off its clock, a changed body, or a signature it has already seen. A node whose credential was revoked cannot be delegated to.

Heartbeat (node → primary)

POST /api/cluster/heartbeat Authorization: Node n-3f9a12bc:<node_secret> {"cpu":24.0,"mem":48.0,"disk":61.0,"version":"0.10.4","rtt_ms":3.1, "apps":[{"app_id":"…","template":"whoami","status":"running","port":8081,"protocol":"http"}]}
{"directives":[{"id":"…","kind":"deploy","app":{"service_id":"…","template":"whoami","name":"Whoami"}}]}

Alerts

Configure alert rules and notification channels to monitor app and system health.

MethodPathDescription
GET/api/alerts/rulesList alert rules
POST/api/alerts/rulesCreate an alert rule
GET/api/alerts/channelsList notification channels
POST/api/alerts/channelsCreate a notification channel
POST/api/alerts/channels/{id}/testSend a test notification

Example: Create an Alert Rule

curl -X POST http://localhost:8080/api/alerts/rules \ -H "Authorization: Bearer <token>" \ -H "Content-Type: application/json" \ -d '{ "name": "High CPU Usage", "metric": "system.cpu.usage_percent", "condition": "greater_than", "threshold": 90, "duration": "5m", "channel_id": "ch_abc123", "severity": "warning" }'

Example: Create a Notification Channel

curl -X POST http://localhost:8080/api/alerts/channels \ -H "Authorization: Bearer <token>" \ -H "Content-Type: application/json" \ -d '{ "name": "Ops Team Slack", "type": "slack", "config": { "webhook_url": "https://hooks.slack.com/services/T00/B00/xxxx" } }'

Security

Run security scans, view findings, and manage Web Application Firewall (WAF) rules.

MethodPathDescription
GET/api/security/scoreGet the overall security score
POST/api/security/scanTrigger a security scan
GET/api/security/findingsList security findings
GET/api/waf/rulesList WAF rules

Example: Get Security Score

curl http://localhost:8080/api/security/score \ -H "Authorization: Bearer <token>"
{ "overall_score": 85, "categories": { "tls": 95, "headers": 80, "firewall": 90, "authentication": 75, "updates": 85 }, "findings_count": { "critical": 0, "high": 1, "medium": 3, "low": 5 } }

Example: Trigger a Security Scan

curl -X POST http://localhost:8080/api/security/scan \ -H "Authorization: Bearer <token>"
{ "scan_id": "scan_abc123", "status": "running", "started_at": "2025-01-15T10:30:00Z" }

API Keys

Manage API keys for programmatic access to the Cloud OS instance.

MethodPathDescription
GET/api/keysList all API keys
POST/api/keysCreate a new API key
DELETE/api/keys/{id}Revoke an API key

See the Authentication page for detailed examples of creating and using API keys.


Webhooks

Configure outgoing webhooks to receive notifications when events occur on the Cloud OS instance.

MethodPathDescription
GET/api/webhooksList configured webhooks
POST/api/webhooksCreate a new webhook
PUT/api/webhooks/{id}Update a webhook
DELETE/api/webhooks/{id}Delete a webhook

See Webhooks for detailed webhook setup, event types, and signature verification.


Timeline

The system event timeline shown on the Overview screen. Events are recorded automatically in the background (recording never slows down the request that caused it).

MethodPathDescription
GET/api/timelineRecent events, newest first. Query: category, from, to (RFC 3339), limit (1–200, default 50)
GET/api/timeline/statsEvent counts per category
CategoryRecorded events
appsApp installed (with version), updated (to version), uninstalled, failed to install/update, crashed, ran out of memory
authSuccessful sign-in — Sign-in from MacBook · Berlin; 5+ failed sign-ins from one IP within 15 minutes (warning)
securitySecurity scan · N critical (critical severity when N > 0), scan failures
backupsBackup completed · 412 GB, Backup verified · 412 GB, backup / verification failures (error)
alertsAlert fired (severity from the rule), alert resolved
systemUpdate installed · v0.10.4 (recorded on the first boot of a new version)

Sign-in events name the device from the browser’s User-Agent. Private / LAN addresses read local network. The city of a public address is only shown when an offline MaxMind GeoLite2 City database is configured (auth.geoip_db in the node config or the QUAZZAR_GEOIP_DB environment variable); nothing is looked up online.

curl "http://localhost:8080/api/timeline?category=backups&limit=5" \ -H "Authorization: Bearer <token>"
{ "events": [ { "id": "6f1c…", "event_type": "backup.verified", "category": "backups", "title": "Backup verified · 412 GB", "description": "", "severity": "info", "source": "backups", "metadata": { "backup_id": "b1", "verification_id": "v1" }, "created_at": "2026-09-24T02:05:00Z" } ], "count": 1 }

Personal mode (Home)

Per-user endpoints behind the Personal-mode Home screen. All require a session or API key and only ever return the caller’s own data.

Weather

MethodPathDescription
GET/api/weatherCurrent conditions and today’s summary. Query: lat, lon (both or neither), units (metric default, or imperial)

Forecasts come from Open-Meteo  (no API key) through the node, cached for 15 minutes per location rounded to two decimals. Without lat/lon the node’s approximate location (the same IP lookup as /api/system/location) is used and location.city is filled in; for explicit coordinates city is "". rain_at is the local time of the first remaining hour today with a precipitation probability of 50 % or more, otherwise null. When the node is offline the endpoint answers 503 {"error":"weather unavailable"}.

{ "location": { "city": "Lisbon", "country": "Portugal", "lat": 38.72, "lon": -9.14 }, "units": "metric", "current": { "temp": 22.4, "code": 0, "condition": "clear", "is_day": true }, "today": { "high": 25.1, "low": 16.3, "precip_probability_max": 70, "rain_at": "19:00" }, "updated_at": "2026-09-24T15:00:00Z" }

To-dos

MethodPathDescription
GET/api/todosThe caller’s to-dos, open first (done ascending, then position)
POST/api/todosCreate — body {"text": "…", "due_at": null}; appended at the end; 201
POST/api/todos/importBulk import {"todos": [{"text": "…", "done": true}]}; skips empty text and text you already have; returns the full list
PATCH/api/todos/{id}Update any of text, done, position, due_at (null clears)
DELETE/api/todos/{id}Delete; 204
POST/api/todos/clear-doneDelete every completed to-do; 204

text must be 1–500 characters. due_at accepts RFC 3339 or YYYY-MM-DD. Setting position moves the item to that index of the list as GET returns it and renumbers the rest, so position always equals the item’s index. Ids belonging to another user answer 404.

{ "todos": [ { "id": "3f0c…", "text": "Book dentist", "done": false, "position": 0, "due_at": null, "created_at": "2026-09-24T09:12:00Z", "updated_at": "2026-09-24T09:12:00Z" } ] }

Drive usage

MethodPathDescription
GET/api/drive/usageHow much of your Drive is used, split into Photos, Docs, Music, Video and Other

Files in the trash are not counted. Files are grouped by MIME type (image/*, video/*, audio/*; text, PDF and office documents are Docs), falling back to the file extension. total_bytes is your quota when one is set, otherwise the size of the disk that holds Drive storage; quota_bytes is null when there is no quota. The figures are cached for 5 minutes. The endpoint answers with zeros when Drive is not running.

{ "used_bytes": 442381631488, "total_bytes": 2000000000000, "quota_bytes": null, "categories": [ { "key": "photos", "label": "Photos", "bytes": 240000000000 }, { "key": "docs", "label": "Docs", "bytes": 100000000000 }, { "key": "music", "label": "Music", "bytes": 60000000000 }, { "key": "video", "label": "Video", "bytes": 30000000000 }, { "key": "other", "label": "Other", "bytes": 12381631488 } ] }

Now playing

MethodPathDescription
GET/api/music/now-playingWhat you are listening to, on whichever device reported last
PUT/api/music/now-playingReport playback state (the player calls this as you play, pause and seek)

The PUT body has the same shape as the response without updated_at. Only track.path is required: when title, artist or album are missing they are taken from the path (Artist/Album/03 - Title.flac). device defaults to a name derived from the browser (MacBook, iPhone, …). Send "track": null to clear the card. cover_url is only kept when it is a same-origin path, otherwise null. If a device stops reporting while playing is true, the state reads as playing: false once the rest of the track plus 30 seconds has passed. Before anything was played GET returns "track": null, "playing": false and "updated_at": null.

{ "track": { "path": "Music/Nils Frahm/Spaces/Says.flac", "title": "Says", "artist": "Nils Frahm", "album": "Spaces", "cover_url": null }, "duration_s": 502, "position_s": 167, "playing": true, "device": "MacBook", "updated_at": "2026-09-24T15:00:00Z" }

Recent Orbit files

MethodPathDescription
GET/api/orbit/recentYour documents, spreadsheets, slide decks, notes and whiteboards, most recently changed first. Query: limit (default 20, max 100)

kind is one of doc, sheet, slides, note, whiteboard, and path is the page that opens the item (/docs/:id, /sheets/:id, /slides/:id, /notes?id=:id, /whiteboards/:id). Notes saved by external AI agents through Memory MCP are left out. Orbit files currently have a single owner — share links are anonymous and live co-editing is not attributed — so collaborators lists the owner and last_edited_by is the owner.

{ "items": [ { "id": "abc", "kind": "sheet", "title": "Family budget 2025", "updated_at": "2026-09-24T12:00:00Z", "path": "/sheets/abc", "last_edited_by": { "id": "u1", "name": "Alex Moreau" }, "collaborators": [ { "id": "u1", "name": "Alex Moreau", "avatar_url": null } ] } ] }

AI

Interact with locally hosted AI models for chat and RAG (Retrieval-Augmented Generation) search.

MethodPathAuthDescription
GET/api/ai/modelssessionList available AI models
POST/api/ai/models/pulladminDownload a model
DELETE/api/ai/models/{name}adminDelete a model
POST/api/ai/ollama/install · /start · /stop · /reconfigure, DELETE /api/ai/ollamaadminManage the Ollama runtime
POST/api/ai/localai/install · /start · /stop, DELETE /api/ai/localaiadminManage the LocalAI runtime
POST / PUT / DELETE/api/ai/providers…, /api/ai/routing…, PUT /api/ai/cost/budgetadminNode-wide provider, routing and budget config (reads: session)
GET / PUT/api/ai/cost/budgetsession / adminMonthly budget plus daily_cap_usd and per_user_daily_cap_usd (USD per UTC day, 0 = off; omitted on PUT = unchanged); GET also returns spent_today_usd and the caller’s my_spent_today_usd
GET / PUT/api/ai/agent-loopsession / adminMolly agent loop: max_iterations (2–40, 0 = default 10) and tiers — { "<provider id>": { "fast", "standard", "deep" } }, empty = routed model
GET/api/ai/health/canaryadminAI health check status: enabled, interval_seconds, running, next_run_at, and the last report (status, per-check checks[])
POST/api/ai/health/canary/runadminRun the AI health check now and return {report}; 409 while a run is in progress, 503 when disabled
POST / DELETE/api/ai/mcp/…adminAdd / remove / connect MCP servers, call MCP tools (reads: session)
POST / DELETE/api/ai/finetune/…adminFine-tune jobs, datasets, evaluations (reads: session)
POST/api/voice/install/{engine}adminInstall whisper.cpp / Piper (Orbit Pro)
GET / POST/api/ai/conversationssessionList your conversations / start one (you become its owner)
GET / DELETE/api/ai/conversations/{id}ownerRead / delete a conversation
GET/api/ai/conversations/{id}/streamownerChat (SSE)
POST/api/ai/conversations/{id}/compactownerSummarise older messages
GET/api/ai/memorysessionList (?ns=) or search (?q=) the node-wide memory store
POST/api/ai/memoryadminStore or update an entry ({namespace, key, value, metadata})
DELETE/api/ai/memory/{id}adminDelete an entry
POST/api/ai/agents/{id}/feedbacksessionRate an agent interaction (1–5)
GET/api/ai/agents/{id}/knowledgesessionPatterns the agent learned from feedback
DELETE/api/ai/agents/{id}/knowledge/{kid}adminRemove a learned pattern
POST/api/rag/searchsessionPerform a RAG search over indexed documents

Conversations are private. AI Hub (/api/ai/conversations…) and Molly (/api/molly/conversations…, including /stream, PATCH, DELETE and /cost) only ever list the caller’s own conversations, and every request for another user’s conversation answers 404 conversation not found — the same as a conversation that does not exist. Admins get no exception. Conversation objects carry user_id. DELETE /api/molly/memory/{id} only deletes the caller’s own memory entries. AI Hub conversations created before this change belong to the node’s first admin.

The AI Hub memory store and agent knowledge are node-wide (no owner): every signed-in user can read them, only admins can change them. The GraphQL appLogs query was removed (it returned placeholder data); read logs through the admin-only REST routes.

Skill changes (/api/skills/create, /install, /marketplace/{id}/install, /{id}/toggle, PUT /{id}/schedule, /{id}/edit, DELETE /{id}), agent bundle import (POST /api/marketplace/agents/import) and import-wizard installs (/api/import/compose/install, /api/import/{casaos,umbrel}/import) are admin only too; running an installed skill (POST /api/skills/{id}/run) stays open to signed-in users.

Workflows

MethodPathAuthDescription
GET/api/ai/workflows, /api/ai/workflows/{id}, …/executionssessionRead workflows and runs
POST / PUT / DELETE/api/ai/workflows…adminCreate, save, delete, run, cancel, schedule

Workflows carry created_by: the admin who created or last saved (PUT) the workflow. Workflows created before this field existed have created_by: "". When a run starts — manually, on its schedule or with steps delegated to another cluster node — a workflow whose created_by is empty or no longer an admin does not run its http steps or steps with a target_node: each such step fails with blocked: this workflow has no admin owner, so http and cluster steps do not run; an admin must open and save it once, the monitor emits a step_blocked event, and the execution ends failed with that message in error. Other steps (agent, transform, condition, delay) run as before. Open and save the workflow as an admin to adopt it.

POST /api/ai/workflows/{id}/execute answers 202 {"execution_id", "status": "running"} and the run continues in the background after the response — closing the tab does not stop it. Each run has its own 6-hour timeout (the execution then ends failed with timed out after 6h0m0s); POST /api/ai/workflows/{id}/executions/{execId}/cancel stops it early.

Molly chat stream events

GET /api/molly/conversations/{id}/stream?message=… answers with Server-Sent Events. The first event of a successfully routed turn is meta, describing the model the router picked; local is true when the provider runs on this node (Ollama, LocalAI):

event: meta data: {"model":"llama3.1:8b","provider":"ollama","local":true} event: chunk data: {"content":"Your disks look healthy…"} event: integrity data: {…} event: done data: {}

Other events keep their existing order: tool_call / tool_result (when Molly uses tools), chunk, integrity, done, or ai_error on failure.

Example: Chat with a Local AI Model

curl -X POST http://localhost:8080/api/ai/chat \ -H "Authorization: Bearer <token>" \ -H "Content-Type: application/json" \ -d '{ "model": "llama3", "messages": [ { "role": "user", "content": "How do I configure Nextcloud to use external storage?" } ] }'
curl -X POST http://localhost:8080/api/rag/search \ -H "Authorization: Bearer <token>" \ -H "Content-Type: application/json" \ -d '{ "query": "backup encryption setup", "limit": 5 }'

Marketplace

MethodPathAuthDescription
GET/api/marketplace, /api/marketplace/{id}, …/reviews, /api/marketplace/{category}/items…sessionBrowse
POST/api/marketplace/submit, /api/marketplace/{id}/rate, /api/marketplace/community/…sessionSubmit, rate, review
POST/api/marketplace/{id}/install, /api/marketplace/{category}/items/{name}/install, /api/marketplace/agents/{id}/installadminInstall
POST / DELETE/api/marketplace/{id}/uninstall, DELETE /api/marketplace/{id}/installadminUninstall
PUT/api/marketplace/reviews/{id}adminApprove or reject a submission (publishes it to every user)

Any marketplace write whose path has an install, uninstall, reinstall, remove or import segment needs the admin role.

Mail

MethodPathAuthDescription
GET / PUT/api/mail/relayadminOutbound SMTP relay (password never returned)
GET / PUT/api/mail/stack, GET /api/mail/stack/composeadminPostfix / Dovecot / Rspamd stack
GET / POST / PUT / DELETE/api/mail/mailboxes…adminEvery user’s mailbox rows
GET/api/mail/statussessionIs the stack on, and does the caller have a mailbox
POST/api/mail/provisionsessionCreate the caller’s mailbox
GET/api/mail/folders, /api/mail/inbox, /api/mail/messages/{folder}/{uid}[/attachments/{part}]sessionRead the caller’s own mailbox
POST/api/mail/messages/{folder}/{uid}/flag · /move, /api/mail/sendsessionAct on the caller’s own mailbox (the sender is always the caller’s address)

Webmail routes never take a mailbox id: the mailbox is resolved from the signed-in user, so a user can only reach their own.

Family

The supervisor of a Family-managed node is its admin. The Control Panel reaches the supervisor routes over its agent tunnel.

MethodPathAuthDescription
GET/api/family/bannerpublicIs monitoring active (shown before sign-in)
GET/api/family/selfsessionWhat is collected about users of this node, and retention
GET/api/family/managed-statussessionIs the node enrolled with a Control Panel
GET/api/family/hardeningsessionLUKS / BIOS / GRUB password attestation (three booleans)
GET / PUT/api/family/users, /api/family/users/{uid}/monitoradminMonitored users
GET / PUT/api/family/policies/{uid}adminPolicies
GET/api/family/events, /alerts, /summary/daily, /heartbeatadminActivity, alerts, daily summary, daemon heartbeat
POST/api/family/screen/start/{uid} · /screen/stop/{uid}, GET /api/family/screen/streamadminLive screen view (Orbit Pro)
PUT/api/family/hardeningadminRecord the hardening attestation
GET/api/family/instance-idadminId to paste into the Control Panel when enrolling
POST/api/family/pair/generate · /pair/verifyadminPairing codes
POST/api/family/uninstalladminWipe all Family data on the node

Orbit Forms (public)

A published form is reachable without a session; its public token is the credential. Unknown or unpublished tokens return 404.

MethodPathDescription
GET/f/{token}The public form page (HTML — the web app with the form embedded as <script id="form-schema" type="application/json">)
GET/api/public/forms/{token}/schemaThe same form as JSON
POST/forms/{token}/submitSubmit a response (multipart/form-data, rate-limited)
{ "id": "…", "public_token": "…", "title": "Contact us", "description": "", "schema": { "fields": [ { "id": "f1", "type": "text", "label": "Name", "required": true } ] }, "require_login": false, "captcha_enabled": false }

Audit

View the audit log of all administrative actions performed on the Cloud OS instance.

MethodPathDescription
GET/api/audit/logsList audit log entries

Example: Get Audit Logs

curl "http://localhost:8080/api/audit/logs?limit=50" \ -H "Authorization: Bearer <token>"
[ { "id": "aud_abc123", "action": "app.installed", "resource": "app_def456", "details": "Installed Nextcloud from template", "ip_address": "192.168.1.100", "timestamp": "2025-01-15T10:30:00Z" }, { "id": "aud_def456", "action": "backup.created", "resource": "bak_ghi789", "details": "Manual backup to s3-primary", "ip_address": "192.168.1.100", "timestamp": "2025-01-15T10:25:00Z" } ]

Audit logs are immutable and cannot be deleted through the API. They are retained according to the configured retention policy.


Memory MCP

Quazzar ships a built-in Model Context Protocol  server. External AI clients (Claude Desktop, Claude Code, ChatGPT, Cursor, VS Code Copilot) connect with a bearer token and read/write durable memories backed by the same notes store the UI uses.

See Memory MCP for the tool / resource / prompt surface and Memory MCP quickstart for the wiring walkthrough.

MCP transport endpoints

MethodPathDescription
GET / POST/mcpStreamable HTTP MCP endpoint (MCP spec 2025-03+). Bearer authed.
GET/mcp/sseLegacy SSE event stream for older clients.
POST/mcp/messagesLegacy SSE JSON-RPC sink paired with /mcp/sse.

Authorization header on every request: Authorization: Bearer quaz_....

API keys

Session-authed endpoints (used by the Settings UI) for managing MCP bearer tokens.

MethodPathDescription
GET/api/mcp/keysList every key owned by the current user (metadata only — raw bearer is never returned)
POST/api/mcp/keysCreate a key. Raw bearer is returned in the response once.
DELETE/api/mcp/keys/{id}Revoke a key. Immediate — the client loses access on the next request.
POST/api/mcp/keys/{id}/rotateRevoke the existing key and mint a new one with the same name, scopes, namespace, and expiry window. Raw bearer is returned once.

Create key

curl -X POST http://localhost:8080/api/mcp/keys \ -H "Authorization: Bearer <session-token>" \ -H "Content-Type: application/json" \ -d '{ "name": "Claude on my laptop", "client_type": "claude_desktop", "namespace": "work", "scopes": ["read_memory", "write_memory"], "expires_in_days": 90 }'
{ "id": "key_abc123", "name": "Claude on my laptop", "client_type": "claude_desktop", "namespace": "work", "scopes": ["read_memory", "write_memory"], "key_prefix": "quaz_AB12CDE", "expires_at": "2026-07-21T10:30:00Z", "raw": "quaz_AB12CDEFGH345JKLMNPQRSTVWXYZ2" }

The raw field is shown only on create and rotate. Raw bearers are bcrypt-hashed on disk.

Rotate key

curl -X POST http://localhost:8080/api/mcp/keys/key_abc123/rotate \ -H "Authorization: Bearer <session-token>"

Returns the same shape as create, with a new raw bearer. The old bearer is revoked in place (ErrRevoked on use).

Active sessions

MethodPathDescription
GET/api/mcp/sessionsList every live MCP stream bound to the current user
DELETE/api/mcp/sessions/{id}Disconnect a specific session
curl http://localhost:8080/api/mcp/sessions \ -H "Authorization: Bearer <session-token>"
[ { "id": "sess_abc", "client_name": "claude-desktop", "transport": "streamable-http", "ip": "192.168.1.42", "connected_at": "2026-04-21T10:10:00Z", "last_active_at": "2026-04-21T10:30:00Z" } ]

Each MCP connection is tracked as a separate session — restarting a client opens a new one.

Embedding and analytics

MethodPathDescription
GET/api/mcp/memory/embed-statsEmbedding backend status, queue depth, and coverage
POST/api/mcp/memory/reindexPush every note owned by the caller onto the embedding queue
GET/api/mcp/memory/analyticsAggregate totals, top tags, top clients, embedding coverage over time
curl http://localhost:8080/api/mcp/memory/embed-stats \ -H "Authorization: Bearer <session-token>"
{ "queue_size": 0, "total": 1250, "total_with_embedding": 1247, "backend_name": "ollama", "backend_dim": 768, "enabled": true }
curl -X POST http://localhost:8080/api/mcp/memory/reindex \ -H "Authorization: Bearer <session-token>"
{ "enqueued": 1250, "enabled": true }

Pending items collapse via the queue’s UNIQUE(note_id) constraint, so triggering a reindex on a noisy node still completes in bounded work.

Privacy guard and visibility

Owner-only: each route acts on the signed-in user’s own memories. See Memory MCP → Privacy guard.

MethodPathDescription
GET/api/mcp/memory/quarantineWrites the privacy guard held, newest first ({"items": [...]})
POST/api/mcp/memory/quarantine/{id}/releaseStore a held write as a private memory (a held update is applied in place); returns the memory
DELETE/api/mcp/memory/quarantine/{id}Discard a held write ({"discarded": true})
PUT/api/mcp/memory/notes/{id}/visibilitySet a memory’s tier: {"visibility": "shared" | "workspace" | "private"}. The only way to lower a tier
{ "items": [ { "id": "0b8c…", "namespace": "default", "title": "Landlord", "content": "Rent goes to DE89 …", "tags": ["mcp", "memory"], "client_name": "claude_desktop", "pii_class": "financial_iban", "detector": "mod97", "detail": "an IBAN bank account number", "created_at": "2026-09-25T10:00:00Z" } ] }

POST /api/molly/memory/remember answers 422 with the reason when the guard blocks the text, and 202 with {"status": "quarantined", "quarantine_id": …} when it holds it.

Cross-node sync (Orbit Pro)

MethodPathDescription
GET/api/mcp/sync/statusList enabled namespaces, timestamps, and errors
POST/api/mcp/sync/enableBody: { "namespace": "work" }. Opts the namespace in to replication
POST/api/mcp/sync/disableBody: { "namespace": "work" }. Pauses but keeps cursor
POST/api/mcp/sync/resyncBody: { "namespace": "work" }. Forces one push + one pull pass
POST/api/mcp/sync/resetBody: { "namespace": "work" }. Drops cursor and outbox

Enable returns HTTP 402 Payment Required with the canonical upgrade shape when the caller is on the Community plan.


Notes graph

Session-authed endpoints for the Orbit Notes mind-map view.

MethodPathDescription
GET/api/notesList your notes, most recently updated first. Query: folder, limit (positive integer, optional), sort=updated (optional; the default order)
GET/api/notes/graphReturn nodes + links with filter support
PATCH/api/notes/{id}/metaAdjust importance or toggle archived on any note

GET /api/notes/graph

Every filter on the Notes mind-map toolbar maps onto a query parameter:

ParamValuesDefault
sourceComma-separated user,mcp,mollyall three
namespaceComma-separated namespace namesall
clientComma-separated mcp_client_name valuesall
tagsComma-separated tag namesno filter
min_importance0.0..1.00
include_archived1 / 00
curl "http://localhost:8080/api/notes/graph?source=user,mcp&namespace=work,personal&client=claude-desktop&tags=urgent,review&min_importance=0.4&include_archived=1" \ -H "Authorization: Bearer <session-token>"
{ "nodes": [ { "id": "note_abc", "title": "Incident 2026-04-19", "source": "mcp", "namespace": "work", "client": "claude-desktop", "importance": 0.7, "created_at": "2026-04-19T14:32:00Z", "updated_at": "2026-04-19T14:32:00Z", "last_referenced_at": "2026-04-20T09:10:00Z" } ], "links": [ { "source": "note_abc", "target": "note_def", "source_kind": "mcp", "target_kind": "user", "mixed_sources": true } ] }

PATCH /api/notes/{id}/meta

curl -X PATCH http://localhost:8080/api/notes/note_abc/meta \ -H "Authorization: Bearer <session-token>" \ -H "Content-Type: application/json" \ -d '{ "importance": 0.75 }'

Accepted bodies:

  • { "importance": 0.75 } — set importance to a 0.0..1.0 value.
  • { "archived": true } — appends #archived, drops importance to 0.
  • { "archived": false } — strips #archived, leaves importance untouched.

External MCP callers route through memory_update / memory_delete instead, which enforce per-key scope checks.


Molly memory

Session-authed endpoints for Molly’s memory slash commands and settings. Not MCP — external clients use /mcp instead.

MethodPathDescription
POST/api/molly/memory/rememberPersist a new memory (body: content, title?, tags?, importance?)
POST/api/molly/memory/recallHybrid search bound to the user + Molly namespace
GET/api/molly/memory/recent?limit=NList the N most recently referenced Molly memories
POST/api/molly/memory/clearSoft-delete every memory in the Molly namespace
DELETE/api/molly/memory/mcp/{id}Soft (default) or hard (?hard=true) delete a single memory
GET/api/molly/memory/healthKnowledge health report: last_run, counts, open review items, top gaps, switches
POST/api/molly/memory/health/runRun the knowledge healer for the caller now; returns the fresh report (409 when QUAZZAR_AI_MEMORY_HEALER=off, 429 + Retry-After within a minute of the previous run)
POST/api/molly/memory/health/items/{id}Resolve a review item. Body: action (archive, keep, merge), target_id? (either memory of a contradiction). 204; 400 for an unknown action or an already-resolved item; 404 for another user’s item
POST/api/molly/memory/health/gaps/{id}/dismissHide a knowledge gap (204 / 404)
POST/api/molly/messages/{id}/feedbackRate one of the caller’s Molly replies. Body: rating (up / down) or score (1–5). Returns {rating, changed, adjusted, delta}; 404 for a missing, non-assistant or foreign message
curl -X POST http://localhost:8080/api/molly/memory/remember \ -H "Authorization: Bearer <session-token>" \ -H "Content-Type: application/json" \ -d '{ "content": "Deployed v0.6.35 to node-01.", "title": "Deploy 2026-04-21", "tags": ["deploy"], "importance": 0.7 }'

All endpoints return 503 Service Unavailable when the MCP Memory service is not wired (air-gap builds).


Billing and Licensing

Feature gates for the Orbit Pro paid tier. See Orbit Pro for the tier matrix.

MethodPathDescription
GET/api/license/orbit-featuresProjection of the current plan’s Orbit feature gate map
POST/api/billing/upgradeStart a Stripe Checkout session (proxied through the CP)

Feature gate projection

curl http://localhost:8080/api/license/orbit-features \ -H "Authorization: Bearer <session-token>"
{ "plan": "community", "features": { "orbit_collab": false, "orbit_graph": false, "orbit_full_formulas": false, "orbit_charts": false, "orbit_pivot": false, "orbit_history": false, "orbit_advanced_export": false, "orbit_memory_sync": false, "orbit_unlimited_shares": false, "orbit_slides_themes": false }, "limits": { "share_links_max": 3, "mcp_rate_per_minute": 30, "version_history_hours": 24 } }

The web client uses this to render gated controls before the user clicks.

Upgrade flow

curl -X POST http://localhost:8080/api/billing/upgrade \ -H "Authorization: Bearer <session-token>" \ -H "Content-Type: application/json" \ -d '{ "plan": "pro", "return_to": "https://node.example.com/docs" }'
{ "checkout_url": "https://checkout.stripe.com/c/pay/cs_live_..." }

The request is proxied through the Control Panel to Stripe. After checkout, Stripe redirects to return_to with a session id. The CC agent picks up the new plan on the next heartbeat — the node’s license.Gate is wired to the agent’s plan accessor, so the feature unlocks on the next request without a restart.

Canonical 402 response

Every gated path returns HTTP 402 Payment Required on the Community plan with this shape:

{ "error": "upgrade_required", "feature": "orbit_pivot", "current_plan": "community", "required_plan": "pro", "message": "Pivot tables require Orbit Pro.", "upgrade_url": "/api/billing/upgrade" }

Clients catch the 402 via isUpgradeError() and render either an inline <UpgradePrompt> or open a modal <OrbitUpgradeDialog> so the user can upgrade without losing the action that triggered the gate.