mcpbeat

Hz Store Pwa

meta-quest/hz-store-pwa

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.

8k tokens
context cost
the whole folder, loaded on every use
7
files
instructions only
0
copies elsewhere
how many repositories repackaged it
173
stars on the repo
on the repository, not the skill itself

Install

one command, takes just this skill from the repository
npx skills add https://github.com/meta-quest/agentic-tools --skill hz-store-pwa

The instruction itself

14 sections, as written by the author

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(); })   // 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.

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.apppublic (200). Use this as

<DOMAIN> everywhere downstream.

  • Hashed per-deploy URL …-<team>.vercel.app401 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   # bin: bubblewrap

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 keystorepause 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"   // ← "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).

  • Buildupdate 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
   # → app-release-signed.apk + app-release-bundle.aab
   "$BT/apksigner" verify --print-certs app-release-signed.apk | grep -i SHA-256  # must == keystore
  • 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.

How to use it

Copy the folder

Take meta-quest/hz-store-pwa from the repository into ~/.claude/skills for personal use, or into .claude/skills inside a project.

Check the name does not clash

The agent identifies a skill by the name field in its header. Two skills with the same name cannot sit side by side — one of them will be ignored.

Install what it needs

The instructions reference npm, npx. Without those the skill loads but fails at the first command.