Skip to content

List the enabled apps the CALLER IS ENTITLED TO (favourite is resolved for the caller).

GET
/v1/apps
curl --request GET \
--url http://localhost:8080/v1/apps \
--header 'Authorization: Bearer <token>'

UI-P1, BREAKING (2026-07-27): this list was previously public (security: []) and returned the full catalogue — names, descriptions, cover URLs, stream defaults — to any unauthenticated caller. That was an information-disclosure defect on a self-hosted deployment; it now requires a bearer and returns 401 without one. Both known consumers (the web SPA and quasar-client) already send the bearer. See control-api.md.

STEAM LIBRARY DISCOVERY PHASE 2, NOT ADDITIVE: this list is now enabled = true AND entitled. An app appears only if the caller holds an ‘all’ entitlement (visible to everyone) or a (‘user’, caller) one. NO SHAPE OR STATUS CODE CHANGES and a client that renders what it receives needs no change - what changes is that the list can now be SHORTER THAN THE CATALOGUE.

Day one it is byte-identical to the pre-Phase-2 list: migration 0043 backfills an ‘all’ row for every existing app in the same transaction that creates the table, so the filter’s first evaluation returns exactly what the unfiltered query returned. It narrows only as an admin deliberately narrows it.

THE FILTER APPLIES TO EVERY ROLE, ADMINS INCLUDED, AND THERE IS NO BYPASS. An admin browsing the library is a user browsing THEIR OWN library; a fleet-wide god view here would be actively wrong once library discovery is on, since it would show every other user’s games. The god view is GET /v1/admin/apps, which is unfiltered and unchanged, so nothing becomes unreachable. An admin who wants an app in their own library grants themselves the entitlement - one call, and it leaves an audit row, which if isAdmin { skip } does not.

THIS IS UX, NOT THE AUTHORIZATION BOUNDARY. The boundary is POST /v1/sessions: a client that ignores this list and launches an app id directly is refused there, not here.

OK.

Media typeapplication/json
object
items
required
Array<object>
object
id
required
string format: uuid
name
required
string
description
required
string
cover_url
required

UI-P7: the TILE crop - the 2:3 PORTRAIT library-tile artwork (16:10 before #385; the move to portrait box art is an operator-directed deviation from the signed-off mockup, and the hero crop stays wide). Null when the app has no artwork, which is the shipped default and renders the gradient tile. Written EXCLUSIVELY by the artwork service (/v1/admin/apps/{id}/artwork) for any app that has an artwork record; a value set directly via AppWrite is honoured only while the app has no artwork record.

string | null
hero_url
required

UI-P7, ADDITIVE: the HERO crop - a much wider banner asset for the detail/hero panels. A DIFFERENT source asset from cover_url, not the same image scaled: a ~2.1:1 tile stretched into a ~3:1 hero reads as a blown-up thumbnail. Null independently of cover_url (a title may have one crop and not the other); a client falls back hero_url -> cover_url -> gradient. Read-only: not accepted on AppWrite, written only by the artwork service.

string | null
kind
required

UI-P1: presentation-only library classification (apps.kind). Always serialized; ‘game’ for every pre-UI-P1 row. Phase 3 added ‘launcher’ - see AppKind.

string
Allowed values: game desktop launcher
parent_app_id
required

Steam library discovery Phase 3, ADDITIVE (apps.parent_app_id, migration 0044): the app this tile is DERIVED from, or null for a normal app - which is every app predating 0044. Always serialized. A derived tile carries identity and presentation only and borrows everything executable (image, runtime_spec, managed home, resource defaults, mounts) from its parent at launch; see control-api.md §Derived tiles. ON THE PUBLIC READ SHAPE DELIBERATELY, unlike origin and library_provider, and for one concrete reason: the single-writer lock is held by the PARENT’s home, so with any tile in a family live, every other tile in that family answers 409 home_in_use. The client needs parent_app_id to mark the siblings of a live session as blocked instead of letting the user discover it by clicking. That is a presentation nicety - THE ENFORCEMENT IS THE SERVER’S 409, never this field.

string | null format: uuid
external_source
required

Steam library discovery Phase 1, ADDITIVE (apps.external_source, migration 0042): “this app IS provider X’s title Y”, read together with external_id. Always serialized and never omitted - “” is the meaningful default (“not a provider title”, the state of every pre-0042 app), so a client must be able to tell it from absent. Defined here on AppListItem, so App and AdminApp inherit it exactly as they inherit kind. Phase 1’s only reader is the artwork service (§Cover artwork): a tagged app resolves its art by id and never enters the fuzzy title matcher. Nothing in scheduling, admission, profile/codec resolution, or the agent wire reads it.

string
Allowed values: "" steam
external_id
required

Steam library discovery Phase 1, ADDITIVE (apps.external_id, migration 0042): the provider-native id, today a Steam appid. Always serialized; “” when the app is not a provider title. A bare positive integer, no leading zero, no sign, no whitespace, no separators - the grammar is ARGUMENT-INJECTION CONTAINMENT, not tidiness; see AppWrite.external_id and control-api.md.

string
/^([1-9][0-9]{0,9})?$/
favourite
required

UI-P1: whether THE CALLING USER has favourited this app. Always serialized. Resolved per request from the bearer identity - never a stored property of the app, never settable via AppWrite, never assertable by a client. Set/cleared via PUT/DELETE /v1/me/favourites/{app_id}.

boolean
default_width
required
integer
default_height
required
integer
default_fps
required
integer
default_bitrate_kbps
required
integer
default_profile_id
required

UI-P4: the LAUNCH PROFILE this app pins or prefers, per profile_policy.

string | null
profile_policy
required

How an app resolves its launch profile. UI-P4 REMOVED the value ‘custom’ (breaking): under the two-object model every app points at a launch profile, and ‘custom’ was also the one mode that could not express a codec. profile_policy:“custom” is now 400 validation_failed. inherit = the user/global default decides; prefer = the app’s default_profile_id, the user may still override; force = the app’s profile always.

string
Allowed values: inherit prefer force
display_stream

The stream advertised in the library. UI-P4: resolved through the launch profile’s TOP rung (position 1), falling back to the app’s default_* columns when no launch profile resolves. Advertised, not resolved - a launch may fall through to a lower rung and stream at a different resolution; the session’s own stream block is the truth.

object
width
required
integer
height
required
integer
fps
required
integer
bitrate_kbps
required
integer
h264_profile
string
Allowed values: constrained-baseline baseline main high
codec

Multi-codec: the resolved session video codec on session.stream; absent on an app’s display_stream. Additive; h264 for every pre-multi-codec session. h264_profile applies only when codec is h264.

string
Allowed values: h264 h265 av1
playout0_ms

AS-02: tier-selected initial receiver playout target (ms) on session.stream; absent on an app’s display_stream.

integer
mic

Microphone capture (2026-08-02): the GRANTED state on session.stream (request mic AND instance mic_capture_enabled); absent on an app’s display_stream and on pre-amendment sessions (absent = false).

boolean
external_width

Session-display-stream (approved 2026-08-16) (2026-08-16, approved — PR #15). Current EXTERNAL (encoded/streamed) width on session.stream; absent on an app’s display_stream. PRESENT WHENEVER THE CONTROL PLANE KNOWS the current external size — i.e. it has seen a 202 from PATCH /v1/sessions/{id}/display or a session_metrics sample reporting it — INCLUDING when that size equals the launch width/height. ABSENT MEANS UNKNOWN (a fresh control plane, or no such signal yet for this session), NOT “at launch size” — do not infer launch size from absence. Ephemeral, in-memory control-plane cache of the last-known value (agent session_metrics is the authoritative source), lost on a control-plane restart until the next 202 or session_metrics sample repopulates it. See control-api.md for the INTERNAL-vs-EXTERNAL vocabulary.

integer
external_height

Session-display-stream (approved 2026-08-16). Pairs with external_width; see its description.

integer
external_resize_supported

Session-display-stream (approved 2026-08-16). Whether the assigned host’s encoder can live-resize the stream at all (readback of agent-api.md session_metrics.external_resize_supported). Absent until an amendment-aware agent reports = unknown, never false.

boolean
external_owner

Abr-resolution-fps-ladder (approved 2026-08-16) amendment (2026-08-16, approved — PR #15). Who currently owns the live external size on session.stream: “auto” (the host’s ABR resolution ladder) or “pinned” (a PATCH /v1/sessions/{id}/display stream_width/ stream_height set it to a non-launch size, which suspends the ladder for the rest of the session or until released). Readback of agent-api.md session_metrics.external_owner. PRESENT ONLY WHEN KNOWN AND external_width/ external_height differ from the launch width/height — mirrors external_width’s presence rule, since the agent reports external_owner only in that same window. Absent on every pre-amendment session and while the external size sits at launch.

string
Allowed values: auto pinned
rungs

Session-display-stream (approved 2026-08-16). The fixed, aspect-ratio-filtered table of [width,height] pairs this session’s stream_width/stream_height may be set to via PATCH /v1/sessions/{id}/display (always <= the launch size, launch size always included). Always present on session.stream for a running session; absent on an app’s display_stream. Not the admin-configured stream-profile “rungs” (AS10-01) — a separate, fixed table unrelated to the admin encode-rung catalog. 21:9 family membership is by a set of reduced ratios {43:18, 64:27, 7:3} — 3440x1440 reduces to 43:18, 2560x1080 to 64:27 — control-api.md’s table is the reference, not a computed tolerance.

Array<Array<integer>>
enabled
required
boolean
next_cursor
required
string | null
Example
{
"items": [
{
"kind": "game",
"external_source": "",
"profile_policy": "inherit",
"display_stream": {
"h264_profile": "constrained-baseline",
"codec": "h264",
"external_owner": "auto"
}
}
]
}

Missing/invalid/expired/revoked token.

Media typeapplication/json
object
error
required
object
code
required

E.g. validation_failed, unauthorized, forbidden, not_found, conflict, session_quota_exceeded, home_in_use, home_not_provisioned, parent_app_disabled, profile_ineligible, profile_not_launchable_for_app, no_host_available, capacity_exhausted, restart_required, rate_limited, internal. Open string, not an enum: new codes are additive and an unknown one falls through to a client’s generic per-status branch.

string
message
required
string
live_sessions

Present on restart_required.

integer
session_id

Steam library discovery Phase 3, ADDITIVE: present on home_in_use when the guard could name the CONFLICTING live session - the one already holding the home. It is here so the client can offer “go to your running session” with a link instead of a dead-end toast. OMITTED rather than empty when the conflict is known but the session is not, so a client branches on presence and never renders a link to nowhere. Load-bearing once derived tiles exist: the lock is held by the PARENT’s home, so a user who clicks a game tile can be refused because a DIFFERENT app (the Steam launcher, or another game from the same install) is running, and without the session id the refusal reads as a bug. See control-api.md §Derived tiles.

string format: uuid
derived_tiles

Steam library discovery Phase 3, ADDITIVE: present on the 409 conflict from DELETE /v1/apps/{id} when the app has derived tiles and ?delete_derived=true was not sent. A LIST, not a count - the point of the confirmation is that the admin sees what they are about to destroy. Capped; an empty array means the tiles could not be listed, never that there are none.

Array<object>
object
id
required
string format: uuid
name
required
string
Examplegenerated
{
"error": {
"code": "example",
"message": "example",
"live_sessions": 1,
"session_id": "2489E9AD-2EE2-8E00-8EC9-32D5F69181C0",
"derived_tiles": [
{
"id": "2489E9AD-2EE2-8E00-8EC9-32D5F69181C0",
"name": "example"
}
]
}
}