| 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
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:
- Whether the web app auto-enters a WebXR session on launch (immersive only).
- 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 for the full decision guide.
Step 1: Build the Web App
Immersive WebXR app (IWSDK)
Scaffold with @iwsdk/create (the only supported scaffolder):
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:
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(); })
.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.
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:
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:
curl -s -o /dev/null -w "%{http_code}\n" https://<DOMAIN>/manifest.webmanifest
See 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:
{ "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 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).
npm i -g @meta-quest/bubblewrap-cli
Prereqs are pre-provisioned in ~/.bubblewrap (its own JDK 17 + Android SDK). Find
the tools dynamically:
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.
-
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.
-
twa-manifest.json (scripted path) — author it from the authoritative
TwaManifest schema. The critical field:
"horizonOSAppMode": "immersive"
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).
-
Build — update regenerates gradle and bumps version; passwords go via env
vars (no password CLI flags exist):
cd <twa-dir>
export BUBBLEWRAP_KEYSTORE_PASSWORD=<PW> BUBBLEWRAP_KEY_PASSWORD=<PW>
bubblewrap update && bubblewrap build
"$BT/apksigner" verify --print-certs app-release-signed.apk | grep -i SHA-256
-
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.
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:
./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 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 — decision guide, IWSDK
scaffolding, project layout, and the auto-enter-session rationale.
- Vercel Deployment — Vite config, deploy commands, the
canonical-vs-hashed URL distinction, and the redeploy-vs-rebuild rule.
- PWA Manifest + Icons — full manifest reference,
multi-origin setup, and the
sharp icon-generation script.
- Bubblewrap Packaging — keystore handling,
twa-manifest.json template, build verification, asset links, and security.
- Store Upload —
ovr-platform-util download, auth, the
upload command, and the Developer Distribution Agreement blocker.
- Troubleshooting — common failure modes across the
whole pipeline and how to fix them.