Change the live render resolution / interface scale / external resolution of a running session (owner or admin).
const url = 'http://localhost:8080/v1/sessions/2489E9AD-2EE2-8E00-8EC9-32D5F69181C0/display';const options = { method: 'PATCH', headers: {Authorization: 'Bearer <token>', 'Content-Type': 'application/json'}, body: '{"render_width":1,"render_height":1,"ui_scale":1,"stream_width":1,"stream_height":1}'};
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/sessions/2489E9AD-2EE2-8E00-8EC9-32D5F69181C0/display";
let payload = json!({ "render_width": 1, "render_height": 1, "ui_scale": 1, "stream_width": 1, "stream_height": 1 });
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/sessions/2489E9AD-2EE2-8E00-8EC9-32D5F69181C0/display \ --header 'Authorization: Bearer <token>' \ --header 'Content-Type: application/json' \ --data '{ "render_width": 1, "render_height": 1, "ui_scale": 1, "stream_width": 1, "stream_height": 1 }'SESSION-DISPLAY-UPDATE amendment (additive, pre-approved 2026-08-15). Best-effort relay to the assigned host’s agent-api session_display_update message - modelled directly on POST /v1/sessions/{id}/swap: no session-state transition, and a rejection is always a no-op (the session is left running exactly as it was).
RENDER RESOLUTION AND UI SCALE ARE EPHEMERAL: agent-held only, never written to the sessions table, never present on the Session resource. The encode caps, interpipe boundary, and the session’s pinned stream WxH are UNCHANGED by render_width/render_height/ ui_scale - only the compositor’s advertised wl_output logical mode and wp_fractional_scale_v1 preferred_scale move. Clients read the live values back via session_metrics (agent-api.md) - the only authoritative readback - or keep their own last-acked value.
SESSION-DISPLAY-STREAM (approved 2026-08-16) amendment (additive, 2026-08-16, approved 2026-08-16 (PR #15); do not implement against it until merged). Adds stream_width/stream_height: the EXTERNAL (encoded/streamed) size, independent of render_width/render_height (INTERNAL). Unlike render size, this changes what the encoder actually produces - the coded size moves at the next IDR, with no WebRTC renegotiation. Gated on the assigned host’s encoder supporting a live resize (readback: Session.stream.external_resize_supported). Ephemeral like render size, but mirrored into an in-memory control-plane cache (Session.stream.external_width/external_height) for GET convenience; session_metrics remains the sole authoritative readback. See control-api.md for the full INTERNAL-vs-EXTERNAL vocabulary and the fixed stream.rungs table.
Authorizations
Section titled “Authorizations”Parameters
Section titled “Parameters”Path Parameters
Section titled “Path Parameters”Request Bodyrequired
Section titled “Request Bodyrequired”PATCH /v1/sessions/{id}/display body (session-display-update, additive, pre-approved 2026-08-15; stream_width/stream_height added by session-display-stream (approved 2026-08-16), 2026-08-16, approved 2026-08-16 (PR #15)). At least one property (or both-or-neither pair) must be present. render_width/render_height are BOTH-OR-NEITHER, and independently so are stream_width/stream_height (the server rejects either pair supplied alone with 400 validation_failed). Omitted properties are left unchanged - this is a partial update, not a snapshot. render_width/render_height must be even and within [16, the session’s pinned LAUNCH width/height]; ui_scale must be within [1.0, 3.0]; stream_width/stream_height must be even and must resolve to one of the session’s stream.rungs pairs (always <= the launch size). (2026-08-16 amendment: render and external/stream size are INDEPENDENT axes - render is bounded only by the pinned launch size, never by the current or any past external size; a stream size below the current render size is downsampled by the encoder from the unchanged render framebuffer, and render is never forced down to match.) Ephemeral - not persisted to the sessions table; stream_width/ stream_height is mirrored into an in-memory cache surfaced as Session.stream.external_width/external_height for GET convenience only.
object
New app-facing wl_output logical width, in pixels. Must be even and ≤ the session’s pinned LAUNCH width, independent of the current external/stream size. Requires render_height.
New app-facing wl_output logical height, in pixels. Must be even and ≤ the session’s pinned LAUNCH height, independent of the current external/stream size. Requires render_width.
New wp_fractional_scale_v1 preferred_scale hint pushed to session toplevels.
Session-display-stream (approved 2026-08-16) (approved 2026-08-16 (PR #15)). New EXTERNAL (encoded/streamed) width, in pixels. Must be even and must, together with stream_height, match one of the session’s stream.rungs pairs. Requires stream_height. Rejected with 409 external_resize_unsupported if the assigned host’s encoder cannot live-resize.
Session-display-stream (approved 2026-08-16) (approved 2026-08-16 (PR #15)). Pairs with stream_width; see its description. Requires stream_width.
Examplegenerated
{ "render_width": 1, "render_height": 1, "ui_scale": 1, "stream_width": 1, "stream_height": 1}Responses
Section titled “Responses”Accepted; agent is applying the update. Body is the current session (Session shape is unchanged by this endpoint).
object
object
First-run-experience §S5. Machine-readable classification of a terminal failure - “app_exited_early” today. Sits beside error_message (free-text prose that may be rewritten freely); the UI branches on THIS. Always serialized; null unless a failure warranted it.
First-run-experience §S5. The app container’s own captured log tail (newline-joined, oldest first, ~100 lines bound) - the only surviving copy, since app containers run –rm. Always serialized; null unless a failure warranted capturing it. Rendered preformatted, distinct from error_message’s prose rendering.
The profile the session was launched from; null for a legacy/tier/override launch. UI-P4: this is now a LAUNCH PROFILE id, i.e. the USER’S PICK. The rung it resolved to is stream_profile_id.
UI-P4: the RUNG this launch resolved to (e.g. “1080p60-h264”). Always serialized; null for every pre-UI-P4 session and for any legacy/tier/override/console launch. profile_id answers “what did the user pick”, this answers “what did they get” - and because a rung carries its own resolution, the two can legitimately disagree about width/height/fps/ bitrate. The stream block below is always the truth for the running session.
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.
UI-P6: how this session’s rung/codec was resolved. Persisted at launch and echoed on every session body; null for every pre-UI-P6 session and for any launch that walked no rung chain (console, or a legacy/tier launch that resolved no launch profile).
THE THREE OUTCOMES ARE DELIBERATELY DISTINGUISHABLE and a client must not collapse them, because stream.codec is identical in all three. Won on merit: selected with rejected_by=null, clamps_bypassed=false, floor=false, override=null. Operator override: override names the forced codec and the selected rung has clamps_bypassed=true with rejected_by=null (it SKIPPED clamps 2/3, 4, 5 and 6 rather than surviving them). Floor: floor=true and the selected rung is clamps_bypassed=true WHILE STILL CARRYING the rejected_by that killed it - it was dispatched despite being rejected. floor and clamps_bypassed answer different questions (“did anything survive?” vs “was THIS rung measured?”) and the override case sets the second without the first; do not merge them.
object
The dispatched rung id.
Multi-codec: the WIRE session video codec (h265 is HEVC). Resolved server-side at launch; the client answers the single codec the host offers. Distinct from CatalogCodec, which spells HEVC ‘hevc’.
NO rung survived the clamp chain and the unconditional h264 floor fired.
Every rung WALKED, in position order. The walk stops at the first survivor, so a clean top-rung win lists exactly one entry - that is the whole decision, not a truncation.
UI-P6: one rung’s verdict in the resolution walk.
object
Wire vocabulary (h264|h265|av1), except when rejected_by is unknown_codec, where it is the raw unmappable catalog value - the only useful thing to show for a hand-edited row.
UI-P6: the clamp that rejected a rung during the walk. Treat this set as OPEN - render an unrecognised value rather than assuming the list is closed. host_encoder=clamp 1, client_decode=clamp 2/3 (codec), decode_height=clamp 2/3 (resolution), decode_history=clamp 4, hardware_encoder=clamp 5, encoder_throughput=clamp 6 (#506: the host’s reported sustained encode throughput for this codec cannot carry the LAUNCH-EFFECTIVE width x height x fps - the rung’s values with any explicit stream.* size override applied - see agent-api.md capacity.codec_throughput; a host that reports no hint never rejects this way), unknown_codec=a rung whose catalog codec does not map (hand-edited data; codec then carries the raw catalog value).
The rung that was actually dispatched. Exactly one entry per recorded decision.
This rung was dispatched WITHOUT being measured against the full clamp chain - the clamp-0 override path (clamps 2/3/4/5/6 skipped) or the floor (every clamp skipped). Never set on a rung that won the walk.
UI-P6: the wire codec the CLIENT reports it is actually decoding (normalised getStats mimeType), beside stream.codec, which is what the SERVER resolved. They should agree; when they do not, that is how a silent fallback or a mis-negotiated m-line presents, so both are kept and neither is reconciled away. NOT constrained to the Codec enum - a value the server never resolves (vp9) is preserved rather than dropped, because it is the loudest disagreement there is. Always serialized; null until the client reports one.
AS10-06: computed stream-health classification; present only when the session is running/unsustainable.
AS10-06: optional human explanation accompanying a degraded health state.
Steam game-exit lifecycle amendment (2026-08-02): coarse in-container application launch state last reported by the agent (agent-api.md session_metrics. app_launch_state, the normative definition). Read-only; there is no request field. OMITTED when the app image does not report launch state - absence means UNKNOWN, and a client must treat an absent field and an unrecognised value identically and fall back to transport-level readiness. A hint and a metric, never a session-state authority and never access control: state remains the only progress signal.
Example
{ "session": { "state": "pending", "stream": { "h264_profile": "constrained-baseline", "codec": "h264", "external_owner": "auto" }, "codec_decision": { "result_codec": "h264", "override": "h264", "considered": [ { "rejected_by": "host_encoder" } ] }, "health_state": "healthy", "app_launch_state": "starting" }}Validation_failed — render_width/render_height not both-or-neither, dims not even, dims out of [16, session pinned LAUNCH WxH] (independent of the current external/stream size, 2026-08-16 amendment), ui_scale outside [1.0, 3.0], or stream_width/stream_height not both-or-neither or not one of the session’s stream.rungs pairs.
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" } ] }}Caller is neither owner nor admin.
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" } ] }}Session_not_running - the session’s top-level state is not running - or display_update_rejected - the agent rejected the update, or no ack arrived within the control plane’s command timeout - or (session-display-stream, approved 2026-08-16, AWAITING SIGN-OFF) external_resize_unsupported - the assigned host’s encoder cannot live-resize the stream, only returned when the request includes stream_width/stream_height. The session is left untouched in every case.
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" } ] }}