# 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 Sto…

- **Type:** Skill
- **Install:** `agentstack add skill-meta-quest-agentic-tools-hz-store-pwa`
- **Verified:** Yes — security-reviewed for prompt injection and unsafe behavior
- **Seller:** [meta-quest](https://agentstack.voostack.com/s/meta-quest)
- **Installs:** 0
- **Category:** [Agent Skills](https://agentstack.voostack.com/c/agent-skills)
- **Latest version:** 0.1.0
- **License:** Apache-2.0
- **Upstream author:** [meta-quest](https://github.com/meta-quest)
- **Source:** https://github.com/meta-quest/agentic-tools/tree/main/skills/hz-store-pwa
- **Website:** https://developers.meta.com/horizon

## Install

```sh
agentstack add skill-meta-quest-agentic-tools-hz-store-pwa
```

Requires the [AgentStack CLI](https://agentstack.voostack.com/docs/cli). Works with Claude Code, Cursor, and any MCP-compatible agent.

## About

# 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. ``, ``, ``,
``) — 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 = 
3. Manifest + icons     → installable web app manifest, PNG icons, live on 
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  --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 } };
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 
```

Two URLs result:

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

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:///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 ``
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` `` (`` + ``
+ ``). 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 
   export BUBBLEWRAP_KEYSTORE_PASSWORD= BUBBLEWRAP_KEY_PASSWORD=
   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:///.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:///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  --app-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//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
  ``.
- **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 ``.
- **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.

## Source & license

This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.

- **Author:** [meta-quest](https://github.com/meta-quest)
- **Source:** [meta-quest/agentic-tools](https://github.com/meta-quest/agentic-tools)
- **License:** Apache-2.0
- **Homepage:** https://developers.meta.com/horizon

Install and usage instructions live in the source repository linked above.

## Pricing

- **Free** — Free

## Security capabilities

Automated source analysis of v0.1.0 — what this tool can access:

- **Network access:** yes
- **Filesystem access:** no
- **Shell / process execution:** no
- **Environment & secrets:** no
- **Dynamic code execution:** no

*"Yes" means the capability is present in the source — more access means more to trust, not that it is unsafe.*


## Versions

- **0.1.0** — security scan: passed — Imported from the upstream source.

## Links

- Listing page: https://agentstack.voostack.com/l/skill-meta-quest-agentic-tools-hz-store-pwa
- Seller: https://agentstack.voostack.com/s/meta-quest
- Browse the marketplace: https://agentstack.voostack.com/browse

---
Listed on AgentStack — the marketplace for AI agent skills and MCP servers. Every listing is security-reviewed. Creators keep 70%.
