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.
npx skills add https://github.com/meta-quest/agentic-tools --skill hz-store-pwa
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.
Use this skill when you need to:
@meta-quest/bubblewrap-cliovr-platform-utilthat 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.
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.
The app mode is the single most important decision, chosen once. It changes exactly
two things downstream:
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.
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.
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.
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:
https://<project>.vercel.app → public (200). Use this as<DOMAIN> everywhere downstream.
…-<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.
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.
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.
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 authoritativeTwaManifest 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).
update regenerates gradle and bumps version; passwords go via envvars (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
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.
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.
WebXR-only) and the horizonOSAppMode value (Step 4).
bubblewrap update — it fetches them from<DOMAIN>.
the next launch. Rebuild and re-upload the APK only for native changes (id, name,
icon, version, app mode, packaging).
horizonOSAppMode mismatch is the #1 failure — "2D" set to immersive hangson a loading screen; "immersive" set to 2D shows a URL bar. Fix the value and
rebuild.
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-writtentwa-manifest.json + update + build).
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 adesktop browser or emulator; test on the headset.
deployment protection and can't be used as <DOMAIN>.
corners produce visible artifacts after the platform applies its mask.
/.well-known/assetlinks.json is live with a matching package name and cert SHA-256.
common first-time upload failure.
scaffolding, project layout, and the auto-enter-session rationale.
canonical-vs-hashed URL distinction, and the redeploy-vs-rebuild rule.
multi-origin setup, and the sharp icon-generation script.
twa-manifest.json template, build verification, asset links, and security.
ovr-platform-util download, auth, theupload command, and the Developer Distribution Agreement blocker.
whole pipeline and how to fix them.
Assess Kubernetes workloads and cluster configuration for AKS Automatic compatibility. Identifies incompatibilities, generates fixes, and guides migration from AKS Standard to AKS Automatic. WHEN: migrate to AKS Automatic, check AKS Automatic readiness, validate manifests for Automatic, assess cluster for Automatic compatibility, fix deployment for Automatic compatibility, identify AKS Automatic migration blockers, is my cluster ready for AKS Automatic.
Discovers available Azure OpenAI model capacity across regions and projects. Analyzes quota limits, compares availability, and recommends optimal deployment locations based on capacity requirements. USE FOR: find capacity, check quota, where can I deploy, capacity discovery, best region for capacity, multi-project capacity search, quota analysis, model availability, region comparison, check TPM availability. DO NOT USE FOR: actual deployment (hand off to preset or customize after discovery), quota increase requests (direct user to Azure Portal), listing existing deployments.
Interactive guided deployment flow for Azure OpenAI models with full customization control. Step-by-step selection of model version, SKU (GlobalStandard/Standard/ProvisionedManaged), capacity, RAI policy (content filter), and advanced options (dynamic quota, priority processing, spillover). USE FOR: custom deployment, customize model deployment, choose version, select SKU, set capacity, configure content filter, RAI policy, deployment options, detailed deployment, advanced deployment, PTU deployment, provisioned throughput. DO NOT USE FOR: quick deployment to optimal region (use preset).
Unified Azure OpenAI model deployment skill with intelligent intent-based routing. Handles quick preset deployments, fully customized deployments (version/SKU/capacity/RAI policy), and capacity discovery across regions and projects. USE FOR: deploy model, deploy gpt, create deployment, model deployment, deploy openai model, set up model, provision model, find capacity, check model availability, where can I deploy, best region for model, capacity analysis. DO NOT USE FOR: listing existing deployments (use foundry_models_deployments_list MCP tool), deleting deployments, agent creation (use agent/create), project creation (use project/create).
Intelligently deploys Azure OpenAI models to optimal regions by analyzing capacity across all available regions. Automatically checks current region first and shows alternatives if needed. USE FOR: quick deployment, optimal region, best region, automatic region selection, fast setup, multi-region capacity check, high availability deployment, deploy to best location. DO NOT USE FOR: custom SKU selection (use customize), specific version selection (use customize), custom capacity configuration (use customize), PTU deployments (use customize).
This skill should be used when working with LaminDB, an open-source data framework for biology that makes data queryable, traceable, reproducible, and FAIR. Use when managing biological datasets (scRNA-seq, spatial, flow cytometry, etc.), tracking computational workflows, curating and validating data with biological ontologies, building data lakehouses, or ensuring data lineage and reproducibility in biological research. Covers data management, annotation, ontologies (genes, cell types, diseases, tissues), schema validation, integrations with workflow managers (Nextflow, Snakemake) and MLOps platforms (W&B, MLflow), and deployment strategies.
Latch platform for bioinformatics workflows. Build pipelines with Latch SDK, @workflow/@task decorators, deploy serverless workflows, LatchFile/LatchDir, Nextflow/Snakemake integration.
Run Python code in the cloud with serverless containers, GPUs, and autoscaling. Use when deploying ML models, running batch processing jobs, scheduling compute-intensive tasks, or serving APIs that require GPU acceleration or dynamic scaling.
Take meta-quest/hz-store-pwa from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
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.
The instructions reference npm, npx.
Without those the skill loads but fails at the first command.