diff --git a/.agents/skills/eas-app-stores/SKILL.md b/.agents/skills/eas-app-stores/SKILL.md new file mode 100644 index 0000000..7916b4f --- /dev/null +++ b/.agents/skills/eas-app-stores/SKILL.md @@ -0,0 +1,160 @@ +--- +name: eas-app-stores +description: EAS service (paid). Deploy Expo apps to the app stores with EAS - build and submit to the iOS App Store, Google Play Store, and TestFlight, configure eas.json build and submit profiles, manage app versions and build numbers, and publish App Store metadata and ASO. Use whenever the user wants to deploy, release, or ship an app to production or the app stores, is preparing a production build, running eas build or eas submit, shipping to TestFlight, bumping version or build numbers, or setting up store listing metadata. For deploying an Expo website or API routes, use the eas-hosting skill. +version: 1.0.0 +license: MIT +--- + +# App Store Deployment + +> **EAS service - costs apply.** This skill uses Expo Application Services (EAS), a paid product with free-tier limits. `eas build` and `eas submit` consume your plan's build minutes, and store submission requires paid Apple Developer and Google Play accounts. Review https://expo.dev/pricing before running cloud commands. + +This skill covers building and releasing Expo apps to the iOS App Store, Google Play Store, and TestFlight using EAS (Expo Application Services). For deploying an Expo website or API routes to EAS Hosting, use the `eas-hosting` skill. + +## References + +Consult these resources as needed: + +- ./references/workflows.md -- CI/CD workflows for automated store releases and PR previews +- ./references/testflight.md -- Submitting iOS builds to TestFlight for beta testing +- ./references/app-store-metadata.md -- Managing App Store metadata and ASO optimization +- ./references/play-store.md -- Submitting Android builds to Google Play Store +- ./references/ios-app-store.md -- iOS App Store submission and review process + +## Quick Start + +### Install EAS CLI + +```bash +npm install -g eas-cli +eas login +``` + +### Initialize EAS + +```bash +npx eas-cli@latest init +``` + +This creates `eas.json` with build profiles. + +## Build Commands + +### Production Builds + +```bash +# iOS App Store build +npx eas-cli@latest build -p ios --profile production + +# Android Play Store build +npx eas-cli@latest build -p android --profile production + +# Both platforms +npx eas-cli@latest build --profile production +``` + +### Submit to Stores + +```bash +# iOS: Build and submit to App Store Connect +npx eas-cli@latest build -p ios --profile production --submit + +# Android: Build and submit to Play Store +npx eas-cli@latest build -p android --profile production --submit + +# Shortcut for iOS TestFlight +npx testflight +``` + +## Web & API Route Hosting + +Deploying an Expo website or Expo Router API routes to EAS Hosting (`npx expo export -p web` then `eas deploy`) is covered by the `eas-hosting` skill. This skill focuses on native app store releases. + +## EAS Configuration + +Standard `eas.json` for production deployments: + +```json +{ + "cli": { + "version": ">= 16.0.1", + "appVersionSource": "remote" + }, + "build": { + "production": { + "autoIncrement": true, + "ios": { + "resourceClass": "m-medium" + } + }, + "development": { + "developmentClient": true, + "distribution": "internal" + } + }, + "submit": { + "production": { + "ios": { + "appleId": "your@email.com", + "ascAppId": "1234567890" + }, + "android": { + "serviceAccountKeyPath": "./google-service-account.json", + "track": "internal" + } + } + } +} +``` + +## Platform-Specific Guides + +### iOS + +- Use `npx testflight` for quick TestFlight submissions +- Configure Apple credentials via `eas credentials` +- See ./references/testflight.md for credential setup +- See ./references/ios-app-store.md for App Store submission + +### Android + +- Set up Google Play Console service account +- Configure tracks: internal → closed → open → production +- See ./references/play-store.md for detailed setup + +## Automated Releases + +EAS Workflows automate the build → submit → update pipeline for CI/CD. See ./references/workflows.md for store-release examples. To author or validate workflow YAML, use the `eas-workflows` skill - it works from the live workflow schema. + +## Version Management + +EAS manages version numbers automatically with `appVersionSource: "remote"`: + +```bash +# Check current versions +eas build:version:get + +# Manually set version +eas build:version:set -p ios --build-number 42 +``` + +## Monitoring + +```bash +# List recent builds +eas build:list + +# Check build status +eas build:view + +# View submission status +eas submit:list +``` + +## Submitting Feedback +If you encounter errors, misleading or outdated information in this skill, report it so Expo can improve: +```bash +npx --yes submit-expo-feedback@latest --category skills --subject "eas-app-stores" "" +``` +Only submit when you have something specific and actionable to report. Include as much relevant context as possible. +If an AI agent repeatedly failed or the user had to take over an Expo task, load the expo-skill-feedback skill and follow its eval-candidate flow instead of reusing the command above. diff --git a/.agents/skills/eas-app-stores/agents/openai.yaml b/.agents/skills/eas-app-stores/agents/openai.yaml new file mode 100644 index 0000000..79ce425 --- /dev/null +++ b/.agents/skills/eas-app-stores/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "EAS App Stores" + short_description: "Paid EAS service. Build and submit Expo apps to the iOS App Store, Google Play Store, and TestFlight, with eas.json profiles, versioning, and store metadata" + default_prompt: "Use $eas-app-stores when preparing production EAS builds, store submissions, TestFlight distribution, app-store metadata and ASO, or version and build-number management for an Expo app. For web or API route hosting, use eas-hosting instead." diff --git a/.agents/skills/eas-app-stores/references/app-store-metadata.md b/.agents/skills/eas-app-stores/references/app-store-metadata.md new file mode 100644 index 0000000..758a3bd --- /dev/null +++ b/.agents/skills/eas-app-stores/references/app-store-metadata.md @@ -0,0 +1,497 @@ +# App Store Metadata + +Manage App Store metadata and optimize for ASO using EAS Metadata. + +## Contents + +- [What is EAS Metadata?](#what-is-eas-metadata) +- [Getting Started](#getting-started) +- [Configuration File](#configuration-file) +- [App Store Optimization (ASO)](#app-store-optimization-aso) +- [Categories](#categories) +- [Localization](#localization) +- [Dynamic Configuration](#dynamic-configuration) +- [Age Rating (Advisory)](#age-rating-advisory) +- [Release Strategy](#release-strategy) +- [Review Information](#review-information) +- [ASO Checklist](#aso-checklist) +- [VS Code Integration](#vs-code-integration) +- [Common Issues](#common-issues) +- [CI/CD Integration](#cicd-integration) +- [Tips](#tips) + +## What is EAS Metadata? + +EAS Metadata automates App Store presence management from the command line using a `store.config.json` file instead of manually filling forms in App Store Connect. It includes built-in validation to catch common rejection pitfalls. + +**Current Status:** Preview, Apple App Store only. + +## Getting Started + +### Pull Existing Metadata + +If your app is already published, pull current metadata: + +```bash +eas metadata:pull +``` + +This creates `store.config.json` with your current App Store configuration. + +### Push Metadata Updates + +After editing your config, push changes: + +```bash +eas metadata:push +``` + +**Important:** You must submit a binary via `eas submit` before pushing metadata for new apps. + +## Configuration File + +Create `store.config.json` at your project root: + +```json +{ + "configVersion": 0, + "apple": { + "copyright": "2025 Your Company", + "categories": ["UTILITIES", "PRODUCTIVITY"], + "info": { + "en-US": { + "title": "App Name", + "subtitle": "Your compelling tagline", + "description": "Full app description...", + "keywords": ["keyword1", "keyword2", "keyword3"], + "releaseNotes": "What's new in this version...", + "promoText": "Limited time offer!", + "privacyPolicyUrl": "https://example.com/privacy", + "supportUrl": "https://example.com/support", + "marketingUrl": "https://example.com" + } + }, + "advisory": { + "alcoholTobaccoOrDrugUseOrReferences": "NONE", + "gamblingSimulated": "NONE", + "medicalOrTreatmentInformation": "NONE", + "profanityOrCrudeHumor": "NONE", + "sexualContentGraphicAndNudity": "NONE", + "sexualContentOrNudity": "NONE", + "horrorOrFearThemes": "NONE", + "matureOrSuggestiveThemes": "NONE", + "violenceCartoonOrFantasy": "NONE", + "violenceRealistic": "NONE", + "violenceRealisticProlongedGraphicOrSadistic": "NONE", + "contests": "NONE", + "gambling": false, + "unrestrictedWebAccess": false, + "seventeenPlus": false + }, + "release": { + "automaticRelease": true, + "phasedRelease": true + }, + "review": { + "firstName": "John", + "lastName": "Doe", + "email": "review@example.com", + "phone": "+1 555-123-4567", + "notes": "Demo account: test@example.com / password123" + } + } +} +``` + +## App Store Optimization (ASO) + +### Title Optimization (30 characters max) + +The title is the most important ranking factor. Include your brand name and 1-2 strongest keywords. + +```json +{ + "title": "Budgetly - Money Tracker" +} +``` + +**Best Practices:** + +- Brand name first for recognition +- Include highest-volume keyword +- Avoid generic words like "app" or "the" +- Title keywords boost rankings by ~10% + +### Subtitle Optimization (30 characters max) + +The subtitle appears below your title in search results. Use it for your unique value proposition. + +```json +{ + "subtitle": "Smart Expense & Budget Planner" +} +``` + +**Best Practices:** + +- Don't duplicate keywords from title (Apple counts each word once) +- Highlight your main differentiator +- Include secondary high-value keywords +- Focus on benefits, not features + +### Keywords Field (100 characters max) + +Hidden from users but crucial for discoverability. Use comma-separated keywords without spaces after commas. + +```json +{ + "keywords": [ + "finance,budget,expense,money,tracker,savings,bills,income,spending,wallet,personal,weekly,monthly" + ] +} +``` + +**Best Practices:** + +- Use all 100 characters +- Separate with commas only (no spaces) +- No duplicates from title/subtitle +- Include singular forms (Apple handles plurals) +- Add synonyms and alternate spellings +- Include competitor brand names (carefully) +- Use digits instead of spelled numbers ("5" not "five") +- Skip articles and prepositions + +### Description Optimization + +The iOS description is NOT indexed for search but critical for conversion. Focus on convincing users to download. + +```json +{ + "description": "Take control of your finances with Budgetly, the intuitive money management app trusted by over 1 million users.\n\nKEY FEATURES:\n• Smart budget tracking - Set limits and watch your progress\n• Expense categorization - Know exactly where your money goes\n• Bill reminders - Never miss a payment\n• Beautiful charts - Visualize your financial health\n• Bank sync - Connect 10,000+ institutions\n• Cloud backup - Your data, always safe\n\nWHY BUDGETLY?\nUnlike complex spreadsheets or basic calculators, Budgetly learns your spending habits and provides personalized insights. Our users save an average of $300/month within 3 months.\n\nPRIVACY FIRST\nYour financial data is encrypted end-to-end. We never sell your information.\n\nDownload Budgetly today and start your journey to financial freedom!" +} +``` + +**Best Practices:** + +- Front-load the first 3 lines (visible before "more") +- Use bullet points for features +- Include social proof (user counts, ratings, awards) +- Add a clear call-to-action +- Mention privacy/security for sensitive apps +- Update with each release + +### Release Notes + +Shown to existing users deciding whether to update. + +```json +{ + "releaseNotes": "Version 2.5 brings exciting improvements:\n\n• NEW: Dark mode support\n• NEW: Widget for home screen\n• IMPROVED: 50% faster sync\n• FIXED: Notification timing issues\n\nLove Budgetly? Please leave a review!" +} +``` + +### Promo Text (170 characters max) + +Appears above description; can be updated without new binary. Great for time-sensitive promotions. + +```json +{ + "promoText": "🎉 New Year Special: Premium features free for 30 days! Start 2025 with better finances." +} +``` + +## Categories + +Primary category is most important for browsing and rankings. + +```json +{ + "categories": ["FINANCE", "PRODUCTIVITY"] +} +``` + +**Available Categories:** + +- BOOKS, BUSINESS, DEVELOPER_TOOLS, EDUCATION +- ENTERTAINMENT, FINANCE, FOOD_AND_DRINK +- GAMES (with subcategories), GRAPHICS_AND_DESIGN +- HEALTH_AND_FITNESS, KIDS (age-gated) +- LIFESTYLE, MAGAZINES_AND_NEWSPAPERS +- MEDICAL, MUSIC, NAVIGATION, NEWS +- PHOTO_AND_VIDEO, PRODUCTIVITY, REFERENCE +- SHOPPING, SOCIAL_NETWORKING, SPORTS +- STICKERS (with subcategories), TRAVEL +- UTILITIES, WEATHER + +## Localization + +Localize metadata for each target market. Keywords should be researched per locale—direct translations often miss regional search terms. + +```json +{ + "info": { + "en-US": { + "title": "Budgetly - Money Tracker", + "subtitle": "Smart Expense Planner", + "keywords": ["budget,finance,money,expense,tracker"] + }, + "es-ES": { + "title": "Budgetly - Control de Gastos", + "subtitle": "Planificador de Presupuesto", + "keywords": ["presupuesto,finanzas,dinero,gastos,ahorro"] + }, + "ja": { + "title": "Budgetly - 家計簿アプリ", + "subtitle": "簡単支出管理", + "keywords": ["家計簿,支出,予算,節約,お金"] + }, + "de-DE": { + "title": "Budgetly - Haushaltsbuch", + "subtitle": "Ausgaben Verwalten", + "keywords": ["budget,finanzen,geld,ausgaben,sparen"] + } + } +} +``` + +**Supported Locales:** +`ar-SA`, `ca`, `cs`, `da`, `de-DE`, `el`, `en-AU`, `en-CA`, `en-GB`, `en-US`, `es-ES`, `es-MX`, `fi`, `fr-CA`, `fr-FR`, `he`, `hi`, `hr`, `hu`, `id`, `it`, `ja`, `ko`, `ms`, `nl-NL`, `no`, `pl`, `pt-BR`, `pt-PT`, `ro`, `ru`, `sk`, `sv`, `th`, `tr`, `uk`, `vi`, `zh-Hans`, `zh-Hant` + +## Dynamic Configuration + +Use JavaScript for dynamic values like copyright year or fetched translations. + +### Basic Dynamic Config + +```js +// store.config.js +const baseConfig = require("./store.config.json"); + +const year = new Date().getFullYear(); + +module.exports = { + ...baseConfig, + apple: { + ...baseConfig.apple, + copyright: `${year} Your Company, Inc.`, + }, +}; +``` + +### Async Configuration (External Localization) + +```js +// store.config.js +module.exports = async () => { + const baseConfig = require("./store.config.json"); + + // Fetch translations from CMS/localization service + const translations = await fetch( + "https://api.example.com/app-store-copy" + ).then((r) => r.json()); + + return { + ...baseConfig, + apple: { + ...baseConfig.apple, + info: translations, + }, + }; +}; +``` + +### Environment-Based Config + +```js +// store.config.js +const baseConfig = require("./store.config.json"); + +const isProduction = process.env.EAS_BUILD_PROFILE === "production"; + +module.exports = { + ...baseConfig, + apple: { + ...baseConfig.apple, + info: { + "en-US": { + ...baseConfig.apple.info["en-US"], + promoText: isProduction + ? "Download now and get started!" + : "[BETA] Help us test new features!", + }, + }, + }, +}; +``` + +Update `eas.json` to use JS config: + +```json +{ + "cli": { + "metadataPath": "./store.config.js" + } +} +``` + +## Age Rating (Advisory) + +Answer content questions honestly to get an appropriate age rating. + +**Content Descriptors:** + +- `NONE` - Content not present +- `INFREQUENT_OR_MILD` - Occasional mild content +- `FREQUENT_OR_INTENSE` - Regular or strong content + +```json +{ + "advisory": { + "alcoholTobaccoOrDrugUseOrReferences": "NONE", + "contests": "NONE", + "gambling": false, + "gamblingSimulated": "NONE", + "horrorOrFearThemes": "NONE", + "matureOrSuggestiveThemes": "NONE", + "medicalOrTreatmentInformation": "NONE", + "profanityOrCrudeHumor": "NONE", + "sexualContentGraphicAndNudity": "NONE", + "sexualContentOrNudity": "NONE", + "unrestrictedWebAccess": false, + "violenceCartoonOrFantasy": "NONE", + "violenceRealistic": "NONE", + "violenceRealisticProlongedGraphicOrSadistic": "NONE", + "seventeenPlus": false, + "kidsAgeBand": "NINE_TO_ELEVEN" + } +} +``` + +**Kids Age Bands:** `FIVE_AND_UNDER`, `SIX_TO_EIGHT`, `NINE_TO_ELEVEN` + +## Release Strategy + +Control how your app rolls out to users. + +```json +{ + "release": { + "automaticRelease": true, + "phasedRelease": true + } +} +``` + +**Options:** + +- `automaticRelease: true` - Release immediately upon approval +- `automaticRelease: false` - Manual release after approval +- `automaticRelease: "2025-02-01T10:00:00Z"` - Schedule release (RFC 3339) +- `phasedRelease: true` - 7-day gradual rollout (1%, 2%, 5%, 10%, 20%, 50%, 100%) + +## Review Information + +Provide contact info and test credentials for the App Review team. + +```json +{ + "review": { + "firstName": "Jane", + "lastName": "Smith", + "email": "app-review@company.com", + "phone": "+1 (555) 123-4567", + "demoUsername": "demo@example.com", + "demoPassword": "ReviewDemo2025!", + "notes": "To test premium features:\n1. Log in with demo credentials\n2. Navigate to Settings > Subscription\n3. Tap 'Restore Purchase' - sandbox purchase will be restored\n\nFor location features, allow location access when prompted." + } +} +``` + +## ASO Checklist + +### Before Each Release + +- [ ] Update keywords based on performance data +- [ ] Refresh description with new features +- [ ] Write compelling release notes +- [ ] Update promo text if running campaigns +- [ ] Verify all URLs are valid + +### Monthly ASO Tasks + +- [ ] Analyze keyword rankings +- [ ] Research competitor keywords +- [ ] Check conversion rates in App Analytics +- [ ] Review user feedback for keyword ideas +- [ ] A/B test screenshots in App Store Connect + +### Keyword Research Tips + +1. **Brainstorm features** - List all app capabilities +2. **Mine reviews** - Find words users actually use +3. **Analyze competitors** - Check their titles/subtitles +4. **Use long-tail keywords** - Less competition, higher intent +5. **Consider misspellings** - Common typos can drive traffic +6. **Track seasonality** - Some keywords peak at certain times + +### Metrics to Monitor + +- **Impressions** - How often your app appears in search +- **Product Page Views** - Users who tap to learn more +- **Conversion Rate** - Views → Downloads +- **Keyword Rankings** - Position for target keywords +- **Category Ranking** - Position in your categories + +## VS Code Integration + +Install the [Expo Tools extension](https://marketplace.visualstudio.com/items?itemName=expo.vscode-expo-tools) for: + +- Auto-complete for all schema properties +- Inline validation and warnings +- Quick fixes for common issues + +## Common Issues + +### "Binary not found" + +Push a binary with `eas submit` before pushing metadata. + +### "Invalid keywords" + +- Check total length is ≤100 characters +- Remove spaces after commas +- Remove duplicate words + +### "Description too long" + +Description maximum is 4000 characters. + +### Pull doesn't update JS config + +`eas metadata:pull` creates a JSON file; import it into your JS config. + +## CI/CD Integration + +Automate metadata updates in your deployment pipeline: + +```yaml +# .eas/workflows/release.yml +jobs: + submit-and-metadata: + steps: + - name: Submit to App Store + run: eas submit -p ios --latest + + - name: Push Metadata + run: eas metadata:push +``` + +## Tips + +- Update metadata every 4-6 weeks for optimal ASO +- 70% of App Store visitors use search to find apps +- Apps with 4+ star ratings get featured more often +- Localized apps see 128% more downloads per country +- First 3 lines of description are most critical (shown before "more") +- Use all 100 keyword characters—every character counts diff --git a/.agents/skills/eas-app-stores/references/ios-app-store.md b/.agents/skills/eas-app-stores/references/ios-app-store.md new file mode 100644 index 0000000..ec6b882 --- /dev/null +++ b/.agents/skills/eas-app-stores/references/ios-app-store.md @@ -0,0 +1,371 @@ +# Submitting to iOS App Store + +## Contents + +- [Prerequisites](#prerequisites) +- [Credential Setup](#credential-setup) +- [Submission Commands](#submission-commands) +- [App Store Connect Configuration](#app-store-connect-configuration) +- [TestFlight vs App Store](#testflight-vs-app-store) +- [App Review Process](#app-review-process) +- [Version and Build Numbers](#version-and-build-numbers) +- [Release Options](#release-options) +- [Certificates and Provisioning](#certificates-and-provisioning) +- [App Store Metadata](#app-store-metadata) +- [Troubleshooting](#troubleshooting) +- [CI/CD Integration](#cicd-integration) +- [Tips](#tips) + +## Prerequisites + +1. **Apple Developer Account** - Enroll at [developer.apple.com](https://developer.apple.com) +2. **App Store Connect App** - Create your app record before first submission +3. **Apple Credentials** - Configure via EAS or environment variables + +## Credential Setup + +### Using EAS Credentials + +```bash +eas credentials -p ios +``` + +This interactive flow helps you: +- Create or select a distribution certificate +- Create or select a provisioning profile +- Configure App Store Connect API key (recommended) + +### App Store Connect API Key (Recommended) + +API keys avoid 2FA prompts in CI/CD: + +1. Go to App Store Connect → Users and Access → Keys +2. Click "+" to create a new key +3. Select "App Manager" role (minimum for submissions) +4. Download the `.p8` key file + +Configure in `eas.json`: + +```json +{ + "submit": { + "production": { + "ios": { + "ascApiKeyPath": "./AuthKey_XXXXX.p8", + "ascApiKeyIssuerId": "xxxxx-xxxx-xxxx-xxxx-xxxxx", + "ascApiKeyId": "XXXXXXXXXX" + } + } + } +} +``` + +Or use environment variables: + +```bash +EXPO_ASC_API_KEY_PATH=./AuthKey.p8 +EXPO_ASC_API_KEY_ISSUER_ID=xxxxx-xxxx-xxxx-xxxx-xxxxx +EXPO_ASC_API_KEY_ID=XXXXXXXXXX +``` + +### Apple ID Authentication (Alternative) + +For manual submissions, you can use Apple ID: + +```bash +EXPO_APPLE_ID=your@email.com +EXPO_APPLE_TEAM_ID=XXXXXXXXXX +``` + +Note: Requires app-specific password for accounts with 2FA. + +## Submission Commands + +```bash +# Build and submit to App Store Connect +eas build -p ios --profile production --submit + +# Submit latest build +eas submit -p ios --latest + +# Submit specific build +eas submit -p ios --id BUILD_ID + +# Quick TestFlight submission +npx testflight +``` + +## App Store Connect Configuration + +### First-Time Setup + +Before submitting, complete in App Store Connect: + +1. **App Information** + - Primary language + - Bundle ID (must match `app.json`) + - SKU (unique identifier) + +2. **Pricing and Availability** + - Price tier + - Available countries + +3. **App Privacy** + - Privacy policy URL + - Data collection declarations + +4. **App Review Information** + - Contact information + - Demo account (if login required) + - Notes for reviewers + +### EAS Configuration + +```json +{ + "cli": { + "version": ">= 16.0.1", + "appVersionSource": "remote" + }, + "build": { + "production": { + "ios": { + "resourceClass": "m-medium", + "autoIncrement": true + } + } + }, + "submit": { + "production": { + "ios": { + "appleId": "your@email.com", + "ascAppId": "1234567890", + "appleTeamId": "XXXXXXXXXX" + } + } + } +} +``` + +Find `ascAppId` in App Store Connect → App Information → Apple ID. + +## TestFlight vs App Store + +### TestFlight (Beta Testing) + +- Builds go to TestFlight automatically after submission +- Internal testers (up to 100) - immediate access +- External testers (up to 10,000) - requires beta review +- Builds expire after 90 days + +### App Store (Production) + +- Requires passing App Review +- Submit for review from App Store Connect +- Choose release timing (immediate, scheduled, manual) + +## App Review Process + +### What Reviewers Check + +1. **Functionality** - App works as described +2. **UI/UX** - Follows Human Interface Guidelines +3. **Content** - Appropriate and accurate +4. **Privacy** - Data handling matches declarations +5. **Legal** - Complies with local laws + +### Common Rejection Reasons + +| Issue | Solution | +|-------|----------| +| Crashes/bugs | Test thoroughly before submission | +| Incomplete metadata | Fill all required fields | +| Placeholder content | Remove "lorem ipsum" and test data | +| Missing login credentials | Provide demo account | +| Privacy policy missing | Add URL in App Store Connect | +| Guideline 4.2 (minimum functionality) | Ensure app provides value | + +### Expedited Review + +Request expedited review for: +- Critical bug fixes +- Time-sensitive events +- Security issues + +Go to App Store Connect → your app → App Review → Request Expedited Review. + +## Version and Build Numbers + +iOS uses two version identifiers: + +- **Version** (`CFBundleShortVersionString`): User-facing, e.g., "1.2.3" +- **Build Number** (`CFBundleVersion`): Internal, must increment for each upload + +Configure in `app.json`: + +```json +{ + "expo": { + "version": "1.2.3", + "ios": { + "buildNumber": "1" + } + } +} +``` + +With `autoIncrement: true`, EAS handles build numbers automatically. + +## Release Options + +### Automatic Release + +Release immediately when approved: + +```json +{ + "apple": { + "release": { + "automaticRelease": true + } + } +} +``` + +### Scheduled Release + +```json +{ + "apple": { + "release": { + "automaticRelease": "2025-03-01T10:00:00Z" + } + } +} +``` + +### Phased Release + +Gradual rollout over 7 days: + +```json +{ + "apple": { + "release": { + "phasedRelease": true + } + } +} +``` + +Rollout: Day 1 (1%) → Day 2 (2%) → Day 3 (5%) → Day 4 (10%) → Day 5 (20%) → Day 6 (50%) → Day 7 (100%) + +## Certificates and Provisioning + +### Distribution Certificate + +- Required for App Store submissions +- Limited to 3 per Apple Developer account +- Valid for 1 year +- EAS manages automatically + +### Provisioning Profile + +- Links app, certificate, and entitlements +- App Store profiles don't include device UDIDs +- EAS creates and manages automatically + +### Check Current Credentials + +```bash +eas credentials -p ios + +# Sync with Apple Developer Portal +eas credentials -p ios --sync +``` + +## App Store Metadata + +Use EAS Metadata to manage App Store listing from code: + +```bash +# Pull existing metadata +eas metadata:pull + +# Push changes +eas metadata:push +``` + +See ./app-store-metadata.md for detailed configuration. + +## Troubleshooting + +### "No suitable application records found" + +Create the app in App Store Connect first with matching bundle ID. + +### "The bundle version must be higher" + +Increment build number. With `autoIncrement: true`, this is automatic. + +### "Missing compliance information" + +Add export compliance to `app.json`: + +```json +{ + "expo": { + "ios": { + "config": { + "usesNonExemptEncryption": false + } + } + } +} +``` + +### "Invalid provisioning profile" + +```bash +eas credentials -p ios --sync +``` + +### Build stuck in "Processing" + +App Store Connect processing can take 5-30 minutes. Check status in App Store Connect → TestFlight. + +## CI/CD Integration + +For automated submissions in CI/CD: + +```yaml +# .eas/workflows/release.yml +name: Release to App Store + +on: + push: + tags: ['v*'] + +jobs: + build: + type: build + params: + platform: ios + profile: production + + submit: + type: submit + needs: [build] + params: + platform: ios + profile: production +``` + +## Tips + +- Submit to TestFlight early and often for feedback +- Use beta app review for external testers to catch issues before App Store review +- Respond to reviewer questions promptly in App Store Connect +- Keep demo account credentials up to date +- Monitor App Store Connect notifications for review updates +- Use phased release for major updates to catch issues early diff --git a/.agents/skills/eas-app-stores/references/play-store.md b/.agents/skills/eas-app-stores/references/play-store.md new file mode 100644 index 0000000..9d67530 --- /dev/null +++ b/.agents/skills/eas-app-stores/references/play-store.md @@ -0,0 +1,248 @@ +# Submitting to Google Play Store + +## Prerequisites + +1. **Google Play Console Account** - Register at [play.google.com/console](https://play.google.com/console) +2. **App Created in Console** - Create your app listing before first submission +3. **Service Account** - For automated submissions via EAS + +Once these are complete, the default `eas submit` works for a first-time submission and creates the app's first release on the internal testing track. Store listing, content rating, and pricing are only required before promoting a release to production. + +## Service Account Setup + +### 1. Create Service Account + +1. Go to Google Cloud Console → IAM & Admin → Service Accounts +2. Create a new service account +3. Grant the "Service Account User" role +4. Create and download a JSON key + +### 2. Link to Play Console + +1. Go to Play Console → Setup → API access +2. Click "Link" next to your Google Cloud project +3. Under "Service accounts", click "Manage Play Console permissions" +4. Grant "Release to production" permission (or appropriate track permissions) + +### 3. Configure EAS + +Add the service account key path to `eas.json`: + +```json +{ + "submit": { + "production": { + "android": { + "serviceAccountKeyPath": "./google-service-account.json", + "track": "internal" + } + } + } +} +``` + +Store the key file securely and add it to `.gitignore`. + +## Environment Variables + +For CI/CD, use environment variables instead of file paths: + +```bash +# Base64-encoded service account JSON +EXPO_ANDROID_SERVICE_ACCOUNT_KEY_BASE64=... +``` + +Or use EAS Secrets: + +```bash +eas secret:create --name GOOGLE_SERVICE_ACCOUNT --value "$(cat google-service-account.json)" --type file +``` + +Then reference in `eas.json`: + +```json +{ + "submit": { + "production": { + "android": { + "serviceAccountKeyPath": "@secret:GOOGLE_SERVICE_ACCOUNT" + } + } + } +} +``` + +## Release Tracks + +Google Play uses tracks for staged rollouts: + +| Track | Purpose | +|-------|---------| +| `internal` | Internal testing (up to 100 testers) | +| `alpha` | Closed testing | +| `beta` | Open testing | +| `production` | Public release | + +### Track Configuration + +```json +{ + "submit": { + "production": { + "android": { + "track": "production", + "releaseStatus": "completed" + } + }, + "internal": { + "android": { + "track": "internal", + "releaseStatus": "completed" + } + } + } +} +``` + +### Release Status Options + +- `completed` - Immediately available on the track +- `draft` - Upload only, release manually in Console +- `halted` - Pause an in-progress rollout +- `inProgress` - Staged rollout (requires `rollout` percentage) + +## Staged Rollout + +```json +{ + "submit": { + "production": { + "android": { + "track": "production", + "releaseStatus": "inProgress", + "rollout": 0.1 + } + } + } +} +``` + +This releases to 10% of users. Increase via Play Console or subsequent submissions. + +## Submission Commands + +```bash +# Build and submit to internal track +eas build -p android --profile production --submit + +# Submit existing build to Play Store +eas submit -p android --latest + +# Submit specific build +eas submit -p android --id BUILD_ID +``` + +## App Signing + +### Google Play App Signing (Recommended) + +EAS uses Google Play App Signing by default: + +1. First upload: EAS creates upload key, Play Store manages signing key +2. Play Store re-signs your app with the signing key +3. Upload key can be reset if compromised + +### Checking Signing Status + +```bash +eas credentials -p android +``` + +## Version Codes + +Android requires incrementing `versionCode` for each upload: + +```json +{ + "build": { + "production": { + "autoIncrement": true + } + } +} +``` + +With `appVersionSource: "remote"`, EAS tracks version codes automatically. + +## First Submission Checklist + +Before your first Play Store submission: + +- [ ] Create app in Google Play Console +- [ ] Complete app content declaration (privacy policy, ads, etc.) +- [ ] Set up store listing (title, description, screenshots) +- [ ] Complete content rating questionnaire +- [ ] Set up pricing and distribution +- [ ] Create service account with proper permissions +- [ ] Configure `eas.json` with service account path + +## Common Issues + +### "App not found" + +The app must exist in Play Console before EAS can submit. Create it manually first. + +### "Version code already used" + +Increment `versionCode` in `app.json` or use `autoIncrement: true` in `eas.json`. + +### "Service account lacks permission" + +Ensure the service account has "Release to production" permission in Play Console → API access. + +### "APK not acceptable" + +Play Store requires AAB (Android App Bundle) for new apps: + +```json +{ + "build": { + "production": { + "android": { + "buildType": "app-bundle" + } + } + } +} +``` + +## Internal Testing Distribution + +For quick internal distribution without Play Store: + +```bash +# Build with internal distribution +eas build -p android --profile development + +# Share the APK link with testers +``` + +Or use EAS Update for OTA updates to existing installs. + +## Monitoring Submissions + +```bash +# Check submission status +eas submit:list -p android + +# View specific submission +eas submit:view SUBMISSION_ID +``` + +## Tips + +- Start with `internal` track for testing before production +- Use staged rollouts for production releases +- Keep service account key secure - never commit to git +- Set up Play Console notifications for review status +- Pre-launch reports in Play Console catch issues before review diff --git a/.agents/skills/eas-app-stores/references/testflight.md b/.agents/skills/eas-app-stores/references/testflight.md new file mode 100644 index 0000000..e16932a --- /dev/null +++ b/.agents/skills/eas-app-stores/references/testflight.md @@ -0,0 +1,58 @@ +# TestFlight + +Always ship to TestFlight first. Internal testers, then external testers, then App Store. Never skip this. + +## Submit + +```bash +npx testflight +``` + +That's it. One command builds and submits to TestFlight. + +## Skip the Prompts + +Set these once and forget: + +```bash +EXPO_APPLE_ID=you@email.com +EXPO_APPLE_TEAM_ID=XXXXXXXXXX +``` + +The CLI prints your Team ID when you run `npx testflight`. Copy it. + +## Why TestFlight First + +- Internal testers get builds instantly (no review) +- External testers require one Beta App Review, then instant updates +- Catch crashes before App Store review rejects you +- TestFlight crash reports are better than App Store crash reports +- 90 days to test before builds expire +- Real users on real devices, not simulators + +## Tester Strategy + +**Internal (100 max)**: Your team. Immediate access. Use for every build. + +**External (10,000 max)**: Beta users. First build needs review (~24h), then instant. Always have an external group—even if it's just friends. Real feedback beats assumptions. + +## Tips + +- Submit to external TestFlight the moment internal looks stable +- Beta App Review is faster and more lenient than App Store Review +- Add release notes—testers actually read them +- Use TestFlight's built-in feedback and screenshots +- Never go straight to App Store. Ever. + +## Troubleshooting + +**"No suitable application records found"** +Create the app in App Store Connect first. Bundle ID must match. + +**"The bundle version must be higher"** +Use `autoIncrement: true` in `eas.json`. Problem solved. + +**Credentials issues** +```bash +eas credentials -p ios +``` diff --git a/.agents/skills/eas-app-stores/references/workflows.md b/.agents/skills/eas-app-stores/references/workflows.md new file mode 100644 index 0000000..4901f75 --- /dev/null +++ b/.agents/skills/eas-app-stores/references/workflows.md @@ -0,0 +1,120 @@ +# EAS Workflows + +Automate builds, submissions, and PR-preview updates with EAS Workflows. The examples below are store-release-oriented starting points. + +When you need to write, edit, or validate a workflow YAML file beyond these examples, use the `eas-workflows` skill. For website and API-route deploy workflows (`type: deploy`), see the `eas-hosting` skill. + +## PR Previews with EAS Update + +Deploy OTA updates for pull requests: + +```yaml +name: PR Preview + +on: + pull_request: + types: [opened, synchronize] + +jobs: + publish: + type: update + params: + branch: "pr-${{ github.event.pull_request.number }}" + message: "PR #${{ github.event.pull_request.number }}" +``` + +## Production Release + +Complete release workflow for both platforms: + +```yaml +name: Release + +on: + push: + tags: ['v*'] + +jobs: + build-ios: + type: build + params: + platform: ios + profile: production + + build-android: + type: build + params: + platform: android + profile: production + + submit-ios: + type: submit + needs: [build-ios] + params: + platform: ios + profile: production + + submit-android: + type: submit + needs: [build-android] + params: + platform: android + profile: production +``` + +## Build on Push + +Trigger builds when pushing to specific branches: + +```yaml +name: Build + +on: + push: + branches: + - main + - release/* + +jobs: + build: + type: build + params: + platform: all + profile: production +``` + +## Conditional Jobs + +Run jobs based on conditions: + +```yaml +name: Conditional Release + +on: + push: + branches: [main] + +jobs: + check-changes: + type: run + params: + command: | + if git diff --name-only HEAD~1 | grep -q "^src/"; then + echo "has_changes=true" >> $GITHUB_OUTPUT + fi + + build: + type: build + needs: [check-changes] + if: needs.check-changes.outputs.has_changes == 'true' + params: + platform: all + profile: production +``` + +## Tips + +- Use `workflow_dispatch` for manual production releases +- Combine PR previews with GitHub status checks +- Use tags for versioned releases +- Keep sensitive values in EAS Secrets, not workflow files diff --git a/.agents/skills/eas-hosting/SKILL.md b/.agents/skills/eas-hosting/SKILL.md new file mode 100644 index 0000000..7ec4c20 --- /dev/null +++ b/.agents/skills/eas-hosting/SKILL.md @@ -0,0 +1,431 @@ +--- +name: eas-hosting +description: EAS service (paid). Deploy Expo websites and Expo Router API routes to EAS Hosting - export the web bundle, run eas deploy for production and PR preview URLs, manage environment secrets and custom domains, and work within the Cloudflare Workers runtime. Also covers authoring API routes (+api.ts handlers, HTTP methods, request handling, CORS). Use when deploying an Expo web app or API routes, setting up EAS Hosting, or configuring hosting environments and domains. Not for native builds or store releases - use the eas-app-stores skill for those. +version: 1.0.0 +license: MIT +--- + +# EAS Hosting + +> **EAS service - costs apply.** EAS Hosting is a paid Expo Application Services product with free-tier limits; production deploys use your plan's request and bandwidth allowance. See https://expo.dev/pricing. Authoring API routes and exporting the web bundle are free and open source, and you can self-host the exported server output instead of EAS Hosting. + +EAS Hosting deploys your Expo **web app and API routes** to Expo's managed edge (Cloudflare Workers). Export the web bundle with `npx expo export -p web` and ship it with `eas deploy` - the same command deploys any Expo Router API routes bundled alongside it. This skill covers deploying a website, authoring API routes, and the hosting runtime; see the Deployment section below for the deploy workflow. + +## When to Use API Routes + +Use API routes when you need: + +- **Server-side secrets** — API keys, database credentials, or tokens that must never reach the client +- **Database operations** — Direct database queries that shouldn't be exposed +- **Third-party API proxies** — Hide API keys when calling external services (OpenAI, Stripe, etc.) +- **Server-side validation** — Validate data before database writes +- **Webhook endpoints** — Receive callbacks from services like Stripe or GitHub +- **Rate limiting** — Control access at the server level +- **Heavy computation** — Offload processing that would be slow on mobile + +## When NOT to Use API Routes + +Avoid API routes when: + +- **Data is already public** — Use direct fetch to public APIs instead +- **No secrets required** — Static data or client-safe operations +- **Real-time updates needed** — Use WebSockets or services like Supabase Realtime +- **Simple CRUD** — Consider Firebase, Supabase, or Convex for managed backends +- **File uploads** — Use direct-to-storage uploads (S3 presigned URLs, Cloudflare R2) +- **Authentication only** — Use Clerk, Auth0, or Firebase Auth instead + +## File Structure + +API routes live in the `app` directory with `+api.ts` suffix: + +``` +app/ + api/ + hello+api.ts → GET /api/hello + users+api.ts → /api/users + users/[id]+api.ts → /api/users/:id + (tabs)/ + index.tsx +``` + +## Basic API Route + +```ts +// app/api/hello+api.ts +export function GET(request: Request) { + return Response.json({ message: "Hello from Expo!" }); +} +``` + +## HTTP Methods + +Export named functions for each HTTP method: + +```ts +// app/api/items+api.ts +export function GET(request: Request) { + return Response.json({ items: [] }); +} + +export async function POST(request: Request) { + const body = await request.json(); + return Response.json({ created: body }, { status: 201 }); +} + +export async function PUT(request: Request) { + const body = await request.json(); + return Response.json({ updated: body }); +} + +export async function DELETE(request: Request) { + return new Response(null, { status: 204 }); +} +``` + +## Dynamic Routes + +```ts +// app/api/users/[id]+api.ts +export function GET(request: Request, { id }: { id: string }) { + return Response.json({ userId: id }); +} +``` + +## Request Handling + +### Query Parameters + +```ts +export function GET(request: Request) { + const url = new URL(request.url); + const page = url.searchParams.get("page") ?? "1"; + const limit = url.searchParams.get("limit") ?? "10"; + + return Response.json({ page, limit }); +} +``` + +### Headers + +```ts +export function GET(request: Request) { + const auth = request.headers.get("Authorization"); + + if (!auth) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + return Response.json({ authenticated: true }); +} +``` + +### JSON Body + +```ts +export async function POST(request: Request) { + const { email, password } = await request.json(); + + if (!email || !password) { + return Response.json({ error: "Missing fields" }, { status: 400 }); + } + + return Response.json({ success: true }); +} +``` + +## Environment Variables + +Use `process.env` for server-side secrets: + +```ts +// app/api/ai+api.ts +export async function POST(request: Request) { + const { prompt } = await request.json(); + + const response = await fetch("https://api.openai.com/v1/chat/completions", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${process.env.OPENAI_API_KEY}`, + }, + body: JSON.stringify({ + model: "gpt-4", + messages: [{ role: "user", content: prompt }], + }), + }); + + const data = await response.json(); + return Response.json(data); +} +``` + +Set environment variables: + +- **Local**: Create `.env` file (never commit) +- **EAS Hosting**: Use `eas env:create` or Expo dashboard + +## CORS Headers + +Add CORS for web clients: + +```ts +const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization", +}; + +export function OPTIONS() { + return new Response(null, { headers: corsHeaders }); +} + +export function GET() { + return Response.json({ data: "value" }, { headers: corsHeaders }); +} +``` + +## Error Handling + +```ts +export async function POST(request: Request) { + try { + const body = await request.json(); + // Process... + return Response.json({ success: true }); + } catch (error) { + console.error("API error:", error); + return Response.json({ error: "Internal server error" }, { status: 500 }); + } +} +``` + +## Testing Locally + +Start the development server with API routes: + +```bash +npx expo serve +``` + +This starts a local server at `http://localhost:8081` with full API route support. + +Test with curl: + +```bash +curl http://localhost:8081/api/hello +curl -X POST http://localhost:8081/api/users -H "Content-Type: application/json" -d '{"name":"Test"}' +``` + +## Deployment to EAS Hosting + +### Prerequisites + +```bash +npm install -g eas-cli +eas login +``` + +### Deploy + +Deploying ships your web bundle and any Expo Router API routes together - `eas deploy` handles both. The export runs whether you have a full website, an API-routes-only backend, or both. + +```bash +# Export the web bundle (includes any API routes) +npx expo export -p web + +# Deploy a preview (PR-style URL) +npx eas-cli@latest deploy + +# Deploy to production +npx eas-cli@latest deploy --prod +``` + +Everything lands on EAS Hosting (Cloudflare Workers). + +### Environment Variables for Production + +```bash +# Create a secret +eas env:create --name OPENAI_API_KEY --value sk-xxx --environment production + +# Or use the Expo dashboard +``` + +### Custom Domain + +Configure in `eas.json` or Expo dashboard. + +### Automate with EAS Workflows + +Deploy the website (and API routes) on every push to main with a `type: deploy` workflow: + +`.eas/workflows/deploy.yml` + +```yaml +name: Deploy + +on: + push: + branches: + - main + +# https://docs.expo.dev/eas/workflows/syntax/#deploy +jobs: + deploy_web: + type: deploy + params: + prod: true +``` + +Preview deploys for pull requests use the same job type with `prod: false`: + +```yaml +name: Web PR Preview + +on: + pull_request: + types: [opened, synchronize] + +jobs: + preview: + type: deploy + params: + prod: false +``` + +To author or validate workflow YAML beyond these examples, use the `eas-workflows` skill. + +## EAS Hosting Runtime (Cloudflare Workers) + +API routes run on Cloudflare Workers. Key limitations: + +### Missing/Limited APIs + +- **No Node.js filesystem** — `fs` module unavailable +- **No native Node modules** — Use Web APIs or polyfills +- **Limited execution time** — 30 second timeout for CPU-intensive tasks +- **No persistent connections** — WebSockets require Durable Objects +- **fetch is available** — Use standard fetch for HTTP requests + +### Use Web APIs Instead + +```ts +// Use Web Crypto instead of Node crypto +const hash = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode("data") +); + +// Use fetch instead of node-fetch +const response = await fetch("https://api.example.com"); + +// Use Response/Request (already available) +return new Response(JSON.stringify(data), { + headers: { "Content-Type": "application/json" }, +}); +``` + +### Database Options + +Since filesystem is unavailable, use cloud databases: + +- **Cloudflare D1** — SQLite at the edge +- **Turso** — Distributed SQLite +- **PlanetScale** — Serverless MySQL +- **Supabase** — Postgres with REST API +- **Neon** — Serverless Postgres + +Example with Turso: + +```ts +// app/api/users+api.ts +import { createClient } from "@libsql/client/web"; + +const db = createClient({ + url: process.env.TURSO_URL!, + authToken: process.env.TURSO_AUTH_TOKEN!, +}); + +export async function GET() { + const result = await db.execute("SELECT * FROM users"); + return Response.json(result.rows); +} +``` + +## Calling API Routes from Client + +```ts +// From React Native components +const response = await fetch("/api/hello"); +const data = await response.json(); + +// With body +const response = await fetch("/api/users", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "John" }), +}); +``` + +## Common Patterns + +### Authentication Middleware + +```ts +// utils/auth.ts +export async function requireAuth(request: Request) { + const token = request.headers.get("Authorization")?.replace("Bearer ", ""); + + if (!token) { + throw new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }); + } + + // Verify token... + return { userId: "123" }; +} + +// app/api/protected+api.ts +import { requireAuth } from "../../utils/auth"; + +export async function GET(request: Request) { + const { userId } = await requireAuth(request); + return Response.json({ userId }); +} +``` + +### Proxy External API + +```ts +// app/api/weather+api.ts +export async function GET(request: Request) { + const url = new URL(request.url); + const city = url.searchParams.get("city"); + + const response = await fetch( + `https://api.weather.com/v1/current?city=${city}&key=${process.env.WEATHER_API_KEY}` + ); + + return Response.json(await response.json()); +} +``` + +## Rules + +- NEVER expose API keys or secrets in client code +- ALWAYS validate and sanitize user input +- Use proper HTTP status codes (200, 201, 400, 401, 404, 500) +- Handle errors gracefully with try/catch +- Keep API routes focused — one responsibility per endpoint +- Use TypeScript for type safety +- Log errors server-side for debugging + +## Submitting Feedback +If you encounter errors, misleading or outdated information in this skill, report it so Expo can improve: +```bash +npx --yes submit-expo-feedback@latest --category skills --subject "eas-hosting" "" +``` +Only submit when you have something specific and actionable to report. Include as much relevant context as possible. +If an AI agent repeatedly failed or the user had to take over an Expo task, load the expo-skill-feedback skill and follow its eval-candidate flow instead of reusing the command above. diff --git a/.agents/skills/eas-hosting/agents/openai.yaml b/.agents/skills/eas-hosting/agents/openai.yaml new file mode 100644 index 0000000..a5b2bf1 --- /dev/null +++ b/.agents/skills/eas-hosting/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "EAS Hosting" + short_description: "Paid EAS service. Deploy Expo websites and Expo Router API routes to EAS Hosting, with environment secrets, custom domains, and the Cloudflare Workers runtime" + default_prompt: "Use $eas-hosting to deploy an Expo web app or Expo Router API routes to EAS Hosting, author +api.ts route handlers, manage hosting environment secrets and custom domains, and work within the Cloudflare Workers runtime." diff --git a/.agents/skills/eas-observe/SKILL.md b/.agents/skills/eas-observe/SKILL.md new file mode 100644 index 0000000..b2d0e10 --- /dev/null +++ b/.agents/skills/eas-observe/SKILL.md @@ -0,0 +1,54 @@ +--- +name: eas-observe +description: EAS service (paid). Use for anything related to EAS Observe - adding `expo-observe` to an Expo project (AppMetricsRoot/ObserveRoot HOC, markInteractive and ObserveInteractiveMarker, the useObserve hook, the Expo Router / React Navigation integrations for per-route metrics, user-defined events via `Observe.logEvent`, error reporting via ObserveErrorBoundary and `Observe.reportError`, and runtime config such as sampleRate and dispatchInDebug), querying via the EAS CLI (`eas observe:metrics-summary`, `observe:metrics`, `observe:routes`, `observe:events`, `observe:session`, `observe:versions`), interpreting the resulting metrics (cold/warm launch, TTR, TTI, navigation cold/warm TTR, update download, and the TTI frameRate/device/network params for triaging slow startups), or shipping an Observe integration inside a third-party package. +version: 1.1.0 +license: MIT +--- + +# EAS Observe + +> **EAS service - costs apply.** EAS Observe is an Expo Application Services product. The free EAS plan allows up to 10,000 monthly active users, with a limited set of features; higher usage requires a paid subscription. For details, see https://expo.dev/pricing#plan-features. + +EAS Observe tracks startup, navigation, and custom-event performance from production Expo apps. It needs a development or production build — the native library is not in Expo Go. + +> **Source of truth:** https://docs.expo.dev/eas/observe/ — always consult the canonical docs when API details matter, especially get-started, configuration, integrations, and the metrics reference. EAS Observe is evolving; this skill's references are written to stay accurate but may lag the docs. + +## Which reference to read + +The four reference files in `./references/` cover what people typically need this skill for: + +- **Adding EAS Observe to a project** → [`./references/setup.md`](./references/setup.md). Install, wrap the root layout (`AppMetricsRoot` on SDK 55, `ObserveRoot` on SDK 56+), mark the app interactive (global `markInteractive()` on SDK 55, the `useObserve()` hook or `` on SDK 56+), optional per-route navigation metrics through the Expo Router / React Navigation integrations, user-defined events via `Observe.logEvent` (SDK 56+), error reporting, and runtime configuration (sampling, dispatch, environments, custom endpoint). +- **Querying metrics from the terminal** → [`./references/queries.md`](./references/queries.md). The six `eas observe:*` commands — `metrics-summary`, `metrics`, `routes`, `events`, `session`, `versions` — with flags, metric aliases, table layouts, JSON shapes, and common workflows. +- **Reading a dashboard or CLI output** → [`./references/metrics.md`](./references/metrics.md). Target thresholds per metric, what the automatic TTI params mean (`frameRate.*`, `device.*`, `network.*`), and diagnostic patterns for telling slow-but-smooth startup apart from main-thread contention, hard blocks, or throttled devices. +- **Shipping an Observe integration in a library** → [`./references/third-party.md`](./references/third-party.md). For package authors only (SDK 57+): optional peer dependency, config declaration merging, `Observe.registerIntegration()`, and event naming. + +## Quick links to the docs + +- Get started: https://docs.expo.dev/eas/observe/get-started/ +- Dashboard guide: https://docs.expo.dev/eas/observe/dashboard/ +- Querying with EAS CLI: https://docs.expo.dev/eas/observe/eas-cli/ +- Metrics reference: https://docs.expo.dev/eas/observe/reference/metrics/ +- Expo Router integration: https://docs.expo.dev/eas/observe/integrations/expo-router/ +- React Navigation integration: https://docs.expo.dev/eas/observe/integrations/react-navigation/ +- User-defined events: https://docs.expo.dev/eas/observe/events/ +- Configuration: https://docs.expo.dev/eas/observe/configuration/ +- Third-party integrations: https://docs.expo.dev/eas/observe/integrations/third-party/ +- EAS Update download performance: https://docs.expo.dev/eas/observe/eas-update/ +- Troubleshooting: https://docs.expo.dev/eas/observe/reference/troubleshooting/ + +## Known gaps between the docs and the shipped code + +Verified against `eas-cli` 21.8.0 and `expo-observe` 57.0.9. Trust this skill's references over the docs on these points, but re-check with `--help` and the installed package before relying on them: + +- All six CLI commands are on the [Querying with EAS CLI](https://docs.expo.dev/eas/observe/eas-cli/) page. Older doc builds list only four and omit `observe:routes` and `observe:session`. +- Navigation metric aliases are `nav_cold_ttr`, `nav_warm_ttr`, and `nav_tti`. There are no bare `cold_ttr` / `warm_ttr` aliases in the CLI. +- Sorting uses `--sort `. There is no `--order` flag. +- `ObserveErrorBoundary`, `Observe.reportError`, and `configure({ errorHandlingEnabled })` are exported but undocumented. Observe still has no crash reporting; use Sentry or BugSnag for that. + +## Submitting Feedback +If you encounter errors, misleading or outdated information in this skill, report it so Expo can improve: +```bash +npx --yes submit-expo-feedback@latest --category skills --subject "eas-observe" "" +``` +Only submit when you have something specific and actionable to report. Include as much relevant context as possible. +If an AI agent repeatedly failed or the user had to take over an Expo task, load the expo-skill-feedback skill and follow its eval-candidate flow instead of reusing the command above. diff --git a/.agents/skills/eas-observe/agents/openai.yaml b/.agents/skills/eas-observe/agents/openai.yaml new file mode 100644 index 0000000..444535a --- /dev/null +++ b/.agents/skills/eas-observe/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "EAS Observe" + short_description: "Paid EAS service. Set up expo-observe app metrics and query launch, route, event, and version performance with the EAS CLI" + default_prompt: "Use $eas-observe to add expo-observe instrumentation (AppMetricsRoot/ObserveRoot, useObserve, ObserveInteractiveMarker, the router integrations, error reporting), query metrics with eas observe:* commands, and interpret cold/warm launch, TTR, and TTI results." diff --git a/.agents/skills/eas-observe/references/metrics.md b/.agents/skills/eas-observe/references/metrics.md new file mode 100644 index 0000000..5cf8a94 --- /dev/null +++ b/.agents/skills/eas-observe/references/metrics.md @@ -0,0 +1,98 @@ +# EAS Observe metrics — interpretation cheatsheet + +Quick reference for reading EAS Observe dashboards and CLI output. + +> Source: https://docs.expo.dev/eas/observe/reference/metrics/ — this is the canonical reference for metrics. Consult this page for the latest guidance, full prose definitions, optimization tips, and rationale. + +All durations are in seconds. Metric data is retained for a minimum of 60 days. By default, every installation dispatches all of its events; high-volume apps can sample per installation with `configure({ sampleRate })` — see [Sampling](https://docs.expo.dev/eas/observe/configuration/#sampling). + +## Target thresholds + +| Metric | Full name | Target | Auto-collected? | +|---|---|---|---| +| Cold launch | `expo.app_startup.cold_launch_time` | **< 1.5s** | Yes (native-only — JS code does not affect it) | +| Warm launch | `expo.app_startup.warm_launch_time` | **< 0.5s** | Yes (OS decides when warm vs cold happens) | +| Bundle load | `expo.app_startup.bundle_load_time` | **< 0.3s** | Yes (JS load + evaluation, before `runApplication`) | +| Time to first render (TTR) | `expo.app_startup.ttr` | **< 2s** incl. cold launch | Yes when root is wrapped with `AppMetricsRoot` (SDK 55) / `ObserveRoot` (SDK 56+) | +| Time to interactive (TTI) | `expo.app_startup.tti` | **< 3s** incl. cold launch | **No** — call `markInteractive()` once the screen is genuinely usable | + +Both TTR and TTI are measured *from native launch* through the React render, so the cold-launch portion counts against them. + +## Interpreting TTI events (automatic params) + +Every TTI event carries automatic params in three groups: frame rate, device state, and network state. Read the frame-rate group to classify *what kind* of slowness you're seeing, then read the device and network groups to decide whether the cause is the code or the conditions. + +### Frame rate — what kind of slowness + +| Param | Definition | What it indicates | +|---|---|---| +| `expo.frameRate.slowFrames` | Count of frames ≥ 17ms | Main thread consistently busy during launch (heavy layout, sync bridge calls, too many components rendering) | +| `expo.frameRate.frozenFrames` | Count of frames ≥ 700ms | Hard freezes. Even one during startup is a serious issue (sync I/O, large JSON parsing, blocking network) | +| `expo.frameRate.totalDelay` | Total accumulated time (seconds) frames exceeded their target duration | Best single "smoothness" number — compare to TTI | + +**Diagnostic patterns:** + +- **High TTI + low totalDelay** → slow but smooth. The launch sequence itself is long. Optimize bundle size, data-fetch waterfalls, initialization chains. +- **High TTI + high totalDelay + many slowFrames** → main-thread contention. Offload work, simplify the initial render tree. +- **High TTI + high totalDelay + any frozenFrames** → something is blocking hard. Look for synchronous I/O, large JSON parsing, or blocking network calls. + +### Device state — is the regression environmental? + +| Param | Type | What it indicates | +|---|---|---| +| `expo.device.lowPowerMode` | boolean | OS power saver was active (Low Power Mode on iOS, Battery Saver on Android). It throttles CPU, GPU, and background work. A regression that disappears when you filter this out is environmental, not a code change. | +| `expo.device.thermalState` | `nominal` \| `fair` \| `serious` \| `critical` \| `unknown` | Sustained `serious`/`critical` means the OS is throttling. Startup slows independently of any app change. | +| `expo.device.batteryLevel` | number, 0–1 | Fractional charge at TTI. Rules out throttling on devices that manage performance aggressively at low charge. Omitted when the OS reports no value. | +| `expo.device.batteryCharging` | boolean | Charging raises sustained CPU ceilings on iOS and some Android OEMs. Non-charging samples are the more conservative population. | + +### Network state — is startup network-bound? + +| Param | Type | What it indicates | +|---|---|---| +| `expo.network.connected` | boolean | If TTI degrades only when `true`, startup is network-bound. If it degrades when `false`, the app does too much before showing cached content. | +| `expo.network.type` | `wifi` \| `cellular` \| `ethernet` \| `none` \| `other` \| `unknown` | Compare cellular against Wi-Fi. A large gap points to network-bound startup work. VPN traffic reports the underlying transport. The value set is identical on both platforms, so dashboards need no per-platform branching. | +| `expo.network.isExpensive` | boolean | Both platforms. The OS considers the connection metered (cellular, hotspot). Present only when a network exists. | +| `expo.network.isConstrained` | boolean | **iOS only.** Low Data Mode is on for this path, so the system defers background transfers. | +| `expo.network.dataSaverEnabled` | boolean | **Android only.** Data Saver is on. It is the nearest equivalent of Low Data Mode, but process-wide rather than per-path, hence the separate key. | + +### Network requests — was the network the cause? + +TTI events summarize the HTTP requests made during launch, from the end of the native launch to the `markInteractive()` call. Traffic is observed automatically — `URLSession` on iOS, `OkHttpClient` on Android, which covers `fetch` — and Observe's own uploads are excluded. All of these are omitted when the window held no requests. + +| Param | Unit | What it indicates | +|---|---|---| +| `expo.network.requests.count` | count | Requests that finished in the window. A request still in flight when the app became interactive is not counted anywhere in this table. | +| `expo.network.requests.failed` | count | Errored, returned 4xx/5xx, never got a response, or broke partway through the body. Redirects are not failures. | +| `expo.network.requests.bytesReceived` / `.bytesSent` | bytes | On-the-wire totals for the window. | +| `expo.network.requests.totalDuration` | seconds | Sum of every request duration, failures included. Exceeds wall-clock when requests overlap; one timeout contributes the client's full timeout interval. | +| `expo.network.requests.throughputBytesPerSecond` | bytes/sec | Received bytes over the time bytes were actually moving (union of transfer windows, measured from each first byte). Excludes DNS, connect, server think time, cache hits, and failures. Requests the OS did not clearly identify as network loads are excluded too. Omitted when nothing was received. | +| `expo.network.requests.slowest.duration` | seconds | The single longest **completed** request. Requests that never produced a response are excluded, since a timeout measures the client's own setting. | +| `expo.network.requests.slowest.host` | string | Host of that request. | +| `expo.network.requests.slowest.statusCode` | number | Explains an empty response: `bytesReceived` of 0 is routine on a 304, a problem on a 200. | +| `expo.network.requests.slowest.timeToFirstByte` | seconds | Includes server processing, so treat it as a proxy for network quality, not a measurement of it. | +| `expo.network.requests.slowest.bytesReceived` | bytes | Separates "slow because it moved a lot of data" from "slow while idle". | + +**Diagnostic patterns:** + +- **`slowest.duration` mostly `timeToFirstByte`** → the server was slow to answer. Optimize the endpoint, or stop blocking startup on it. +- **Small `timeToFirstByte` + large `bytesReceived`** → the transfer itself was slow. Shrink the payload or defer it. +- **High `failed` + high `totalDuration`** → the launch burned time on requests that never arrived. Add timeouts and render cached content first. +- **Low `throughputBytesPerSecond` on `wifi`** → suspect the population, not the code; cross-check `isExpensive` and `isConstrained` / `dataSaverEnabled`. + +> The summary is bounded by an in-memory ring buffer of the 200 most recent requests. A launch that makes more undercounts, so read these as a sample of a very busy window. + +### Custom params + +You can attach your own params to the TTI event, and override the route name it is tagged with. See [`./setup.md`](./setup.md) for the call syntax. + +## Dispatch caveats + +- **Debug builds** (native debug OR JS bundle with `__DEV__` = true) do **not** dispatch metrics unless `configure({ dispatchInDebug: true })` is set. +- The `environment` tag (defaults to `process.env.NODE_ENV`) is metadata only — it does not gate dispatch by itself. +- Offline events are buffered on-device and flushed when the app backgrounds or `Observe.dispatchEvents()` is called. + +## Cross-references + +- Full metric definitions and optimization guidance: https://docs.expo.dev/eas/observe/reference/metrics/ +- Setup steps (`AppMetricsRoot` / `ObserveRoot`, `markInteractive`): see [`./setup.md`](./setup.md). +- Querying metrics via the EAS CLI: see [`./queries.md`](./queries.md). diff --git a/.agents/skills/eas-observe/references/queries.md b/.agents/skills/eas-observe/references/queries.md new file mode 100644 index 0000000..5abcb2c --- /dev/null +++ b/.agents/skills/eas-observe/references/queries.md @@ -0,0 +1,403 @@ +# EAS Observe CLI + +EAS Observe collects app performance telemetry and custom events from Expo apps and exposes them through six EAS CLI commands. Pass the `--help` flag to any command for the latest API — the flags below were verified against `eas-cli` 21.8.0. + +> Source: https://docs.expo.dev/eas/observe/eas-cli/ — the canonical CLI page. This reference adds table layouts, JSON output shapes, and pagination details that the docs page does not cover. + +## Commands Overview + +| Command | Purpose | +|---------|---------| +| `eas observe:metrics-summary` | Per-version statistical aggregates for startup and navigation metrics (median, p90, etc.) | +| `eas observe:metrics` | Individual metric samples ordered by value or timestamp (paginated) | +| `eas observe:routes` | Per-route statistical aggregates for navigation metrics (Nav Cold TTR, Nav Warm TTR, Nav TTI) | +| `eas observe:events` | Custom events emitted by the app via `logEvent` — name summary, all events, or filtered by event name (paginated) | +| `eas observe:session` | Full timeline of metric and log events for one session | +| `eas observe:versions` | App version hierarchy with build numbers, OTA update IDs, and event counts | + +> Older published docs list only `metrics-summary`, `metrics`, `events`, and `versions`. All six are on the [Querying with EAS CLI](https://docs.expo.dev/eas/observe/eas-cli/) page; run `--help` to confirm them on your installed version. + +All six commands share these flags: + +- `--start ` and `--end ` — explicit time range +- `--days ` — show data from the last N days (mutually exclusive with `--start`/`--end`, minimum 1) +- `--project-id ` — run against a specific project without needing a project directory. When passed, the command will not try to create a new EAS project where one is unneeded. +- `--json` — machine-readable output (implies `--non-interactive`) +- `--non-interactive` — fail instead of prompting + +`--platform ios` / `--platform android` (default: both) is on every command **except `observe:session`**, which is scoped to one session already. + +Default time range is the last 60 days when none of `--days`, `--start`, `--end` is given. + +**Plan gating.** Observe is a paid feature, and the server rejects queries the account's plan does not include (`EAS_OBSERVE_PLAN_UPGRADE_REQUIRED` or `EAS_OBSERVE_FEATURE_NOT_AVAILABLE_IN_FREE_TIER`). The CLI surfaces the server's upgrade message, which links to the account's billing page. Session timelines in particular are checked before the interactive picker runs. A plan-gate failure is not a bug in the command or its flags. + +## Supported Metrics + +### App-startup metrics + +| Alias | Full name | Display | +|-------|-----------|---------| +| `tti` | `expo.app_startup.tti` | Startup TTI (time to interactive) | +| `ttr` | `expo.app_startup.ttr` | Startup TTR (time to render) | +| `cold_launch` | `expo.app_startup.cold_launch_time` | Cold Launch | +| `warm_launch` | `expo.app_startup.warm_launch_time` | Warm Launch | +| `bundle_load` | `expo.app_startup.bundle_load_time` | Bundle Load | +| `update_download` | `expo.updates.download_time` | Update Download | + +### Navigation metrics + +Emitted only when a navigation integration is enabled (SDK 56+). Measured per route name. + +| Alias | Full name | Display | +|-------|-----------|---------| +| `nav_cold_ttr` | `expo.navigation.cold_ttr` | Nav Cold TTR | +| `nav_warm_ttr` | `expo.navigation.warm_ttr` | Nav Warm TTR | +| `nav_tti` | `expo.navigation.tti` | Nav TTI | + +**Which command takes which alias.** `observe:metrics` (positional argument) and `observe:metrics-summary --metric` accept **all nine** aliases — startup and navigation. `observe:routes --metric` accepts only the three navigation aliases. Use the `nav_` prefix everywhere; there are no bare `cold_ttr` / `warm_ttr` aliases. + +`observe:metrics` also accepts a full metric name in place of an alias, for example `eas observe:metrics expo.app_startup.tti`. `observe:routes` accepts full navigation names the same way. The `--metric` flags on `metrics-summary` and `routes` are strict oclif options, so they take aliases only. + +## `eas observe:metrics-summary` + +Shows per-version statistical aggregates for one or more metrics, with separate tables per platform. + +```bash +# All default metrics, last 60 days, both platforms +eas observe:metrics-summary + +# Single metric +eas observe:metrics-summary --metric tti + +# Multiple metrics — each renders as its own table +eas observe:metrics-summary --metric tti --metric cold_launch + +# Navigation metrics aggregate per version here, per route in observe:routes +eas observe:metrics-summary --metric nav_tti + +# Choose which statistics to display +eas observe:metrics-summary --metric tti --stat median --stat p90 --stat eventCount + +# Narrow time range and platform +eas observe:metrics-summary --metric tti --days 14 --platform ios +``` + +**Stat flags:** exactly `min`, `median`, `max`, `average`, `p80`, `p90`, `p99`, `eventCount`. This command takes **no aliases** — `med`, `avg`, and `count` are rejected here (they work only on `observe:routes`). + +**Default stats:** `median` + `eventCount` in the table; all eight in JSON. + +This command has no `--limit`, `--after`, `--app-version`, or `--update-id`. It always aggregates every version in the time range. + +**Table layout:** +- One table per metric (with merged value + event count cells, e.g. `0.45s (150)`) +- Each table shows iOS and Android in separate sections +- App Version column includes build numbers in parentheses (e.g. `1.2.0 (42)`) +- Footer row per platform shows total events per metric +- **Update IDs are omitted from the table** to keep output readable when a version has many updates; they are included in the JSON output as an array per version + +**JSON output shape:** +```json +{ + "versions": [ + { + "appVersion": "1.2.0", + "platform": "IOS", + "buildNumbers": ["42"], + "updateIds": ["abc-def-...", "..."], + "metrics": { + "expo.app_startup.tti": { "median": 0.45, "p90": 0.9, "...": "..." } + } + } + ], + "totalEventCounts": { + "expo.app_startup.tti": { "IOS": 1234, "ANDROID": 890 } + } +} +``` + +## `eas observe:metrics` + +Shows individual performance metric samples, paginated. The metric is a positional argument, not a flag. If omitted and running interactively, prompts for selection; in non-interactive mode it throws an error. + +```bash +# Interactive: prompts for metric +eas observe:metrics + +# Specify metric as positional arg +eas observe:metrics tti + +# Navigation metrics work here too +eas observe:metrics nav_tti --sort slowest + +# Filter by version or update, sort by slowest +eas observe:metrics tti --app-version 1.2.0 --sort slowest --limit 20 + +# Pagination — pass the endCursor from the previous run +eas observe:metrics tti --after +``` + +**Sample-specific flags:** +- `--sort ` — defaults to `oldest` +- `--limit ` — samples per page (default 10, max 100) +- `--after ` — pagination cursor from the previous run +- `--app-version ` — filter by app version string +- `--update-id ` — filter by EAS update ID + +**Table layout:** +- Summary header shows the metric name, time range, and total sample count across all versions (e.g. `TTI samples for the last 60 days — 1,234 total events`) +- Columns: Value, App Version (with build number), Update (only when any sample has one), Platform, Device, Country, Timestamp +- When `hasNextPage` is true, prints `Next page: --after ` hint below the table +- JSON output also includes `sessionId`, `easClientId`, and a `customParams` object per sample + +## `eas observe:routes` + +Shows per-route statistical aggregates for navigation metrics (Cold TTR, Warm TTR, Nav TTI), grouped by route name with separate sections per platform. + +```bash +# All three navigation metrics, default stats, last 60 days, both platforms +eas observe:routes + +# Single metric, last 7 days, iOS only +eas observe:routes --metric nav_tti --days 7 --platform ios + +# Multiple metrics and stats +eas observe:routes --metric nav_cold_ttr --metric nav_warm_ttr --stat median --stat p90 --stat count + +# Filter to a single build +eas observe:routes --app-version 1.2.0 --build-number 42 + +# Narrow to specific routes (repeat the flag for multiple routes) +eas observe:routes --route-name /new --route-name /settings + +# Pagination — each platform has its own cursor; pass the relevant endCursor +eas observe:routes --after +``` + +**Routes-specific flags:** +- `--metric ` — navigation metric(s) to display, can be repeated. Defaults to all three. +- `--stat ` — statistic(s) per metric. Aliases: `med` → `median`, `event_count` / `eventCount` → `count`. +- `--limit ` — routes per page (default **50**, max **200**, different from `metrics`/`events` which default to 10). +- `--after ` — pagination cursor from the previous run. +- `--app-version ` — filter by app version string. +- `--build-number ` — filter by app build number (routes-only). +- `--route-name ` — filter by route name. Repeatable; only the listed routes are returned across both platforms. Duplicates are de-duplicated; omitting the flag returns all routes. +- `--update-id ` — filter by EAS update ID. + +**Default stats:** `median` + `count` in the table; `median`, `p90`, `count` in JSON. + +**Table layout:** +- Summary header with the chosen stats and time range, e.g. `Med, P90 values (navigation count) for the last 7 days`. +- Separate iOS and Android sections. +- First column is **Route**, followed by one column per metric/stat. With both display stats and `count`, cells are merged like `0.32s (1240)`. +- Each platform has its own pagination hint: `Next page (iOS): --after `. + +**JSON output shape:** +```json +{ + "routes": [ + { + "routeName": "(tabs)/home", + "platform": "IOS", + "metrics": { + "expo.navigation.cold_ttr": { "median": 0.32, "p90": 0.85, "count": 1240 }, + "expo.navigation.tti": { "median": 0.55, "p90": 1.10, "count": 1240 } + } + } + ], + "pageInfoByPlatform": { + "IOS": { "hasNextPage": true, "endCursor": "..." }, + "ANDROID": { "hasNextPage": false, "endCursor": null } + } +} +``` + +## `eas observe:events` + +Shows custom events emitted by the app via the `logEvent` API in `expo-observe`. Behavior depends on what is passed: + +| Invocation | Result | +|---|---| +| `observe:events` | Summary table of available event names with counts | +| `observe:events --all-events` | Full list of events across **all** event names | +| `observe:events ` | Full list of events filtered by that event name | + +```bash +# List the available custom event names and their counts (last 60 days) +eas observe:events + +# All events across all names, last 7 days, iOS only +eas observe:events --all-events --days 7 --platform ios + +# Only events with the given name +eas observe:events login_failed --limit 50 + +# Drill into a single session +eas observe:events --all-events --session-id + +# Pagination +eas observe:events login_failed --after +``` + +**Events-specific flags:** +- `--all-events` — when no event name argument is given, list all events instead of the name summary. Cannot be combined with an event name argument. +- `--session-id ` — filter to events from a single session (events-only). With no event name argument, this lists the session's events instead of the event-name summary. For the full timeline — metrics as well as log events — use `observe:session`. +- `--app-version ` — filter by app version string +- `--update-id ` — filter by EAS update ID +- `--limit ` — events per page (default 10, max 100) +- `--after ` — pagination cursor + +**Table layout (event listings):** +- Summary header: ` events