A single app (requires auth; response is wrapped in {app}).
const url = 'http://localhost:8080/v1/apps/2489E9AD-2EE2-8E00-8EC9-32D5F69181C0';const options = {method: 'GET', headers: {Authorization: 'Bearer <token>'}};
try { const response = await fetch(url, options); const data = await response.json(); console.log(data);} catch (error) { console.error(error);}use reqwest;
#[tokio::main]pub async fn main() { let url = "http://localhost:8080/v1/apps/2489E9AD-2EE2-8E00-8EC9-32D5F69181C0";
let mut headers = reqwest::header::HeaderMap::new(); headers.insert("Authorization", "Bearer <token>".parse().unwrap());
let client = reqwest::Client::new(); let response = client.get(url) .headers(headers) .send() .await;
let results = response.unwrap() .json::<serde_json::Value>() .await .unwrap();
dbg!(results);}curl --request GET \ --url http://localhost:8080/v1/apps/2489E9AD-2EE2-8E00-8EC9-32D5F69181C0 \ --header 'Authorization: Bearer <token>'This route has always had TWO behaviours: for a non-admin it is the library read; for an admin it returns the full admin shape (disabled apps included, runtime_spec included).
STEAM LIBRARY DISCOVERY PHASE 2: for a NON-ADMIN caller this is now 404 when the caller is not entitled, folded into the existing no-such-app answer rather than given its own status. The endpoint is already an existence check for a non-admin (a disabled app is 404 too), so collapsing “not entitled” into it leaks nothing and is the correct posture for a per-user library. 403 is reserved for the two WRITES (PUT /v1/me/favourites/{app_id}, POST /v1/sessions) where the caller has deliberately named an app and needs an actionable answer.
THE ADMIN BRANCH IS DELIBERATELY NOT ENTITLEMENT-FILTERED - DO NOT “FIX” IT. It is the admin app-EDITOR’s loader (web/src/api/admin.ts:160) and there is no GET /v1/admin/apps/{id} route to move that load onto, so filtering it would make an app UN-EDITABLE the instant an admin restricted it: an operator lockout with no recovery inside the product, reachable by using the feature exactly as intended.
THE RESULTING ASYMMETRY, STATED SO IT IS NOT READ AS A BUG: an admin can read one app’s detail here while that same app is ABSENT from their own GET /v1/apps. That is consistent - the admin surface is “the fleet’s configuration”, the library is “what I may launch”, and this route serves both - and it concedes no information, since GET /v1/admin/apps would hand an admin the same rows. It grants no launch either: POST /v1/sessions and …/swap are entitlement-checked for every role.
Authorizations
Section titled “Authorizations”Parameters
Section titled “Parameters”Path Parameters
Section titled “Path Parameters”Responses
Section titled “Responses”OK.
object
object
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.
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.
UI-P1: presentation-only library classification (apps.kind). Always serialized; ‘game’ for every pre-UI-P1 row. Phase 3 added ‘launcher’ - see AppKind.
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.
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.
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.
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}.
UI-P4: the LAUNCH PROFILE this app pins or prefers, per profile_policy.
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.
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
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.
AS-02: tier-selected initial receiver playout target (ms) on session.stream; absent on an app’s display_stream.
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).
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.
Session-display-stream (approved 2026-08-16). Pairs with external_width; see its description.
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.
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.
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.
Example
{ "app": { "kind": "game", "external_source": "", "profile_policy": "inherit", "display_stream": { "h264_profile": "constrained-baseline", "codec": "h264", "external_owner": "auto" } }}Missing/invalid/expired/revoked token.
object
object
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.
Present on restart_required.
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.
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.
object
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" } ] }}No such app; for a non-admin also a disabled app, and (Phase 2) an app the caller holds no entitlement for.
object
object
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.
Present on restart_required.
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.
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.
object
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" } ] }}