Documents saga a2ui 0.11.0-rc.1 (prerelease). This is the SDK guideThe reference application lets a signed-in synthetic subscriber press Play to authorize and fetch a generated audio episode. Its Lit and React controls use the same application controller, actions and server. The server checks entitlement on every Play and again on every media request. A capability is usable only with a proof from the requesting view’s private key. The design is the authorized playback Blueprint. For setup and the complete delivery rules, see PLAY1 and PLAY2.docs/authorized-playback.mdat source revisionfb4126c. It is published as a snapshot: later source changes do not alter it. Licensed under Apache-2.0.
Where playback fits in the SDK
All seven SDK packages have public npm releases; public installation owns the package recipe. The synthetic playback application remains development tooling outside that release set. Playback is an authored application view. Its controller, media service and Web Audio player belong to the application, while@saga-a2ui/core/actions owns action execution and @saga-a2ui/mcp/view owns bridge delivery and teardown. The Lit page adds an optional description card; the React page uses the application’s own React control and no description card. The tree below follows the Lit page; both use the same playback controller and server.
@saga-a2ui/core/actions) supplies direct bindings, asynchronous local actions, resource scopes and server actions. The original v0.1.0 snapshot (dda384de, 2026-10-18) did not use it for Play; the current source does (CORE2, PR #133). The control opens its own instance on an action host and the application binds play, pause and resume to it; Play is an Action.server and Pause and Resume are local actions. The sections below describe the migrated path. The request bounds, the session-label rule and the player’s lease ownership are application decisions that stay in apps/lit-host, as they did before the migration.
The MCP integration uses ext-apps 2.0.3 and the split MCP SDK 2 packages (client, core, server 2.3.1; node 2.1.1). The listener uses NodeStreamableHTTPServerTransport; a handler reads verified identity from ctx.http.authInfo and request metadata from ctx.mcpReq._meta. Browser views use App and PostMessageTransport. These replace the old monolithic SDK imports without changing the capability/proof protocol below.
One Play press
Editable Mermaid · SVG
The press calls player.prepare() synchronously, so the browser unlocks audio inside the user’s click before any network work starts. The view then creates an ECDSA P-256 (ES256) key pair. The private key is non-extractable and stays in the view. Only the public key travels, in the Play call’s _meta under saga-a2ui/recipient-key; the arguments stay { episodeId }.
The host forwards the call to POST /playback/mcp with the signed-in user’s bearer token. Before any tool runs, the endpoint checks the body size, the token and its playback:play scope, then the JSON-RPC shape, the tool name and the argument bounds.
play_episode takes the account from the verified token, never from the arguments, and its strict input schema rejects a forged accountId. If the public key is missing or malformed, it returns “Playback is not supported here.” before looking anything up. Otherwise it checks the account’s entitlement to the episode at that moment and asks the media service for a capability bound to the account, the episode and the public key’s RFC 7638 thumbprint. The result has two projections. content holds only the fixed sentence “Playback authorized.” The capability, media URL and expiry go only in _meta['saga-a2ui/playback'], which MCP Apps documents as not intended for model context.
The model cannot call play_episode: it declares visibility: ['app'], so the host offers it only to the view. The model-visible tool is get_playable_episode. It mounts the player and requires sign-in, but it checks no entitlement and issues no capability.
The view accepts a Play result only if it names the selected episode, the sign-in the view pinned when it opened, and the media path for that episode. It then fetches the bytes with the capability and a newly signed proof, without following redirects. The media service checks that request on its own, including whether the account is still entitled. The view decodes the bytes with Web Audio and shows Playing only after the audio clock advances.
What the media service checks
Editable Mermaid · Editable Excalidraw · SVG
GET /playback/media/{episodeId} does not identify the person. Cookies and bearer tokens play no part. The capability, the proof and the account’s current entitlement decide every request. The proof is a JWT signed with the view’s private key. It names the method, the exact URL and the SHA-256 hash of the capability, and carries its own iat and single-use jti, so every request, including each byte range, needs a new proof. A capability lasts 60 seconds from issue on the service’s clock, and the service stores only its hash.
After the proof checks, the service asks the application’s checkEntitlement again, for the account and episode the capability was issued for. If the answer is no, the service refuses the request and drops the capability, so access granted again later needs a new Play. If the lookup itself fails, or the storage read fails, the answer is 503 with the body {"error":"unavailable"}. That is neither a grant nor a refusal, and the capability stays usable.
Every refusal is the same 403 with the body {"error":"forbidden"}, so a caller cannot tell which check failed, and storage is not read. Only a caller who passed every check gets the Range header interpreted: one bytes= range is 206, any other bytes= range is 416 with the total length and no body, and no range or another unit gets the whole body with 200. Every answer that can carry bytes or a status is Cache-Control: private, no-store with Vary: Origin, Range, x-saga-capability, x-saga-proof, so a cache cannot answer one user with another’s bytes.
Revocation and expiry decide the next request only. A response that already passed every check finishes, and bytes the browser already received cannot be retracted.
The media path answers any origin (Access-Control-Allow-Origin: *) because the sandboxed iframe sends Origin: null. That is safe only because nothing ambient authorizes a media request. The service never redirects, and the player fetches with redirect: 'error', so the capability and proof headers never follow a redirect to another destination.
Player view states
Editable Mermaid · SVG
The view shows what the audio actually did. Pause shows paused only once the audio context has suspended, and Resume shows playing only once the audio clock advances again. A Resume that the browser withholds shows resume-blocked and keeps the position; the next press retries it without a new authorization. A Pause or Resume that the player cannot perform shows media-failed. A 403 from the media service, meaning expiry or revocation between the grant and the fetch, shows expired. Any other media failure (network, 5xx, a redirect, undecodable bytes, a blocked origin) shows media-failed. If a short episode ends before the player confirms that it started, the view keeps that end and shows ended instead of the late “started” result.
Play is one action invocation, bound to the rendered control and owned by the shared action runtime. A changed selection changes the invocation’s subject, so an undecided Play is canceled at its next checkpoint (its signal aborts), and the player owner stops the audio at once. Any opener, including a malformed one that leaves the same item selected, also retires an undecided Play, and the media request is sent only after a final checkpoint that reads the work deadline. If the control that started a Play is retired or replaced while it is pending, the view shows ready (nothing sent), plain unavailable (grant known) or the unknown-outcome unavailable (request sent); a later press on the replacement is a new Play. A late response from an earlier invocation cannot restore audio. Presses are ignored while authorization is pending (the binding is busy) or audio is loaded (application state). Every authorization retry is a new Play with a fresh entitlement check; the view never reuses a capability. Resume of already-loaded audio is different: it uses the existing decoded bytes. While the controller is active, a new opener result resets it to ready when it carries the pinned sign-in label. An opener or a Play grant under a different label ends the controller for good: it clears the selection, stops the player, shows session-changed with no control and asks the user to open a new view or reload. Later openers, including one under the old label or an unreadable one, are ignored, and Play, Pause and Resume do nothing. A new view or a reload pins its own label.
The action’s work deadline (30,000 ms by default, overridable through the action host’s limits) bounds prerequisites and the handler after the synchronous activation prefix: recipient-key creation, the host call, proof signing, the media request and body, decoding and confirmed player start. Before returning success, the handler transfers the player lease to the application owner. Success presentation and later hooks have separate per-hook budgets (5,000 ms by default); no work timer runs for the duration of successful playback. The transferred player can outlive the invocation. Natural completion marks the current lease ended and updates the presentation; the owner releases it on a new Play, a new selection, sign-in-label loss or view retirement.
If the work deadline expires, the invocation fails with a timeout (the signal aborts, the owner stops the audio, a late result is dropped, including one that arrives after the deadline but before its timer) and the view shows unavailable. If the Play request may already have reached the server (a rejected, timed-out or aborted host call, a generic tool error, or a success whose private grant is missing, malformed or for another episode) the outcome is unknown, so the view says playback could not be confirmed, offers no Try again and asks the user to open the episode again; the view-state diagram above predates this wording. Only the server’s fixed denied and unsupported refusals and a valid matching grant make the result known; a generic error does not, because the server issues the capability before it finishes the response. An expiry before anything was sent, or after a valid matching grant, keeps Try again. The view never claims that a rejected host call was a sign-in problem, because the host does not say.
Host teardown is terminal for this controller. It aborts the media fetch, releases the player and prevents Play, Pause, Resume, end events and outstanding asynchronous work from presenting anything or starting another request. The diagram’s retired node describes this internal lifetime; it is not a displayed player status. The lifetime runner stops delivery, calls controller teardown once and closes the connection; a host teardown response is sent before that close. dispose() and pagehide use the same ending path, and repeated disposal is harmless.
Cleanup belongs to the Play that started the work. The player owner hands out one lease per Play and ends exactly the lease it is given. If an old player.start() settles after retirement, it releases the player only if no lease holds it now; an old completion must never stop newer audio. The original acquisition-scoped cleanup predates the action-runtime migration; the current handler transfers or releases each lease through that runtime. The label-loss rule above follows the 2026-10-19 decision (final loss, recovery by a new view or reload). A delayed opener under the same label cannot be detected, and none of this proves teardown, view reuse or cancellation behavior in an actual assistant host.
The shared request gate
Both protected MCP endpoints run the same checks in the same order, inserveProtected in listener.ts. Each endpoint supplies only a ProtectedRequestPolicy (its tool names and size bounds), an AuthRequirement (its challenge realm and scope) and a server factory.
204 and rejects any method other than GET or OPTIONS with 405.
Where the code lives
Limits
- Bounded host observations on 2026-10-18 exercised the v0.1.0 playback implementation in ChatGPT web and Claude web, both in Edge. ChatGPT rendered the card immediately; Claude needed a display retry and failed to render it after reload. In both, an explicit Play completed entitlement checking and delivered private playback metadata without a tool/protocol error. Audio did not play: the fixture’s media URL remained loopback and no media read reached it. This is evidence of authorization and metadata delivery, not successful audio, host teardown or final host acceptance.
- Recipient binding needs the host to forward
_metaon the view’s proxiedtools/call, and media delivery needs iframefetchwith custom headers to reach the declared origin. A local host fixture tests dropped request metadata: Play fails closed asunsupported, with no entitlement check or protected fetch. - The view controller does not check a media URL’s origin itself. The resource’s declared
connect-srcis what blocks an undeclared destination, so a host that ignores the resource’s CSP would not block one. - The built player fetches the whole body and never sends
Range. Range handling is tested at the service’s HTTP edge through the real listener, not through the player. - No shared cache is simulated. Cache safety rests on the
private, no-storeandVaryheaders. - The synthetic entitlement lookup and storage are in memory. A real application owns how quickly a revocation takes effect; this service guarantees only that it asks on every request.
- Headless Chromium shows that the audio clock advances, not that the sound is audible.