hz-store-pwa

$npx mdskill add meta-quest/agentic-tools/hz-store-pwa

Ship a web app to Meta Horizon Store as a PWA/TWA.

  • Packages web apps for 2D panels or immersive WebXR on Quest.
  • Uses bubblewrap-cli, Vercel, IWSDK, and ovr-platform-util.
  • Recommends 2D vs immersive mode based on app capabilities.
  • Produces a signed APK and uploads it to the Horizon Store.

SKILL.md

.github/skills/hz-store-pwaView on GitHub ↗
---
name: hz-store-pwa
license: Apache-2.0
description: Guides shipping a web app to the Meta Quest and Horizon OS Store as a PWA/TWA — both 2D windowed panels and immersive WebXR/VR. Covers building the web app (IWSDK for WebXR, any responsive PWA for 2D), Vercel deploy, web app manifest + icons, the WebXR-only auto-enter-session step, choosing 2D vs immersive mode in @meta-quest/bubblewrap-cli, keystore/Digital-Asset-Links, and ovr-platform-util Store upload. Use before any IWSDK/WebXR build, PWA packaging, bubblewrap, or Horizon Store upload work.
allowed-tools: Bash(npx:*) Bash(npm:*) Bash(curl:*) Bash(bubblewrap:*)
---

# Store PWA/TWA Skill

Guide the end-to-end process of wrapping a web app as a Meta Quest app and shipping
it to the Meta Horizon Store. This skill covers both delivery modes — a **2D
windowed panel** and an **immersive WebXR/VR** experience — through the same
pipeline: build the web app, deploy to Vercel, add a PWA manifest + icons, package
as a signed Quest APK with `@meta-quest/bubblewrap-cli`, and upload with
`ovr-platform-util`.

Commands use `<…>` tokens (e.g. `<DOMAIN>`, `<HORIZON_APP_ID>`, `<team-slug>`,
`<PW>`) — substitute your own values before running.

## When to Use This Skill

Use this skill when you need to:

- Ship a web app (2D or WebXR) to the Meta Horizon Store as a PWA/TWA
- Decide whether an app should run as a 2D panel or an immersive WebXR session
- Build a WebXR app with IWSDK and wire up auto-enter-session for the installed PWA
- Deploy a PWA to Vercel and produce a valid, installable web app manifest + icons
- Package a live PWA into a signed Quest APK with `@meta-quest/bubblewrap-cli`
- Configure the signing keystore and Digital Asset Links so the TWA will launch
- Upload a build to the Store with `ovr-platform-util`
- Troubleshoot a 2D app stuck loading, an immersive app showing a URL bar, a TWA
  that won't launch, or an upload that's blocked

For deeper IWSDK app-building guidance, see the `hz-iwsdk-webxr` skill. For the
broader Store submission process (VRC compliance, store assets, review tracking),
see the `hz-store-submit` skill.

## Pipeline Overview

The full pipeline follows this order. The two mode-specific deltas are flagged; all
other steps are identical for 2D and immersive.

```
0. Pick app mode        → 2D panel vs immersive WebXR (sets steps 1 + 4)
1. Build the web app    → IWSDK WebXR app (immersive) OR any responsive PWA (2D)
2. Deploy to Vercel     → public HTTPS origin = <DOMAIN>
3. Manifest + icons     → installable web app manifest, PNG icons, live on <DOMAIN>
4. Package as APK       → bubblewrap: keystore, twa-manifest, build, asset links
5. Upload to the Store  → ovr-platform-util upload-quest-build
```

Dependencies between steps matter — see [Order of Operations](#order-of-operations)
at the end.

## Step 0: Pick the App Mode First

The app mode is the single most important decision, chosen once. It changes exactly
two things downstream:

1. Whether the web app auto-enters a WebXR session on launch (immersive only).
2. The `horizonOSAppMode` value in `twa-manifest.json` (`"immersive"` vs `"2D"`).

| | **2D PWA** | **Immersive WebXR PWA** |
|---|---|---|
| Runs as | windowed 2D panel on Horizon | enters a full VR/WebXR session |
| Web app | any responsive PWA (IWSDK optional) | WebXR app (IWSDK is the easy path) |
| Auto-enter `requestSession` | **NO — do not add it** (Step 1) | **YES — built into the app** (Step 1) |
| `horizonOSAppMode` | `"2D"` (Step 4) | `"immersive"` (Step 4) |

A wrong `horizonOSAppMode` value is the classic failure mode: a 2D app set to
`immersive` is stuck loading; an immersive app set to `2D` shows a browser URL bar.

See [`references/app-modes.md`](references/app-modes.md) for the full decision guide.

## Step 1: Build the Web App

### Immersive WebXR app (IWSDK)

Scaffold with `@iwsdk/create` (the only supported scaffolder):

```bash
npx @iwsdk/create@latest <app-name> --yes --mode vr --no-metaspatial \
  --no-physics --no-locomotion --grabbing
```

Toggle `--physics` (Havok gravity/collisions), `--locomotion` (roam a large space),
and `--grabbing` (hands/controllers pick objects up) to fit the app. For arcade-style
apps prefer deterministic manual motion over physics.

**Don't reinvent IWSDK app code.** The template's bundled `CLAUDE.md`,
`.claude/skills/iwsdk-*` skills, and the `iwsdk-rag` MCP are the source of truth for
imports, ECS, XR input, physics, UI, and debugging. Query those rather than guessing.

**Build auto-enter into the immersive app from the start.** An installed immersive
PWA opens with no 2D page, so the app itself must start the session on load (the
app-icon tap is the user activation). Gate it on `getDigitalGoodsService` so it runs
only in the installed PWA, never a browser tab:

```ts
const nav = navigator as Navigator & { xr?: { isSessionSupported?: (m:string)=>Promise<boolean> } };
if ("getDigitalGoodsService" in window && nav.xr?.isSessionSupported) {
  nav.xr.isSessionSupported("immersive-vr")
    .then(s => { if (s) world.launchXR(); })   // IWSDK launchXR == requestSession + setup
    .catch(() => {});
}
```

`getDigitalGoodsService` is device-only — validate this path on the headset.

### 2D windowed app

Any responsive web app/PWA works — IWSDK is not required. It runs as a single-
instance standalone panel with its own Library entry. Make sure it's a valid
installable PWA (Step 3) and build/deploy it like any static/SPA site (Step 2). Do
**NOT** add the auto-enter code above.

Full scaffolding flags, project layout, and the auto-enter rationale are in
[`references/app-modes.md`](references/app-modes.md).

## Step 2: Deploy to Vercel

The web app must be live on a public HTTPS origin before packaging — `bubblewrap`
fetches the manifest and icons from it. Set `base: "./"` in your Vite config, then:

```bash
npx -y vercel@latest whoami
npx -y vercel@latest teams ls
npx -y vercel@latest deploy --prod --yes --scope <team-slug>
```

Two URLs result:

- **Canonical alias** `https://<project>.vercel.app` → **public (200)**. Use this as
  `<DOMAIN>` everywhere downstream.
- Hashed per-deploy URL `…-<team>.vercel.app` → **401** under deployment protection.
  Not for sharing, not usable as `<DOMAIN>`.

Verify the root and manifest both return 200, and that the manifest is served as
`application/manifest+json`:

```bash
curl -s -o /dev/null -w "%{http_code}\n" https://<DOMAIN>/manifest.webmanifest
```

See [`references/vercel-deploy.md`](references/vercel-deploy.md) for details and the
redeploy-vs-rebuild rule.

## Step 3: PWA Manifest + Icons

Both modes need a valid, installable manifest and PNG icons, live on `<DOMAIN>`
before `bubblewrap update` runs. Place `public/manifest.webmanifest`:

```json
{ "name":"…","short_name":"…","description":"…","start_url":"/","scope":"/",
  "display":"standalone","orientation":"landscape",
  "background_color":"#06010f","theme_color":"#0a0418",
  "icons":[
    {"src":"/icons/icon-192.png","type":"image/png","sizes":"192x192","purpose":"any"},
    {"src":"/icons/icon-512.png","type":"image/png","sizes":"512x512","purpose":"any"},
    {"src":"/icons/icon-512-maskable.png","type":"image/png","sizes":"512x512","purpose":"maskable"}]}
```

Link it in `index.html` `<head>` (`<link rel="manifest">` + `<meta name="theme-color">`
+ `<link rel="icon">`). For multi-origin 2D apps, add `additional_trusted_origins`
and host asset links on each origin.

There's no ImageMagick/PIL here — generate icons with `sharp` (`npm i -D sharp`) from
an SVG. The maskable icon must be full-bleed and opaque (no transparency or rounded
corners). Vite copies `public/` into `dist/`.

See [`references/manifest-and-icons.md`](references/manifest-and-icons.md) for the
icon script and the full manifest reference.

## Step 4: Package as a Quest APK (bubblewrap)

`bubblewrap` wraps the live PWA into a signed Android APK (a Trusted Web Activity).

```bash
npm i -g @meta-quest/bubblewrap-cli   # bin: bubblewrap
```

Prereqs are pre-provisioned in `~/.bubblewrap` (its own JDK 17 + Android SDK). Find
the tools dynamically:

```bash
KT=$(find ~/.bubblewrap/jdk -path '*/bin/keytool' | head -1)
BT=$(ls -d ~/.bubblewrap/android_sdk/build-tools/* | sort -V | tail -1)
```

`bubblewrap init` uses an interactive `inquirer` wizard that needs a real TTY. A
non-TTY caller (an agent driving Bash) can't answer it and there are no value flags
to bypass it — **use the scripted path** below.

1. **Signing keystore** — **pause and ask the developer which key to use** before
   building. The key is permanent: every future update must reuse it. Either reuse an
   existing keystore (ask for its path, alias, and passwords — required when updating
   a published app) or generate a new one **outside** the deployable web tree.

2. **`twa-manifest.json`** (scripted path) — author it from the authoritative
   `TwaManifest` schema. The critical field:

   ```json
   "horizonOSAppMode": "immersive"   // ← "immersive" for WebXR | "2D" for a 2D panel app
   ```

   A wrong value is the classic failure mode (Step 0). `applicationId` = numeric
   Horizon App ID (`"0"` builds & sideloads without IAP; set the real id before Store
   work).

3. **Build** — `update` regenerates gradle and bumps version; passwords go via env
   vars (no password CLI flags exist):

   ```bash
   cd <twa-dir>
   export BUBBLEWRAP_KEYSTORE_PASSWORD=<PW> BUBBLEWRAP_KEY_PASSWORD=<PW>
   bubblewrap update && bubblewrap build
   # → app-release-signed.apk + app-release-bundle.aab
   "$BT/apksigner" verify --print-certs app-release-signed.apk | grep -i SHA-256  # must == keystore
   ```

4. **Digital Asset Links** — a TWA "will not launch" if this fails. Host
   `public/.well-known/assetlinks.json` on the same domain (and every trusted
   origin), with the package name and the colon-hex cert SHA-256. Redeploy, then
   `curl https://<DOMAIN>/.well-known/assetlinks.json` to confirm.

**Security:** the keystore and app secret NEVER go to the public host — verify with
`curl -o /dev/null -w "%{http_code}" https://<DOMAIN>/android.keystore` (expect 404).
Back up the keystore.

Full keystore handling, the complete `twa-manifest.json` template, build
verification, and asset-link details are in
[`references/bubblewrap-packaging.md`](references/bubblewrap-packaging.md).

## Step 5: Upload to the Meta Horizon Store

`hzdb` / `metavr` are device-only and cannot upload. Use `ovr-platform-util` — the
same command works for 2D and WebXR builds:

```bash
./ovr-platform-util upload-quest-build \
  --app-id <HORIZON_APP_ID> --app-secret <SECRET> \
  --apk app-release-signed.apk \
  --channel ALPHA --age-group MIXED_AGES \
  --notes "…" --disable-progress-bar
```

Required: `--app-id`, `--apk`, `--channel`, `--age-group`
(`TEENS_AND_ADULTS | MIXED_AGES | CHILDREN`), and `--app-secret` or `--token`.
Channels: `ALPHA`/`BETA`/`RC` for testing, `STORE` for production. Auth is the app's
**App Secret** (Dashboard → app → API tab) or a user token — ask the user, never
invent it.

**Likely first-time blocker:** `must first agree to our Developer Distribution
Agreement` — an org admin must sign it once at
`https://developer.oculus.com/manage/organizations/<ORG_ID>/legal-documents/`. Pause,
ask the user, then retry the same command.

See [`references/store-upload.md`](references/store-upload.md) for tool download, auth,
and the DDA blocker.

## Order of Operations

- **Decide 2D vs immersive up front** (Step 0) — it sets the auto-enter step (Step 1,
  WebXR-only) and the `horizonOSAppMode` value (Step 4).
- **Manifest + icons must be LIVE before `bubblewrap update`** — it fetches them from
  `<DOMAIN>`.
- **Asset links must be live before the TWA will launch** — on every trusted origin.
- **Web-only fixes need only a Vercel redeploy** — the installed TWA picks them up on
  the next launch. Rebuild and re-upload the APK only for native changes (id, name,
  icon, version, **app mode**, packaging).

## Gotchas

- **`horizonOSAppMode` mismatch is the #1 failure** — `"2D"` set to `immersive` hangs
  on a loading screen; `"immersive"` set to `2D` shows a URL bar. Fix the value and
  rebuild.
- **The signing key is permanent** — every update must reuse the same keystore, alias,
  and `packageId`. A lost keystore means a new app entry. Back it up, and keep it
  outside the deployable web tree.
- **`bubblewrap init` needs a TTY** — agents must use the scripted path (hand-written
  `twa-manifest.json` + `update` + `build`).
- **Auto-enter is immersive-only** — never add the `launchXR()` snippet to a 2D app,
  and always gate it on `getDigitalGoodsService` so it doesn't fire in a browser tab.
- **`getDigitalGoodsService` is device-only** — auto-enter can't be validated in a
  desktop browser or emulator; test on the headset.
- **Use the canonical Vercel alias** — the hashed per-deploy URL returns 401 under
  deployment protection and can't be used as `<DOMAIN>`.
- **The maskable icon must be full-bleed and opaque** — transparency or rounded
  corners produce visible artifacts after the platform applies its mask.
- **Asset links gate launch** — a TWA "will not launch" until
  `/.well-known/assetlinks.json` is live with a matching package name and cert SHA-256.
- **DDA blocks the first upload** — only an org admin can sign it; it's the most
  common first-time upload failure.

## References

- [App Modes: 2D vs Immersive](references/app-modes.md) — decision guide, IWSDK
  scaffolding, project layout, and the auto-enter-session rationale.
- [Vercel Deployment](references/vercel-deploy.md) — Vite config, deploy commands, the
  canonical-vs-hashed URL distinction, and the redeploy-vs-rebuild rule.
- [PWA Manifest + Icons](references/manifest-and-icons.md) — full manifest reference,
  multi-origin setup, and the `sharp` icon-generation script.
- [Bubblewrap Packaging](references/bubblewrap-packaging.md) — keystore handling,
  `twa-manifest.json` template, build verification, asset links, and security.
- [Store Upload](references/store-upload.md) — `ovr-platform-util` download, auth, the
  upload command, and the Developer Distribution Agreement blocker.
- [Troubleshooting](references/troubleshooting.md) — common failure modes across the
  whole pipeline and how to fix them.

More from meta-quest/agentic-tools

SkillDescription
hz-android-2d-portingGuides porting existing Android 2D apps to Meta Quest and Horizon OS — input adaptation, panel layout, and design requirements. Use when adapting a mobile Android app for Quest.
hz-api-upgradeUpgrades Meta Quest apps to newer Horizon OS SDK versions — migration guides, deprecated API replacements, changelog. Use when updating SDK versions or fixing deprecated API warnings.
hz-immersive-designerGuides design of comfortable, intuitive VR/MR experiences for Meta Quest and Horizon OS — comfort guidelines, interaction patterns, spatial layout, accessibility. Use during UX design review or when evaluating comfort and accessibility.
hz-iwsdk-webxrBuilds WebXR experiences for Meta Quest and Horizon OS using the Immersive Web SDK (IWSDK) — ECS architecture, Three.js integration, spatial UI. Use when creating web-based VR/MR apps for Quest Browser.
hz-new-project-creationScaffolds new Meta Quest and Horizon OS projects with recommended settings for Unity, Unreal, Android/Spatial SDK, or WebXR. Use when creating a new Quest app from scratch.
hz-perfetto-debugAnalyzes Meta Quest and Horizon OS VR performance using Perfetto traces — frame timing, CPU/GPU bottlenecks, render pass analysis. Use when profiling frame drops, jank, or thermal issues on Quest devices.
hz-platform-sdkGuides integration of the Horizon Platform SDK for Meta Quest and Horizon OS Android/Kotlin apps — achievements, IAP, users, leaderboards, presence, notifications, abuse reporting, entitlements, asset files, application lifecycle, consent, device integrity, language packs, user age categories, and rate and review. Covers setup, initialization, API usage, data types, error handling, and best practices for all 17 public platform SDK packages.
hz-psdk-integrationFor 3P / EXTERNAL developers: guides interactive Horizon Platform SDK (PSDK) integration for Meta Quest and Horizon OS Android/Kotlin apps — analyzes the codebase, recommends public platform features, plans the integration, and validates on device. Uses the external toolchain (Gradle, metavr, a developers.meta.com / Rocksteady App ID). For 1P apps inside fbsource (buck, Chesterfield/ni, adb), use psdk_1p_onboarding_agent instead.
hz-quest-verify-first>-
hz-simpleperf-debugProfiles Meta Quest and Horizon OS application CPU performance using simpleperf — workload classification, CPU hotspot recording, kernel overhead measurement. Use when diagnosing whether an app is CPU-bound, memory-bound, or I/O-bound on Quest devices.