# Glyph — Full Documentation > Glyph is hosted server-driven UI for Android, built on `androidx.compose.remote`. Tenants sign up, push binary `.rc` screen documents from their Gradle build, and Android apps render the updated UI on next launch — natively, no WebView, no JSON schema, no Play Store release. This file concatenates every page under for one-shot ingestion by AI agents. The structured index lives at . Individual pages are still served as plain markdown at . Site URL: Upstream framework: --- # Glyph documentation Glyph hosts [`androidx.compose.remote`](https://developer.android.com/jetpack/androidx/releases/compose-remote) documents for your Android apps. Write screens in Kotlin, push them with one Gradle task, and your users render the new UI on next launch — natively, no WebView. ## Pages - [Getting started](/docs/getting-started) - [Platforms](/docs/platforms) — Android, iOS, Web, KMP support matrix. - [Authoring screens](/docs/authoring) — `@RemoteScreen` + `RemoteText` / `RemoteColumn` / `RemoteBox`. - [Consuming screens](/docs/consuming) — `Glyph.Foo(...)` typed accessors. - [Params and actions](/docs/params-and-actions) — typed slots, `hostAction(...)`, `valueChange`. - [Consumer keys](/docs/consumer-keys) — opt-in read auth. - [REST API](/docs/api-reference). --- # Getting started ## 1. Create an app Sign in at , click **Create app**, copy the generated `appId` and `apiKey` (the publish key — `rck_...`). ## 2. settings.gradle.kts ```kotlin pluginManagement { repositories { maven { url = uri("https://artifacts.premex.se/api/maven/premex/glyph/") } gradlePluginPortal() } } dependencyResolutionManagement { repositories { maven { url = uri("https://artifacts.premex.se/api/maven/premex/glyph/") } google() mavenCentral() } } // Foojay resolves the JDK 21 toolchain Glyph requires (see below). plugins { id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" } ``` --- ## Producer (publishes screens) ### 3. Authoring module — `screens/build.gradle.kts` ```kotlin plugins { id("com.android.library") id("org.jetbrains.kotlin.android") id("org.jetbrains.kotlin.plugin.compose") // REQUIRED — see note id("se.premex.glyph") version "0.3.5" } glyph { appId = "your-app-id" apiKey = providers.environmentVariable("GLYPH_API_KEY") generatedPackage = "com.example.app.glyph" // pick a package you own } android { namespace = "com.example.app.screens" compileSdk = 37 defaultConfig { minSdk = 29 } // Glyph needs ≥ 29 testOptions { unitTests { isIncludeAndroidResources = true } } } kotlin { jvmToolchain(21) // Glyph needs JDK 21 } ``` > **The `org.jetbrains.kotlin.plugin.compose` plugin is required.** > Without it `@Composable` annotations are inert and you'll see > `Argument type mismatch: actual type is 'Function0', but > 'ComposableFunction0' was expected` on every nested > `RemoteBox { … }` / `RemoteColumn { … }`. **Hard requirements** | | Why | |---|---| | `minSdk = 29` | Inherited from `androidx.compose.remote:1.0.0-alpha13`. | | `compileSdk = 37` | `androidx.compose.remote:1.0.0-alpha13` requires `compileSdk` ≥ 37. | | `jvmToolchain(21)` | Plugin + capture-test classes target JDK 21. Foojay (declared in `settings.gradle.kts` above) downloads it for you. | The `se.premex.glyph` plugin auto-applies KSP and brings in `plugin-runtime`, `plugin-ksp`, `remote-creation-compose`, plus the Robolectric capture-test deps. No manual dependency wiring needed. ### 4. Write a screen ```kotlin @file:OptIn(androidx.compose.remote.creation.compose.ExperimentalRemoteCreationComposeApi::class) @file:Suppress("RestrictedApi") package com.example.app.screens import androidx.compose.remote.creation.compose.action.hostAction import androidx.compose.remote.creation.compose.layout.RemoteBox import androidx.compose.remote.creation.compose.layout.RemoteColumn import androidx.compose.remote.creation.compose.layout.RemoteText import androidx.compose.remote.creation.compose.modifier.RemoteModifier import androidx.compose.remote.creation.compose.modifier.clickable import androidx.compose.remote.creation.compose.modifier.fillMaxWidth import androidx.compose.remote.creation.compose.modifier.padding import androidx.compose.remote.creation.compose.state.RemoteFloat import androidx.compose.remote.creation.compose.state.RemoteString import androidx.compose.remote.creation.compose.state.rdp import androidx.compose.remote.creation.compose.state.rf import androidx.compose.remote.creation.compose.state.rs import androidx.compose.remote.creation.compose.state.rsp import androidx.compose.runtime.Composable import se.premex.glyph.runtime.RemoteScreen @RemoteScreen("checkout") @Composable fun checkout( userName: RemoteString = "guest".rs, totalAmount: RemoteFloat = 0f.rf, ) { RemoteBox(modifier = RemoteModifier.fillMaxWidth().padding(24.rdp)) { RemoteColumn { RemoteText("Hi, ".rs + userName, fontSize = 22.rsp) RemoteText("Total: ".rs + totalAmount.toRemoteString(), fontSize = 16.rsp) RemoteBox( modifier = RemoteModifier .fillMaxWidth() .padding(top = 8.rdp) .clickable(hostAction("purchase".rs)), ) { RemoteText("Purchase", fontSize = 18.rsp) } RemoteBox( modifier = RemoteModifier .fillMaxWidth() .padding(top = 8.rdp) .clickable(hostAction("cancel".rs)), ) { RemoteText("Cancel", fontSize = 16.rsp) } } } } ``` > **Both file-level annotations are required on `androidx.compose.remote:1.0.0-alpha13`.** > Lots of APIs are marked `@RestrictTo(LIBRARY_GROUP)` (e.g. `RemoteFloat.toRemoteString()`) > or `@ExperimentalRemoteCreationComposeApi` (e.g. `RemoteText`, `RemoteBox`). Without > these two lines at the top of the file you'll see errors like *"can only be called from > within the same library group"* or *"This declaration is experimental"*. They'll likely > relax in a later alpha — for now, every example file in this repo carries them. ### 5. Publish ```sh GLYPH_API_KEY=rck_... ./gradlew :screens:glyphPublish ``` The bytes + contract land on the server. The producer is done — no artefact is shared with the consumer. --- ## Consumer (renders screens) ### 6. Consumer Android app — `app/build.gradle.kts` ```kotlin plugins { id("com.android.application") id("org.jetbrains.kotlin.android") id("org.jetbrains.kotlin.plugin.compose") id("se.premex.glyph") version "0.3.5" } glyph { appId = "your-app-id" generatedPackage = "com.example.app.glyph" // pick a package you own } android { defaultConfig { minSdk = 29 } } kotlin { jvmToolchain(21) } ``` No dependency on the producer's authoring module. The plugin's codegen reads from the server's manifest. `generatedPackage` is required — the plugin won't guess one for you. ### 7. Sync the manifest ```sh ./gradlew :app:glyphSync ``` Fetches `https://glyph.premex.se/v1/apps//manifest.json` and writes a snapshot to `app/glyph.lock.json`. Commit it to version control — like a lockfile, it pins which screens + contracts your build sees, so CI is reproducible without network. Re-run `glyphSync` whenever the producer ships changes. ### 8. Render ```kotlin import com.example.app.glyph.Glyph // matches `generatedPackage` class MyApp : Application() { override fun onCreate() { super.onCreate() RemoteComposeClient.install(context = this) } } @Composable fun App() { Glyph.Checkout( userName = "Stefan", totalAmount = 49.99f, callbacks = object : Glyph.CheckoutCallbacks { override fun onPurchase() { /* navigate */ } override fun onCancel() { /* navigate */ } }, modifier = Modifier.fillMaxSize(), ) } ``` `glyphGenerate` (run automatically before `compileKotlin`) reads `glyph.lock.json` and emits `Glyph.(...)` accessors into your `generatedPackage` — one parameter per typed slot, a `Callbacks` interface for clicks. The accessor delegates to the SDK, which fetches the bytes from the server at runtime with ETag revalidation + disk cache. --- # Platforms Glyph hosts one binary `.rc` document per screen and one shared manifest per app. Each platform's SDK speaks the same wire format and contributes a host-native renderer. | Platform | SDK | Renderer | Status | |---|---|---|---| | **Android** | `se.premex:glyph-sdk:0.3.5` | `androidx.compose.remote:remote-player-compose:1.0.0-alpha13` | shipping | | **iOS** | `Glyph` Swift Package | rcX C++ engine + Skia-Ganesh-Metal | network + types ship; renderer wiring documented | | **Web** | TypeScript player bundle (vendored at `/rc-player/bundle.js` on the portal) | Canvas2D | embedded in the portal as live previews | | **KMP common** | `:sdk-kmp` | — (types + URL helpers only) | shipping JVM, native targets blocked on AGP+KMP plumbing | | **JVM Desktop** | (planned) | Compose Multiplatform `:remote-player` from the experiments repo | path documented | | **C++ / iOS native / macOS** | (planned) | rcX engine direct | upstream `players/cpp/` | ## Wire format — what's shared All SDKs decode the same JSON manifest emitted by the server at `/v1/apps//manifest.json`: ```json { "schemaVersion": 1, "appId": "your-app-id", "screens": [ { "id": "checkout", "version": 7, "etag": "\"sha256-...\"", "sizeBytes": 721, "dimensions": { "width": 360, "height": 640 }, "contract": { "actions": ["purchase", "cancel"], "params": [ { "id": "userName", "type": "string" }, { "id": "totalAmount", "type": "float" } ] }, "previewUrl": "https://.../checkout.png" } ] } ``` `.rc` bytes live at `/v1/apps//screens/.rc` and are ETag-revalidated. Same bytes render identically on every player (Android Compose, iOS Skia-Metal, browser Canvas2D, JVM desktop). ## Android (shipping) ```kotlin plugins { id("se.premex.glyph") version "0.3.5" // … } glyph { appId = "your-app-id" generatedPackage = "com.example.app.glyph" } ``` ```kotlin import com.example.app.glyph.Glyph @Composable fun App() { Glyph.Checkout( userName = "Stefan", totalAmount = 49.99f, callbacks = object : Glyph.CheckoutCallbacks { override fun onPurchase() { /* … */ } override fun onCancel() { /* … */ } }, ) } ``` Runtime fetches `.rc` bytes from glyph.premex.se with ETag revalidation + disk cache; player Composable renders the document. See [Getting started](/docs/getting-started) for the full Producer + Consumer walk-through. ## iOS (network ships, renderer pending) Add via Swift Package Manager — point at this repo's `sdk-ios/` directory or its eventual SPM mirror. ```swift import Glyph @main struct MyApp: App { init() { GlyphClient.configure(appId: "your-app-id") } var body: some Scene { WindowGroup { GlyphScreen( screenID: "compose-checkout", params: ["userName": "Stefan", "totalAmount": Float(49.99)], onAction: { id in /* … */ } ) } } } ``` `GlyphClient.configure(...)` initialises a singleton with `URLSession` + a per-app disk cache. `GlyphScreen` is a SwiftUI view that fetches the bytes and hands them to the renderer. The renderer currently shows a placeholder — to wire it up, build the `librccore` / `librcskia` static libs from [camaelon/remotecompose-experiments/players/cpp](https://github.com/camaelon/remotecompose-experiments/tree/main/players/cpp) and follow `sdk-ios/README.md → Wiring the renderer`. Same Skia-Ganesh-Metal backend the Android player uses, so visual parity is expected. ## Web (live previews on the portal) The portal at embeds the screen renderer directly. Each `ScreenCard` shows a live `` playing the actual `.rc` bytes plus a "Try values" panel that lets you push typed slot values (`setNamedStringOverride` / `…FloatOverride` / `…IntegerOverride` / `…ColorOverride` / `…BooleanOverride`) into the running document and watch it react. The bundle (`/rc-player/bundle.js`, ~99 KB gzipped, Apache 2.0 from [camaelon/remotecompose-experiments/players/typescript](https://github.com/camaelon/remotecompose-experiments/tree/main/players/typescript)) exposes a global factory: ```html ``` You can use this bundle directly in any web app to render Glyph screens — same engine, same bytes, no Android device. ## KMP / Compose Multiplatform (commonMain ships, native targets dormant) `:sdk-kmp` ships the manifest + contract types as platform-agnostic Kotlin in `commonMain`. Today only the JVM target is enabled while the AGP 9.x + Kotlin Multiplatform + Kotlin Native build-service plumbing settles. Once iOS / Android targets are flipped back on, the same manifest decoder powers every platform. The renderer per platform: - **androidMain** — folds in `:sdk-android` (already shipping). - **jvmMain / desktopMain** — `:remote-player` Kotlin module from [camaelon/remotecompose-experiments/players/compose](https://github.com/camaelon/remotecompose-experiments/tree/main/players/compose). - **iosMain** — cinterop to the rcX C++ libs (same as the iOS SDK). - **jsMain** — wraps the TypeScript bundle already vendored on this site. --- # Authoring screens ```kotlin @RemoteScreen("checkout") @Composable fun checkout( userName: RemoteString = "guest".rs, totalAmount: RemoteFloat = 0f.rf, ) { RemoteBox(modifier = RemoteModifier.fillMaxWidth().padding(24.rdp)) { RemoteColumn { RemoteText("Hi, ".rs + userName, fontSize = 22.rsp, fontWeight = FontWeight.Bold) RemoteText("Total: ".rs + totalAmount.toRemoteString(), fontSize = 16.rsp) RemoteSpacer() RemoteBox( modifier = RemoteModifier .fillMaxWidth() .padding(top = 8.rdp) .clickable(hostAction("purchase".rs)), ) { RemoteText("Purchase", fontSize = 18.rsp, color = RemoteColor(Color(0xFF4F46E5))) } RemoteBox( modifier = RemoteModifier .fillMaxWidth() .padding(top = 8.rdp) .clickable(hostAction("cancel".rs)), ) { RemoteText("Cancel", fontSize = 16.rsp) } } } } ``` ## Layout primitives | | | |---|---| | `RemoteBox(modifier) { ... }` | Z-stack | | `RemoteColumn(modifier) { ... }` | Vertical stack | | `RemoteRow(modifier) { ... }` | Horizontal stack | | `RemoteText(text, color, fontSize, fontWeight)` | Text | | `RemoteImage(...)` | Image | | `RemoteSpacer()` | Spacer | | `RemoteCanvas(...) { ... }` | Free-form drawing | ## Modifiers ```kotlin RemoteModifier .fillMaxWidth() .padding(top = 8.rdp) .background(RemoteColor(Color.White)) .clickable(hostAction("submit".rs)) ``` Units: `Int.rdp` for sizes, `Int.rsp` for text sizes, `Float.rf` for `RemoteFloat` literals, `String.rs` for `RemoteString` literals. ## Typed parameters `RemoteString` / `RemoteFloat` / `RemoteInt` / `RemoteColor` parameters become typed slots the host pushes values into at runtime. KSP generates the binding wrapper — no `rememberNamedRemoteString(...)` ceremony in the function body. ```kotlin @RemoteScreen("greeting") @Composable fun greeting(name: RemoteString = "world".rs) { RemoteText("Hello, ".rs + name) } ``` Consumer sets the value with `setUserLocalString("name", "Stefan")` — or, with a typed accessor, just pass it as a Kotlin parameter: ```kotlin Glyph.Greeting(name = "Stefan", callbacks = ...) ``` ## Click actions Two flavours: | Pattern | When to use | |---|---| | `clickable(hostAction("name".rs))` | Tap fires a callback in the consumer app | | `clickable(valueChange(state, newValue))` | Tap mutates document-internal state — no host callback | A host action arrives in the SDK's `onAction(id: String)` lambda; typed accessors route it to a generated `Callbacks.on()` method. (The underlying `HostAction` class went `internal` in alpha11 — use the `hostAction(...)` factory.) ## In-document state + animations ```kotlin @RemoteScreen("toggle") @Composable fun toggle() { val visibility = rememberMutableRemoteInt(0) val opacity = animateRemoteFloat(rf = visibility.toRemoteFloat(), duration = 0.3f) // alpha11 added the .alpha(RemoteFloat) modifier — the text fades in // and out of view as the eased float swings between 0 and 1, with no // host round-trip on the tap. RemoteText( text = "now you see me".rs, modifier = RemoteModifier .alpha(opacity) .clickable(valueChange(visibility, (visibility + 1) % 2)), ) } ``` ## Publish ```sh GLYPH_API_KEY=rck_... ./gradlew :screens:glyphPublish ``` --- # Consuming screens Producer pushes to the server. Consumer pulls a contract snapshot and gets typed Kotlin generated from it. The consumer never depends on the producer's source. ## 1. Configure ```kotlin // app/build.gradle.kts plugins { id("com.android.application") id("org.jetbrains.kotlin.android") id("org.jetbrains.kotlin.plugin.compose") id("se.premex.glyph") version "0.3.5" } glyph { appId = "your-app-id" generatedPackage = "com.example.app.glyph" // pick a package you own } ``` ## 2. Sync the manifest ```sh ./gradlew :app:glyphSync ``` Writes `app/glyph.lock.json`. Commit it. Rerun when the producer ships changes. ## 3. Render ```kotlin import com.example.app.glyph.Glyph @Composable fun App() { Glyph.Checkout( userName = "Stefan", totalAmount = 49.99f, callbacks = object : Glyph.CheckoutCallbacks { override fun onPurchase() { /* … */ } override fun onCancel() { /* … */ } }, modifier = Modifier.fillMaxSize(), ) } ``` The accessor fetches the screen bytes at runtime from `glyph.premex.se` — no further setup. --- # Params and actions ## Params Declare typed slots on the screen function. KSP generates the binding. ```kotlin @RemoteScreen("checkout") @Composable fun checkout( userName: RemoteString = "guest".rs, totalAmount: RemoteFloat = 0f.rf, accent: RemoteColor = RemoteColor(Color.Blue), ) { RemoteText("Hi, ".rs + userName, color = accent) RemoteText("Total: ".rs + totalAmount.toRemoteString()) } ``` | Type | Default literal | |---|---| | `RemoteString` | `"guest".rs` | | `RemoteFloat` | `0f.rf` | | `RemoteInt` | `0.ri` | | `RemoteColor` | `RemoteColor(Color.Black)` | Consumer pushes values via the typed accessor — Kotlin parameters map straight onto slot ids: ```kotlin Glyph.Checkout(userName = "Stefan", totalAmount = 49.99f, callbacks = ...) ``` ## Actions ### Host callback — `hostAction(...)` ```kotlin RemoteBox(modifier = RemoteModifier.clickable(hostAction("purchase".rs))) { RemoteText("Purchase") } ``` (`alpha010` exposed this as the `HostAction(...)` constructor; alpha11 made the class `internal` and re-exposed it as the top-level `hostAction(...)` factory function. Update existing screens by swapping `HostAction(` → `hostAction(` and the import.) Consumer side — the typed accessor routes each name to a generated `Callbacks.on()` method: ```kotlin Glyph.Checkout( userName = "Stefan", totalAmount = 49.99f, callbacks = object : Glyph.CheckoutCallbacks { override fun onPurchase() { /* … */ } override fun onCancel() { /* … */ } }, ) ``` ### In-document state — `valueChange` Tap mutates a document-owned slot. No host callback fires. ```kotlin val visibility = rememberMutableRemoteInt(0) val opacity = animateRemoteFloat(rf = visibility.toRemoteFloat(), duration = 0.3f) // alpha11+ : feed the eased float into the new `.alpha(RemoteFloat)` // modifier and the text literally fades — the player handles the // interpolation, the host never sees the tap. RemoteText( text = "now you see me".rs, modifier = RemoteModifier .alpha(opacity) .clickable(valueChange(visibility, (visibility + 1) % 2)), ) ``` ## String concat with slots ```kotlin RemoteText("Hi, ".rs + userName) // RemoteString + RemoteString RemoteText("Total: ".rs + totalAmount.toRemoteString()) RemoteText(userName + " owes ".rs + totalAmount.toRemoteString()) ``` --- # Consumer keys Reads (`/v1/apps/:id/screens/*.rc`, `manifest.json`, `preview.png`) are anonymous by default. Anyone who knows your `appId` can fetch the bytes — `appId` is a public identifier, like a Firebase project id. For apps that want gated reads, the **consumer key** mechanism: ## Two key types | | Publish key | Consumer key | |---|---|---| | Prefix | `rck_` | `rcc_` | | Used by | Gradle plugin (`glyphPublish`) | Android SDK (`RemoteComposeClient.install`) | | Created | When you create the app | Manually, in Settings | | Per app | one (rotate to invalidate) | many (issue per release channel) | | Gates | Writes (uploads, retention runs) | Reads (when `requireConsumerKey` is on) | ## Enabling read auth In the portal's Settings tab for an app: 1. Click **Create key** under **Consumer keys**, give it a label (`android-prod`, `android-staging`, etc.). Copy the `rcc_...` value. 2. Toggle **Require a consumer key for reads** to on. Reads now require the `X-Consumer-Key` header. Manifests and screen bytes return `401 Unauthorized` without it. ## Wiring the SDK The consumer key is a runtime credential — the app developer sources it however they prefer. The Gradle plugin does **not** plumb it through `BuildConfig`, because shipping a read credential is the app's choice, not the build's. ```kotlin class MyApp : Application() { override fun onCreate() { super.onCreate() RemoteComposeClient.install( context = this, consumerKey = BuildConfig.GLYPH_CONSUMER_KEY, // or fetch from Keystore, server, etc. ) } } ``` The SDK adds `X-Consumer-Key: rcc_...` to every read. ## Revoking Each consumer key has a short id (`ck_...`) shown in the portal. Click **Revoke** to delete it; the next read using that key returns 401. Issue a fresh one and ship a new app version. ## When to enable - ✅ Apps with internal-only screens you don't want competitors scraping. - ✅ Apps where one tenant runs multiple environments and wants to invalidate staging keys without rotating prod. - ❌ Public landing screens or marketing pages where any traffic is good traffic. You can flip the toggle on and off — existing screens stay where they are. ## REST shape ```http GET /v1/apps/{appId}/manifest.json HTTP/1.1 Host: glyph.premex.se X-Consumer-Key: rcc_SchmAFROp_... ``` ```http GET /v1/apps/{appId}/screens/{screenId}.rc HTTP/1.1 Host: glyph.premex.se If-None-Match: "sha256:..." X-Consumer-Key: rcc_SchmAFROp_... ``` Same header on both. See [api-reference](/docs/api-reference) for the full endpoint list. --- # REST API reference Single Cloud Function (`api`) at `https://glyph.premex.se` with internal regex routing. Hosting rewrites `/v1/**` to it. ## Public routes (Android SDK + Gradle plugin) | | | | |---|---|---| | `POST` | `/v1/apps/:appId/screens/:screenId/versions` | Upload — `X-API-Key: rck_...` | | `GET` `HEAD` | `/v1/apps/:appId/screens/:screenId.rc` | Fetch screen bytes | | `GET` `HEAD` | `/v1/apps/:appId/screens/:screenId/preview.png` | Fetch preview PNG | | `GET` `HEAD` | `/v1/apps/:appId/manifest.json` | List screens | | `POST` | `/v1/apps/:appId/retention/run` | Apply retention now — `X-API-Key: rck_...` | Reads are anonymous by default. With `requireConsumerKey` on, all read routes require `X-Consumer-Key: rcc_...` — see [consumer-keys](/docs/consumer-keys). ### Upload payload ```json { "rcBase64": "...", "contract": { "actions": ["purchase", "cancel"], "params": [{ "id": "userName", "type": "string" }] }, "previewBase64": "...", "dimensions": { "width": 360, "height": 640 } } ``` The server stores `latestHash = sha256(rcBytes)`. If the same content is re-uploaded, the version doesn't bump but `contract` and `dimensions` still merge (so you can fix a contract without changing the bytes). ### Manifest shape ```json { "schemaVersion": 1, "appId": "your-app-id", "generatedAt": 1730000000000, "screens": [ { "id": "checkout", "version": 3, "sizeBytes": 726, "etag": "\"sha256:abc...\"", "hash": "abc...", "rcUrl": "/v1/apps/your-app-id/screens/checkout.rc", "previewUrl":"/v1/apps/your-app-id/screens/checkout/preview.png", "dimensions": { "width": 360, "height": 640 }, "contract": { "actions": [...], "params": [...] }, "prefetchOnLaunch": false, "updatedAt": 1730000000000 } ] } ``` ### Caching The server emits `Cache-Control: public, max-age=0, must-revalidate` on `.rc`, `.png`, and `manifest.json` responses, plus an `ETag` keyed on the `sha256` of the bytes. Clients revalidate per request; unchanged content returns `304`. ## Tenant routes (portal SPA) `Authorization: Bearer ` | | | | |---|---|---| | `GET` | `/v1/me` | Profile (uid, email) | | `GET` | `/v1/me/apps` | List your apps | | `POST` | `/v1/me/apps` | Create app — body `{ displayName? }` | | `GET` | `/v1/me/apps/:appId` | App detail + screen list | | `PATCH` | `/v1/me/apps/:appId` | Update — body `{ displayName?, requireConsumerKey? }` | | `DELETE` | `/v1/me/apps/:appId` | Delete app + all its bytes | | `POST` | `/v1/me/apps/:appId/regenerate-key` | Rotate publish key | | `POST` | `/v1/me/apps/:appId/consumer-keys` | Create consumer key — body `{ label? }` | | `DELETE` | `/v1/me/apps/:appId/consumer-keys/:keyId` | Revoke consumer key | ## Status codes | | | |---|---| | `200` / `304` | Read succeeded (or cache hit) | | `201` | Upload created a new version | | `200` `unchanged: true` | Upload matched existing hash; contract may have merged | | `401` | Wrong / missing `X-API-Key` (writes) or `X-Consumer-Key` (reads, when required) | | `403` | Tenant route — you're not the owner of `:appId` | | `404` | App or screen doesn't exist | | `413` | `.rc` payload over 1 MB | ## Live test ```sh curl https://glyph.premex.se/v1/apps/quick-wave-7982/manifest.json ``` That tenant is the demo's published target — its screens are documented in this repo's READMEs. Anonymous reads work, so the curl returns the manifest immediately.