From meta-portal-skill
Build, sideload, and iterate on Android apps for Meta Portal devices (touch displays and TV). Use when the user asks to create a Portal app, sideload an APK to a Portal, debug a Portal app, or work with Portal-specific design constraints (large tabletop displays, far-viewing-distance UI). Also covers the hzdb MCP server's tool surface — screenshots, device logcat, Perfetto tracing, Meta docs search.
How this skill is triggered — by the user, by Claude, or both
Slash command
/meta-portal-skill:portal-deviceThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Meta Portal devices (Portal+, Portal TV, etc.) run a customized Android. Apps are built with the standard Android toolchain (Kotlin + Jetpack Compose work well) but have a few platform quirks you need to know about.
Meta Portal devices (Portal+, Portal TV, etc.) run a customized Android. Apps are built with the standard Android toolchain (Kotlin + Jetpack Compose work well) but have a few platform quirks you need to know about.
This skill walks through the dev loop, captures hard-won lessons, and points at deeper reference docs in docs/.
Use the portal-samples repo as the template:
The fastest start is to copy that repo's app/build.gradle.kts, app/src/main/AndroidManifest.xml, gradle/libs.versions.toml, and ui/theme/ into your new project, then build from there.
If ./gradlew assembleDebug fails with "SDK location not found", you need the Android SDK installed. On macOS:
brew install --cask android-commandlinetools
brew install --cask temurin # JDK, if not present
export ANDROID_HOME=/opt/homebrew/share/android-commandlinetools
yes | sdkmanager --licenses >/dev/null 2>&1
sdkmanager "platform-tools" "platforms;android-36" "build-tools;36.0.0"
Then create <project>/local.properties (gitignored — never commit):
sdk.dir=/opt/homebrew/share/android-commandlinetools
Match the platform/build-tools versions to the project's compileSdk / targetSdk in app/build.gradle.kts. The portal-samples repo targets API 36 at time of writing.
→ Deeper detail: docs/sdk-setup.md
./gradlew assembleDebugadb devices -l should list the Portal (model Portal_ or similar).adb install -r app/build/outputs/apk/debug/app-debug.apk (the -r reinstalls over an existing version).adb shell monkey -p <package.name> -c android.intent.category.LAUNCHER 1→ Deeper detail: docs/sideload.md
Portal devices have form factors that differ meaningfully from a phone:
Row + weight-based layouts that fill the width.Official Meta references (open these if the user asks about design specifics):
Only document things here that took real iteration to figure out — not "the error message told me what to do." Each entry should describe the symptom, the cause that was non-obvious, and the fix.
Symptom: the floating element appears far from the user's finger (typically offset by ~the height of the play area).
Non-obvious cause: if you render the dragged element inside a child column that uses Arrangement.Bottom, the column lays it out at the bottom of the column first, then applies your offset { IntOffset(x, y) }. So the offset is applied on top of an already-displaced layout position, not from the column's origin. Reasoning about "the disk should be at the top of the stack so my offset is from the top" is wrong — the disk is at the bottom of the stack because of the arrangement, and your math is off by the arrangement's worth of pixels.
Fix: lift the dragged overlay out of the constrained parent entirely. Render it as a child of the outer container Box that holds the play area. Track that Box's root position with onGloballyPositioned. Position the overlay absolutely with offset { IntOffset(x, y) } computed from the container's origin. Leave a same-sized Spacer in the original column so the static layout doesn't shift while dragging.
Trying to fix this with relative math inside the constrained column ate two debug cycles. Don't go down that path.
Column + Arrangement.Bottom lays children top-to-bottomSymptom: building a Tower-of-Hanoi-style stacked column, the disks visually appear with the smallest on the bottom and largest on the top — inverted from intent.
Non-obvious cause: verticalArrangement = Arrangement.Bottom only tells the Column where the stack of children sits in the available space, not the order of the children. Children still flow in declaration order (first emitted → topmost child within the stack, last emitted → bottommost child within the stack). It feels like "Bottom = build upward" but it isn't.
Fix: if your data is ordered bottom-to-top (e.g., disks[0] is the largest at the base, disks.last() is the smallest at the top), iterate in reverse when emitting children, so the largest disk is emitted last and lands at the visual bottom:
for (index in disks.indices.reversed()) {
Disk(size = disks[index])
}
Symptom: Unresolved reference 'Icons' after writing Icons.Filled.ArrowBack or Icons.Filled.Science.
Non-obvious cause: androidx.compose.material3:material3 does not transitively include androidx.compose.material.icons.*. Many Compose examples online assume both are present; they aren't, by default, in a clean Portal-samples-style project.
Fix: either add implementation("androidx.compose.material:material-icons-extended:<version>"), or — for 1–3 icons total — just use Unicode/emoji inside Text(...). The latter avoids ~10 MB of icon resources.
→ More: docs/material3-icons.md
adb shell input tap doesn't reliably hit Compose IconButtons on PortalSymptom: scripted adb shell input tap X Y taps land inside a Compose button's visual bounds but the button never fires — confirmed by screenshots before/after showing no state change. Burns iteration cycles when verifying UI changes from CLI.
Non-obvious cause: unclear, but tap injection on Portal's Compose UI seems to require a longer-duration touch than the instant input tap provides. Empirically Compose's gesture detector treats the synthesized tap as a moved-during-press cancellation.
Fix: use a swipe with identical start and end coordinates plus a long hold duration:
adb shell input swipe <x> <y> <x> <y> 200
The 200ms hold reliably registers as a tap on Compose buttons, IconButtons, and clickable Cards. (Real finger taps on the device always work — this is a CLI-only issue.)
Portal renders 3D scenes well via SceneView + Filament. There's a body of non-obvious gotchas around camera framing, model loading, and glTF scaling.
→ See docs/3d-modeling.md before starting any SceneView work on Portal.
When the hzdb MCP server is connected (via .mcp.json at the project root or user-level config), you get a set of Portal-specific tools. Use them proactively:
mcp__hzdb__take_screenshot — capture the current device screen. Pass method: "screencap" for plain Android screencap (works reliably on Portal). Use this to verify any UI change is actually rendering correctly after install.mcp__hzdb__get_device_logcat — pull logcat. Use when an app crashes after install or behavior diverges from expectation.mcp__hzdb__meta_docs_search + mcp__hzdb__meta_docs_get_page — search and fetch official Meta developer docs. Prefer this over guessing API names.mcp__hzdb__capture_perfetto_trace / load_trace_for_analysis / analyze_trace — performance work. Use when investigating jank or startup time.mcp__hzdb__search_api_reference + get_api_details — Horizon OS API lookup.To install the server at the project level so it ships with the repo:
npx -y @meta-quest/hzdb mcp install project --dotfile true -y
That writes .mcp.json — commit it so teammates and CI pick it up automatically.
→ Deeper detail: docs/hzdb-mcp.md
After every install, launch the app and screenshot it before declaring the change done. Type-checks and unit tests verify code correctness, not feature correctness. The hzdb take_screenshot tool makes this cheap — there's no excuse to skip it.
If the Portal has locked itself, wake it first:
adb shell input keyevent KEYCODE_WAKEUP
adb shell input keyevent KEYCODE_MENU
Then re-launch the app.
When you discover a new Portal-specific gotcha, capture it here (or in docs/ if it's long) before moving on. Each entry should describe the symptom, the cause, and the fix — not just the fix in isolation. Future-you will thank present-you.
npx claudepluginhub zainrizvi/meta-portal-skill --plugin meta-portal-skillProvides behavioral guidelines to reduce common LLM coding mistakes, focusing on simplicity, surgical changes, assumption surfacing, and verifiable success criteria.
Searches, retrieves, and installs Agent Skills from prompts.chat registry using MCP tools like search_skills and get_skill. Activates for finding skills, browsing catalogs, or extending Claude.