Edit an app.
const url = 'http://localhost:8080/v1/apps/2489E9AD-2EE2-8E00-8EC9-32D5F69181C0';const options = { method: 'PATCH', headers: {Authorization: 'Bearer <token>', 'Content-Type': 'application/json'}, body: '{"name":"example","description":"example","cover_url":"example","kind":"game","external_source":"","external_id":"example","parent_app_id":"2489E9AD-2EE2-8E00-8EC9-32D5F69181C0","library_provider":"","enabled":true,"default_width":1,"default_height":1,"default_fps":1,"default_bitrate_kbps":1,"default_profile_id":"example","profile_policy":"inherit","runtime_spec":{"image":"example","args":["example"],"env":{"additionalProperty":"example"},"mounts":["example"],"gpu":true},"runtime_preset_id":"2489E9AD-2EE2-8E00-8EC9-32D5F69181C0","launchable_profile_ids":["example"],"entitle":"all"}'};
try { const response = await fetch(url, options); const data = await response.json(); console.log(data);} catch (error) { console.error(error);}use std::str::FromStr;use serde_json::json;use reqwest;
#[tokio::main]pub async fn main() { let url = "http://localhost:8080/v1/apps/2489E9AD-2EE2-8E00-8EC9-32D5F69181C0";
let payload = json!({ "name": "example", "description": "example", "cover_url": "example", "kind": "game", "external_source": "", "external_id": "example", "parent_app_id": "2489E9AD-2EE2-8E00-8EC9-32D5F69181C0", "library_provider": "", "enabled": true, "default_width": 1, "default_height": 1, "default_fps": 1, "default_bitrate_kbps": 1, "default_profile_id": "example", "profile_policy": "inherit", "runtime_spec": json!({ "image": "example", "args": ("example"), "env": json!({"additionalProperty": "example"}), "mounts": ("example"), "gpu": true }), "runtime_preset_id": "2489E9AD-2EE2-8E00-8EC9-32D5F69181C0", "launchable_profile_ids": ("example"), "entitle": "all" });
let mut headers = reqwest::header::HeaderMap::new(); headers.insert("Authorization", "Bearer <token>".parse().unwrap()); headers.insert("Content-Type", "application/json".parse().unwrap());
let client = reqwest::Client::new(); let response = client.request(reqwest::Method::from_str("PATCH").unwrap(), url) .headers(headers) .json(&payload) .send() .await;
let results = response.unwrap() .json::<serde_json::Value>() .await .unwrap();
dbg!(results);}curl --request PATCH \ --url http://localhost:8080/v1/apps/2489E9AD-2EE2-8E00-8EC9-32D5F69181C0 \ --header 'Authorization: Bearer <token>' \ --header 'Content-Type: application/json' \ --data '{ "name": "example", "description": "example", "cover_url": "example", "kind": "game", "external_source": "", "external_id": "example", "parent_app_id": "2489E9AD-2EE2-8E00-8EC9-32D5F69181C0", "library_provider": "", "enabled": true, "default_width": 1, "default_height": 1, "default_fps": 1, "default_bitrate_kbps": 1, "default_profile_id": "example", "profile_policy": "inherit", "runtime_spec": { "image": "example", "args": [ "example" ], "env": { "additionalProperty": "example" }, "mounts": [ "example" ], "gpu": true }, "runtime_preset_id": "2489E9AD-2EE2-8E00-8EC9-32D5F69181C0", "launchable_profile_ids": [ "example" ], "entitle": "all" }'Authorizations
Section titled “Authorizations”Parameters
Section titled “Parameters”Path Parameters
Section titled “Path Parameters”Request Bodyrequired
Section titled “Request Bodyrequired”Admin create/edit shape. runtime_spec + resource defaults are admin/scheduler-internal.
object
UI-P7 amendment (2026-07-28, control-api.md §“The app write shape and cover_url”): enforced, not just documented. Once the app has an app_artwork record this write is REFUSED whole-request with 409 conflict, never silently dropped and never a partial update of the request’s other fields. Only meaningful while the app has no artwork record (see AppListItem.cover_url), and even then a non-null/non-empty value must be http/https or a schemeless same-origin path — any other scheme is 400 validation_failed.
UI-P1, OPTIONAL. Absent = the server default on create, unchanged on patch - absence is NEVER a zero value (the cb97bfb trap: an omitted field decoding to “”/0 and being written clobbers the schema default). kind: “” is NOT “use the default”; it is 400 validation_failed, as is any value outside the enum. The DB CHECK is the backstop, not the primary gate. Note crud.decodeJSON sets DisallowUnknownFields(), so sending kind to a control plane without this amendment is a hard 400, not a silent ignore - deploy the control plane before the client.
Steam library discovery Phase 1, OPTIONAL. Absent = the server default (“”) on create, UNCHANGED on patch - absence is NEVER a zero value (the cb97bfb trap), and here an absent field silently overwriting the column would un-tag an app’s appid on any unrelated PATCH and send its artwork back to the fuzzy matcher. UNLIKE kind, an explicit “” IS valid: “” is the real domain value for “not a provider title”, so external_source: “” is a deliberate clear, not a malformed request. Any other value is 400 validation_failed. The handler is the primary gate; the DB CHECK (apps_external_source_ck) is the backstop. Validated INDEPENDENTLY of external_id - the contract states no pairing rule, and a half-set pair is inert (the artwork resolver requires both before it takes the by-id path). Note crud.decodeJSON sets DisallowUnknownFields(), so sending this to a control plane without this amendment is a hard 400, not a silent ignore - deploy the control plane before the client.
Steam library discovery Phase 1, OPTIONAL, with the same presence semantics as external_source: absent = default (“”) on create, unchanged on patch; explicit “” is a deliberate clear. A non-empty value must be a bare positive integer, no leading zero, no sign, no whitespace, no separators (400 validation_failed otherwise), so “0”, “007”, “1 2” and “-applaunch 480 -foo” are all rejected. THAT GRAMMAR IS ARGUMENT-INJECTION CONTAINMENT, NOT A FORMAT PREFERENCE: the value ends up in STEAM_STARTUP_FLAGS, which the quasar-steam entrypoint word-splits with read -r -a, so a stored “480 -foo” would reach the Steam client as two extra arguments. The handler is the primary gate; the DB CHECK (apps_external_id_ck) carries the same regex as the backstop and is the one that also covers an admin editing the value later.
Steam library discovery Phase 3, OPTIONAL. The app this tile is DERIVED from. On create, absent/null = a normal app (today’s behaviour). On patch it is TRI-STATE, exactly like runtime_preset_id: absent = unchanged, explicit null = clear (the tile becomes a normal app), a uuid = set it. A uuid that does not resolve is 400 validation_failed at write time, never an FK error surfacing at launch. SETTING IT PUTS THE ROW UNDER THE DERIVED-TILE SHAPE RULE, which is a database CHECK (apps_derived_shape_ck) and not a convention: the tile must carry runtime_spec = ‘{}’, managed_home = false, runtime_preset_id = null, library_provider = “”, and a non-empty external_source + external_id. A write that violates any of those is 400 validation_failed at the handler, with the CHECK as the backstop that also survives a later direct edit. THE TILE STORES NO RUNTIME OF ITS OWN ON PURPOSE. Merging at launch rather than flattening at save is what makes an edit to the parent - an image bump, a new GPU flag, a new mount - reach every derived tile with no re-sync and no stale copies. It is the same decision as UI-P3’s runtime presets, and it is the reason the validated Tower experiment (which hardcoded a host path into a tile’s runtime_spec.mounts) cannot ship. A parent may not itself be derived: one level, never a chain. One tile per (parent_app_id, external_source, external_id) fleet-wide - a duplicate is 409 conflict, from the apps_parent_external_uk unique index. Note crud.decodeJSON sets DisallowUnknownFields(), so sending this to a control plane without this amendment is a hard 400 - deploy the control plane before the client. EVERY ONE OF THESE RULES ANSWERS 4xx, NEVER 500. apps_derived_shape_ck maps to 400 validation_failed and apps_parent_external_uk to 409 conflict, both naming what the operator can fix: a CHECK violation reaching a client as 500 internal is a lie, because the request is malformed and the server is not. Two rules the database cannot express as a row CHECK are enforced at the handler and answer 400: parent_app_id must name an EXISTING app that is NOT ITSELF DERIVED (one level, never a chain - home resolution substitutes the parent exactly once, so a grandchild would resolve its home to a tile that owns none), and library_provider may not be set on a derived tile, evaluated against the EFFECTIVE patched-or-stored shape rather than the request alone, so a two-request path cannot assemble a state a single request would be refused for.
Steam library discovery Phase 3, OPTIONAL, with the same presence semantics as external_source: absent = the server default (“”) on create, UNCHANGED on patch - absence is NEVER a zero value (the cb97bfb trap) - and an explicit “” IS valid, as a deliberate un-marking. Any other value outside the enum is 400 validation_failed. MARKING AN APP HERE IS THE ENTIRE TRIGGER FOR DISCOVERY (Phase 4), so an absent field silently overwriting the column on an unrelated PATCH would turn scanning off for a whole instance without anyone touching the setting. It is refused on a derived tile (parent_app_id set) - a tile cannot be a provider - and it is INDEPENDENT of kind: an admin UI may suggest kind=‘launcher’ alongside it, but nothing server-side reads kind, and gating discovery on kind would let a presentation dropdown silently stop a background job.
UI-P4: a LAUNCH PROFILE id. An id that does not resolve is 400 validation_failed.
DRIFT FIX (UI-P4), not a new field - the Go handler has validated this on create and patch since migration 0015 (crud/handler.go validProfilePolicy) while this schema omitted the property entirely. OPTIONAL, with the same presence semantics as kind: absent = the server default on create, unchanged on patch, and absence is NEVER a zero value. UI-P4 removed ‘custom’ from the enum, so profile_policy:“custom” is now 400 validation_failed on a control plane carrying this amendment.
Agent-internal container spec (not exposed on public read shapes).
object
object
UI-P3, OPTIONAL. The shared runtime preset this app inherits its container configuration from. On create, absent/null = no preset = the app carries everything itself (today’s behaviour). On patch it is TRI-STATE: absent = unchanged, explicit null = clear the reference, a uuid = set it. A uuid that does not resolve is 400 validation_failed at write time, never an FK error surfacing at launch. NOT a launch profile - see RuntimePreset. Note crud.decodeJSON sets DisallowUnknownFields(), so sending this to a control plane without the UI-P3 amendment is a hard 400 - deploy the control plane before the client.
UI-P5, OPTIONAL. The LAUNCH PROFILE ids a user may pick for this app. On create, absent or [] = unrestricted = today’s behaviour. On patch: absent = UNCHANGED, [] = clear the allow-list (back to unrestricted), a non-empty array = replace it wholesale (it is a set, not an ordered list). EXPLICIT null IS 400 validation_failed. The contract gives null no meaning for this field - unlike default_profile_id and runtime_preset_id, where null explicitly means “clear” - and [] already says clear. Reinterpreting null would silently act on a value the caller clearly meant something by, which is exactly the defect fixed for the runtime-preset list fields. Every id must name a USER-VISIBLE launch profile (400 otherwise). A RUNG id (a stream profile) is not a launch profile and is rejected - the two id spaces look alike and differ only in table. Duplicates are deduped rather than rejected. SETTING IT WHILE profile_policy IS ‘force’ IS 400: that policy pins the app’s launch profile, so no allow-list can ever apply. Switching an app TO ‘force’ CLEARS any stored list, even when the patch says nothing about it, so nothing can silently reactivate on a later switch back to ‘prefer’. Note crud.decodeJSON sets DisallowUnknownFields(), so sending this to a control plane without the UI-P5 amendment is a hard 400 - deploy the control plane before the client.
Steam library discovery Phase 2, OPTIONAL and CREATE-ONLY. “all” (the default, and what an absent field means) creates the app with an (‘all’, granted_by=‘admin’) entitlement, so it is immediately visible to everyone - i.e. EXACTLY the pre-entitlements behaviour of creating an app. “none” creates it entitled to nobody, for an admin who wants to configure access before anyone sees it. THE DEFAULT IS THE WHOLE POINT: once GET /v1/apps is entitlement-filtered a new app is invisible until something entitles it, so without a default grant “I made an app and nobody can see it” becomes the default experience - the same failure as an un-backfilled migration, one app at a time. Any other value is 400 validation_failed, REJECTED rather than treated as “none”, because a typo (“nome”, “None”) that quietly created an invisible app would be diagnosed as “the catalogue is broken”. CREATE-ONLY: this property is declared on the shared AppWrite shape, but PATCH /v1/apps/{id} does NOT accept it - sending it there is 400 validation_failed (crud.decodeJSON sets DisallowUnknownFields()). It describes how an app is BORN, not a property it carries; after creation, access is edited through /v1/admin/apps/{id}/entitlements, which is the surface that produces an audit row and can express a per-user grant. NOT a stored column and never returned on any read shape - the resulting entitlement row is what persists.
Responses
Section titled “Responses”Updated.
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" } }}Malformed or invalid request.
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" } ] }}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" } ] }}Authenticated but insufficient role / not the owner (precedes resource lookup).
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 resource.
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" } ] }}#534 AMENDMENT (2026-08-25), ADDITIVE: code library_discovery_disabled - the edit would set a non-empty library_provider, or enable a reconciler-suspended provider app, while library_discovery_enabled is false (either write would be immediately reverted by the reconciler). The ways out are deliberate: clear library_provider, keep the app disabled, or enable library discovery in Settings. See the POST /v1/apps 409 for the rationale.
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" } ] }}