Skip to content

Added support for expo#212

Closed
Jackson57279 wants to merge 2 commits intomasterfrom
feat/add-expo-support
Closed

Added support for expo#212
Jackson57279 wants to merge 2 commits intomasterfrom
feat/add-expo-support

Conversation

@Jackson57279
Copy link
Copy Markdown
Collaborator

@Jackson57279 Jackson57279 commented Jan 16, 2026


Summary by cubic

Added first-class Expo/React Native support with four preview modes: web, Expo Go (QR), Android emulator (VNC), and EAS build. Also replaced rate limit fallback from Vercel AI Gateway to OpenRouter.

  • New Features

    • Expo added to framework selector and detection; code agent selects Expo prompts per preview mode.
    • New agents: EAS build (trigger/status/cancel) and Expo QR generator.
    • Sandbox templates: zapdev-expo-web, zapdev-expo-full (tunnel for Expo Go), zapdev-expo-android (emulator + VNC), with Dockerfiles and start script.
    • Sandbox utils updated for Expo templates, ports (8081, 5900), and dev commands.
    • Convex schema: frameworkEnum includes EXPO; new fields for preview mode, QR/VNC/EAS URLs, APK URL.
    • Usage limits: tier-based gating for web/expo-go/emulator/EAS with daily quotas.
    • UI: ExpoPreviewSelector component for choosing preview mode.
    • Docs: EXPO_INTEGRATION.md covering modes, SDKs, templates, and troubleshooting.
    • Dependencies: added qrcode and @types/qrcode.
  • Migration

    • Set EXPO_ACCESS_TOKEN in environment for EAS builds.
    • Removed VERCEL_AI_GATEWAY_API_KEY; rate limit fallback now uses OpenRouter.

Written for commit 3a96c67. Summary will update on new commits.

Summary by CodeRabbit

  • New Features

    • Expo/React Native support with four preview modes (Web, Expo Go, Android Emulator, EAS Build)
    • Tier-aware preview selector UI and enforced preview mode limits per subscription tier
    • QR code & deep-link generation for mobile previews
    • EAS Build integration for native app builds and download links
    • New sandbox templates for Expo (web, full, android)
  • Chores

    • Added qrcode dependency
  • Documentation

    • Added comprehensive Expo integration guide

✏️ Tip: You can customize this high-level summary in your review settings.


Note

Introduces comprehensive Expo/React Native support with multiple preview modes and related infrastructure changes.

  • Adds Expo to framework detection/prompts; code agent selects mode-specific Expo prompts and handles framework in generation flow
  • New sandbox templates: zapdev-expo-web, zapdev-expo-full (tunnel for Expo Go), zapdev-expo-android (emulator + VNC) with Dockerfiles and start script
  • Updates sandbox utils for Expo templates, ports (8081, 5900), and dev commands
  • Expands Convex schema/enums to include EXPO and fragment fields (expoPreviewMode, expoQrCodeUrl, expoVncUrl, expoEasBuildUrl, expoApkUrl); import and sandboxSessions accept EXPO
  • Adds tiered usage limits for Expo modes (web/expo-go/emulator/eas-build)
  • New agents: EAS build (trigger/status/cancel) and Expo QR code generator; adds qrcode and @types/qrcode
  • Replaces Vercel AI Gateway fallback with OpenRouter for Cerebras models; updates provider options and tests
  • UI: ExpoPreviewSelector component; docs: EXPO_INTEGRATION.md; env example cleanup and notes for EXPO_ACCESS_TOKEN
  • CI: adds .github/workflows/opencode.yml to run OpenCode on comments

Written by Cursor Bugbot for commit 3a96c67. Configure here.

@vercel
Copy link
Copy Markdown

vercel bot commented Jan 16, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Review Updated (UTC)
zapdev Ready Ready Preview, Comment Jan 19, 2026 0:35am

@codecapyai
Copy link
Copy Markdown

codecapyai bot commented Jan 16, 2026

CodeCapy Review ₍ᐢ•(ܫ)•ᐢ₎

Codebase Summary

ZapDev is an AI-powered development platform that provides users with real-time, conversational development of web applications using Next.js, React, and now Expo for cross-platform mobile development. The application offers live previews, file explorers, and integrated build environments (E2B sandboxes) among many other features.

PR Changes

This pull request adds comprehensive support for Expo integration. It updates backend types and schema to include the 'EXPO' framework and several preview modes (web, expo-go, android-emulator, eas-build). Changes include updates to package lock files, new Expo-specific prompts for the code agent, updates to the framework selector, new UI components (ExpoPreviewSelector), and several sandbox template configurations for Expo (expo-android, expo-full, expo-web). Overall, it enables users to build mobile and cross-platform apps with multiple preview modes.

Setup Instructions

  1. Install pnpm globally by running: sudo npm install -g pnpm
  2. Clone the repository and navigate into it: cd ZapDev
  3. Install dependencies using: pnpm install
  4. Build the E2B sandbox template as per the instructions in the README (ensure Docker is running and follow the steps to build the desired template)
  5. Set up environment variables by copying env.example to .env and filling in necessary API keys and URLs
  6. Start the development server with: pnpm dev
  7. Open a browser and navigate to http://localhost:3000 to view the web application
  8. For Expo-specific testing, follow the instructions in the Expo integration documentation to launch sandbox environments for different preview modes

Generated Test Cases

1: Framework Selector Returns 'expo' for Mobile App Requests ❗️❗️❗️

Description: Tests that when a user inputs a mobile-specific request (e.g., 'Build a mobile todo app for iOS and Android'), the framework selector correctly identifies and returns 'expo'. This ensures that Expo becomes the default framework choice for mobile app requests.

Prerequisites:

  • User is on the framework selector page where the prompt is displayed.

Steps:

  1. Navigate to the framework selector screen in the application.
  2. Enter a query such as 'Build a mobile todo app for iOS and Android' in the input field.
  3. Submit the query.
  4. Observe the framework name returned by the system.

Expected Result: The system should respond with the exact framework name 'expo', indicating that the framework identification logic correctly detects mobile app requirements.

2: Expo Preview Selector Component - Free Tier Options ❗️❗️❗️

Description: Verifies that the Expo Preview Selector component correctly displays available preview modes and disables options that are locked for the free tier. This is important to ensure correct presentation of features based on subscription tiers.

Prerequisites:

  • User is logged in with a free tier account.
  • The Expo Preview Selector component is rendered on the appropriate page.

Steps:

  1. Open the page that contains the Expo Preview Selector.
  2. Observe the preview options displayed: 'Web Preview' and 'Expo Go (QR Code)' should be selectable, while 'Android Emulator' and 'EAS Build (Production)' should appear grayed out (locked) with a lock badge.
  3. Click on 'Web Preview' and then 'Expo Go (QR Code)' ensuring they are selectable.
  4. Attempt to click on the disabled options and verify that no selection is made.

Expected Result: The component must show only 'Web Preview' and 'Expo Go' as selectable for free tier users, while 'Android Emulator' and 'EAS Build' are displayed in a disabled state with appropriate lock badges and opacity changes.

3: Expo Prompt Generation in Code Agent Based on Preview Mode ❗️❗️❗️

Description: Checks that the code agent generates the correct Expo prompt based on the selected preview mode. This confirms that the system picks the correct instructions (EXPO_WEB_PROMPT for 'web' and EXPO_NATIVE_PROMPT for native modes) for rendering the mobile application.

Prerequisites:

  • User has selected 'expo' as the framework in the framework selector.
  • A preview mode is selected (e.g., 'web' and 'expo-go') from the Expo Preview Selector component.

Steps:

  1. Trigger the AI code generation process with a prompt requesting an Expo project.
  2. For 'web' preview mode, inspect the prompt that is passed to the code generation function.
  3. Similarly, for 'expo-go' or 'android-emulator', verify that the corresponding native prompt is used.
  4. Check that the response contains Expo-specific instructions (e.g., usage of React Native components, StyleSheet.create, and Expo SDK module references).

Expected Result: The generated prompt should match the EXPO_WEB_PROMPT or EXPO_NATIVE_PROMPT accordingly, ensuring that the code agent tailors its behavior based on the selected Expo preview mode.

4: Expo QR Code Generation and Display for Expo Go Preview ❗️❗️

Description: Ensures that the QR code generation functionality works and displays a valid QR code image for the Expo Go preview mode. This improves user experience for testing mobile apps via scanning with Expo Go on a real device.

Prerequisites:

  • User has selected 'expo-go' in the Expo Preview Selector.
  • The sandbox is running and has a valid URL for the Expo Go preview.

Steps:

  1. Navigate to the Expo preview page corresponding to the 'expo-go' mode.
  2. Wait for the QR code to be generated and displayed on the page.
  3. Verify that the QR code is visible and that its image source is a data URL starting with 'data:image/png;base64,'.
  4. Optionally, check that clicking on the QR code or a related link shows additional details (if applicable).

Expected Result: The QR code should display properly as a base64 encoded PNG image, enabling users to scan it with the Expo Go app. The image URL should begin with 'data:image/png;base64,' indicating correct generation.

5: Correct E2B Sandbox Template Selection for Expo Preview Modes ❗️❗️

Description: Validates that the system chooses the correct E2B sandbox template based on the selected Expo preview mode. This affects how the sandbox environment is configured and launched for different Expo experiences.

Prerequisites:

  • User has selected 'expo' as the framework and chosen a preview mode (e.g., 'web', 'expo-go', or 'android-emulator').

Steps:

  1. Trigger the sandbox creation process for an Expo project in the selected preview mode.
  2. Observe the command or configuration used to select the E2B template.
  3. Verify that for 'android-emulator', the template used is 'zapdev-expo-android'; for 'expo-go', the template is 'zapdev-expo-full'; and for 'web', the template is 'zapdev-expo-web'.

Expected Result: The system must select and use the correct E2B sandbox template corresponding to the chosen Expo preview mode, ensuring that the environment matches the preview requirements.

Raw Changes Analyzed
File: bun.lock
Changes:
@@ -66,7 +66,6 @@
         "e2b": "^2.9.0",
         "embla-carousel-react": "^8.6.0",
         "eslint-config-next": "^16.1.1",
-        "exa-js": "^2.0.12",
         "firecrawl": "^4.10.0",
         "input-otp": "^1.4.2",
         "jest": "^30.2.0",
@@ -76,6 +75,7 @@
         "next-themes": "^0.4.6",
         "npkill": "^0.12.2",
         "prismjs": "^1.30.0",
+        "qrcode": "^1.5.4",
         "random-word-slugs": "^0.1.7",
         "react": "^19.2.3",
         "react-day-picker": "^9.13.0",
@@ -101,6 +101,7 @@
         "@tailwindcss/postcss": "^4.1.18",
         "@types/node": "^24.10.4",
         "@types/prismjs": "^1.26.5",
+        "@types/qrcode": "^1.5.6",
         "@types/react": "^19.2.7",
         "@types/react-dom": "^19.2.3",
         "eslint": "^9.39.2",
@@ -1026,6 +1027,8 @@
 
     "@types/prismjs": ["@types/prismjs@1.26.5", "", {}, "sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ=="],
 
+    "@types/qrcode": ["@types/qrcode@1.5.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw=="],
+
     "@types/react": ["@types/react@19.2.7", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg=="],
 
     "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
@@ -1350,8 +1353,6 @@
 
     "crc": ["crc@4.3.2", "", { "peerDependencies": { "buffer": ">=6.0.3" }, "optionalPeers": ["buffer"] }, "sha512-uGDHf4KLLh2zsHa8D8hIQ1H/HtFQhyHrc0uhHBcoKGol/Xnb+MPYfUMw7cvON6ze/GUESTudKayDcJC5HnJv1A=="],
 
-    "cross-fetch": ["cross-fetch@4.1.0", "", { "dependencies": { "node-fetch": "^2.7.0" } }, "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw=="],
-
     "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
 
     "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
@@ -1536,8 +1537,6 @@
 
     "eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="],
 
-    "exa-js": ["exa-js@2.0.12", "", { "dependencies": { "cross-fetch": "~4.1.0", "dotenv": "~16.4.7", "openai": "^5.0.1", "zod": "^3.22.0", "zod-to-json-schema": "^3.20.0" } }, "sha512-56ZYm8FLKAh3JXCptr0vlG8f39CZxCl4QcPW9QR4TSKS60PU12pEfuQdf+6xGWwQp+doTgXguCqqzxtvgDTDKw=="],
-
     "execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="],
 
     "exit-x": ["exit-x@0.2.2", "", {}, "sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ=="],
@@ -2042,8 +2041,6 @@
 
     "open-file-explorer": ["open-file-explorer@1.0.2", "", {}, "sha512-U4p+VW5uhtgK5W7qSsRhKioYAHCiTX9PiqV4ZtAFLMGfQ3QhppaEevk8k8+DSjM6rgc1yNIR2nttDuWfdNnnJQ=="],
 
-    "openai": ["openai@5.23.2", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.23.8" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-MQBzmTulj+MM5O8SKEk/gL8a7s5mktS9zUtAkU257WjvobGc9nKcBuVwjyEEcb9SI8a8Y2G/mzn3vm9n1Jlleg=="],
-
     "openapi-fetch": ["openapi-fetch@0.14.1", "", { "dependencies": { "openapi-typescript-helpers": "^0.0.15" } }, "sha512-l7RarRHxlEZYjMLd/PR0slfMVse2/vvIAGm75/F7J6MlQ8/b9uUQmUF2kCPrQhJqMXSxmYWObVgeYXbFYzZR+A=="],
 
     "openapi-typescript-helpers": ["openapi-typescript-helpers@0.0.15", "", {}, "sha512-opyTPaunsklCBpTK8JGef6mfPhLSnyy5a0IN9vKtx3+4aExf+KxEqYwIy3hqkedXIB97u357uLMJsOnm3GVjsw=="],
@@ -2732,10 +2729,6 @@
 
     "eslint-plugin-react-hooks/zod": ["zod@4.1.12", "", {}, "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ=="],
 
-    "exa-js/dotenv": ["dotenv@16.4.7", "", {}, "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ=="],
-
-    "exa-js/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
-
     "execa/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
 
     "express/cookie": ["cookie@0.7.1", "", {}, "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w=="],

File: convex/importData.ts
Changes:
@@ -16,7 +16,8 @@ export const importProject = internalMutation({
       v.literal("ANGULAR"),
       v.literal("REACT"),
       v.literal("VUE"),
-      v.literal("SVELTE")
+      v.literal("SVELTE"),
+      v.literal("EXPO")
     ),
     createdAt: v.string(), // ISO date string
     updatedAt: v.string(), // ISO date string
@@ -89,7 +90,8 @@ export const importFragment = internalMutation({
       v.literal("ANGULAR"),
       v.literal("REACT"),
       v.literal("VUE"),
-      v.literal("SVELTE")
+      v.literal("SVELTE"),
+      v.literal("EXPO")
     ),
     createdAt: v.string(),
     updatedAt: v.string(),
@@ -130,7 +132,8 @@ export const importFragmentDraft = internalMutation({
       v.literal("ANGULAR"),
       v.literal("REACT"),
       v.literal("VUE"),
-      v.literal("SVELTE")
+      v.literal("SVELTE"),
+      v.literal("EXPO")
     ),
     createdAt: v.string(),
     updatedAt: v.string(),
@@ -278,7 +281,8 @@ export const importProjectAction = action({
       v.literal("ANGULAR"),
       v.literal("REACT"),
       v.literal("VUE"),
-      v.literal("SVELTE")
+      v.literal("SVELTE"),
+      v.literal("EXPO")
     ),
     createdAt: v.string(),
     updatedAt: v.string(),
@@ -320,7 +324,8 @@ export const importFragmentAction = action({
       v.literal("ANGULAR"),
       v.literal("REACT"),
       v.literal("VUE"),
-      v.literal("SVELTE")
+      v.literal("SVELTE"),
+      v.literal("EXPO")
     ),
     createdAt: v.string(),
     updatedAt: v.string(),
@@ -343,7 +348,8 @@ export const importFragmentDraftAction = action({
       v.literal("ANGULAR"),
       v.literal("REACT"),
       v.literal("VUE"),
-      v.literal("SVELTE")
+      v.literal("SVELTE"),
+      v.literal("EXPO")
     ),
     createdAt: v.string(),
     updatedAt: v.string(),

File: convex/sandboxSessions.ts
Changes:
@@ -16,7 +16,8 @@ export const create = mutation({
       v.literal("ANGULAR"),
       v.literal("REACT"),
       v.literal("VUE"),
-      v.literal("SVELTE")
+      v.literal("SVELTE"),
+      v.literal("EXPO")
     ),
     autoPauseTimeout: v.optional(v.number()), // Default 10 minutes
   },

File: convex/schema.ts
Changes:
@@ -6,7 +6,15 @@ export const frameworkEnum = v.union(
   v.literal("ANGULAR"),
   v.literal("REACT"),
   v.literal("VUE"),
-  v.literal("SVELTE")
+  v.literal("SVELTE"),
+  v.literal("EXPO")
+);
+
+export const expoPreviewModeEnum = v.union(
+  v.literal("web"),
+  v.literal("expo-go"),
+  v.literal("android-emulator"),
+  v.literal("eas-build")
 );
 
 export const messageRoleEnum = v.union(
@@ -115,6 +123,11 @@ export default defineSchema({
     files: v.any(),
     metadata: v.optional(v.any()),
     framework: frameworkEnum,
+    expoPreviewMode: v.optional(expoPreviewModeEnum),
+    expoQrCodeUrl: v.optional(v.string()),
+    expoVncUrl: v.optional(v.string()),
+    expoEasBuildUrl: v.optional(v.string()),
+    expoApkUrl: v.optional(v.string()),
     createdAt: v.optional(v.number()),
     updatedAt: v.optional(v.number()),
   })

File: convex/usage.ts
Changes:
@@ -9,6 +9,59 @@ const UNLIMITED_POINTS = Number.MAX_SAFE_INTEGER;
 const DURATION_MS = 24 * 60 * 60 * 1000; // 24 hours in milliseconds
 const GENERATION_COST = 1;
 
+// Expo-specific limits by tier
+export const EXPO_LIMITS = {
+  free: {
+    webPreview: true,
+    expoGo: true,
+    androidEmulator: false,
+    easBuild: false,
+    maxBuildsPerDay: 5,
+    maxEmulatorMinutes: 0
+  },
+  pro: {
+    webPreview: true,
+    expoGo: true,
+    androidEmulator: true,
+    easBuild: true,
+    maxBuildsPerDay: 50,
+    maxEmulatorMinutes: 120 // 2 hours per day
+  },
+  enterprise: {
+    webPreview: true,
+    expoGo: true,
+    androidEmulator: true,
+    easBuild: true,
+    maxBuildsPerDay: 500,
+    maxEmulatorMinutes: 600 // 10 hours per day
+  }
+} as const;
+
+export type ExpoPreviewMode = 'web' | 'expo-go' | 'android-emulator' | 'eas-build';
+export type UserTier = 'free' | 'pro' | 'enterprise';
+
+/**
+ * Check if user can use a specific Expo preview mode
+ */
+export function canUseExpoPreviewMode(
+  tier: UserTier,
+  mode: ExpoPreviewMode
+): boolean {
+  const limits = EXPO_LIMITS[tier];
+  switch (mode) {
+    case 'web':
+      return limits.webPreview;
+    case 'expo-go':
+      return limits.expoGo;
+    case 'android-emulator':
+      return limits.androidEmulator;
+    case 'eas-build':
+      return limits.easBuild;
+    default:
+      return false;
+  }
+}
+
 /**
  * Check and consume credits for a generation
  * Returns true if credits were successfully consumed, false if insufficient credits

File: explanations/EXPO_INTEGRATION.md
Changes:
@@ -0,0 +1,206 @@
+# Expo/React Native Integration
+
+ZapDev supports Expo/React Native for cross-platform mobile app development with multiple preview modes.
+
+## Overview
+
+Expo enables building iOS, Android, and web apps from a single codebase using React Native. ZapDev integrates Expo with 4 distinct preview modes to support different development and testing scenarios.
+
+## Preview Modes
+
+### 1. Web Preview (Free Tier)
+- **Speed:** ~30 seconds
+- **Description:** Uses `react-native-web` for fast browser-based preview
+- **Limitations:** No native APIs (camera, location, haptics, etc.)
+- **Best for:** Quick prototyping, UI development, web-compatible features
+
+### 2. Expo Go QR Code (Free Tier)
+- **Speed:** ~1-2 minutes
+- **Description:** Generate a QR code that users scan with the Expo Go app
+- **Limitations:** Limited to Expo SDK modules, no custom native code
+- **Best for:** Real device testing, sharing demos with stakeholders
+
+### 3. Android Emulator (Pro Tier)
+- **Speed:** ~3-5 minutes
+- **Description:** Full Android emulator running in E2B with VNC access
+- **Limitations:** Requires Pro subscription, higher resource usage
+- **Best for:** Full Android testing, GPU-dependent features, native APIs
+
+### 4. EAS Build (Pro Tier)
+- **Speed:** ~5-15 minutes
+- **Description:** Cloud builds via Expo Application Services
+- **Output:** Installable APK (Android) or IPA (iOS) files
+- **Best for:** Production releases, App Store/Play Store submissions
+
+## Framework Detection
+
+ZapDev automatically detects Expo projects from user prompts containing:
+- "mobile app", "iOS", "Android"
+- "React Native", "Expo"
+- "cross-platform", "native app"
+- "phone app"
+
+## AI Prompt Guidelines
+
+When generating Expo code, the AI follows these rules:
+
+1. **Components:** Use React Native components (View, Text, TouchableOpacity, etc.)
+2. **Styling:** Use `StyleSheet.create()` - NO CSS files, NO className, NO Tailwind
+3. **Imports:** `import { View, Text } from 'react-native'`
+4. **Entry Point:** `App.tsx` as the root component
+5. **Navigation:** Use `expo-router` for multi-screen apps
+
+### Example Component
+
+```tsx
+import { StyleSheet, View, Text, TouchableOpacity } from 'react-native';
+import { StatusBar } from 'expo-status-bar';
+
+export default function App() {
+  return (
+    <View style={styles.container}>
+      <Text style={styles.title}>Hello Expo</Text>
+      <TouchableOpacity style={styles.button}>
+        <Text style={styles.buttonText}>Press Me</Text>
+      </TouchableOpacity>
+      <StatusBar style="auto" />
+    </View>
+  );
+}
+
+const styles = StyleSheet.create({
+  container: {
+    flex: 1,
+    backgroundColor: '#fff',
+    alignItems: 'center',
+    justifyContent: 'center',
+  },
+  title: {
+    fontSize: 24,
+    fontWeight: 'bold',
+    marginBottom: 20,
+  },
+  button: {
+    backgroundColor: '#007AFF',
+    paddingHorizontal: 20,
+    paddingVertical: 12,
+    borderRadius: 8,
+  },
+  buttonText: {
+    color: '#fff',
+    fontSize: 16,
+    fontWeight: '600',
+  },
+});
+```
+
+## Expo SDK Modules
+
+### Pre-installed (All Templates)
+- `expo-status-bar` - Status bar control
+- `expo-font` - Custom fonts
+- `expo-linear-gradient` - Gradient backgrounds
+- `expo-blur` - Blur effects
+
+### Available via `npx expo install`
+- `expo-camera` - Camera access
+- `expo-image-picker` - Photo library/camera capture
+- `expo-location` - GPS/location
+- `expo-haptics` - Haptic feedback
+- `expo-notifications` - Push notifications
+- `expo-file-system` - File operations
+- `expo-av` - Audio/video playback
+- `expo-sensors` - Accelerometer, gyroscope
+- `expo-secure-store` - Secure storage
+- `expo-sqlite` - Local database
+
+## Web Compatibility
+
+When using Web Preview mode, these components are **NOT available**:
+- `expo-camera`
+- `expo-location`
+- `expo-haptics`
+- `expo-sensors`
+- `expo-notifications` (limited)
+- `expo-secure-store`
+
+### Web Alternatives
+- **Camera:** Use `<input type="file" accept="image/*" capture>`
+- **Location:** Use `navigator.geolocation`
+- **Storage:** Use AsyncStorage or localStorage
+
+## E2B Sandbox Templates
+
+### zapdev-expo-web
+- Base: `node:21-slim`
+- Pre-installed: react-native-web, @expo/metro-runtime
+- Port: 8081 (Metro bundler)
+- Command: `npx expo start --web`
+
+### zapdev-expo-full
+- Base: `node:21-slim`
+- Pre-installed: All Expo SDK modules
+- Port: 8081 (with tunnel for Expo Go)
+- Command: `npx expo start --tunnel`
+
+### zapdev-expo-android
+- Base: `ubuntu:22.04`
+- Includes: Android SDK, emulator, VNC server
+- Ports: 5900 (VNC), 8081 (Metro), 5555 (ADB)
+- Resources: 4 vCPU, 8GB RAM
+
+## Subscription Tiers
+
+| Feature | Free | Pro | Enterprise |
+|---------|------|-----|------------|
+| Web Preview | ✅ | ✅ | ✅ |
+| Expo Go (QR) | ✅ | ✅ | ✅ |
+| Android Emulator | ❌ | ✅ | ✅ |
+| EAS Build | ❌ | ✅ | ✅ |
+| Max Builds/Day | 5 | 50 | 500 |
+| Emulator Minutes/Day | 0 | 120 | 600 |
+
+## Environment Variables
+
+For EAS Build support, add to `.env`:
+```bash
+EXPO_ACCESS_TOKEN=your_expo_token_here
+```
+
+Get your token from: https://expo.dev/settings/access-tokens
+
+## Troubleshooting
+
+### Web Preview Shows Blank Screen
+- Ensure you're using web-compatible components
+- Check console for `react-native-web` errors
+- Avoid native-only modules
+
+### Expo Go QR Not Working
+- Verify tunnel is running (`--tunnel` flag)
+- Check network connectivity
+- Ensure Expo Go app is up to date
+
+### Android Emulator Not Starting
+- Requires Pro tier subscription
+- VNC may take 30-60s to initialize
+- Check if KVM is available on E2B
+
+### EAS Build Failing
+- Verify `EXPO_ACCESS_TOKEN` is set
+- Check `eas.json` configuration
+- Ensure `app.json` has required fields (slug, version)
+
+## Example Prompts
+
+1. "Build a mobile todo app for iOS and Android"
+2. "Create a React Native camera app"
+3. "Make a cross-platform fitness tracker"
+4. "Build an Expo app with location tracking"
+5. "Create a mobile social media feed"
+
+## Related Documentation
+
+- [Expo Official Docs](https://docs.expo.dev)
+- [React Native Docs](https://reactnative.dev)
+- [E2B Expo Template](https://e2b.dev/docs/template/examples/expo)

File: package.json
Changes:
@@ -73,7 +73,6 @@
     "e2b": "^2.9.0",
     "embla-carousel-react": "^8.6.0",
     "eslint-config-next": "^16.1.1",
-
     "firecrawl": "^4.10.0",
     "input-otp": "^1.4.2",
     "jest": "^30.2.0",
@@ -83,6 +82,7 @@
     "next-themes": "^0.4.6",
     "npkill": "^0.12.2",
     "prismjs": "^1.30.0",
+    "qrcode": "^1.5.4",
     "random-word-slugs": "^0.1.7",
     "react": "^19.2.3",
     "react-day-picker": "^9.13.0",
@@ -108,6 +108,7 @@
     "@tailwindcss/postcss": "^4.1.18",
     "@types/node": "^24.10.4",
     "@types/prismjs": "^1.26.5",
+    "@types/qrcode": "^1.5.6",
     "@types/react": "^19.2.7",
     "@types/react-dom": "^19.2.3",
     "eslint": "^9.39.2",

File: sandbox-templates/expo-android/e2b.Dockerfile
Changes:
@@ -0,0 +1,56 @@
+# Expo Android Emulator Template with VNC
+FROM ubuntu:22.04
+
+ENV DEBIAN_FRONTEND=noninteractive
+
+# Install base dependencies
+RUN apt-get update && apt-get install -y \
+    curl wget git unzip openjdk-17-jdk \
+    x11vnc xvfb fluxbox \
+    qemu-kvm libvirt-daemon-system libvirt-clients bridge-utils \
+    supervisor \
+    && apt-get clean && rm -rf /var/lib/apt/lists/*
+
+# Install Node.js 21
+RUN curl -fsSL https://deb.nodesource.com/setup_21.x | bash - \
+    && apt-get install -y nodejs
+
+# Set up Android SDK
+ENV ANDROID_HOME=/opt/android-sdk
+ENV ANDROID_SDK_ROOT=/opt/android-sdk
+ENV PATH=$PATH:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator
+
+RUN mkdir -p $ANDROID_HOME/cmdline-tools \
+    && cd $ANDROID_HOME/cmdline-tools \
+    && wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -O cmdline-tools.zip \
+    && unzip -q cmdline-tools.zip \
+    && mv cmdline-tools latest \
+    && rm cmdline-tools.zip
+
+# Accept licenses and install Android SDK components
+RUN yes | sdkmanager --licenses > /dev/null 2>&1 || true
+RUN sdkmanager "platform-tools" "platforms;android-34" "emulator" "system-images;android-34;google_apis;x86_64"
+
+# Create AVD (Android Virtual Device)
+RUN echo no | avdmanager create avd -n expo_emulator -k "system-images;android-34;google_apis;x86_64" --force
+
+WORKDIR /home/user
+
+# Create Expo project
+RUN npx create-expo-app@latest . --template blank-typescript --yes
+
+# Install dependencies
+RUN npm install react-dom react-native-web @expo/metro-runtime
+RUN npx expo install expo-font expo-linear-gradient expo-blur expo-status-bar expo-camera expo-image-picker expo-location expo-haptics
+
+# Install global tools
+RUN npm install -g @expo/cli eas-cli
+
+# Copy start script
+COPY start_android.sh /start_android.sh
+RUN chmod +x /start_android.sh
+
+# Expose ports: VNC(5900), ADB(5555), Metro(8081), Expo(19000-19002)
+EXPOSE 5900 5555 8081 19000 19001 19002
+
+CMD ["/start_android.sh"]

File: sandbox-templates/expo-android/e2b.toml
Changes:
@@ -0,0 +1,15 @@
+# E2B Sandbox Template Configuration for Expo Android Emulator
+
+# Template name used when creating sandboxes
+template_id = "zapdev-expo-android"
+
+# Dockerfile to build the template
+dockerfile = "e2b.Dockerfile"
+
+# Start command (runs when sandbox starts)
+start_cmd = "/start_android.sh"
+
+# Template resource configuration (higher specs for emulator)
+[resources]
+cpu_count = 4
+memory_mb = 8192

File: sandbox-templates/expo-android/start_android.sh
Changes:
@@ -0,0 +1,47 @@
+#!/bin/bash
+
+# Start virtual display
+echo "[INFO] Starting virtual display..."
+Xvfb :99 -screen 0 1280x720x24 &
+export DISPLAY=:99
+
+# Wait for Xvfb to start
+sleep 2
+
+# Start window manager
+echo "[INFO] Starting window manager..."
+fluxbox &
+
+# Start VNC server
+echo "[INFO] Starting VNC server on port 5900..."
+x11vnc -display :99 -forever -shared -rfbport 5900 -nopw &
+
+# Wait for display services
+sleep 2
+
+# Start Android emulator
+echo "[INFO] Starting Android emulator..."
+$ANDROID_HOME/emulator/emulator -avd expo_emulator \
+    -no-audio \
+    -no-boot-anim \
+    -gpu swiftshader_indirect \
+    -no-snapshot \
+    -memory 2048 \
+    -cores 2 &
+
+# Wait for emulator to boot
+echo "[INFO] Waiting for emulator to boot..."
+adb wait-for-device
+
+# Wait for boot completion
+echo "[INFO] Waiting for boot completion..."
+while [[ -z $(adb shell getprop sys.boot_completed 2>/dev/null | tr -d '\r') ]]; do
+    sleep 2
+done
+
+echo "[INFO] Emulator ready!"
+
+# Start Expo Metro bundler with Android
+cd /home/user
+echo "[INFO] Starting Expo development server..."
+npx expo start --android --port 8081 --host 0.0.0.0

File: sandbox-templates/expo-full/e2b.Dockerfile
Changes:
@@ -0,0 +1,23 @@
+# Expo Full Template (Web + Expo Go support with tunnel)
+FROM node:21-slim
+
+RUN apt-get update && apt-get install -y curl git qrencode && apt-get clean && rm -rf /var/lib/apt/lists/*
+
+WORKDIR /home/user
+
+# Create Expo app with TypeScript blank template
+RUN npx create-expo-app@latest . --template blank-typescript --yes
+
+# Install web dependencies
+RUN npm install react-dom react-native-web @expo/metro-runtime
+
+# Install common Expo SDK modules
+RUN npx expo install expo-font expo-linear-gradient expo-blur expo-status-bar expo-camera expo-image-picker expo-location expo-haptics
+
+# Install Expo CLI globally for tunnel support
+RUN npm install -g @expo/cli eas-cli
+
+WORKDIR /home/user
+
+# Start Metro bundler with tunnel for Expo Go access
+CMD ["npx", "expo", "start", "--port", "8081", "--host", "0.0.0.0", "--tunnel"]

File: sandbox-templates/expo-full/e2b.toml
Changes:
@@ -0,0 +1,15 @@
+# E2B Sandbox Template Configuration for Expo Full (Web + Expo Go)
+
+# Template name used when creating sandboxes
+template_id = "zapdev-expo-full"
+
+# Dockerfile to build the template
+dockerfile = "e2b.Dockerfile"
+
+# Start command (runs when sandbox starts)
+start_cmd = "npx expo start --port 8081 --host 0.0.0.0 --tunnel"
+
+# Template resource configuration
+[resources]
+cpu_count = 2
+memory_mb = 2048

File: sandbox-templates/expo-web/e2b.Dockerfile
Changes:
@@ -0,0 +1,20 @@
+# Expo Web Preview Template
+FROM node:21-slim
+
+RUN apt-get update && apt-get install -y curl git && apt-get clean && rm -rf /var/lib/apt/lists/*
+
+WORKDIR /home/user
+
+# Create Expo app with TypeScript blank template
+RUN npx create-expo-app@latest . --template blank-typescript --yes
+
+# Install web dependencies
+RUN npm install react-dom react-native-web @expo/metro-runtime
+
+# Install common Expo SDK modules
+RUN npx expo install expo-font expo-linear-gradient expo-blur expo-status-bar
+
+WORKDIR /home/user
+
+# Start Metro bundler for web on port 8081
+CMD ["npx", "expo", "start", "--web", "--port", "8081", "--host", "0.0.0.0"]

File: sandbox-templates/expo-web/e2b.toml
Changes:
@@ -0,0 +1,15 @@
+# E2B Sandbox Template Configuration for Expo Web
+
+# Template name used when creating sandboxes
+template_id = "zapdev-expo-web"
+
+# Dockerfile to build the template
+dockerfile = "e2b.Dockerfile"
+
+# Start command (runs when sandbox starts)
+start_cmd = "npx expo start --web --port 8081 --host 0.0.0.0"
+
+# Template resource configuration
+[resources]
+cpu_count = 2
+memory_mb = 2048

File: src/agents/code-agent.ts
Changes:
@@ -12,6 +12,7 @@ import {
   type AgentState,
   type AgentRunInput,
   type ModelId,
+  type ExpoPreviewMode,
   MODEL_CONFIGS,
   selectModelForTask,
   frameworkToConvexEnum,
@@ -37,6 +38,9 @@ import {
   REACT_PROMPT,
   VUE_PROMPT,
   SVELTE_PROMPT,
+  EXPO_PROMPT,
+  EXPO_WEB_PROMPT,
+  EXPO_NATIVE_PROMPT,
 } from "@/prompt";
 import { sanitizeTextForDatabase } from "@/lib/utils";
 import { filterAIGeneratedFiles } from "@/lib/filter-ai-files";
@@ -111,7 +115,7 @@ const extractSummaryText = (value: string): string => {
   return trimmed;
 };
 
-const getFrameworkPrompt = (framework: Framework): string => {
+const getFrameworkPrompt = (framework: Framework, expoPreviewMode?: ExpoPreviewMode): string => {
   switch (framework) {
     case "nextjs":
       return NEXTJS_PROMPT;
@@ -123,6 +127,11 @@ const getFrameworkPrompt = (framework: Framework): string => {
       return VUE_PROMPT;
     case "svelte":
       return SVELTE_PROMPT;
+    case "expo":
+      // Use appropriate prompt based on preview mode
+      if (expoPreviewMode === "web") return EXPO_WEB_PROMPT;
+      if (expoPreviewMode === "android-emulator" || expoPreviewMode === "expo-go") return EXPO_NATIVE_PROMPT;
+      return EXPO_PROMPT;
     default:
       return NEXTJS_PROMPT;
   }
@@ -157,7 +166,7 @@ async function detectFramework(prompt: string): Promise<Framework> {
 
       const detectedFramework = text.trim().toLowerCase();
       if (
-        ["nextjs", "angular", "react", "vue", "svelte"].includes(detectedFramework)
+        ["nextjs", "angular", "react", "vue", "svelte", "expo"].includes(detectedFramework)
       ) {
         return detectedFramework as Framework;
       }

File: src/agents/eas-build.ts
Changes:
@@ -0,0 +1,257 @@
+import { Sandbox } from "@e2b/code-interpreter";
+import { getSandbox, runCodeCommand } from "./sandbox-utils";
+
+export interface EASBuildConfig {
+  platform: 'android' | 'ios' | 'all';
+  profile: 'development' | 'preview' | 'production';
+  expoToken?: string;
+}
+
+export interface EASBuildResult {
+  buildId: string;
+  buildUrl: string;
+  platform: string;
+  status: 'pending' | 'in-queue' | 'in-progress' | 'finished' | 'errored' | 'canceled';
+}
+
+export interface EASBuildStatus {
+  status: 'pending' | 'in-queue' | 'in-progress' | 'finished' | 'errored' | 'canceled';
+  downloadUrl?: string;
+  artifacts?: {
+    buildUrl?: string;
+    applicationArchiveUrl?: string;
+  };
+  error?: string;
+}
+
+/**
+ * Initialize EAS in a sandbox (creates eas.json if it doesn't exist)
+ */
+export async function initializeEAS(sandbox: Sandbox): Promise<void> {
+  console.log('[INFO] Initializing EAS configuration...');
+  
+  // Check if eas.json exists
+  const checkResult = await runCodeCommand(sandbox, 'test -f eas.json && echo "exists"');
+  
+  if (!checkResult.stdout.includes('exists')) {
+    // Create default eas.json configuration
+    const easConfig = {
+      cli: {
+        version: ">= 13.0.0"
+      },
+      build: {
+        development: {
+          developmentClient: true,
+          distribution: "internal"
+        },
+        preview: {
+          distribution: "internal",
+          android: {
+            buildType: "apk"
+          }
+        },
+        production: {
+          autoIncrement: true
+        }
+      },
+      submit: {
+        production: {}
+      }
+    };
+    
+    // Write eas.json
+    await sandbox.files.write('/home/user/eas.json', JSON.stringify(easConfig, null, 2));
+    console.log('[INFO] Created eas.json configuration');
+  }
+  
+  // Ensure app.json has required fields for EAS
+  try {
+    const appJsonContent = await sandbox.files.read('/home/user/app.json');
+    if (typeof appJsonContent === 'string') {
+      const appJson = JSON.parse(appJsonContent);
+      
+      // Ensure required fields exist
+      if (!appJson.expo) appJson.expo = {};
+      if (!appJson.expo.slug) appJson.expo.slug = 'zapdev-app';
+      if (!appJson.expo.name) appJson.expo.name = 'ZapDev App';
+      if (!appJson.expo.version) appJson.expo.version = '1.0.0';
+      
+      // Add EAS project ID placeholder if not present
+      if (!appJson.expo.extra) appJson.expo.extra = {};
+      if (!appJson.expo.extra.eas) appJson.expo.extra.eas = {};
+      
+      await sandbox.files.write('/home/user/app.json', JSON.stringify(appJson, null, 2));
+      console.log('[INFO] Updated app.json for EAS compatibility');
+    }
+  } catch (error) {
+    console.warn('[WARN] Could not update app.json:', error);
+  }
+}
+
+/**
+ * Trigger an EAS Build
+ */
+export async function triggerEASBuild(
+  sandboxId: string,
+  config: EASBuildConfig
+): Promise<EASBuildResult> {
+  const sandbox = await getSandbox(sandboxId);
+  const expoToken = config.expoToken || process.env.EXPO_ACCESS_TOKEN;
+  
+  if (!expoToken) {
+    throw new Error('EXPO_ACCESS_TOKEN is required for EAS builds. Set it in environment variables.');
+  }
+  
+  // Initialize EAS if needed
+  await initializeEAS(sandbox);
+  
+  console.log(`[INFO] Triggering EAS build for platform: ${config.platform}, profile: ${config.profile}`);
+  
+  // Build the command with proper token handling
+  const buildCommand = `EXPO_TOKEN="${expoToken}" npx eas-cli build --platform ${config.platform} --profile ${config.profile} --non-interactive --json --no-wait`;
+  
+  const result = await runCodeCommand(sandbox, buildCommand);
+  
+  if (result.exitCode !== 0) {
+    console.error('[ERROR] EAS build command failed:', result.stderr);
+    throw new Error(`EAS build failed: ${result.stderr || result.stdout}`);
+  }
+  
+  try {
+    // Parse the JSON output from EAS CLI
+    const output = result.stdout.trim();
+    const jsonMatch = output.match(/\[[\s\S]*\]|\{[\s\S]*\}/);
+    
+    if (!jsonMatch) {
+      throw new Error('Could not parse EAS build output');
+    }
+    
+    const buildData = JSON.parse(jsonMatch[0]);
+    const build = Array.isArray(buildData) ? buildData[0] : buildData;
+    
+    return {
+      buildId: build.id,
+      buildUrl: `https://expo.dev/accounts/${build.accountName || 'user'}/projects/${build.projectId || 'project'}/builds/${build.id}`,
+      platform: build.platform || config.platform,
+      status: build.status || 'pending'
+    };
+  } catch (parseError) {
+    console.error('[ERROR] Failed to parse EAS build output:', result.stdout);
+    throw new Error(`Failed to parse EAS build response: ${parseError instanceof Error ? parseError.message : String(parseError)}`);
+  }
+}
+
+/**
+ * Check the status of an EAS build
+ */
+export async function checkEASBuildStatus(
+  buildId: string,
+  expoToken?: string
+): Promise<EASBuildStatus> {
+  const token = expoToken || process.env.EXPO_ACCESS_TOKEN;
+  
+  if (!token) {
+    throw new Error('EXPO_ACCESS_TOKEN is required to check build status');
+  }
+  
+  try {
+    const response = await fetch(`https://api.expo.dev/v2/builds/${buildId}`, {
+      headers: {
+        'Authorization': `Bearer ${token}`,
+        'Accept': 'application/json'
+      }
+    });
+    
+    if (!response.ok) {
+      throw new Error(`Failed to fetch build status: ${response.status} ${response.statusText}`);
+    }
+    
+    const data = await response.json();
+    
+    return {
+      status: data.status,
+      downloadUrl: data.artifacts?.buildUrl || data.artifacts?.applicationArchiveUrl,
+      artifacts: data.artifacts,
+      error: data.error
+    };
+  } catch (error) {
+    console.error('[ERROR] Failed to check EAS build status:', error);
+    throw new Error(`Failed to check build status: ${error instanceof Error ? error.message : String(error)}`);
+  }
+}
+
+/**
+ * Poll for EAS build completion
+ */
+export async function waitForEASBuild(
+  buildId: string,
+  expoToken?: string,
+  maxWaitMs: number = 15 * 60 * 1000, // 15 minutes default
+  pollIntervalMs: number = 10000 // 10 seconds
+): Promise<EASBuildStatus> {
+  const startTime = Date.now();
+  
+  while (Date.now() - startTime < maxWaitMs) {
+    const status = await checkEASBuildStatus(buildId, expoToken);
+    
+    if (status.status === 'finished') {
+      console.log(`[INFO] EAS build ${buildId} completed successfully`);
+      return status;
+    }
+    
+    if (status.status === 'errored' || status.status === 'canceled') {
+      console.error(`[ERROR] EAS build ${buildId} failed with status: ${status.status}`);
+      throw new Error(`EAS build failed: ${status.error || status.status}`);
+    }
+    
+    console.log(`[DEBUG] EAS build ${buildId} status: ${status.status}`);
+    await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
+  }
+  
+  throw new Error(`EAS build timed out after ${maxWaitMs / 1000} seconds`);
+}
+
+/**
+ * Get the download URL for a completed build
+ */
+export async function getEASBuildDownloadUrl(
+  buildId: string,
+  expoToken?: string
+): Promise<string | null> {
+  const status = await checkEASBuildStatus(buildId, expoToken);
+  
+  if (status.status !== 'finished') {
+    return null;
+  }
+  
+  return status.downloadUrl || null;
+}
+
+/**
+ * Cancel an in-progress EAS build
+ */
+export async function cancelEASBuild(
+  buildId: string,
+  expoToken?: string
+): Promise<boolean> {
+  const token = expoToken || process.env.EXPO_ACCESS_TOKEN;
+  
+  if (!token) {
+    throw new Error('EXPO_ACCESS_TOKEN is required to cancel a build');
+  }
+  
+  try {
+    const response = await fetch(`https://api.expo.dev/v2/builds/${buildId}/cancel`, {
+      method: 'POST',
+      headers: {
+        'Authorization': `Bearer ${token}`,
+        'Accept': 'application/json'
+      }
+    });
+    
+    return response.ok;
+  } catch (error) {
+    console.error('[ERROR] Failed to cancel EAS build:', error);
+    return false;
+  }
+}

File: src/agents/expo-qr.ts
Changes:
@@ -0,0 +1,93 @@
+import QRCode from 'qrcode';
+
+/**
+ * Generate a QR code for Expo Go app to scan
+ * @param sandboxUrl The sandbox URL (e.g., https://8081-abc123.e2b.dev)
+ * @returns Base64 data URL of the QR code image
+ */
+export async function generateExpoGoQR(sandboxUrl: string): Promise<string> {
+  try {
+    // Expo Go expects exp:// protocol URLs
+    const url = new URL(sandboxUrl);
+    const expoUrl = `exp://${url.host}`;
+    
+    // Generate QR code as data URL
+    const qrDataUrl = await QRCode.toDataURL(expoUrl, {
+      width: 400,
+      margin: 2,
+      color: {
+        dark: '#000000',
+        light: '#FFFFFF'
+      },
+      errorCorrectionLevel: 'M'
+    });
+    
+    console.log(`[INFO] Generated Expo Go QR code for: ${expoUrl}`);
+    return qrDataUrl;
+  } catch (error) {
+    console.error('[ERROR] Failed to generate Expo Go QR code:', error);
+    throw new Error(`Failed to generate QR code: ${error instanceof Error ? error.message : String(error)}`);
+  }
+}
+
+/**
+ * Get the official Expo QR code service URL
+ * This uses Expo's hosted service to generate QR codes
+ * @param sandboxUrl The sandbox URL
+ * @returns URL to Expo's QR code service
+ */
+export function getExpoOfficialQRUrl(sandboxUrl: string): string {
+  const encodedUrl = encodeURIComponent(sandboxUrl);
+  return `https://qr.expo.dev/development-client?url=${encodedUrl}`;
+}
+
+/**
+ * Generate QR code for EAS Update (for production apps)
+ * @param projectId Expo project ID
+ * @param channel Update channel (e.g., 'preview', 'production')
+ * @param runtimeVersion The runtime version
+ * @returns URL to Expo's QR code service for the update
+ */
+export function getEASUpdateQRUrl(
+  projectId: string,
+  channel: string = 'preview',
+  runtimeVersion?: string
+): string {
+  let url = `https://qr.expo.dev/eas-update?projectId=${projectId}&channel=${channel}`;
+  if (runtimeVersion) {
+    url += `&runtimeVersion=${encodeURIComponent(runtimeVersion)}`;
+  }
+  return url;
+}
+
+/**
+ * Generate a deep link URL for Expo Go
+ * @param sandboxUrl The sandbox URL
+ * @returns Deep link URL that opens in Expo Go
+ */
+export function getExpoGoDeepLink(sandboxUrl: string): string {
+  const url = new URL(sandboxUrl);
+  return `exp://${url.host}`;
+}
+
+/**
+ * Check if a URL is accessible (for Expo Go tunnel)
+ * @param url The URL to check
+ * @returns Whether the URL is accessible
+ */
+export async function checkUrlAccessible(url: string): Promise<boolean> {
+  try {
+    const controller = new AbortController();
+    const timeoutId = setTimeout(() => controller.abort(), 5000);
+    
+    const response = await fetch(url, {
+      method: 'HEAD',
+      signal: controller.signal
+    });
+    
+    clearTimeout(timeoutId);
+    return response.ok;
+  } catch {
+    return false;
+  }
+}

File: src/agents/sandbox-utils.ts
Changes:
@@ -1,5 +1,5 @@
 import { Sandbox } from "@e2b/code-interpreter";
-import { SANDBOX_TIMEOUT, type Framework } from "./types";
+import { SANDBOX_TIMEOUT, type Framework, type ExpoPreviewMode } from "./types";
 
 const SANDBOX_CACHE = new Map<string, Sandbox>();
 const PROJECT_SANDBOX_MAP = new Map<string, string>();
@@ -307,35 +307,47 @@ export async function readFileFast(
   }
 }
 
-export function getE2BTemplate(framework: Framework): string {
+export function getE2BTemplate(framework: Framework, expoPreviewMode?: ExpoPreviewMode): string {
   switch (framework) {
     case "nextjs": return "zapdev";
     case "angular": return "zapdev-angular";
     case "react": return "zapdev-react";
     case "vue": return "zapdev-vue";
     case "svelte": return "zapdev-svelte";
+    case "expo":
+      if (expoPreviewMode === "android-emulator") return "zapdev-expo-android";
+      if (expoPreviewMode === "expo-go") return "zapdev-expo-full";
+      return "zapdev-expo-web"; // Default to web preview (fastest)
     default: return "zapdev";
   }
 }
 
-export function getFrameworkPort(framework: Framework): number {
+export function getFrameworkPort(framework: Framework, expoPreviewMode?: ExpoPreviewMode): number {
   switch (framework) {
     case "nextjs": return 3000;
     case "angular": return 4200;
     case "react":
     case "vue":
     case "svelte": return 5173;
+    case "expo":
+      if (expoPreviewMode === "android-emulator") return 5900; // VNC port
+      return 8081; // Metro bundler port
     default: return 3000;
   }
 }
 
-export function getDevServerCommand(framework: Framework): string {
+export function getDevServerCommand(framework: Framework, expoPreviewMode?: ExpoPreviewMode): string {
   switch (framework) {
     case "nextjs": return "npm run dev";
     case "angular": return "npm run start -- --host 0.0.0.0 --port 4200";
     case "react":
     case "vue":
     case "svelte": return "npm run dev -- --host 0.0.0.0 --port 5173";
+    case "expo":
+      if (expoPreviewMode === "web") return "npx expo start --web --port 8081 --host 0.0.0.0";
+      if (expoPreviewMode === "expo-go") return "npx expo start --tunnel --port 8081";
+      if (expoPreviewMode === "android-emulator") return "/start_android.sh";
+      return "npx expo start --web --port 8081 --host 0.0.0.0";
     default: return "npm run dev";
   }
 }
@@ -408,6 +420,7 @@ export const getFindCommand = (framework: Framework): string => {
   const ignorePatterns = ["node_modules", ".git", "dist", "build"];
   if (framework === "nextjs") ignorePatterns.push(".next");
   if (framework === "svelte") ignorePatterns.push(".svelte-kit");
+  if (framework === "expo") ignorePatterns.push(".expo");
   
   return `find /home/user -type f -not -path '*/${ignorePatterns.join('/* -not -path */')}/*' 2>/dev/null`;
 };

File: src/agents/types.ts
Changes:
@@ -1,6 +1,8 @@
 export const SANDBOX_TIMEOUT = 60_000 * 60;
 
-export type Framework = "nextjs" | "angular" | "react" | "vue" | "svelte";
+export type Framework = "nextjs" | "angular" | "react" | "vue" | "svelte" | "expo";
+
+export type ExpoPreviewMode = "web" | "expo-go" | "android-emulator" | "eas-build";
 
 export interface AgentState {
   summary: string;
@@ -9,6 +11,14 @@ export interface AgentState {
   summaryRetryCount: number;
 }
 
+export interface ExpoAgentState extends AgentState {
+  previewMode: ExpoPreviewMode;
+  qrCodeUrl?: string;
+  vncUrl?: string;
+  easBuildUrl?: string;
+  apkDownloadUrl?: string;
+}
+
 export interface AgentRunInput {
   projectId: string;
   value: string;
@@ -23,6 +33,11 @@ export interface AgentRunResult {
   summary: string;
   sandboxId: string;
   framework: Framework;
+  expoPreviewMode?: ExpoPreviewMode;
+  expoQrCodeUrl?: string;
+  expoVncUrl?: string;
+  expoEasBuildUrl?: string;
+  expoApkUrl?: string;
 }
 
 export const MODEL_CONFIGS = {
@@ -145,16 +160,17 @@ export function selectModelForTask(
 
 export function frameworkToConvexEnum(
   framework: Framework
-): "NEXTJS" | "ANGULAR" | "REACT" | "VUE" | "SVELTE" {
+): "NEXTJS" | "ANGULAR" | "REACT" | "VUE" | "SVELTE" | "EXPO" {
   const mapping: Record<
     Framework,
-    "NEXTJS" | "ANGULAR" | "REACT" | "VUE" | "SVELTE"
+    "NEXTJS" | "ANGULAR" | "REACT" | "VUE" | "SVELTE" | "EXPO"
   > = {
     nextjs: "NEXTJS",
     angular: "ANGULAR",
     react: "REACT",
     vue: "VUE",
     svelte: "SVELTE",
+    expo: "EXPO",
   };
   return mapping[framework];
 }

File: src/components/ExpoPreviewSelector.tsx
Changes:
@@ -0,0 +1,150 @@
+'use client';
+
+import { useState } from 'react';
+import { Card, CardContent } from '@/components/ui/card';
+import { Badge } from '@/components/ui/badge';
+import { cn } from '@/lib/utils';
+
+export type ExpoPreviewMode = 'web' | 'expo-go' | 'android-emulator' | 'eas-build';
+export type UserTier = 'free' | 'pro' | 'enterprise';
+
+interface PreviewOption {
+  mode: ExpoPreviewMode;
+  title: string;
+  description: string;
+  badge?: string;
+  buildTime: string;
+  tier: UserTier;
+  icon: string;
+}
+
+const PREVIEW_OPTIONS: PreviewOption[] = [
+  {
+    mode: 'web',
+    title: 'Web Preview',
+    description: 'Fastest preview using react-native-web',
+    buildTime: '~30 seconds',
+    tier: 'free',
+    icon: '🌐'
+  },
+  {
+    mode: 'expo-go',
+    title: 'Expo Go (QR Code)',
+    description: 'Test on real device via Expo Go app',
+    buildTime: '~1-2 minutes',
+    tier: 'free',
+    icon: '📱'
+  },
+  {
+    mode: 'android-emulator',
+    title: 'Android Emulator',
+    description: 'Full Android emulator with VNC access',
+    badge: 'Pro',
+    buildTime: '~3-5 minutes',
+    tier: 'pro',
+    icon: '🤖'
+  },
+  {
+    mode: 'eas-build',
+    title: 'EAS Build (Production)',
+    description: 'Cloud builds for App Store/Play Store',
+    badge: 'Pro',
+    buildTime: '~5-15 minutes',
+    tier: 'pro',
+    icon: '🚀'
+  }
+];
+
+interface ExpoPreviewSelectorProps {
+  onSelect: (mode: ExpoPreviewMode) => void;
+  userTier?: UserTier;
+  selectedMode?: ExpoPreviewMode;
+  className?: string;
+}
+
+export function ExpoPreviewSelector({
+  onSelect,
+  userTier = 'free',
+  selectedMode,
+  className
+}: ExpoPreviewSelectorProps) {
+  const [selected, setSelected] = useState<ExpoPreviewMode>(selectedMode ?? 'web');
+
+  const handleSelect = (mode: ExpoPreviewMode) => {
+    const option = PREVIEW_OPTIONS.find(o => o.mode === mode);
+    if (!option) return;
+    
+    const tierOrder: Record<UserTier, number> = { free: 0, pro: 1, enterprise: 2 };
+    const isLocked = tierOrder[userTier] < tierOrder[option.tier];
+    
+    if (!isLocked) {
+      setSelected(mode);
+      onSelect(mode);
+    }
+  };
+
+  return (
+    <div className={cn('grid grid-cols-1 sm:grid-cols-2 gap-3', className)}>
+      {PREVIEW_OPTIONS.map((option) => {
+        const tierOrder: Record<UserTier, number> = { free: 0, pro: 1, enterprise: 2 };
+        const isLocked = tierOrder[userTier] < tierOrder[option.tier];
+        const isSelected = selected === option.mode;
+
+        return (
+          <Card
+            key={option.mode}
+            className={cn(
+              'cursor-pointer transition-all duration-200',
+              isSelected && 'ring-2 ring-primary bg-primary/5',
+              isLocked && 'opacity-60 cursor-not-allowed',
+              !isLocked && !isSelected && 'hover:bg-muted/50'
+            )}
+            onClick={() => handleSelect(option.mode)}
+          >
+            <CardContent className="p-4">
+              <div className="flex items-start justify-between mb-2">
+                <div className="flex items-center gap-2">
+                  <span className="text-xl">{option.icon}</span>
+                  <h4 className="font-semibold text-sm">{option.title}</h4>
+                </div>
+                <div className="flex gap-1">
+                  {option.badge && (
+                    <Badge variant="secondary" className="text-xs">
+                      {option.badge}
+                    </Badge>
+                  )}
+                  {isLocked && (
+                    <Badge variant="outline" className="text-xs">
+                      🔒
+                    </Badge>
+                  )}
+                </div>
+              </div>
+              <p className="text-xs text-muted-foreground mb-2">
+                {option.description}
+              </p>
+              <p className="text-xs text-muted-foreground/70">
+                Build time: {option.buildTime}
+              </p>
+            </CardContent>
+          </Card>
+        );
+      })}
+    </div>
+  );
+}
+
+export function ExpoPreviewInfo({ mode }: { mode: ExpoPreviewMode }) {
+  const option = PREVIEW_OPTIONS.find(o => o.mode === mode);
+  if (!option) return null;
+
+  return (
+    <div className="flex items-center gap-2 text-sm text-muted-foreground">
+      <span>{option.icon}</span>
+      <span>{option.title}</span>
+      <span className="text-xs">({option.buildTime})</span>
+    </div>
+  );
+}
+
+export { PREVIEW_OPTIONS };

File: src/lib/frameworks.ts
Changes:
@@ -341,6 +341,73 @@ export const frameworks: Record<string, FrameworkData> = {
       'SSG',
       'production React'
     ]
+  },
+  expo: {
+    slug: 'expo',
+    name: 'Expo',
+    title: 'Cross-Platform Mobile Development with Expo & React Native',
+    description: 'Expo is the easiest way to build iOS, Android, and web apps from a single codebase using React Native. Create production-ready mobile applications with our AI-powered development tools.',
+    metaDescription: 'Create mobile apps with Expo and React Native using AI. Multiple preview modes: web, Expo Go, Android emulator, and EAS Build for production iOS/Android apps.',
+    features: [
+      'Cross-Platform (iOS/Android/Web)',
+      'Hot Reload & Fast Refresh',
+      'Expo SDK Modules',
+      'Multiple Preview Modes',
+      'EAS Build Integration',
+      'Over-the-Air Updates',
+      'TypeScript Support',
+      'expo-router Navigation'
+    ],
+    useCases: [
+      'Mobile-First Applications',
+      'Social Media Apps',
+      'E-commerce Mobile Apps',
+      'Fitness & Health Trackers',
+      'Photo & Video Apps',
+      'Location-Based Services',
+      'Progressive Web Apps'
+    ],
+    advantages: [
+      'One Codebase, Three Platforms',
+      'Rich Native Module Ecosystem',
+      'Fast Development Cycle',
+      'Real Device Testing (Expo Go)',
+      'Cloud Builds (No Xcode/Android Studio)',
+      'Strong Community Support'
+    ],
+    icon: '📱',
+    color: '#000020',
+    popularity: 85,
+    ecosystem: [
+      {
+        name: 'Expo Go',
+        description: 'Instant preview on real devices',
+        url: '/frameworks/expo/expo-go'
+      },
+      {
+        name: 'EAS Build',
+        description: 'Cloud-based iOS/Android builds',
+        url: '/frameworks/expo/eas-build'
+      },
+      {
+        name: 'expo-router',
+        description: 'File-based navigation system',
+        url: '/frameworks/expo/router'
+      }
+    ],
+    relatedFrameworks: ['react', 'nextjs'],
+    keywords: [
+      'Expo development',
+      'React Native',
+      'cross-platform mobile',
+      'iOS development',
+      'Android development',
+      'mobile app framework',
+      'Expo SDK',
+      'React Native components',
+      'EAS Build',
+      'mobile development'
+    ]
   }
 };
 

File: src/prompt.ts
Changes:
@@ -4,5 +4,6 @@ export { ANGULAR_PROMPT } from "./prompts/angular";
 export { REACT_PROMPT } from "./prompts/react";
 export { VUE_PROMPT } from "./prompts/vue";
 export { SVELTE_PROMPT } from "./prompts/svelte";
+export { EXPO_PROMPT, EXPO_WEB_PROMPT, EXPO_NATIVE_PROMPT } from "./prompts/expo";
 export { FRAMEWORK_SELECTOR_PROMPT } from "./prompts/framework-selector";
 export { NEXTJS_PROMPT as PROMPT } from "./prompts/nextjs";

File: src/prompts/expo.ts
Changes:
@@ -0,0 +1,263 @@
+import { SHARED_RULES } from "./shared";
+
+export const EXPO_SHARED_RULES = `
+Environment:
+- Writable file system via createOrUpdateFiles
+- Command execution via terminal (use "npm install <package> --yes" or "npx expo install <package>")
+- Read files via readFiles
+- Do not modify package.json or lock files directly — install packages using the terminal only
+- All files are under /home/user
+- Entry point is App.tsx (root component)
+
+File Safety Rules:
+- All CREATE OR UPDATE file paths must be relative (e.g., "App.tsx", "components/Button.tsx")
+- NEVER use absolute paths like "/home/user/..." or "/home/user/app/..."
+- NEVER include "/home/user" in any file path — this will cause critical errors
+- When using readFiles or accessing the file system, you MUST use the actual path (e.g. "/home/user/components/Button.tsx")
+
+Runtime Execution:
+- Development servers are not started manually in this environment
+- The Metro bundler is already running
+- Use validation commands like "npx expo export:web" to verify your work
+- Short-lived commands for type-checking and builds are allowed as needed for testing
+
+Error Prevention & Code Quality (CRITICAL):
+1. MANDATORY Validation Before Completion:
+   - Run: npx tsc --noEmit (for type checking)
+   - Fix ANY and ALL TypeScript errors immediately
+   - Only output <task_summary> after validation passes with no errors
+
+2. Handle All Errors: Every function must include proper error handling
+3. Type Safety: Use TypeScript properly with explicit types
+
+Instructions:
+1. Use React Native components exclusively (View, Text, TouchableOpacity, etc.)
+2. Use StyleSheet.create() for ALL styling — NO CSS files, NO className
+3. Use Expo SDK modules for native functionality
+4. Break complex UIs into multiple components
+5. Use TypeScript with proper types
+6. You MUST use the createOrUpdateFiles tool to make all file changes
+7. You MUST use the terminal tool to install any packages (npx expo install <package>)
+8. Do not print code inline or wrap code in backticks
+
+Final output (MANDATORY):
+After ALL tool calls are complete and the task is finished, you MUST output:
+
+<task_summary>
+A short, high-level summary of what was created or changed.
+</task_summary>
+`;
+
+export const EXPO_PROMPT = `
+You are a senior React Native engineer using Expo in a sandboxed environment.
+
+${EXPO_SHARED_RULES}
+
+Environment:
+- Framework: Expo SDK 52+ with React Native 0.76+
+- Entry file: App.tsx (root component)
+- Styling: StyleSheet API (React Native styles)
+- Navigation: expo-router (file-based routing) or React Navigation
+- Dev port: 8081 (Metro bundler)
+
+Critical Rules:
+1. Use React Native components: View, Text, TouchableOpacity, ScrollView, FlatList, Image, TextInput, etc.
+2. Styling MUST use StyleSheet.create() — NO CSS files, NO className, NO Tailwind
+3. Import from 'react-native': \`import { View, Text, StyleSheet } from 'react-native'\`
+4. Use Expo SDK modules: expo-camera, expo-location, expo-font, expo-image-picker, etc.
+5. "use client" is NOT needed (React Native doesn't use this directive)
+6. File structure: App.tsx as entry, components/ for reusable components
+7. For multi-screen apps: Use expo-router with app/ directory structure
+
+Styling Example:
+\`\`\`tsx
+import { StyleSheet, View, Text, TouchableOpacity } from 'react-native';
+import { StatusBar } from 'expo-status-bar';
+
+export default function App() {
+  return (
+    <View style={styles.container}>
+      <Text style={styles.title}>Hello Expo</Text>
+      <TouchableOpacity style={styles.button} onPress={() => console.log('Pressed')}>
+        <Text style={styles.buttonText}>Press Me</Text>
+      </TouchableOpacity>
+      <StatusBar style="auto" />
+    </View>
+  );
+}
+
+const styles = StyleSheet.create({
+  container: {
+    flex: 1,
+    backgroundColor: '#fff',
+    alignItems: 'center',
+    justifyContent: 'center',
+  },
+  title: {
+    fontSize: 24,
+    fontWeight: 'bold',
+    marginBottom: 20,
+  },
+  button: {
+    backgroundColor: '#007AFF',
+    paddingHorizontal: 20,
+    paddingVertical: 12,
+    borderRadius: 8,
+  },
+  buttonText: {
+    color: '#fff',
+    fontSize: 16,
+    fontWeight: '600',
+  },
+});
+\`\`\`
+
+Expo SDK Modules (pre-installed):
+- expo-status-bar (status bar control)
+- expo-font (custom fonts)
+- expo-linear-gradient (gradient backgrounds)
+- expo-blur (blur effects)
+
+Expo SDK Modules (install with npx expo install):
+- expo-camera (camera access)
+- expo-image-picker (photo library/camera capture)
+- expo-location (GPS/location)
+- expo-haptics (haptic feedback/vibration)
+- expo-notifications (push notifications)
+- expo-file-system (file operations)
+- expo-av (audio/video playback)
+- expo-sensors (accelerometer, gyroscope)
+- expo-secure-store (secure storage)
+- expo-sqlite (local database)
+
+Navigation with expo-router:
+\`\`\`tsx
+// app/_layout.tsx
+import { Stack } from 'expo-router';
+
+export default function Layout() {
+  return <Stack />;
+}
+
+// app/index.tsx
+import { Link } from 'expo-router';
+import { View, Text } from 'react-native';
+
+export default function Home() {
+  return (
+    <View>
+      <Text>Home Screen</Text>
+      <Link href="/details">Go to Details</Link>
+    </View>
+  );
+}
+\`\`\`
+
+Common Patterns:
+1. SafeAreaView for notch handling: \`import { SafeAreaView } from 'react-native-safe-area-context'\`
+2. KeyboardAvoidingView for forms with keyboard
+3. FlatList for performant scrolling lists
+4. ActivityIndicator for loading states
+5. Platform.OS for platform-specific code
+
+Workflow:
+1. FIRST: Generate all code files using createOrUpdateFiles
+2. THEN: Use terminal to install packages if needed (npx expo install <package>)
+3. FINALLY: Provide <task_summary> describing what you built
+
+Preview Modes:
+- **web**: Fast preview using react-native-web, limited native features
+- **expo-go**: Scan QR with Expo Go app for real device testing
+- **android-emulator**: Full Android emulator with VNC access
+- **eas-build**: Production builds for App Store/Play Store
+`;
+
+export const EXPO_WEB_PROMPT = `
+You are a senior React Native engineer using Expo with WEB PREVIEW mode.
+
+${EXPO_SHARED_RULES}
+
+Environment:
+- Framework: Expo SDK 52+ with React Native 0.76+
+- Preview Mode: WEB (using react-native-web)
+- Entry file: App.tsx (root component)
+- Styling: StyleSheet API (React Native styles)
+- Dev port: 8081 (Metro bundler web)
+
+IMPORTANT - Web Compatibility:
+Since this is web preview mode, you MUST only use web-compatible components and APIs.
+
+✅ SAFE for Web (use these):
+- View, Text, Image, ScrollView, FlatList
+- TouchableOpacity, TouchableHighlight, Pressable
+- TextInput, Switch, ActivityIndicator
+- StyleSheet, Dimensions, Platform
+- expo-linear-gradient, expo-blur
+- expo-font (web fonts)
+- expo-status-bar (no-op on web)
+
+❌ NOT Available on Web (avoid these):
+- expo-camera (use file input instead)
+- expo-location (use Geolocation API if needed)
+- expo-haptics (no haptic on web)
+- expo-sensors (no accelerometer/gyroscope on web)
+- expo-notifications (limited on web)
+- expo-secure-store (use localStorage)
+- Native-only modules
+
+Web Alternatives:
+- Camera: Use \`<input type="file" accept="image/*" capture>\`
+- Location: Use \`navigator.geolocation\` if needed
+- Storage: Use AsyncStorage (works on web) or localStorage
+- Vibration: Skip or use Web Vibration API
+
+Critical Rules:
+1. Use React Native components: View, Text, TouchableOpacity, etc.
+2. Styling MUST use StyleSheet.create() — NO CSS files, NO className
+3. Always check Platform.OS if using platform-specific code
+4. Test works on web before completing
+
+${EXPO_PROMPT.split('Workflow:')[1]}
+`;
+
+export const EXPO_NATIVE_PROMPT = `
+You are a senior React Native engineer using Expo with NATIVE PREVIEW mode.
+
+${EXPO_SHARED_RULES}
+
+Environment:
+- Framework: Expo SDK 52+ with React Native 0.76+
+- Preview Mode: NATIVE (Android Emulator or Expo Go)
+- Entry file: App.tsx (root component)
+- Styling: StyleSheet API (React Native styles)
+- Full native API access available
+
+Full Native Access:
+You have access to ALL Expo SDK modules and native APIs:
+- expo-camera (full camera control)
+- expo-location (GPS, background location)
+- expo-haptics (haptic feedback)
+- expo-sensors (accelerometer, gyroscope, magnetometer)
+- expo-notifications (push notifications)
+- expo-contacts (address book)
+- expo-calendar (calendar events)
+- expo-media-library (photo/video library)
+- expo-audio (audio recording/playback)
+- expo-video (video playback)
+- expo-bluetooth-low-energy (BLE)
+
+Native-Specific Patterns:
+1. Use SafeAreaView for proper notch handling
+2. Use KeyboardAvoidingView with behavior="padding" for iOS
+3. Use StatusBar component for status bar styling
+4. Use BackHandler for Android back button
+5. Use Linking for deep links
+
+Performance Tips:
+- Use FlatList instead of ScrollView for long lists
+- Use useMemo/useCallback for expensive operations
+- Use Image.prefetch for remote images
+- Use react-native-reanimated for smooth animations
+
+${EXPO_PROMPT.split('Workflow:')[1]}
+`;

File: src/prompts/framework-selector.ts
Changes:
@@ -1,5 +1,5 @@
 export const FRAMEWORK_SELECTOR_PROMPT = `
-You are a framework selection expert. Your job is to analyze the user's request and determine the most appropriate web framework to use.
+You are a framework selection expert. Your job is to analyze the user's request and determine the most appropriate framework to use.
 
 Available frameworks:
 1. **nextjs** - Next.js 15 with React, Shadcn UI, and Tailwind CSS
@@ -27,9 +27,16 @@ Available frameworks:
    - Pre-installed: DaisyUI (Tailwind components), Tailwind CSS
    - Use when: User mentions "Svelte", "SvelteKit", or emphasizes performance
 
+6. **expo** - Expo/React Native with TypeScript
+   - Best for: Cross-platform mobile apps (iOS + Android + Web), native mobile features
+   - Pre-installed: Expo SDK, React Native components, TypeScript
+   - Preview modes: Web (fast), Expo Go (QR code), Android Emulator (VNC), EAS Build (production)
+   - Use when: User mentions "Expo", "React Native", "mobile app", "iOS", "Android", "cross-platform", "native app", "phone app", or wants to build for mobile devices
+
 Selection Guidelines:
 - If the user explicitly mentions a framework name, choose that framework
-- If the request is ambiguous or doesn't specify, default to **nextjs** (most versatile)
+- If the request is for a MOBILE APP (iOS, Android, phone, native app), choose **expo**
+- If the request is ambiguous or doesn't specify and is for WEB, default to **nextjs** (most versatile)
 - Consider the complexity: enterprise/complex = Angular, simple = React/Vue/Svelte
 - Consider the UI needs: Material Design = Angular or Vue, flexible = Next.js or React
 - Consider performance emphasis: Svelte for highest performance requirements
@@ -41,6 +48,7 @@ You MUST respond with ONLY ONE of these exact strings (no explanation, no markdo
 - react
 - vue
 - svelte
+- expo
 
 Examples:
 User: "Build a Netflix clone"
@@ -64,5 +72,23 @@ Response: nextjs
 User: "Create a Material Design admin panel"
 Response: angular
 
+User: "Build a mobile todo app for iOS and Android"
+Response: expo
+
+User: "Create a React Native camera app"
+Response: expo
+
+User: "Make a cross-platform fitness tracker"
+Response: expo
+
+User: "Build an app for my phone"
+Response: expo
+
+User: "Create a native mobile application"
+Response: expo
+
+User: "Build an Expo app with location tracking"
+Response: expo
+
 Now analyze the user's request and respond with ONLY the framework name.
 `;

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Jan 16, 2026

Note

Other AI code review bot(s) detected

CodeRabbit has detected other AI code review bot(s) in this pull request and will avoid duplicating their findings in the review comments. This may lead to a less comprehensive review.

📝 Walkthrough

Walkthrough

Adds first-class Expo support: schema expansions for Expo metadata, new sandbox templates (web, full, android), EAS build and QR utilities, Expo-aware agent types and prompts, a tiered Expo preview selector UI, and supporting docs/dependencies.

Changes

Cohort / File(s) Summary
Database Schema & Convex Mutations
convex/schema.ts, convex/importData.ts, convex/sandboxSessions.ts
Added EXPO to framework enum; new expoPreviewModeEnum; added optional fragment fields expoPreviewMode, expoQrCodeUrl, expoVncUrl, expoEasBuildUrl, expoApkUrl; updated import/create mutation signatures to accept EXPO.
Usage & Permissions
convex/usage.ts
Added EXPO_LIMITS per-tier config, exported ExpoPreviewMode and UserTier types, and canUseExpoPreviewMode(tier, mode) function.
Agent Types & Core Logic
src/agents/types.ts, src/agents/code-agent.ts, src/agents/sandbox-utils.ts, src/agents/client.ts, src/agents/rate-limit.ts
Extended framework type with expo; added ExpoPreviewMode and Expo-related agent state/result fields; threaded expoPreviewMode through template/port/command functions; detectFramework and prompt selection now support expo; client routing updated to favor OpenRouter fallback (removed gateway export).
EAS Build Integration
src/agents/eas-build.ts
New module implementing EAS workflow: initializeEAS, triggerEASBuild, checkEASBuildStatus, waitForEASBuild, getEASBuildDownloadUrl, cancelEASBuild plus related types and error handling.
QR Code & URL Utilities
src/agents/expo-qr.ts
New exports: generateExpoGoQR, getExpoOfficialQRUrl, getEASUpdateQRUrl, getExpoGoDeepLink, checkUrlAccessible (uses qrcode dependency).
Frontend Components
src/components/ExpoPreviewSelector.tsx
New tier-aware ExpoPreviewSelector and ExpoPreviewInfo components; PREVIEW_OPTIONS metadata exported; types ExpoPreviewMode and UserTier added for UI.
Prompts & Prompt Routing
src/prompts/expo.ts, src/prompt.ts, src/prompts/framework-selector.ts
Added Expo prompt suite: EXPO_SHARED_RULES, EXPO_PROMPT, EXPO_WEB_PROMPT, EXPO_NATIVE_PROMPT; re-exported from src/prompt.ts; framework-selector offers expo option and guidance.
Framework Metadata
src/lib/frameworks.ts
Added expo entry to frameworks metadata map (descriptive data only).
Sandbox Templates: Web / Full / Android
sandbox-templates/expo-web/*, sandbox-templates/expo-full/*, sandbox-templates/expo-android/*
Added three templates: zapdev-expo-web (web preview), zapdev-expo-full (web + Expo Go tunnel), and zapdev-expo-android (Android emulator with VNC/ADB); Dockerfiles, TOML configs, and Android start script included.
Docs & Dependencies
explanations/EXPO_INTEGRATION.md, package.json, env.example
New Expo integration doc; added qrcode and @types/qrcode; removed VERCEL_AI_GATEWAY_API_KEY entry from env.example.
Tests & CI
tests/gateway-fallback.test.ts, .github/workflows/opencode.yml
Updated tests and messages to reference OpenRouter fallback; added opencode GitHub Actions workflow.

Sequence Diagram(s)

sequenceDiagram
    participant Agent as Code Agent
    participant Sandbox as E2B Sandbox
    participant EAS as EAS CLI
    participant ExpoAPI as Expo API
    Agent->>Agent: getFrameworkPrompt('expo', 'eas-build')
    Agent->>Sandbox: triggerEASBuild(config)
    Sandbox->>Sandbox: initializeEAS (eas.json, app.json)
    Sandbox->>EAS: npx eas-cli build --non-interactive
    EAS->>ExpoAPI: POST /builds (with EXPO_ACCESS_TOKEN)
    ExpoAPI-->>EAS: buildId, buildUrl
    EAS-->>Sandbox: JSON output
    Sandbox->>Agent: EASBuildResult {buildId, buildUrl, status}
    Agent->>ExpoAPI: checkEASBuildStatus(buildId)
    ExpoAPI-->>Agent: status, downloadUrl, artifacts
Loading
sequenceDiagram
    participant User as User
    participant Component as ExpoPreviewSelector
    participant Agent as Code Agent
    participant QRGen as expo-qr
    User->>Component: Select preview mode
    Component->>Component: Validate tier access
    Component->>Agent: onSelect('expo-go')
    Agent->>Agent: getFrameworkPrompt('expo', 'expo-go')
    Agent->>Sandbox: Start Metro / sandbox
    Sandbox-->>Agent: sandboxUrl
    Agent->>QRGen: generateExpoGoQR(sandboxUrl)
    QRGen-->>Agent: qrCodeDataUrl
    Agent-->>Component: Update UI with qrCodeUrl / vncUrl
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~55 minutes

Possibly related PRs

  • Changes" #209 — modifies src/agents/client.ts and client selection logic; related to OpenRouter/gateway routing changes in this PR.
  • Added Exa Search API #211 — touches gateway fallback, env.example, and gateway-related logic; strongly related to removal/changes of gateway usages here.

"🐰 I hopped in with a QR and a shell,
Expo bundles and builds that smell!
Emulators hum, Metro's aglow,
QR codes dance, and previews flow.
Hooray—mobile magic on the meadow!"

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 59.09% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Added support for expo' directly describes the main change in the PR, which adds first-class Expo/React Native support across multiple files, frameworks, agents, and components.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@codecapyai
Copy link
Copy Markdown

codecapyai bot commented Jan 16, 2026

🚀 Launching Scrapybara desktop...

@codecapyai
Copy link
Copy Markdown

codecapyai bot commented Jan 16, 2026

❌ Something went wrong:

status_code: 500, body: {'detail': 'Error creating instance: HTTPSConnectionPool(host=\'dd71ce9e4c14175cfb2d4b4d613159f4.sk1.us-west-1.eks.amazonaws.com\', port=443): Max retries exceeded with url: /api/v1/namespaces/scrapybara-instances/services (Caused by NameResolutionError("<urllib3.connection.HTTPSConnection object at 0x7fbb38759b10>: Failed to resolve \'dd71ce9e4c14175cfb2d4b4d613159f4.sk1.us-west-1.eks.amazonaws.com\' ([Errno -2] Name or service not known)"))'}

@stormkit-io
Copy link
Copy Markdown

stormkit-io bot commented Jan 16, 2026

Deployment failed

This pull request failed while building automatically on Stormkit. You can preview the logs using the following link.
https://app.stormkit.io/app/16264/deployments/85472


# Start Android emulator
echo "[INFO] Starting Android emulator..."
$ANDROID_HOME/emulator/emulator -avd expo_emulator \

Check notice

Code scanning / Shellcheck (reported by Codacy)

Double quote to prevent globbing and word splitting. Note

Double quote to prevent globbing and word splitting.
echo "[INFO] Emulator ready!"

# Start Expo Metro bundler with Android
cd /home/user

Check notice

Code scanning / Shellcheck (reported by Codacy)

Use 'cd ... || exit' or 'cd ... || return' in case cd fails. Note

Use 'cd ... || exit' or 'cd ... || return' in case cd fails.
EXPO_ACCESS_TOKEN=your_expo_token_here
```

Get your token from: https://expo.dev/settings/access-tokens

Check notice

Code scanning / Remark-lint (reported by Codacy)

Warn for literal URLs in text. Note

[no-literal-urls] Don’t use literal URLs without angle brackets
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

5 issues found across 24 files

Prompt for AI agents (all issues)

Check if these issues are valid — if so, understand the root cause of each and fix them.


<file name="sandbox-templates/expo-full/e2b.toml">

<violation number="1" location="sandbox-templates/expo-full/e2b.toml:10">
P2: The template starts an Expo dev server on sandbox startup (`expo start`), which violates the project rule to never start dev servers in E2B sandboxes. Use a build or compile command instead of launching the dev server.</violation>
</file>

<file name="src/agents/expo-qr.ts">

<violation number="1" location="src/agents/expo-qr.ts:56">
P2: Encode `projectId` and `channel` before inserting them into the QR service URL so special characters can't break the query string.</violation>
</file>

<file name="src/components/ExpoPreviewSelector.tsx">

<violation number="1" location="src/components/ExpoPreviewSelector.tsx:71">
P2: selectedMode prop changes won’t be reflected after initial render because state is only initialized once. If the parent updates selectedMode, the UI stays stale. Consider syncing state to the prop (or make the component fully controlled).</violation>
</file>

<file name="src/agents/eas-build.ts">

<violation number="1" location="src/agents/eas-build.ts:63">
P3: initializeEAS writes multiple files with `sandbox.files.write` calls; project conventions require batching file writes with `writeFilesBatch` to avoid per-file API latency. Please batch the eas.json and app.json writes together.</violation>

<violation number="2" location="src/agents/eas-build.ts:111">
P1: The EAS build command embeds EXPO_ACCESS_TOKEN in the command string, and `runCodeCommand` logs that command, so the token will be written to logs. Please pass secrets via a non-logged channel (e.g., env injection or redacted logging) to avoid leaking credentials.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

console.log(`[INFO] Triggering EAS build for platform: ${config.platform}, profile: ${config.profile}`);

// Build the command with proper token handling
const buildCommand = `EXPO_TOKEN="${expoToken}" npx eas-cli build --platform ${config.platform} --profile ${config.profile} --non-interactive --json --no-wait`;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: The EAS build command embeds EXPO_ACCESS_TOKEN in the command string, and runCodeCommand logs that command, so the token will be written to logs. Please pass secrets via a non-logged channel (e.g., env injection or redacted logging) to avoid leaking credentials.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/agents/eas-build.ts, line 111:

<comment>The EAS build command embeds EXPO_ACCESS_TOKEN in the command string, and `runCodeCommand` logs that command, so the token will be written to logs. Please pass secrets via a non-logged channel (e.g., env injection or redacted logging) to avoid leaking credentials.</comment>

<file context>
@@ -0,0 +1,257 @@
+  console.log(`[INFO] Triggering EAS build for platform: ${config.platform}, profile: ${config.profile}`);
+  
+  // Build the command with proper token handling
+  const buildCommand = `EXPO_TOKEN="${expoToken}" npx eas-cli build --platform ${config.platform} --profile ${config.profile} --non-interactive --json --no-wait`;
+  
+  const result = await runCodeCommand(sandbox, buildCommand);
</file context>

dockerfile = "e2b.Dockerfile"

# Start command (runs when sandbox starts)
start_cmd = "npx expo start --port 8081 --host 0.0.0.0 --tunnel"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The template starts an Expo dev server on sandbox startup (expo start), which violates the project rule to never start dev servers in E2B sandboxes. Use a build or compile command instead of launching the dev server.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At sandbox-templates/expo-full/e2b.toml, line 10:

<comment>The template starts an Expo dev server on sandbox startup (`expo start`), which violates the project rule to never start dev servers in E2B sandboxes. Use a build or compile command instead of launching the dev server.</comment>

<file context>
@@ -0,0 +1,15 @@
+dockerfile = "e2b.Dockerfile"
+
+# Start command (runs when sandbox starts)
+start_cmd = "npx expo start --port 8081 --host 0.0.0.0 --tunnel"
+
+# Template resource configuration
</file context>

channel: string = 'preview',
runtimeVersion?: string
): string {
let url = `https://qr.expo.dev/eas-update?projectId=${projectId}&channel=${channel}`;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Encode projectId and channel before inserting them into the QR service URL so special characters can't break the query string.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/agents/expo-qr.ts, line 56:

<comment>Encode `projectId` and `channel` before inserting them into the QR service URL so special characters can't break the query string.</comment>

<file context>
@@ -0,0 +1,93 @@
+  channel: string = 'preview',
+  runtimeVersion?: string
+): string {
+  let url = `https://qr.expo.dev/eas-update?projectId=${projectId}&channel=${channel}`;
+  if (runtimeVersion) {
+    url += `&runtimeVersion=${encodeURIComponent(runtimeVersion)}`;
</file context>

selectedMode,
className
}: ExpoPreviewSelectorProps) {
const [selected, setSelected] = useState<ExpoPreviewMode>(selectedMode ?? 'web');
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: selectedMode prop changes won’t be reflected after initial render because state is only initialized once. If the parent updates selectedMode, the UI stays stale. Consider syncing state to the prop (or make the component fully controlled).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/components/ExpoPreviewSelector.tsx, line 71:

<comment>selectedMode prop changes won’t be reflected after initial render because state is only initialized once. If the parent updates selectedMode, the UI stays stale. Consider syncing state to the prop (or make the component fully controlled).</comment>

<file context>
@@ -0,0 +1,150 @@
+  selectedMode,
+  className
+}: ExpoPreviewSelectorProps) {
+  const [selected, setSelected] = useState<ExpoPreviewMode>(selectedMode ?? 'web');
+
+  const handleSelect = (mode: ExpoPreviewMode) => {
</file context>

};

// Write eas.json
await sandbox.files.write('/home/user/eas.json', JSON.stringify(easConfig, null, 2));
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: initializeEAS writes multiple files with sandbox.files.write calls; project conventions require batching file writes with writeFilesBatch to avoid per-file API latency. Please batch the eas.json and app.json writes together.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/agents/eas-build.ts, line 63:

<comment>initializeEAS writes multiple files with `sandbox.files.write` calls; project conventions require batching file writes with `writeFilesBatch` to avoid per-file API latency. Please batch the eas.json and app.json writes together.</comment>

<file context>
@@ -0,0 +1,257 @@
+    };
+    
+    // Write eas.json
+    await sandbox.files.write('/home/user/eas.json', JSON.stringify(easConfig, null, 2));
+    console.log('[INFO] Created eas.json configuration');
+  }
</file context>

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5f6a6de751

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +317 to +320
case "expo":
if (expoPreviewMode === "android-emulator") return "zapdev-expo-android";
if (expoPreviewMode === "expo-go") return "zapdev-expo-full";
return "zapdev-expo-web"; // Default to web preview (fastest)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Thread expo preview mode into sandbox startup

The new Expo branches in getE2BTemplate depend on expoPreviewMode, but the sandbox startup path still only passes framework (e.g., createSandbox calls getE2BTemplate(framework) and startDevServer uses getDevServerCommand(framework)), so expoPreviewMode is always undefined. As a result, selecting expo-go or android-emulator will still use the web template/command and QR/VNC previews never come up. You likely need to carry the preview mode from the request (or saved project state) into sandbox creation and dev server startup so the correct template/command is chosen.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 15

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/agents/sandbox-utils.ts (1)

444-458: getSandboxUrl and startDevServer don't propagate expoPreviewMode.

Both functions call getFrameworkPort(framework) without passing expoPreviewMode, so Expo will always use port 8081 even when android-emulator mode should use port 5900. Similarly, getDevServerCommand is called without the preview mode.

Suggested fix for both functions
-export async function getSandboxUrl(sandbox: Sandbox, framework: Framework): Promise<string> {
-  const port = getFrameworkPort(framework);
+export async function getSandboxUrl(sandbox: Sandbox, framework: Framework, expoPreviewMode?: ExpoPreviewMode): Promise<string> {
+  const port = getFrameworkPort(framework, expoPreviewMode);
   // ...
 }

-export async function startDevServer(sandbox: Sandbox, framework: Framework): Promise<string> {
-  const port = getFrameworkPort(framework);
-  const devCommand = getDevServerCommand(framework);
+export async function startDevServer(sandbox: Sandbox, framework: Framework, expoPreviewMode?: ExpoPreviewMode): Promise<string> {
+  const port = getFrameworkPort(framework, expoPreviewMode);
+  const devCommand = getDevServerCommand(framework, expoPreviewMode);
   // ...
-  return getSandboxUrl(sandbox, framework);
+  return getSandboxUrl(sandbox, framework, expoPreviewMode);
 }

Also applies to: 460-497

🤖 Fix all issues with AI agents
In `@explanations/EXPO_INTEGRATION.md`:
- Around line 165-170: Replace the bare URL with a Markdown link in the EAS
Build instructions: update the text that currently reads "Get your token from:
https://expo.dev/settings/access-tokens" so it uses a Markdown link (e.g., [Get
your token from the Expo settings](https://expo.dev/settings/access-tokens))
while leaving the EXPO_ACCESS_TOKEN example and surrounding text unchanged;
locate the line referencing EXPO_ACCESS_TOKEN and the following URL in
EXPO_INTEGRATION.md to apply the change.

In `@sandbox-templates/expo-android/start_android.sh`:
- Line 47: Replace the runtime dev-server launch command "npx expo start
--android --port 8081 --host 0.0.0.0" with the sandbox-appropriate startup flow:
remove that line and instead start/serve a prebuilt artifact (for example,
install or launch a prebuilt APK/AAB or start a static HTTP server that serves
the prebuilt JS bundle), or invoke the repository's existing sandbox startup
helper that brings up pre-warmed templates; ensure the script no longer spins up
a live Expo dev server and documents which prebuilt artifact or helper command
it now uses.
- Line 45: The script contains an unguarded cd command ("cd /home/user") which
can let the script continue on failure; update the cd invocation so the script
immediately exits on failure (i.e., ensure the cd /home/user command is followed
by a failure guard that exits the script if the directory change fails) to
prevent cascading errors.
- Around line 33-40: The boot-wait loop using adb wait-for-device and the while
checking adb shell getprop sys.boot_completed can hang indefinitely; add a
timeout mechanism (e.g., BOOT_TIMEOUT_SECONDS) and a start time check
before/inside the while loop that breaks and exits non-zero with an error
message if elapsed time exceeds the timeout; update the echo/logging to include
the timeout and ensure the script calls exit 1 on timeout so start_android.sh
fails fast when the emulator doesn't boot.

In `@sandbox-templates/expo-web/e2b.Dockerfile`:
- Around line 2-15: The Dockerfile uses npm/npx for project creation and
installs (RUN npx create-expo-app..., RUN npm install react-dom..., RUN npx expo
install...), but the project requires Bun; install Bun in the image (e.g., via
Bun's official install script or package) and replace npx with bunx and npm
install with bun install in those RUN steps so create-expo-app and expo installs
run under bun/bunx and dependencies are added with bun install.

In `@sandbox-templates/expo-web/e2b.toml`:
- Line 10: The start_cmd currently invokes the dev server via "expo start" which
must be replaced with serving prebuilt static output; update the start_cmd to
not run "expo start" but instead rely on a prebuilt web bundle (prebuild the app
during image build using Expo prebuild/expo export or eas build) and set
start_cmd to a static server command (e.g., using serve, nginx, or a lightweight
node static server) that serves the exported web assets; ensure any CI/image
build step runs the prebuild/export and that start_cmd references the exported
output directory rather than invoking "expo start".

In `@src/agents/eas-build.ts`:
- Around line 94-111: The buildCommand interpolation in triggerEASBuild lets
unvalidated config values (platform, profile) reach a shell and risks command
injection; add runtime validation by defining an allowlist for
EASBuildConfig.platform and EASBuildConfig.profile and reject (throw) any values
not in those sets, or alternatively avoid a shell string and invoke the EAS CLI
via child_process.spawn/execFile with separate args (pass expo token via env
object) so platform/profile are never shell-interpolated; update triggerEASBuild
to validate inputs and use the safe invocation approach and ensure the
buildCommand variable is replaced/removed accordingly.
- Around line 110-113: The EXPO_TOKEN is currently interpolated into
buildCommand which can leak in logs from runCodeCommand; instead set the
EXPO_TOKEN in the sandbox environment and run the eas-cli command without
embedding the token. Modify the code to call the sandbox environment API (e.g.,
sandbox.setEnv or sandbox.putEnv) or pass an env object to runCodeCommand to set
EXPO_TOKEN to expoToken, then change buildCommand to: npx eas-cli build
--platform ${config.platform} --profile ${config.profile} --non-interactive
--json --no-wait so no secret appears in the command string (update usages of
buildCommand, runCodeCommand, sandbox, and expoToken accordingly).
- Line 63: The code uses hardcoded absolute paths when calling sandbox file ops;
update calls to sandbox.files.write and sandbox.files.read in this file to use
relative filenames (e.g., replace '/home/user/eas.json' with 'eas.json',
'/home/user/app.json' with 'app.json') so sandbox file operations work
correctly; check the occurrences in the functions or blocks where
sandbox.files.write('/home/user/eas.json', ...),
sandbox.files.read('/home/user/app.json'), and
sandbox.files.write('/home/user/app.json', ...) appear and change them to use
the relative names 'eas.json' and 'app.json' respectively.

In `@src/agents/sandbox-utils.ts`:
- Around line 339-353: The expo 'eas-build' mode is being treated like a
dev-server mode in getDevServerCommand; update the expo branch in
getDevServerCommand to handle expoPreviewMode === "eas-build" explicitly
(instead of falling through to the web start command) by throwing a clear Error
(e.g., "EAS builds do not use a dev server") so callers like
getDevServerCommand(...) cannot accidentally start a dev server for EAS builds;
modify the switch case for "expo" to check for "eas-build" first and throw the
Error (or alternatively return a documented sentinel string if you prefer
non-throwing behavior).

In `@src/components/ExpoPreviewSelector.tsx`:
- Around line 8-9: Remove the duplicated type declarations ExpoPreviewMode and
UserTier and import them from their single sources instead: replace the local
type definitions in ExpoPreviewSelector with imports for ExpoPreviewMode from
the existing types module (where it's declared) and UserTier from the convex
usage module; update the top of the file to import { ExpoPreviewMode } from the
agents types module and { UserTier } from the convex usage module and ensure any
local references (ExpoPreviewMode, UserTier) use the imported types.
- Around line 94-102: The Card elements are mouse-only interactive; update the
Card component (the instance using key={option.mode} and onClick={() =>
handleSelect(option.mode)}) to add keyboard accessibility by adding
role="button", a tabIndex that is 0 when not disabled and -1 when isLocked, and
an onKeyDown handler that listens for Enter or Space and calls
handleSelect(option.mode) (ensure it respects isLocked and prevents default for
Space to avoid page scroll). Keep the existing onClick behavior and reuse the
isLocked and handleSelect symbols so selection logic remains consistent.
- Line 71: The component initializes local state via useState in
ExpoPreviewSelector.tsx (const [selected, setSelected] =
useState<ExpoPreviewMode>(selectedMode ?? 'web')) but never updates when the
selectedMode prop changes; add a useEffect that watches selectedMode and calls
setSelected(selectedMode ?? 'web') to keep the local selected state in sync with
prop updates (import useEffect alongside useState and reference selected,
setSelected, and selectedMode).

In `@src/prompts/expo.ts`:
- Around line 12-16: The File Safety Rules in src/prompts/expo.ts are
contradictory: they forbid absolute paths ("NEVER use absolute paths" / "NEVER
include '/home/user'") but then require using actual absolute paths for
readFiles. Update the prompt text to clearly distinguish usage: state that
createOrUpdateFiles and other write/create operations must use relative paths
(e.g., "App.tsx", "components/Button.tsx") while readFiles/file-system read
operations must use actual absolute paths (e.g.,
"/home/user/components/Button.tsx"); edit the block containing the "File Safety
Rules" header and the three lines referencing absolute/relative path usage so
the two rules are explicit and unambiguous.
- Line 1: Remove the unused import or use it: either delete the unused import
statement for SHARED_RULES from the top of the file, or integrate SHARED_RULES
into the Expo prompt construction (e.g., merge or append SHARED_RULES into the
exported EXPO_PROMPT or the function that builds the Expo prompt such as
buildExpoPrompt/getExpoPrompt) so the symbol is actually referenced.
🧹 Nitpick comments (9)
sandbox-templates/expo-android/e2b.Dockerfile (1)

47-47: Pin global Expo CLI versions for reproducible template builds.

Unpinned @expo/cli and eas-cli can change behavior between image rebuilds; prefer explicit versions aligned with your repo expectations.

sandbox-templates/expo-full/e2b.Dockerfile (1)

20-20: Redundant WORKDIR directive.

This WORKDIR /home/user is already set on line 6 and can be removed.

🧹 Suggested cleanup
 # Install Expo CLI globally for tunnel support
 RUN npm install -g `@expo/cli` eas-cli
 
-WORKDIR /home/user
-
 # Start Metro bundler with tunnel for Expo Go access
 CMD ["npx", "expo", "start", "--port", "8081", "--host", "0.0.0.0", "--tunnel"]
src/agents/types.ts (1)

3-5: ExpoPreviewMode is defined in multiple places.

The ExpoPreviewMode type is defined identically in:

  • src/agents/types.ts (Line 5)
  • convex/usage.ts (Line 39)
  • src/components/ExpoPreviewSelector.tsx (Line 7)

Consider consolidating to a single source of truth to prevent drift. The canonical definition in src/agents/types.ts could be re-exported where needed, or a shared types file could be created.

src/prompts/expo.ts (1)

220-221: Fragile string splitting to share prompt content.

Using EXPO_PROMPT.split('Workflow:')[1] to extract the workflow section is fragile. If EXPO_PROMPT is modified and "Workflow:" is renamed or removed, this will silently break (returning undefined).

Consider extracting the shared workflow section as a separate constant:

Suggested refactor
+const EXPO_WORKFLOW = `
+Workflow:
+1. FIRST: Generate all code files using createOrUpdateFiles
+2. THEN: Use terminal to install packages if needed (npx expo install <package>)
+3. FINALLY: Provide <task_summary> describing what you built
+
+Preview Modes:
+- **web**: Fast preview using react-native-web, limited native features
+- **expo-go**: Scan QR with Expo Go app for real device testing
+- **android-emulator**: Full Android emulator with VNC access
+- **eas-build**: Production builds for App Store/Play Store
+`;

 export const EXPO_PROMPT = `
 // ... earlier content ...
-Workflow:
-1. FIRST: ...
+${EXPO_WORKFLOW}
 `;

 export const EXPO_WEB_PROMPT = `
 // ... earlier content ...
-${EXPO_PROMPT.split('Workflow:')[1]}
+${EXPO_WORKFLOW}
 `;

Also applies to: 262-263

src/components/ExpoPreviewSelector.tsx (2)

77-78: Extract tierOrder to avoid duplication.

The tierOrder record is defined identically in both handleSelect (line 77) and the render loop (line 89). Extract it to module scope or component scope for DRY.

♻️ Suggested fix
+const TIER_ORDER: Record<UserTier, number> = { free: 0, pro: 1, enterprise: 2 };
+
 export function ExpoPreviewSelector({
   onSelect,
   userTier = 'free',
   selectedMode,
   className
 }: ExpoPreviewSelectorProps) {
   const [selected, setSelected] = useState<ExpoPreviewMode>(selectedMode ?? 'web');

   const handleSelect = (mode: ExpoPreviewMode) => {
     const option = PREVIEW_OPTIONS.find(o => o.mode === mode);
     if (!option) return;
     
-    const tierOrder: Record<UserTier, number> = { free: 0, pro: 1, enterprise: 2 };
-    const isLocked = tierOrder[userTier] < tierOrder[option.tier];
+    const isLocked = TIER_ORDER[userTier] < TIER_ORDER[option.tier];
     
     if (!isLocked) {

And in the render:

     {PREVIEW_OPTIONS.map((option) => {
-      const tierOrder: Record<UserTier, number> = { free: 0, pro: 1, enterprise: 2 };
-      const isLocked = tierOrder[userTier] < tierOrder[option.tier];
+      const isLocked = TIER_ORDER[userTier] < TIER_ORDER[option.tier];
       const isSelected = selected === option.mode;

Also applies to: 89-90


21-56: Consider using lucide-react icons for consistency.

The coding guidelines specify using lucide-react as the icon library. While emojis work visually, using consistent icons like Globe, Smartphone, Bot, and Rocket from lucide-react would align better with the design system.

This is optional since emojis provide good visual distinction for preview modes.

src/agents/eas-build.ts (3)

113-118: Add retry logic for transient build failures.

Per coding guidelines, agent operations should retry build/lint failures up to 2 times with error context. EAS CLI commands can fail transiently due to network issues or rate limiting.

♻️ Suggested implementation
+const MAX_RETRIES = 2;
+
 export async function triggerEASBuild(
   sandboxId: string,
   config: EASBuildConfig
 ): Promise<EASBuildResult> {
   // ... validation and initialization ...

-  const result = await runCodeCommand(sandbox, buildCommand);
-  
-  if (result.exitCode !== 0) {
-    console.error('[ERROR] EAS build command failed:', result.stderr);
-    throw new Error(`EAS build failed: ${result.stderr || result.stdout}`);
-  }
+  let lastError: Error | null = null;
+  
+  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
+    if (attempt > 0) {
+      console.log(`[INFO] Retrying EAS build (attempt ${attempt + 1}/${MAX_RETRIES + 1})`);
+      await new Promise(resolve => setTimeout(resolve, 2000 * attempt));
+    }
+    
+    const result = await runCodeCommand(sandbox, buildCommand);
+    
+    if (result.exitCode === 0) {
+      // Parse and return result...
+    }
+    
+    lastError = new Error(`EAS build failed: ${result.stderr || result.stdout}`);
+    console.error(`[ERROR] EAS build attempt ${attempt + 1} failed:`, result.stderr);
+  }
+  
+  throw lastError;

157-163: Add timeout to fetch calls to prevent hanging.

The fetch call to the Expo API has no timeout. If the API is unresponsive, this could hang indefinitely. Use AbortController with a timeout signal.

♻️ Suggested fix
+const API_TIMEOUT_MS = 30000; // 30 seconds
+
 export async function checkEASBuildStatus(
   buildId: string,
   expoToken?: string
 ): Promise<EASBuildStatus> {
   const token = expoToken || process.env.EXPO_ACCESS_TOKEN;
   
   if (!token) {
     throw new Error('EXPO_ACCESS_TOKEN is required to check build status');
   }
   
   try {
+    const controller = new AbortController();
+    const timeoutId = setTimeout(() => controller.abort(), API_TIMEOUT_MS);
+    
     const response = await fetch(`https://api.expo.dev/v2/builds/${buildId}`, {
       headers: {
         'Authorization': `Bearer ${token}`,
         'Accept': 'application/json'
-      }
+      },
+      signal: controller.signal
     });
+    
+    clearTimeout(timeoutId);

Apply similar pattern to the cancelEASBuild function at line 244.


186-212: Consider handling transient status check failures.

The polling loop exits immediately if checkEASBuildStatus throws (e.g., network glitch). Consider catching transient errors and continuing to poll rather than failing the entire wait operation.

♻️ Suggested approach
 while (Date.now() - startTime < maxWaitMs) {
-    const status = await checkEASBuildStatus(buildId, expoToken);
+    let status: EASBuildStatus;
+    try {
+      status = await checkEASBuildStatus(buildId, expoToken);
+    } catch (error) {
+      console.warn(`[WARN] Status check failed, will retry: ${error}`);
+      await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
+      continue;
+    }
     
     if (status.status === 'finished') {

Comment on lines +165 to +170
For EAS Build support, add to `.env`:
```bash
EXPO_ACCESS_TOKEN=your_expo_token_here
```

Get your token from: https://expo.dev/settings/access-tokens
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Replace the bare URL with a Markdown link to satisfy linting.

✏️ Proposed fix
-Get your token from: https://expo.dev/settings/access-tokens
+Get your token from: [Expo access tokens](https://expo.dev/settings/access-tokens)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
For EAS Build support, add to `.env`:
```bash
EXPO_ACCESS_TOKEN=your_expo_token_here
```
Get your token from: https://expo.dev/settings/access-tokens
For EAS Build support, add to `.env`:
🧰 Tools
🪛 markdownlint-cli2 (0.18.1)

170-170: Bare URL used

(MD034, no-bare-urls)

🤖 Prompt for AI Agents
In `@explanations/EXPO_INTEGRATION.md` around lines 165 - 170, Replace the bare
URL with a Markdown link in the EAS Build instructions: update the text that
currently reads "Get your token from: https://expo.dev/settings/access-tokens"
so it uses a Markdown link (e.g., [Get your token from the Expo
settings](https://expo.dev/settings/access-tokens)) while leaving the
EXPO_ACCESS_TOKEN example and surrounding text unchanged; locate the line
referencing EXPO_ACCESS_TOKEN and the following URL in EXPO_INTEGRATION.md to
apply the change.

Comment on lines +33 to +40
echo "[INFO] Waiting for emulator to boot..."
adb wait-for-device

# Wait for boot completion
echo "[INFO] Waiting for boot completion..."
while [[ -z $(adb shell getprop sys.boot_completed 2>/dev/null | tr -d '\r') ]]; do
sleep 2
done
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add a boot timeout to avoid infinite hangs.
If the emulator fails to boot, this loop never exits and the sandbox will stall. Add a timeout and fail fast.

🛠️ Proposed fix
 # Wait for boot completion
 echo "[INFO] Waiting for boot completion..."
-while [[ -z $(adb shell getprop sys.boot_completed 2>/dev/null | tr -d '\r') ]]; do
-    sleep 2
-done
+BOOT_TIMEOUT_SEC=300
+BOOT_START_TS=$(date +%s)
+while [[ -z $(adb shell getprop sys.boot_completed 2>/dev/null | tr -d '\r') ]]; do
+    if (( $(date +%s) - BOOT_START_TS > BOOT_TIMEOUT_SEC )); then
+        echo "[ERROR] Emulator boot timeout after ${BOOT_TIMEOUT_SEC}s" >&2
+        exit 1
+    fi
+    sleep 2
+done
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
echo "[INFO] Waiting for emulator to boot..."
adb wait-for-device
# Wait for boot completion
echo "[INFO] Waiting for boot completion..."
while [[ -z $(adb shell getprop sys.boot_completed 2>/dev/null | tr -d '\r') ]]; do
sleep 2
done
echo "[INFO] Waiting for emulator to boot..."
adb wait-for-device
# Wait for boot completion
echo "[INFO] Waiting for boot completion..."
BOOT_TIMEOUT_SEC=300
BOOT_START_TS=$(date +%s)
while [[ -z $(adb shell getprop sys.boot_completed 2>/dev/null | tr -d '\r') ]]; do
if (( $(date +%s) - BOOT_START_TS > BOOT_TIMEOUT_SEC )); then
echo "[ERROR] Emulator boot timeout after ${BOOT_TIMEOUT_SEC}s" >&2
exit 1
fi
sleep 2
done
🤖 Prompt for AI Agents
In `@sandbox-templates/expo-android/start_android.sh` around lines 33 - 40, The
boot-wait loop using adb wait-for-device and the while checking adb shell
getprop sys.boot_completed can hang indefinitely; add a timeout mechanism (e.g.,
BOOT_TIMEOUT_SECONDS) and a start time check before/inside the while loop that
breaks and exits non-zero with an error message if elapsed time exceeds the
timeout; update the echo/logging to include the timeout and ensure the script
calls exit 1 on timeout so start_android.sh fails fast when the emulator doesn't
boot.

echo "[INFO] Emulator ready!"

# Start Expo Metro bundler with Android
cd /home/user
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Guard cd to avoid continuing on failure.
Shellcheck notes this; add || exit so failures don’t cascade.

🛠️ Proposed fix
-cd /home/user
+cd /home/user || exit 1
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
cd /home/user
cd /home/user || exit 1
🧰 Tools
🪛 Shellcheck (0.11.0)

[warning] 45-45: Use 'cd ... || exit' or 'cd ... || return' in case cd fails.

(SC2164)

🤖 Prompt for AI Agents
In `@sandbox-templates/expo-android/start_android.sh` at line 45, The script
contains an unguarded cd command ("cd /home/user") which can let the script
continue on failure; update the cd invocation so the script immediately exits on
failure (i.e., ensure the cd /home/user command is followed by a failure guard
that exits the script if the directory change fails) to prevent cascading
errors.

# Start Expo Metro bundler with Android
cd /home/user
echo "[INFO] Starting Expo development server..."
npx expo start --android --port 8081 --host 0.0.0.0
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Avoid launching the Expo dev server in the sandbox.
This starts a dev server at runtime, which conflicts with the guidance to use pre-warmed templates rather than launching dev servers inside sandboxes. Based on learnings, please replace this with a prebuilt/served artifact or align with the expected sandbox startup flow.

🤖 Prompt for AI Agents
In `@sandbox-templates/expo-android/start_android.sh` at line 47, Replace the
runtime dev-server launch command "npx expo start --android --port 8081 --host
0.0.0.0" with the sandbox-appropriate startup flow: remove that line and instead
start/serve a prebuilt artifact (for example, install or launch a prebuilt
APK/AAB or start a static HTTP server that serves the prebuilt JS bundle), or
invoke the repository's existing sandbox startup helper that brings up
pre-warmed templates; ensure the script no longer spins up a live Expo dev
server and documents which prebuilt artifact or helper command it now uses.

Comment on lines +2 to +15
FROM node:21-slim

RUN apt-get update && apt-get install -y curl git && apt-get clean && rm -rf /var/lib/apt/lists/*

WORKDIR /home/user

# Create Expo app with TypeScript blank template
RUN npx create-expo-app@latest . --template blank-typescript --yes

# Install web dependencies
RUN npm install react-dom react-native-web @expo/metro-runtime

# Install common Expo SDK modules
RUN npx expo install expo-font expo-linear-gradient expo-blur expo-status-bar
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Use Bun instead of npm/npx in the Dockerfile.
This project mandates Bun for dependency management and script execution in Dockerfiles. Please replace npm/npx with bun/bunx and install Bun in the image. As per coding guidelines, this is required.

🛠️ Proposed fix
 FROM node:21-slim

-RUN apt-get update && apt-get install -y curl git && apt-get clean && rm -rf /var/lib/apt/lists/*
+RUN apt-get update && apt-get install -y curl git && apt-get clean && rm -rf /var/lib/apt/lists/* \
+  && curl -fsSL https://bun.sh/install | bash \
+  && ln -s /root/.bun/bin/bun /usr/local/bin/bun \
+  && ln -s /root/.bun/bin/bunx /usr/local/bin/bunx
+ENV PATH="/root/.bun/bin:${PATH}"

 WORKDIR /home/user

 # Create Expo app with TypeScript blank template
-RUN npx create-expo-app@latest . --template blank-typescript --yes
+RUN bunx create-expo-app@latest . --template blank-typescript --yes

 # Install web dependencies
-RUN npm install react-dom react-native-web `@expo/metro-runtime`
+RUN bun add react-dom react-native-web `@expo/metro-runtime`

 # Install common Expo SDK modules
-RUN npx expo install expo-font expo-linear-gradient expo-blur expo-status-bar
+RUN bunx expo install expo-font expo-linear-gradient expo-blur expo-status-bar
🤖 Prompt for AI Agents
In `@sandbox-templates/expo-web/e2b.Dockerfile` around lines 2 - 15, The
Dockerfile uses npm/npx for project creation and installs (RUN npx
create-expo-app..., RUN npm install react-dom..., RUN npx expo install...), but
the project requires Bun; install Bun in the image (e.g., via Bun's official
install script or package) and replace npx with bunx and npm install with bun
install in those RUN steps so create-expo-app and expo installs run under
bun/bunx and dependencies are added with bun install.

Comment on lines +8 to +9
export type ExpoPreviewMode = 'web' | 'expo-go' | 'android-emulator' | 'eas-build';
export type UserTier = 'free' | 'pro' | 'enterprise';
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Duplicate type definitions - import from existing sources.

ExpoPreviewMode is already defined in src/agents/types.ts (line 4) and UserTier is defined in convex/usage.ts (lines 39-40). Duplicating these types risks divergence and maintenance burden.

♻️ Suggested fix
-export type ExpoPreviewMode = 'web' | 'expo-go' | 'android-emulator' | 'eas-build';
-export type UserTier = 'free' | 'pro' | 'enterprise';
+import type { ExpoPreviewMode } from '@/agents/types';
+import type { UserTier } from 'convex/usage';
+
+export type { ExpoPreviewMode, UserTier };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export type ExpoPreviewMode = 'web' | 'expo-go' | 'android-emulator' | 'eas-build';
export type UserTier = 'free' | 'pro' | 'enterprise';
import type { ExpoPreviewMode } from '@/agents/types';
import type { UserTier } from 'convex/usage';
export type { ExpoPreviewMode, UserTier };
🤖 Prompt for AI Agents
In `@src/components/ExpoPreviewSelector.tsx` around lines 8 - 9, Remove the
duplicated type declarations ExpoPreviewMode and UserTier and import them from
their single sources instead: replace the local type definitions in
ExpoPreviewSelector with imports for ExpoPreviewMode from the existing types
module (where it's declared) and UserTier from the convex usage module; update
the top of the file to import { ExpoPreviewMode } from the agents types module
and { UserTier } from the convex usage module and ensure any local references
(ExpoPreviewMode, UserTier) use the imported types.

selectedMode,
className
}: ExpoPreviewSelectorProps) {
const [selected, setSelected] = useState<ExpoPreviewMode>(selectedMode ?? 'web');
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

State won't sync when selectedMode prop changes after mount.

The component initializes selected from selectedMode but doesn't update when the prop changes. This can cause UI desync if the parent updates the selection externally.

🔧 Suggested fix using useEffect
 export function ExpoPreviewSelector({
   onSelect,
   userTier = 'free',
   selectedMode,
   className
 }: ExpoPreviewSelectorProps) {
   const [selected, setSelected] = useState<ExpoPreviewMode>(selectedMode ?? 'web');
+
+  useEffect(() => {
+    if (selectedMode !== undefined) {
+      setSelected(selectedMode);
+    }
+  }, [selectedMode]);

Add useEffect to the imports:

-import { useState } from 'react';
+import { useState, useEffect } from 'react';
🤖 Prompt for AI Agents
In `@src/components/ExpoPreviewSelector.tsx` at line 71, The component initializes
local state via useState in ExpoPreviewSelector.tsx (const [selected,
setSelected] = useState<ExpoPreviewMode>(selectedMode ?? 'web')) but never
updates when the selectedMode prop changes; add a useEffect that watches
selectedMode and calls setSelected(selectedMode ?? 'web') to keep the local
selected state in sync with prop updates (import useEffect alongside useState
and reference selected, setSelected, and selectedMode).

Comment on lines +94 to +102
<Card
key={option.mode}
className={cn(
'cursor-pointer transition-all duration-200',
isSelected && 'ring-2 ring-primary bg-primary/5',
isLocked && 'opacity-60 cursor-not-allowed',
!isLocked && !isSelected && 'hover:bg-muted/50'
)}
onClick={() => handleSelect(option.mode)}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add keyboard accessibility for interactive cards.

The cards are clickable but lack keyboard support. Add role="button", tabIndex, and onKeyDown handler for Enter/Space keys to ensure accessibility.

♿ Suggested fix
         return (
           <Card
             key={option.mode}
+            role="button"
+            tabIndex={isLocked ? -1 : 0}
+            aria-disabled={isLocked}
+            onKeyDown={(e) => {
+              if (e.key === 'Enter' || e.key === ' ') {
+                e.preventDefault();
+                handleSelect(option.mode);
+              }
+            }}
             className={cn(
               'cursor-pointer transition-all duration-200',
               isSelected && 'ring-2 ring-primary bg-primary/5',
               isLocked && 'opacity-60 cursor-not-allowed',
-              !isLocked && !isSelected && 'hover:bg-muted/50'
+              !isLocked && !isSelected && 'hover:bg-muted/50',
+              'focus-visible:ring-ring/50 focus-visible:ring-[3px] focus-visible:border-ring'
             )}
             onClick={() => handleSelect(option.mode)}
           >
🤖 Prompt for AI Agents
In `@src/components/ExpoPreviewSelector.tsx` around lines 94 - 102, The Card
elements are mouse-only interactive; update the Card component (the instance
using key={option.mode} and onClick={() => handleSelect(option.mode)}) to add
keyboard accessibility by adding role="button", a tabIndex that is 0 when not
disabled and -1 when isLocked, and an onKeyDown handler that listens for Enter
or Space and calls handleSelect(option.mode) (ensure it respects isLocked and
prevents default for Space to avoid page scroll). Keep the existing onClick
behavior and reuse the isLocked and handleSelect symbols so selection logic
remains consistent.

@@ -0,0 +1,263 @@
import { SHARED_RULES } from "./shared";
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Unused import: SHARED_RULES is imported but never used.

The SHARED_RULES import from ./shared is not referenced anywhere in this file. Either remove the unused import or integrate it into the Expo prompts if intended.

Suggested fix
-import { SHARED_RULES } from "./shared";
-
 export const EXPO_SHARED_RULES = `
🤖 Prompt for AI Agents
In `@src/prompts/expo.ts` at line 1, Remove the unused import or use it: either
delete the unused import statement for SHARED_RULES from the top of the file, or
integrate SHARED_RULES into the Expo prompt construction (e.g., merge or append
SHARED_RULES into the exported EXPO_PROMPT or the function that builds the Expo
prompt such as buildExpoPrompt/getExpoPrompt) so the symbol is actually
referenced.

Comment on lines +12 to +16
File Safety Rules:
- All CREATE OR UPDATE file paths must be relative (e.g., "App.tsx", "components/Button.tsx")
- NEVER use absolute paths like "/home/user/..." or "/home/user/app/..."
- NEVER include "/home/user" in any file path — this will cause critical errors
- When using readFiles or accessing the file system, you MUST use the actual path (e.g. "/home/user/components/Button.tsx")
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Contradictory file path guidance in prompt.

Lines 13-15 state "NEVER use absolute paths" and "NEVER include '/home/user' in any file path", but Line 16 says "When using readFiles...you MUST use the actual path (e.g. /home/user/...)".

This contradiction could confuse the LLM. Consider clarifying:

  • Use relative paths for createOrUpdateFiles (write operations)
  • Use absolute paths for readFiles (read operations)
Suggested clarification
 File Safety Rules:
-- All CREATE OR UPDATE file paths must be relative (e.g., "App.tsx", "components/Button.tsx")
-- NEVER use absolute paths like "/home/user/..." or "/home/user/app/..."
-- NEVER include "/home/user" in any file path — this will cause critical errors
-- When using readFiles or accessing the file system, you MUST use the actual path (e.g. "/home/user/components/Button.tsx")
+- For createOrUpdateFiles: Use RELATIVE paths (e.g., "App.tsx", "components/Button.tsx")
+- For readFiles: Use ABSOLUTE paths (e.g., "/home/user/components/Button.tsx")
+- NEVER include "/home/user" in createOrUpdateFiles paths — only in readFiles paths
🤖 Prompt for AI Agents
In `@src/prompts/expo.ts` around lines 12 - 16, The File Safety Rules in
src/prompts/expo.ts are contradictory: they forbid absolute paths ("NEVER use
absolute paths" / "NEVER include '/home/user'") but then require using actual
absolute paths for readFiles. Update the prompt text to clearly distinguish
usage: state that createOrUpdateFiles and other write/create operations must use
relative paths (e.g., "App.tsx", "components/Button.tsx") while
readFiles/file-system read operations must use actual absolute paths (e.g.,
"/home/user/components/Button.tsx"); edit the block containing the "File Safety
Rules" header and the three lines referencing absolute/relative path usage so
the two rules are explicit and unambiguous.

Jackson57279 added a commit that referenced this pull request Jan 17, 2026
…ter for fallback handling

- Removed Vercel AI Gateway API key from env.example.
- Updated client.ts to eliminate gateway usage and replace it with OpenRouter.
- Adjusted code-agent.ts and rate-limit.ts to reflect OpenRouter in rate limit fallback logic.
- Renamed tests to reflect the change from Vercel AI Gateway to OpenRouter.
@codecapyai
Copy link
Copy Markdown

codecapyai bot commented Jan 19, 2026

CodeCapy Review ₍ᐢ•(ܫ)•ᐢ₎

Codebase Summary

ZapDev is an AI-powered development platform that enables users to create web and mobile applications through real-time sandboxed environments. It leverages AI agents to generate code in frameworks such as Next.js, Angular, React, Vue, Svelte, and now Expo/React Native with multiple preview modes (web, Expo Go, Android Emulator, EAS Build). The system supports framework detection, sandbox creation and interactive UI components, along with integrated build workflows.

PR Changes

This pull request adds first-class Expo/React Native support. Key changes include the integration of Expo into the framework selector, new UI components such as ExpoPreviewSelector for choosing the preview mode, enhanced sandbox templates for Expo (web, full for Expo Go, and Android emulator with VNC), adjustments in Convex schema and usage limits for Expo preview modes, and new agents for handling EAS build workflows and QR code generation.

Setup Instructions

  1. Install Node.js if not already installed.
  2. Install pnpm globally: sudo npm install -g pnpm
  3. Clone the repository and cd into the project directory.
  4. Install dependencies by running: pnpm install
  5. Start the development server: pnpm dev
  6. Open your browser and navigate to http://localhost:3000 to access the web application.

Generated Test Cases

1: ExpoPreviewSelector - Select Web Preview Mode ❗️❗️❗️

Description: Verify that the ExpoPreviewSelector component properly displays all preview mode options and allows the user to select the Web Preview mode. This ensures that the UI reflects the new Expo integration and that selection feedback signals are correct.

Prerequisites:

  • User is logged in and on a page that contains the ExpoPreviewSelector component
  • User's account has a tier that permits the Web Preview mode (free tier)

Steps:

  1. Navigate to the project setup page where the framework is selected.
  2. Switch the framework setting to 'expo' to trigger the display of the ExpoPreviewSelector.
  3. Verify that four preview options are displayed: 'Web Preview', 'Expo Go (QR Code)', 'Android Emulator', and 'EAS Build (Production)'.
  4. Click on the card/button labeled 'Web Preview'.
  5. Observe the card state change (e.g., highlighted border or background) to indicate that 'Web Preview' is selected.

Expected Result: The UI should clearly display all four Expo preview options with 'Web Preview' being selectable. Upon clicking 'Web Preview', the card is visually highlighted indicating selection and the onSelect callback receives the 'web' mode.

2: ExpoPreviewSelector - Locked Options for Free Tier Users ❗️❗️❗️

Description: Ensure that for users on a free tier, preview modes designated for Pro users ('Android Emulator' and 'EAS Build') are marked as locked and cannot be selected. This test validates tier-based gating on UI components.

Prerequisites:

  • User is logged in with a free tier subscription
  • User is on the page containing the ExpoPreviewSelector component

Steps:

  1. Navigate to the framework selection page and switch to 'expo' mode.
  2. Check that the options 'Android Emulator' and 'EAS Build (Production)' are rendered with a lock indicator and reduced opacity.
  3. Attempt to click on the 'Android Emulator' or 'EAS Build' cards.
  4. Observe that the selection does not change—the selected mode remains unchanged or defaults to a permitted mode like 'Web Preview' or 'Expo Go'.

Expected Result: Locked options for Pro features should be visibly marked (with badges or lock icons) and the UI must prevent selection change when a locked option is clicked.

3: Expo QR Code Generation for Expo Go Mode ❗️❗️

Description: Test the QR code generation functionality for Expo Go mode. When a user selects the 'Expo Go (QR Code)' preview mode, the system should generate and display a QR code that points to the correct Expo deep link URL.

Prerequisites:

  • User is logged in and has selected the 'expo' framework with preview mode set to 'expo-go'
  • The backend has provided a valid sandbox URL for the Expo instance

Steps:

  1. On the preview page (after framework selection and sandbox initialization), set the preview mode to 'expo-go' using the ExpoPreviewSelector.
  2. Trigger the sandbox to start if not already running.
  3. Wait for the system to generate the Expo QR code via the QR code generation agent.
  4. Inspect the UI for an image element displaying the QR code.

Expected Result: A QR code should be visible on the screen. Hovering over or inspecting the element should reveal that it is generated using the Expo-specific URL format (e.g., starting with 'exp://').

4: Sandbox Launch Command for Android Emulator Mode ❗️❗️

Description: Verify that when the 'Android Emulator' preview mode is selected under the Expo framework, the system computes the correct development server command. This command should correspond to launching the Android emulator sandbox using the /start_android.sh script.

Prerequisites:

  • User is logged in and on a page where sandbox configurations are shown
  • User's account is set to a Pro tier (or higher) allowing Android Emulator usage

Steps:

  1. Switch the framework selection to 'expo' and select the 'Android Emulator' preview mode from the ExpoPreviewSelector.
  2. Initiate the launch of the sandbox environment.
  3. Intercept or inspect the computed development server command (for example, via logs or a debug mode).

Expected Result: The computed command should match '/start_android.sh' indicating that the system is set to launch the Android Emulator environment with VNC capabilities.

5: EAS Build Workflow - Trigger Build and Display Build URL ❗️❗️❗️

Description: Simulate the EAS Build workflow by triggering a build in the expo preview mode 'eas-build'. This test verifies the integration with EAS CLI, checking that the build command is executed, and the system eventually displays a non-empty build URL to the user.

Prerequisites:

  • User is logged in with a Pro or Enterprise tier that supports EAS Build
  • Environment variable EXPO_ACCESS_TOKEN is set for triggering EAS builds
  • User has selected the 'EAS Build (Production)' preview mode in the ExpoPreviewSelector

Steps:

  1. Navigate to the project sandbox page after selecting the 'expo' framework and the 'EAS Build' preview mode.
  2. Trigger the build process by clicking the appropriate button (e.g., 'Build for Production').
  3. Monitor the logs or UI progress indicator for EAS build initiation.
  4. Wait for the build process to complete (or simulate a successful API response in a test environment).
  5. Verify that the UI displays a build URL or a status message containing a valid URL for the built application.

Expected Result: After a successful build trigger, the UI should show a build URL string (e.g., beginning with 'https://expo.dev/accounts/'). The user should be able to click or copy this URL, indicating that the EAS Build workflow operates correctly.

Raw Changes Analyzed
File: .github/workflows/opencode.yml
Changes:
@@ -0,0 +1,33 @@
+name: opencode
+
+on:
+  issue_comment:
+    types: [created]
+  pull_request_review_comment:
+    types: [created]
+
+jobs:
+  opencode:
+    if: |
+      contains(github.event.comment.body, ' /oc') ||
+      startsWith(github.event.comment.body, '/oc') ||
+      contains(github.event.comment.body, ' /opencode') ||
+      startsWith(github.event.comment.body, '/opencode')
+    runs-on: ubuntu-latest
+    permissions:
+      id-token: write
+      contents: read
+      pull-requests: read
+      issues: read
+    steps:
+      - name: Checkout repository
+        uses: actions/checkout@v6
+        with:
+          persist-credentials: false
+
+      - name: Run opencode
+        uses: anomalyco/opencode/github@latest
+        env:
+          OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
+        with:
+          model: opencode/grok-code
\ No newline at end of file

File: bun.lock
Changes:
@@ -66,7 +66,6 @@
         "e2b": "^2.9.0",
         "embla-carousel-react": "^8.6.0",
         "eslint-config-next": "^16.1.1",
-        "exa-js": "^2.0.12",
         "firecrawl": "^4.10.0",
         "input-otp": "^1.4.2",
         "jest": "^30.2.0",
@@ -76,6 +75,7 @@
         "next-themes": "^0.4.6",
         "npkill": "^0.12.2",
         "prismjs": "^1.30.0",
+        "qrcode": "^1.5.4",
         "random-word-slugs": "^0.1.7",
         "react": "^19.2.3",
         "react-day-picker": "^9.13.0",
@@ -101,6 +101,7 @@
         "@tailwindcss/postcss": "^4.1.18",
         "@types/node": "^24.10.4",
         "@types/prismjs": "^1.26.5",
+        "@types/qrcode": "^1.5.6",
         "@types/react": "^19.2.7",
         "@types/react-dom": "^19.2.3",
         "eslint": "^9.39.2",
@@ -1026,6 +1027,8 @@
 
     "@types/prismjs": ["@types/prismjs@1.26.5", "", {}, "sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ=="],
 
+    "@types/qrcode": ["@types/qrcode@1.5.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw=="],
+
     "@types/react": ["@types/react@19.2.7", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg=="],
 
     "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
@@ -1350,8 +1353,6 @@
 
     "crc": ["crc@4.3.2", "", { "peerDependencies": { "buffer": ">=6.0.3" }, "optionalPeers": ["buffer"] }, "sha512-uGDHf4KLLh2zsHa8D8hIQ1H/HtFQhyHrc0uhHBcoKGol/Xnb+MPYfUMw7cvON6ze/GUESTudKayDcJC5HnJv1A=="],
 
-    "cross-fetch": ["cross-fetch@4.1.0", "", { "dependencies": { "node-fetch": "^2.7.0" } }, "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw=="],
-
     "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
 
     "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
@@ -1536,8 +1537,6 @@
 
     "eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="],
 
-    "exa-js": ["exa-js@2.0.12", "", { "dependencies": { "cross-fetch": "~4.1.0", "dotenv": "~16.4.7", "openai": "^5.0.1", "zod": "^3.22.0", "zod-to-json-schema": "^3.20.0" } }, "sha512-56ZYm8FLKAh3JXCptr0vlG8f39CZxCl4QcPW9QR4TSKS60PU12pEfuQdf+6xGWwQp+doTgXguCqqzxtvgDTDKw=="],
-
     "execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="],
 
     "exit-x": ["exit-x@0.2.2", "", {}, "sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ=="],
@@ -2042,8 +2041,6 @@
 
     "open-file-explorer": ["open-file-explorer@1.0.2", "", {}, "sha512-U4p+VW5uhtgK5W7qSsRhKioYAHCiTX9PiqV4ZtAFLMGfQ3QhppaEevk8k8+DSjM6rgc1yNIR2nttDuWfdNnnJQ=="],
 
-    "openai": ["openai@5.23.2", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.23.8" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-MQBzmTulj+MM5O8SKEk/gL8a7s5mktS9zUtAkU257WjvobGc9nKcBuVwjyEEcb9SI8a8Y2G/mzn3vm9n1Jlleg=="],
-
     "openapi-fetch": ["openapi-fetch@0.14.1", "", { "dependencies": { "openapi-typescript-helpers": "^0.0.15" } }, "sha512-l7RarRHxlEZYjMLd/PR0slfMVse2/vvIAGm75/F7J6MlQ8/b9uUQmUF2kCPrQhJqMXSxmYWObVgeYXbFYzZR+A=="],
 
     "openapi-typescript-helpers": ["openapi-typescript-helpers@0.0.15", "", {}, "sha512-opyTPaunsklCBpTK8JGef6mfPhLSnyy5a0IN9vKtx3+4aExf+KxEqYwIy3hqkedXIB97u357uLMJsOnm3GVjsw=="],
@@ -2732,10 +2729,6 @@
 
     "eslint-plugin-react-hooks/zod": ["zod@4.1.12", "", {}, "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ=="],
 
-    "exa-js/dotenv": ["dotenv@16.4.7", "", {}, "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ=="],
-
-    "exa-js/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
-
     "execa/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
 
     "express/cookie": ["cookie@0.7.1", "", {}, "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w=="],

File: convex/importData.ts
Changes:
@@ -16,7 +16,8 @@ export const importProject = internalMutation({
       v.literal("ANGULAR"),
       v.literal("REACT"),
       v.literal("VUE"),
-      v.literal("SVELTE")
+      v.literal("SVELTE"),
+      v.literal("EXPO")
     ),
     createdAt: v.string(), // ISO date string
     updatedAt: v.string(), // ISO date string
@@ -89,7 +90,8 @@ export const importFragment = internalMutation({
       v.literal("ANGULAR"),
       v.literal("REACT"),
       v.literal("VUE"),
-      v.literal("SVELTE")
+      v.literal("SVELTE"),
+      v.literal("EXPO")
     ),
     createdAt: v.string(),
     updatedAt: v.string(),
@@ -130,7 +132,8 @@ export const importFragmentDraft = internalMutation({
       v.literal("ANGULAR"),
       v.literal("REACT"),
       v.literal("VUE"),
-      v.literal("SVELTE")
+      v.literal("SVELTE"),
+      v.literal("EXPO")
     ),
     createdAt: v.string(),
     updatedAt: v.string(),
@@ -278,7 +281,8 @@ export const importProjectAction = action({
       v.literal("ANGULAR"),
       v.literal("REACT"),
       v.literal("VUE"),
-      v.literal("SVELTE")
+      v.literal("SVELTE"),
+      v.literal("EXPO")
     ),
     createdAt: v.string(),
     updatedAt: v.string(),
@@ -320,7 +324,8 @@ export const importFragmentAction = action({
       v.literal("ANGULAR"),
       v.literal("REACT"),
       v.literal("VUE"),
-      v.literal("SVELTE")
+      v.literal("SVELTE"),
+      v.literal("EXPO")
     ),
     createdAt: v.string(),
     updatedAt: v.string(),
@@ -343,7 +348,8 @@ export const importFragmentDraftAction = action({
       v.literal("ANGULAR"),
       v.literal("REACT"),
       v.literal("VUE"),
-      v.literal("SVELTE")
+      v.literal("SVELTE"),
+      v.literal("EXPO")
     ),
     createdAt: v.string(),
     updatedAt: v.string(),

File: convex/sandboxSessions.ts
Changes:
@@ -16,7 +16,8 @@ export const create = mutation({
       v.literal("ANGULAR"),
       v.literal("REACT"),
       v.literal("VUE"),
-      v.literal("SVELTE")
+      v.literal("SVELTE"),
+      v.literal("EXPO")
     ),
     autoPauseTimeout: v.optional(v.number()), // Default 10 minutes
   },

File: convex/schema.ts
Changes:
@@ -6,7 +6,15 @@ export const frameworkEnum = v.union(
   v.literal("ANGULAR"),
   v.literal("REACT"),
   v.literal("VUE"),
-  v.literal("SVELTE")
+  v.literal("SVELTE"),
+  v.literal("EXPO")
+);
+
+export const expoPreviewModeEnum = v.union(
+  v.literal("web"),
+  v.literal("expo-go"),
+  v.literal("android-emulator"),
+  v.literal("eas-build")
 );
 
 export const messageRoleEnum = v.union(
@@ -115,6 +123,11 @@ export default defineSchema({
     files: v.any(),
     metadata: v.optional(v.any()),
     framework: frameworkEnum,
+    expoPreviewMode: v.optional(expoPreviewModeEnum),
+    expoQrCodeUrl: v.optional(v.string()),
+    expoVncUrl: v.optional(v.string()),
+    expoEasBuildUrl: v.optional(v.string()),
+    expoApkUrl: v.optional(v.string()),
     createdAt: v.optional(v.number()),
     updatedAt: v.optional(v.number()),
   })

File: convex/usage.ts
Changes:
@@ -9,6 +9,59 @@ const UNLIMITED_POINTS = Number.MAX_SAFE_INTEGER;
 const DURATION_MS = 24 * 60 * 60 * 1000; // 24 hours in milliseconds
 const GENERATION_COST = 1;
 
+// Expo-specific limits by tier
+export const EXPO_LIMITS = {
+  free: {
+    webPreview: true,
+    expoGo: true,
+    androidEmulator: false,
+    easBuild: false,
+    maxBuildsPerDay: 5,
+    maxEmulatorMinutes: 0
+  },
+  pro: {
+    webPreview: true,
+    expoGo: true,
+    androidEmulator: true,
+    easBuild: true,
+    maxBuildsPerDay: 50,
+    maxEmulatorMinutes: 120 // 2 hours per day
+  },
+  enterprise: {
+    webPreview: true,
+    expoGo: true,
+    androidEmulator: true,
+    easBuild: true,
+    maxBuildsPerDay: 500,
+    maxEmulatorMinutes: 600 // 10 hours per day
+  }
+} as const;
+
+export type ExpoPreviewMode = 'web' | 'expo-go' | 'android-emulator' | 'eas-build';
+export type UserTier = 'free' | 'pro' | 'enterprise';
+
+/**
+ * Check if user can use a specific Expo preview mode
+ */
+export function canUseExpoPreviewMode(
+  tier: UserTier,
+  mode: ExpoPreviewMode
+): boolean {
+  const limits = EXPO_LIMITS[tier];
+  switch (mode) {
+    case 'web':
+      return limits.webPreview;
+    case 'expo-go':
+      return limits.expoGo;
+    case 'android-emulator':
+      return limits.androidEmulator;
+    case 'eas-build':
+      return limits.easBuild;
+    default:
+      return false;
+  }
+}
+
 /**
  * Check and consume credits for a generation
  * Returns true if credits were successfully consumed, false if insufficient credits

File: env.example
Changes:
@@ -24,9 +24,6 @@ OPENROUTER_BASE_URL="https://openrouter.ai/api/v1"
 # Cerebras API (Z.AI GLM 4.7 model - ultra-fast inference)
 CEREBRAS_API_KEY=""  # Get from https://cloud.cerebras.ai
 
-# Vercel AI Gateway (fallback for Cerebras rate limits)
-VERCEL_AI_GATEWAY_API_KEY=""  # Get from https://vercel.com/dashboard/ai-gateway
-
 # Brave Search API (web search for subagent research - optional)
 BRAVE_SEARCH_API_KEY=""  # Get from https://api-dashboard.search.brave.com/app/keys
 

File: explanations/EXPO_INTEGRATION.md
Changes:
@@ -0,0 +1,206 @@
+# Expo/React Native Integration
+
+ZapDev supports Expo/React Native for cross-platform mobile app development with multiple preview modes.
+
+## Overview
+
+Expo enables building iOS, Android, and web apps from a single codebase using React Native. ZapDev integrates Expo with 4 distinct preview modes to support different development and testing scenarios.
+
+## Preview Modes
+
+### 1. Web Preview (Free Tier)
+- **Speed:** ~30 seconds
+- **Description:** Uses `react-native-web` for fast browser-based preview
+- **Limitations:** No native APIs (camera, location, haptics, etc.)
+- **Best for:** Quick prototyping, UI development, web-compatible features
+
+### 2. Expo Go QR Code (Free Tier)
+- **Speed:** ~1-2 minutes
+- **Description:** Generate a QR code that users scan with the Expo Go app
+- **Limitations:** Limited to Expo SDK modules, no custom native code
+- **Best for:** Real device testing, sharing demos with stakeholders
+
+### 3. Android Emulator (Pro Tier)
+- **Speed:** ~3-5 minutes
+- **Description:** Full Android emulator running in E2B with VNC access
+- **Limitations:** Requires Pro subscription, higher resource usage
+- **Best for:** Full Android testing, GPU-dependent features, native APIs
+
+### 4. EAS Build (Pro Tier)
+- **Speed:** ~5-15 minutes
+- **Description:** Cloud builds via Expo Application Services
+- **Output:** Installable APK (Android) or IPA (iOS) files
+- **Best for:** Production releases, App Store/Play Store submissions
+
+## Framework Detection
+
+ZapDev automatically detects Expo projects from user prompts containing:
+- "mobile app", "iOS", "Android"
+- "React Native", "Expo"
+- "cross-platform", "native app"
+- "phone app"
+
+## AI Prompt Guidelines
+
+When generating Expo code, the AI follows these rules:
+
+1. **Components:** Use React Native components (View, Text, TouchableOpacity, etc.)
+2. **Styling:** Use `StyleSheet.create()` - NO CSS files, NO className, NO Tailwind
+3. **Imports:** `import { View, Text } from 'react-native'`
+4. **Entry Point:** `App.tsx` as the root component
+5. **Navigation:** Use `expo-router` for multi-screen apps
+
+### Example Component
+
+```tsx
+import { StyleSheet, View, Text, TouchableOpacity } from 'react-native';
+import { StatusBar } from 'expo-status-bar';
+
+export default function App() {
+  return (
+    <View style={styles.container}>
+      <Text style={styles.title}>Hello Expo</Text>
+      <TouchableOpacity style={styles.button}>
+        <Text style={styles.buttonText}>Press Me</Text>
+      </TouchableOpacity>
+      <StatusBar style="auto" />
+    </View>
+  );
+}
+
+const styles = StyleSheet.create({
+  container: {
+    flex: 1,
+    backgroundColor: '#fff',
+    alignItems: 'center',
+    justifyContent: 'center',
+  },
+  title: {
+    fontSize: 24,
+    fontWeight: 'bold',
+    marginBottom: 20,
+  },
+  button: {
+    backgroundColor: '#007AFF',
+    paddingHorizontal: 20,
+    paddingVertical: 12,
+    borderRadius: 8,
+  },
+  buttonText: {
+    color: '#fff',
+    fontSize: 16,
+    fontWeight: '600',
+  },
+});
+```
+
+## Expo SDK Modules
+
+### Pre-installed (All Templates)
+- `expo-status-bar` - Status bar control
+- `expo-font` - Custom fonts
+- `expo-linear-gradient` - Gradient backgrounds
+- `expo-blur` - Blur effects
+
+### Available via `npx expo install`
+- `expo-camera` - Camera access
+- `expo-image-picker` - Photo library/camera capture
+- `expo-location` - GPS/location
+- `expo-haptics` - Haptic feedback
+- `expo-notifications` - Push notifications
+- `expo-file-system` - File operations
+- `expo-av` - Audio/video playback
+- `expo-sensors` - Accelerometer, gyroscope
+- `expo-secure-store` - Secure storage
+- `expo-sqlite` - Local database
+
+## Web Compatibility
+
+When using Web Preview mode, these components are **NOT available**:
+- `expo-camera`
+- `expo-location`
+- `expo-haptics`
+- `expo-sensors`
+- `expo-notifications` (limited)
+- `expo-secure-store`
+
+### Web Alternatives
+- **Camera:** Use `<input type="file" accept="image/*" capture>`
+- **Location:** Use `navigator.geolocation`
+- **Storage:** Use AsyncStorage or localStorage
+
+## E2B Sandbox Templates
+
+### zapdev-expo-web
+- Base: `node:21-slim`
+- Pre-installed: react-native-web, @expo/metro-runtime
+- Port: 8081 (Metro bundler)
+- Command: `npx expo start --web`
+
+### zapdev-expo-full
+- Base: `node:21-slim`
+- Pre-installed: All Expo SDK modules
+- Port: 8081 (with tunnel for Expo Go)
+- Command: `npx expo start --tunnel`
+
+### zapdev-expo-android
+- Base: `ubuntu:22.04`
+- Includes: Android SDK, emulator, VNC server
+- Ports: 5900 (VNC), 8081 (Metro), 5555 (ADB)
+- Resources: 4 vCPU, 8GB RAM
+
+## Subscription Tiers
+
+| Feature | Free | Pro | Enterprise |
+|---------|------|-----|------------|
+| Web Preview | ✅ | ✅ | ✅ |
+| Expo Go (QR) | ✅ | ✅ | ✅ |
+| Android Emulator | ❌ | ✅ | ✅ |
+| EAS Build | ❌ | ✅ | ✅ |
+| Max Builds/Day | 5 | 50 | 500 |
+| Emulator Minutes/Day | 0 | 120 | 600 |
+
+## Environment Variables
+
+For EAS Build support, add to `.env`:
+```bash
+EXPO_ACCESS_TOKEN=your_expo_token_here
+```
+
+Get your token from: https://expo.dev/settings/access-tokens
+
+## Troubleshooting
+
+### Web Preview Shows Blank Screen
+- Ensure you're using web-compatible components
+- Check console for `react-native-web` errors
+- Avoid native-only modules
+
+### Expo Go QR Not Working
+- Verify tunnel is running (`--tunnel` flag)
+- Check network connectivity
+- Ensure Expo Go app is up to date
+
+### Android Emulator Not Starting
+- Requires Pro tier subscription
+- VNC may take 30-60s to initialize
+- Check if KVM is available on E2B
+
+### EAS Build Failing
+- Verify `EXPO_ACCESS_TOKEN` is set
+- Check `eas.json` configuration
+- Ensure `app.json` has required fields (slug, version)
+
+## Example Prompts
+
+1. "Build a mobile todo app for iOS and Android"
+2. "Create a React Native camera app"
+3. "Make a cross-platform fitness tracker"
+4. "Build an Expo app with location tracking"
+5. "Create a mobile social media feed"
+
+## Related Documentation
+
+- [Expo Official Docs](https://docs.expo.dev)
+- [React Native Docs](https://reactnative.dev)
+- [E2B Expo Template](https://e2b.dev/docs/template/examples/expo)

File: package.json
Changes:
@@ -73,7 +73,6 @@
     "e2b": "^2.9.0",
     "embla-carousel-react": "^8.6.0",
     "eslint-config-next": "^16.1.1",
-
     "firecrawl": "^4.10.0",
     "input-otp": "^1.4.2",
     "jest": "^30.2.0",
@@ -83,6 +82,7 @@
     "next-themes": "^0.4.6",
     "npkill": "^0.12.2",
     "prismjs": "^1.30.0",
+    "qrcode": "^1.5.4",
     "random-word-slugs": "^0.1.7",
     "react": "^19.2.3",
     "react-day-picker": "^9.13.0",
@@ -108,6 +108,7 @@
     "@tailwindcss/postcss": "^4.1.18",
     "@types/node": "^24.10.4",
     "@types/prismjs": "^1.26.5",
+    "@types/qrcode": "^1.5.6",
     "@types/react": "^19.2.7",
     "@types/react-dom": "^19.2.3",
     "eslint": "^9.39.2",

File: sandbox-templates/expo-android/e2b.Dockerfile
Changes:
@@ -0,0 +1,56 @@
+# Expo Android Emulator Template with VNC
+FROM ubuntu:22.04
+
+ENV DEBIAN_FRONTEND=noninteractive
+
+# Install base dependencies
+RUN apt-get update && apt-get install -y \
+    curl wget git unzip openjdk-17-jdk \
+    x11vnc xvfb fluxbox \
+    qemu-kvm libvirt-daemon-system libvirt-clients bridge-utils \
+    supervisor \
+    && apt-get clean && rm -rf /var/lib/apt/lists/*
+
+# Install Node.js 21
+RUN curl -fsSL https://deb.nodesource.com/setup_21.x | bash - \
+    && apt-get install -y nodejs
+
+# Set up Android SDK
+ENV ANDROID_HOME=/opt/android-sdk
+ENV ANDROID_SDK_ROOT=/opt/android-sdk
+ENV PATH=$PATH:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator
+
+RUN mkdir -p $ANDROID_HOME/cmdline-tools \
+    && cd $ANDROID_HOME/cmdline-tools \
+    && wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -O cmdline-tools.zip \
+    && unzip -q cmdline-tools.zip \
+    && mv cmdline-tools latest \
+    && rm cmdline-tools.zip
+
+# Accept licenses and install Android SDK components
+RUN yes | sdkmanager --licenses > /dev/null 2>&1 || true
+RUN sdkmanager "platform-tools" "platforms;android-34" "emulator" "system-images;android-34;google_apis;x86_64"
+
+# Create AVD (Android Virtual Device)
+RUN echo no | avdmanager create avd -n expo_emulator -k "system-images;android-34;google_apis;x86_64" --force
+
+WORKDIR /home/user
+
+# Create Expo project
+RUN npx create-expo-app@latest . --template blank-typescript --yes
+
+# Install dependencies
+RUN npm install react-dom react-native-web @expo/metro-runtime
+RUN npx expo install expo-font expo-linear-gradient expo-blur expo-status-bar expo-camera expo-image-picker expo-location expo-haptics
+
+# Install global tools
+RUN npm install -g @expo/cli eas-cli
+
+# Copy start script
+COPY start_android.sh /start_android.sh
+RUN chmod +x /start_android.sh
+
+# Expose ports: VNC(5900), ADB(5555), Metro(8081), Expo(19000-19002)
+EXPOSE 5900 5555 8081 19000 19001 19002
+
+CMD ["/start_android.sh"]

File: sandbox-templates/expo-android/e2b.toml
Changes:
@@ -0,0 +1,15 @@
+# E2B Sandbox Template Configuration for Expo Android Emulator
+
+# Template name used when creating sandboxes
+template_id = "zapdev-expo-android"
+
+# Dockerfile to build the template
+dockerfile = "e2b.Dockerfile"
+
+# Start command (runs when sandbox starts)
+start_cmd = "/start_android.sh"
+
+# Template resource configuration (higher specs for emulator)
+[resources]
+cpu_count = 4
+memory_mb = 8192

File: sandbox-templates/expo-android/start_android.sh
Changes:
@@ -0,0 +1,47 @@
+#!/bin/bash
+
+# Start virtual display
+echo "[INFO] Starting virtual display..."
+Xvfb :99 -screen 0 1280x720x24 &
+export DISPLAY=:99
+
+# Wait for Xvfb to start
+sleep 2
+
+# Start window manager
+echo "[INFO] Starting window manager..."
+fluxbox &
+
+# Start VNC server
+echo "[INFO] Starting VNC server on port 5900..."
+x11vnc -display :99 -forever -shared -rfbport 5900 -nopw &
+
+# Wait for display services
+sleep 2
+
+# Start Android emulator
+echo "[INFO] Starting Android emulator..."
+$ANDROID_HOME/emulator/emulator -avd expo_emulator \
+    -no-audio \
+    -no-boot-anim \
+    -gpu swiftshader_indirect \
+    -no-snapshot \
+    -memory 2048 \
+    -cores 2 &
+
+# Wait for emulator to boot
+echo "[INFO] Waiting for emulator to boot..."
+adb wait-for-device
+
+# Wait for boot completion
+echo "[INFO] Waiting for boot completion..."
+while [[ -z $(adb shell getprop sys.boot_completed 2>/dev/null | tr -d '\r') ]]; do
+    sleep 2
+done
+
+echo "[INFO] Emulator ready!"
+
+# Start Expo Metro bundler with Android
+cd /home/user
+echo "[INFO] Starting Expo development server..."
+npx expo start --android --port 8081 --host 0.0.0.0

File: sandbox-templates/expo-full/e2b.Dockerfile
Changes:
@@ -0,0 +1,23 @@
+# Expo Full Template (Web + Expo Go support with tunnel)
+FROM node:21-slim
+
+RUN apt-get update && apt-get install -y curl git qrencode && apt-get clean && rm -rf /var/lib/apt/lists/*
+
+WORKDIR /home/user
+
+# Create Expo app with TypeScript blank template
+RUN npx create-expo-app@latest . --template blank-typescript --yes
+
+# Install web dependencies
+RUN npm install react-dom react-native-web @expo/metro-runtime
+
+# Install common Expo SDK modules
+RUN npx expo install expo-font expo-linear-gradient expo-blur expo-status-bar expo-camera expo-image-picker expo-location expo-haptics
+
+# Install Expo CLI globally for tunnel support
+RUN npm install -g @expo/cli eas-cli
+
+WORKDIR /home/user
+
+# Start Metro bundler with tunnel for Expo Go access
+CMD ["npx", "expo", "start", "--port", "8081", "--host", "0.0.0.0", "--tunnel"]

File: sandbox-templates/expo-full/e2b.toml
Changes:
@@ -0,0 +1,15 @@
+# E2B Sandbox Template Configuration for Expo Full (Web + Expo Go)
+
+# Template name used when creating sandboxes
+template_id = "zapdev-expo-full"
+
+# Dockerfile to build the template
+dockerfile = "e2b.Dockerfile"
+
+# Start command (runs when sandbox starts)
+start_cmd = "npx expo start --port 8081 --host 0.0.0.0 --tunnel"
+
+# Template resource configuration
+[resources]
+cpu_count = 2
+memory_mb = 2048

File: sandbox-templates/expo-web/e2b.Dockerfile
Changes:
@@ -0,0 +1,20 @@
+# Expo Web Preview Template
+FROM node:21-slim
+
+RUN apt-get update && apt-get install -y curl git && apt-get clean && rm -rf /var/lib/apt/lists/*
+
+WORKDIR /home/user
+
+# Create Expo app with TypeScript blank template
+RUN npx create-expo-app@latest . --template blank-typescript --yes
+
+# Install web dependencies
+RUN npm install react-dom react-native-web @expo/metro-runtime
+
+# Install common Expo SDK modules
+RUN npx expo install expo-font expo-linear-gradient expo-blur expo-status-bar
+
+WORKDIR /home/user
+
+# Start Metro bundler for web on port 8081
+CMD ["npx", "expo", "start", "--web", "--port", "8081", "--host", "0.0.0.0"]

File: sandbox-templates/expo-web/e2b.toml
Changes:
@@ -0,0 +1,15 @@
+# E2B Sandbox Template Configuration for Expo Web
+
+# Template name used when creating sandboxes
+template_id = "zapdev-expo-web"
+
+# Dockerfile to build the template
+dockerfile = "e2b.Dockerfile"
+
+# Start command (runs when sandbox starts)
+start_cmd = "npx expo start --web --port 8081 --host 0.0.0.0"
+
+# Template resource configuration
+[resources]
+cpu_count = 2
+memory_mb = 2048

File: src/agents/client.ts
Changes:
@@ -1,6 +1,5 @@
 import { createOpenAI } from "@ai-sdk/openai";
 import { createCerebras } from "@ai-sdk/cerebras";
-import { createGateway } from "ai";
 
 export const openrouter = createOpenAI({
   apiKey: process.env.OPENROUTER_API_KEY!,
@@ -11,10 +10,6 @@ export const cerebras = createCerebras({
   apiKey: process.env.CEREBRAS_API_KEY || "",
 });
 
-export const gateway = createGateway({
-  apiKey: process.env.VERCEL_AI_GATEWAY_API_KEY || "",
-});
-
 // Cerebras model IDs
 const CEREBRAS_MODELS = ["zai-glm-4.7"];
 
@@ -31,7 +26,7 @@ export function getModel(
   options?: ClientOptions
 ) {
   if (isCerebrasModel(modelId) && options?.useGatewayFallback) {
-    return gateway(modelId);
+    return openrouter(modelId);
   }
   if (isCerebrasModel(modelId)) {
     return cerebras(modelId);
@@ -45,7 +40,7 @@ export function getClientForModel(
 ) {
   if (isCerebrasModel(modelId) && options?.useGatewayFallback) {
     return {
-      chat: (_modelId: string) => gateway(modelId),
+      chat: (_modelId: string) => openrouter(modelId),
     };
   }
   if (isCerebrasModel(modelId)) {

File: src/agents/code-agent.ts
Changes:
@@ -12,6 +12,7 @@ import {
   type AgentState,
   type AgentRunInput,
   type ModelId,
+  type ExpoPreviewMode,
   MODEL_CONFIGS,
   selectModelForTask,
   frameworkToConvexEnum,
@@ -37,6 +38,9 @@ import {
   REACT_PROMPT,
   VUE_PROMPT,
   SVELTE_PROMPT,
+  EXPO_PROMPT,
+  EXPO_WEB_PROMPT,
+  EXPO_NATIVE_PROMPT,
 } from "@/prompt";
 import { sanitizeTextForDatabase } from "@/lib/utils";
 import { filterAIGeneratedFiles } from "@/lib/filter-ai-files";
@@ -111,7 +115,7 @@ const extractSummaryText = (value: string): string => {
   return trimmed;
 };
 
-const getFrameworkPrompt = (framework: Framework): string => {
+const getFrameworkPrompt = (framework: Framework, expoPreviewMode?: ExpoPreviewMode): string => {
   switch (framework) {
     case "nextjs":
       return NEXTJS_PROMPT;
@@ -123,6 +127,11 @@ const getFrameworkPrompt = (framework: Framework): string => {
       return VUE_PROMPT;
     case "svelte":
       return SVELTE_PROMPT;
+    case "expo":
+      // Use appropriate prompt based on preview mode
+      if (expoPreviewMode === "web") return EXPO_WEB_PROMPT;
+      if (expoPreviewMode === "android-emulator" || expoPreviewMode === "expo-go") return EXPO_NATIVE_PROMPT;
+      return EXPO_PROMPT;
     default:
       return NEXTJS_PROMPT;
   }
@@ -157,7 +166,7 @@ async function detectFramework(prompt: string): Promise<Framework> {
 
       const detectedFramework = text.trim().toLowerCase();
       if (
-        ["nextjs", "angular", "react", "vue", "svelte"].includes(detectedFramework)
+        ["nextjs", "angular", "react", "vue", "svelte", "expo"].includes(detectedFramework)
       ) {
         return detectedFramework as Framework;
       }
@@ -557,9 +566,14 @@ export async function* runCodeAgent(
         const result = streamText({
           model: client.chat(selectedModel),
           providerOptions: useGatewayFallbackForStream ? {
-            gateway: {
-              only: ['cerebras'],
-            }
+            openai: {
+              extraBody: {
+                provider: {
+                  order: ["cerebras"],
+                  allow_fallbacks: false,
+                },
+              },
+            },
           } : undefined,
           system: frameworkPrompt,
           messages,
@@ -609,7 +623,7 @@ export async function* runCodeAgent(
         const canRetry = isRateLimit || isServer;
 
         if (!useGatewayFallbackForStream && isRateLimit) {
-          console.log(`[GATEWAY-FALLBACK] Rate limit hit for ${selectedModel}. Switching to Vercel AI Gateway with Cerebras-only routing...`);
+          console.log(`[GATEWAY-FALLBACK] Rate limit hit for ${selectedModel}. Switching to OpenRouter with Cerebras provider...`);
           useGatewayFallbackForStream = true;
           continue;
         }
@@ -672,9 +686,14 @@ export async function* runCodeAgent(
           followUpResult = await generateText({
             model: client.chat(selectedModel),
             providerOptions: summaryUseGatewayFallback ? {
-              gateway: {
-                only: ['cerebras'],
-              }
+              openai: {
+                extraBody: {
+                  provider: {
+                    order: ["cerebras"],
+                    allow_fallbacks: false,
+                  },
+                },
+              },
             } : undefined,
             system: frameworkPrompt,
             messages: [
@@ -705,11 +724,11 @@ export async function* runCodeAgent(
           }
 
           if (isRateLimitError(error) && !summaryUseGatewayFallback) {
-            console.log(`[GATEWAY-FALLBACK] Rate limit hit for summary. Switching to Vercel AI Gateway...`);
+            console.log(`[GATEWAY-FALLBACK] Rate limit hit for summary. Switching to OpenRouter...`);
             summaryUseGatewayFallback = true;
           } else if (isRateLimitError(error)) {
             const waitMs = 60_000;
-            console.log(`[GATEWAY-FALLBACK] Gateway rate limit for summary. Waiting ${waitMs / 1000}s...`);
+            console.log(`[GATEWAY-FALLBACK] OpenRouter rate limit for summary. Waiting ${waitMs / 1000}s...`);
             await new Promise(resolve => setTimeout(resolve, waitMs));
           } else {
             const backoffMs = 1000 * Math.pow(2, summaryRetries - 1);

File: src/agents/eas-build.ts
Changes:
@@ -0,0 +1,257 @@
+import { Sandbox } from "@e2b/code-interpreter";
+import { getSandbox, runCodeCommand } from "./sandbox-utils";
+
+export interface EASBuildConfig {
+  platform: 'android' | 'ios' | 'all';
+  profile: 'development' | 'preview' | 'production';
+  expoToken?: string;
+}
+
+export interface EASBuildResult {
+  buildId: string;
+  buildUrl: string;
+  platform: string;
+  status: 'pending' | 'in-queue' | 'in-progress' | 'finished' | 'errored' | 'canceled';
+}
+
+export interface EASBuildStatus {
+  status: 'pending' | 'in-queue' | 'in-progress' | 'finished' | 'errored' | 'canceled';
+  downloadUrl?: string;
+  artifacts?: {
+    buildUrl?: string;
+    applicationArchiveUrl?: string;
+  };
+  error?: string;
+}
+
+/**
+ * Initialize EAS in a sandbox (creates eas.json if it doesn't exist)
+ */
+export async function initializeEAS(sandbox: Sandbox): Promise<void> {
+  console.log('[INFO] Initializing EAS configuration...');
+  
+  // Check if eas.json exists
+  const checkResult = await runCodeCommand(sandbox, 'test -f eas.json && echo "exists"');
+  
+  if (!checkResult.stdout.includes('exists')) {
+    // Create default eas.json configuration
+    const easConfig = {
+      cli: {
+        version: ">= 13.0.0"
+      },
+      build: {
+        development: {
+          developmentClient: true,
+          distribution: "internal"
+        },
+        preview: {
+          distribution: "internal",
+          android: {
+            buildType: "apk"
+          }
+        },
+        production: {
+          autoIncrement: true
+        }
+      },
+      submit: {
+        production: {}
+      }
+    };
+    
+    // Write eas.json
+    await sandbox.files.write('/home/user/eas.json', JSON.stringify(easConfig, null, 2));
+    console.log('[INFO] Created eas.json configuration');
+  }
+  
+  // Ensure app.json has required fields for EAS
+  try {
+    const appJsonContent = await sandbox.files.read('/home/user/app.json');
+    if (typeof appJsonContent === 'string') {
+      const appJson = JSON.parse(appJsonContent);
+      
+      // Ensure required fields exist
+      if (!appJson.expo) appJson.expo = {};
+      if (!appJson.expo.slug) appJson.expo.slug = 'zapdev-app';
+      if (!appJson.expo.name) appJson.expo.name = 'ZapDev App';
+      if (!appJson.expo.version) appJson.expo.version = '1.0.0';
+      
+      // Add EAS project ID placeholder if not present
+      if (!appJson.expo.extra) appJson.expo.extra = {};
+      if (!appJson.expo.extra.eas) appJson.expo.extra.eas = {};
+      
+      await sandbox.files.write('/home/user/app.json', JSON.stringify(appJson, null, 2));
+      console.log('[INFO] Updated app.json for EAS compatibility');
+    }
+  } catch (error) {
+    console.warn('[WARN] Could not update app.json:', error);
+  }
+}
+
+/**
+ * Trigger an EAS Build
+ */
+export async function triggerEASBuild(
+  sandboxId: string,
+  config: EASBuildConfig
+): Promise<EASBuildResult> {
+  const sandbox = await getSandbox(sandboxId);
+  const expoToken = config.expoToken || process.env.EXPO_ACCESS_TOKEN;
+  
+  if (!expoToken) {
+    throw new Error('EXPO_ACCESS_TOKEN is required for EAS builds. Set it in environment variables.');
+  }
+  
+  // Initialize EAS if needed
+  await initializeEAS(sandbox);
+  
+  console.log(`[INFO] Triggering EAS build for platform: ${config.platform}, profile: ${config.profile}`);
+  
+  // Build the command with proper token handling
+  const buildCommand = `EXPO_TOKEN="${expoToken}" npx eas-cli build --platform ${config.platform} --profile ${config.profile} --non-interactive --json --no-wait`;
+  
+  const result = await runCodeCommand(sandbox, buildCommand);
+  
+  if (result.exitCode !== 0) {
+    console.error('[ERROR] EAS build command failed:', result.stderr);
+    throw new Error(`EAS build failed: ${result.stderr || result.stdout}`);
+  }
+  
+  try {
+    // Parse the JSON output from EAS CLI
+    const output = result.stdout.trim();
+    const jsonMatch = output.match(/\[[\s\S]*\]|\{[\s\S]*\}/);
+    
+    if (!jsonMatch) {
+      throw new Error('Could not parse EAS build output');
+    }
+    
+    const buildData = JSON.parse(jsonMatch[0]);
+    const build = Array.isArray(buildData) ? buildData[0] : buildData;
+    
+    return {
+      buildId: build.id,
+      buildUrl: `https://expo.dev/accounts/${build.accountName || 'user'}/projects/${build.projectId || 'project'}/builds/${build.id}`,
+      platform: build.platform || config.platform,
+      status: build.status || 'pending'
+    };
+  } catch (parseError) {
+    console.error('[ERROR] Failed to parse EAS build output:', result.stdout);
+    throw new Error(`Failed to parse EAS build response: ${parseError instanceof Error ? parseError.message : String(parseError)}`);
+  }
+}
+
+/**
+ * Check the status of an EAS build
+ */
+export async function checkEASBuildStatus(
+  buildId: string,
+  expoToken?: string
+): Promise<EASBuildStatus> {
+  const token = expoToken || process.env.EXPO_ACCESS_TOKEN;
+  
+  if (!token) {
+    throw new Error('EXPO_ACCESS_TOKEN is required to check build status');
+  }
+  
+  try {
+    const response = await fetch(`https://api.expo.dev/v2/builds/${buildId}`, {
+      headers: {
+        'Authorization': `Bearer ${token}`,
+        'Accept': 'application/json'
+      }
+    });
+    
+    if (!response.ok) {
+      throw new Error(`Failed to fetch build status: ${response.status} ${response.statusText}`);
+    }
+    
+    const data = await response.json();
+    
+    return {
+      status: data.status,
+      downloadUrl: data.artifacts?.buildUrl || data.artifacts?.applicationArchiveUrl,
+      artifacts: data.artifacts,
+      error: data.error
+    };
+  } catch (error) {
+    console.error('[ERROR] Failed to check EAS build status:', error);
+    throw new Error(`Failed to check build status: ${error instanceof Error ? error.message : String(error)}`);
+  }
+}
+
+/**
+ * Poll for EAS build completion
+ */
+export async function waitForEASBuild(
+  buildId: string,
+  expoToken?: string,
+  maxWaitMs: number = 15 * 60 * 1000, // 15 minutes default
+  pollIntervalMs: number = 10000 // 10 seconds
+): Promise<EASBuildStatus> {
+  const startTime = Date.now();
+  
+  while (Date.now() - startTime < maxWaitMs) {
+    const status = await checkEASBuildStatus(buildId, expoToken);
+    
+    if (status.status === 'finished') {
+      console.log(`[INFO] EAS build ${buildId} completed successfully`);
+      return status;
+    }
+    
+    if (status.status === 'errored' || status.status === 'canceled') {
+      console.error(`[ERROR] EAS build ${buildId} failed with status: ${status.status}`);
+      throw new Error(`EAS build failed: ${status.error || status.status}`);
+    }
+    
+    console.log(`[DEBUG] EAS build ${buildId} status: ${status.status}`);
+    await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
+  }
+  
+  throw new Error(`EAS build timed out after ${maxWaitMs / 1000} seconds`);
+}
+
+/**
+ * Get the download URL for a completed build
+ */
+export async function getEASBuildDownloadUrl(
+  buildId: string,
+  expoToken?: string
+): Promise<string | null> {
+  const status = await checkEASBuildStatus(buildId, expoToken);
+  
+  if (status.status !== 'finished') {
+    return null;
+  }
+  
+  return status.downloadUrl || null;
+}
+
+/**
+ * Cancel an in-progress EAS build
+ */
+export async function cancelEASBuild(
+  buildId: string,
+  expoToken?: string
+): Promise<boolean> {
+  const token = expoToken || process.env.EXPO_ACCESS_TOKEN;
+  
+  if (!token) {
+    throw new Error('EXPO_ACCESS_TOKEN is required to cancel a build');
+  }
+  
+  try {
+    const response = await fetch(`https://api.expo.dev/v2/builds/${buildId}/cancel`, {
+      method: 'POST',
+      headers: {
+        'Authorization': `Bearer ${token}`,
+        'Accept': 'application/json'
+      }
+    });
+    
+    return response.ok;
+  } catch (error) {
+    console.error('[ERROR] Failed to cancel EAS build:', error);
+    return false;
+  }
+}

File: src/agents/expo-qr.ts
Changes:
@@ -0,0 +1,93 @@
+import QRCode from 'qrcode';
+
+/**
+ * Generate a QR code for Expo Go app to scan
+ * @param sandboxUrl The sandbox URL (e.g., https://8081-abc123.e2b.dev)
+ * @returns Base64 data URL of the QR code image
+ */
+export async function generateExpoGoQR(sandboxUrl: string): Promise<string> {
+  try {
+    // Expo Go expects exp:// protocol URLs
+    const url = new URL(sandboxUrl);
+    const expoUrl = `exp://${url.host}`;
+    
+    // Generate QR code as data URL
+    const qrDataUrl = await QRCode.toDataURL(expoUrl, {
+      width: 400,
+      margin: 2,
+      color: {
+        dark: '#000000',
+        light: '#FFFFFF'
+      },
+      errorCorrectionLevel: 'M'
+    });
+    
+    console.log(`[INFO] Generated Expo Go QR code for: ${expoUrl}`);
+    return qrDataUrl;
+  } catch (error) {
+    console.error('[ERROR] Failed to generate Expo Go QR code:', error);
+    throw new Error(`Failed to generate QR code: ${error instanceof Error ? error.message : String(error)}`);
+  }
+}
+
+/**
+ * Get the official Expo QR code service URL
+ * This uses Expo's hosted service to generate QR codes
+ * @param sandboxUrl The sandbox URL
+ * @returns URL to Expo's QR code service
+ */
+export function getExpoOfficialQRUrl(sandboxUrl: string): string {
+  const encodedUrl = encodeURIComponent(sandboxUrl);
+  return `https://qr.expo.dev/development-client?url=${encodedUrl}`;
+}
+
+/**
+ * Generate QR code for EAS Update (for production apps)
+ * @param projectId Expo project ID
+ * @param channel Update channel (e.g., 'preview', 'production')
+ * @param runtimeVersion The runtime version
+ * @returns URL to Expo's QR code service for the update
+ */
+export function getEASUpdateQRUrl(
+  projectId: string,
+  channel: string = 'preview',
+  runtimeVersion?: string
+): string {
+  let url = `https://qr.expo.dev/eas-update?projectId=${projectId}&channel=${channel}`;
+  if (runtimeVersion) {
+    url += `&runtimeVersion=${encodeURIComponent(runtimeVersion)}`;
+  }
+  return url;
+}
+
+/**
+ * Generate a deep link URL for Expo Go
+ * @param sandboxUrl The sandbox URL
+ * @returns Deep link URL that opens in Expo Go
+ */
+export function getExpoGoDeepLink(sandboxUrl: string): string {
+  const url = new URL(sandboxUrl);
+  return `exp://${url.host}`;
+}
+
+/**
+ * Check if a URL is accessible (for Expo Go tunnel)
+ * @param url The URL to check
+ * @returns Whether the URL is accessible
+ */
+export async function checkUrlAccessible(url: string): Promise<boolean> {
+  try {
+    const controller = new AbortController();
+    const timeoutId = setTimeout(() => controller.abort(), 5000);
+    
+    const response = await fetch(url, {
+      method: 'HEAD',
+      signal: controller.signal
+    });
+    
+    clearTimeout(timeoutId);
+    return response.ok;
+  } catch {
+    return false;
+  }
+}

File: src/agents/rate-limit.ts
Changes:
@@ -211,14 +211,14 @@ export async function* withGatewayFallbackGenerator<T>(
       const lastError = error instanceof Error ? error : new Error(String(error));
 
       if (isRateLimitError(error) && !triedGateway) {
-        console.log(`[GATEWAY-FALLBACK] ${context}: Rate limit hit for ${modelId}. Switching to Vercel AI Gateway with Cerebras provider...`);
+        console.log(`[GATEWAY-FALLBACK] ${context}: Rate limit hit for ${modelId}. Switching to OpenRouter with Cerebras provider...`);
         triedGateway = true;
         continue;
       }
 
       if (isRateLimitError(error) && triedGateway) {
         const waitMs = RATE_LIMIT_WAIT_MS;
-        console.log(`[GATEWAY-FALLBACK] ${context}: Gateway rate limit hit. Waiting ${waitMs / 1000}s...`);
+        console.log(`[GATEWAY-FALLBACK] ${context}: OpenRouter rate limit hit. Waiting ${waitMs / 1000}s...`);
         await new Promise(resolve => setTimeout(resolve, waitMs));
         // We've tried both direct and gateway, throw the actual rate limit error
         throw lastError;

File: src/agents/sandbox-utils.ts
Changes:
@@ -1,5 +1,5 @@
 import { Sandbox } from "@e2b/code-interpreter";
-import { SANDBOX_TIMEOUT, type Framework } from "./types";
+import { SANDBOX_TIMEOUT, type Framework, type ExpoPreviewMode } from "./types";
 
 const SANDBOX_CACHE = new Map<string, Sandbox>();
 const PROJECT_SANDBOX_MAP = new Map<string, string>();
@@ -307,35 +307,47 @@ export async function readFileFast(
   }
 }
 
-export function getE2BTemplate(framework: Framework): string {
+export function getE2BTemplate(framework: Framework, expoPreviewMode?: ExpoPreviewMode): string {
   switch (framework) {
     case "nextjs": return "zapdev";
     case "angular": return "zapdev-angular";
     case "react": return "zapdev-react";
     case "vue": return "zapdev-vue";
     case "svelte": return "zapdev-svelte";
+    case "expo":
+      if (expoPreviewMode === "android-emulator") return "zapdev-expo-android";
+      if (expoPreviewMode === "expo-go") return "zapdev-expo-full";
+      return "zapdev-expo-web"; // Default to web preview (fastest)
     default: return "zapdev";
   }
 }
 
-export function getFrameworkPort(framework: Framework): number {
+export function getFrameworkPort(framework: Framework, expoPreviewMode?: ExpoPreviewMode): number {
   switch (framework) {
     case "nextjs": return 3000;
     case "angular": return 4200;
     case "react":
     case "vue":
     case "svelte": return 5173;
+    case "expo":
+      if (expoPreviewMode === "android-emulator") return 5900; // VNC port
+      return 8081; // Metro bundler port
     default: return 3000;
   }
 }
 
-export function getDevServerCommand(framework: Framework): string {
+export function getDevServerCommand(framework: Framework, expoPreviewMode?: ExpoPreviewMode): string {
   switch (framework) {
     case "nextjs": return "npm run dev";
     case "angular": return "npm run start -- --host 0.0.0.0 --port 4200";
     case "react":
     case "vue":
     case "svelte": return "npm run dev -- --host 0.0.0.0 --port 5173";
+    case "expo":
+      if (expoPreviewMode === "web") return "npx expo start --web --port 8081 --host 0.0.0.0";
+      if (expoPreviewMode === "expo-go") return "npx expo start --tunnel --port 8081";
+      if (expoPreviewMode === "android-emulator") return "/start_android.sh";
+      return "npx expo start --web --port 8081 --host 0.0.0.0";
     default: return "npm run dev";
   }
 }
@@ -408,6 +420,7 @@ export const getFindCommand = (framework: Framework): string => {
   const ignorePatterns = ["node_modules", ".git", "dist", "build"];
   if (framework === "nextjs") ignorePatterns.push(".next");
   if (framework === "svelte") ignorePatterns.push(".svelte-kit");
+  if (framework === "expo") ignorePatterns.push(".expo");
   
   return `find /home/user -type f -not -path '*/${ignorePatterns.join('/* -not -path */')}/*' 2>/dev/null`;
 };

File: src/agents/types.ts
Changes:
@@ -1,6 +1,8 @@
 export const SANDBOX_TIMEOUT = 60_000 * 60;
 
-export type Framework = "nextjs" | "angular" | "react" | "vue" | "svelte";
+export type Framework = "nextjs" | "angular" | "react" | "vue" | "svelte" | "expo";
+
+export type ExpoPreviewMode = "web" | "expo-go" | "android-emulator" | "eas-build";
 
 export interface AgentState {
   summary: string;
@@ -9,6 +11,14 @@ export interface AgentState {
   summaryRetryCount: number;
 }
 
+export interface ExpoAgentState extends AgentState {
+  previewMode: ExpoPreviewMode;
+  qrCodeUrl?: string;
+  vncUrl?: string;
+  easBuildUrl?: string;
+  apkDownloadUrl?: string;
+}
+
 export interface AgentRunInput {
   projectId: string;
   value: string;
@@ -23,6 +33,11 @@ export interface AgentRunResult {
   summary: string;
   sandboxId: string;
   framework: Framework;
+  expoPreviewMode?: ExpoPreviewMode;
+  expoQrCodeUrl?: string;
+  expoVncUrl?: string;
+  expoEasBuildUrl?: string;
+  expoApkUrl?: string;
 }
 
 export const MODEL_CONFIGS = {
@@ -145,16 +160,17 @@ export function selectModelForTask(
 
 export function frameworkToConvexEnum(
   framework: Framework
-): "NEXTJS" | "ANGULAR" | "REACT" | "VUE" | "SVELTE" {
+): "NEXTJS" | "ANGULAR" | "REACT" | "VUE" | "SVELTE" | "EXPO" {
   const mapping: Record<
     Framework,
-    "NEXTJS" | "ANGULAR" | "REACT" | "VUE" | "SVELTE"
+    "NEXTJS" | "ANGULAR" | "REACT" | "VUE" | "SVELTE" | "EXPO"
   > = {
     nextjs: "NEXTJS",
     angular: "ANGULAR",
     react: "REACT",
     vue: "VUE",
     svelte: "SVELTE",
+    expo: "EXPO",
   };
   return mapping[framework];
 }

File: src/components/ExpoPreviewSelector.tsx
Changes:
@@ -0,0 +1,150 @@
+'use client';
+
+import { useState } from 'react';
+import { Card, CardContent } from '@/components/ui/card';
+import { Badge } from '@/components/ui/badge';
+import { cn } from '@/lib/utils';
+
+export type ExpoPreviewMode = 'web' | 'expo-go' | 'android-emulator' | 'eas-build';
+export type UserTier = 'free' | 'pro' | 'enterprise';
+
+interface PreviewOption {
+  mode: ExpoPreviewMode;
+  title: string;
+  description: string;
+  badge?: string;
+  buildTime: string;
+  tier: UserTier;
+  icon: string;
+}
+
+const PREVIEW_OPTIONS: PreviewOption[] = [
+  {
+    mode: 'web',
+    title: 'Web Preview',
+    description: 'Fastest preview using react-native-web',
+    buildTime: '~30 seconds',
+    tier: 'free',
+    icon: '🌐'
+  },
+  {
+    mode: 'expo-go',
+    title: 'Expo Go (QR Code)',
+    description: 'Test on real device via Expo Go app',
+    buildTime: '~1-2 minutes',
+    tier: 'free',
+    icon: '📱'
+  },
+  {
+    mode: 'android-emulator',
+    title: 'Android Emulator',
+    description: 'Full Android emulator with VNC access',
+    badge: 'Pro',
+    buildTime: '~3-5 minutes',
+    tier: 'pro',
+    icon: '🤖'
+  },
+  {
+    mode: 'eas-build',
+    title: 'EAS Build (Production)',
+    description: 'Cloud builds for App Store/Play Store',
+    badge: 'Pro',
+    buildTime: '~5-15 minutes',
+    tier: 'pro',
+    icon: '🚀'
+  }
+];
+
+interface ExpoPreviewSelectorProps {
+  onSelect: (mode: ExpoPreviewMode) => void;
+  userTier?: UserTier;
+  selectedMode?: ExpoPreviewMode;
+  className?: string;
+}
+
+export function ExpoPreviewSelector({
+  onSelect,
+  userTier = 'free',
+  selectedMode,
+  className
+}: ExpoPreviewSelectorProps) {
+  const [selected, setSelected] = useState<ExpoPreviewMode>(selectedMode ?? 'web');
+
+  const handleSelect = (mode: ExpoPreviewMode) => {
+    const option = PREVIEW_OPTIONS.find(o => o.mode === mode);
+    if (!option) return;
+    
+    const tierOrder: Record<UserTier, number> = { free: 0, pro: 1, enterprise: 2 };
+    const isLocked = tierOrder[userTier] < tierOrder[option.tier];
+    
+    if (!isLocked) {
+      setSelected(mode);
+      onSelect(mode);
+    }
+  };
+
+  return (
+    <div className={cn('grid grid-cols-1 sm:grid-cols-2 gap-3', className)}>
+      {PREVIEW_OPTIONS.map((option) => {
+        const tierOrder: Record<UserTier, number> = { free: 0, pro: 1, enterprise: 2 };
+        const isLocked = tierOrder[userTier] < tierOrder[option.tier];
+        const isSelected = selected === option.mode;
+
+        return (
+          <Card
+            key={option.mode}
+            className={cn(
+              'cursor-pointer transition-all duration-200',
+              isSelected && 'ring-2 ring-primary bg-primary/5',
+              isLocked && 'opacity-60 cursor-not-allowed',
+              !isLocked && !isSelected && 'hover:bg-muted/50'
+            )}
+            onClick={() => handleSelect(option.mode)}
+          >
+            <CardContent className="p-4">
+              <div className="flex items-start justify-between mb-2">
+                <div className="flex items-center gap-2">
+                  <span className="text-xl">{option.icon}</span>
+                  <h4 className="font-semibold text-sm">{option.title}</h4>
+                </div>
+                <div className="flex gap-1">
+                  {option.badge && (
+                    <Badge variant="secondary" className="text-xs">
+                      {option.badge}
+                    </Badge>
+                  )}
+                  {isLocked && (
+                    <Badge variant="outline" className="text-xs">
+                      🔒
+                    </Badge>
+                  )}
+                </div>
+              </div>
+              <p className="text-xs text-muted-foreground mb-2">
+                {option.description}
+              </p>
+              <p className="text-xs text-muted-foreground/70">
+                Build time: {option.buildTime}
+              </p>
+            </CardContent>
+          </Card>
+        );
+      })}
+    </div>
+  );
+}
+
+export function ExpoPreviewInfo({ mode }: { mode: ExpoPreviewMode }) {
+  const option = PREVIEW_OPTIONS.find(o => o.mode === mode);
+  if (!option) return null;
+
+  return (
+    <div className="flex items-center gap-2 text-sm text-muted-foreground">
+      <span>{option.icon}</span>
+      <span>{option.title}</span>
+      <span className="text-xs">({option.buildTime})</span>
+    </div>
+  );
+}
+
+export { PREVIEW_OPTIONS };

File: src/lib/frameworks.ts
Changes:
@@ -341,6 +341,73 @@ export const frameworks: Record<string, FrameworkData> = {
       'SSG',
       'production React'
     ]
+  },
+  expo: {
+    slug: 'expo',
+    name: 'Expo',
+    title: 'Cross-Platform Mobile Development with Expo & React Native',
+    description: 'Expo is the easiest way to build iOS, Android, and web apps from a single codebase using React Native. Create production-ready mobile applications with our AI-powered development tools.',
+    metaDescription: 'Create mobile apps with Expo and React Native using AI. Multiple preview modes: web, Expo Go, Android emulator, and EAS Build for production iOS/Android apps.',
+    features: [
+      'Cross-Platform (iOS/Android/Web)',
+      'Hot Reload & Fast Refresh',
+      'Expo SDK Modules',
+      'Multiple Preview Modes',
+      'EAS Build Integration',
+      'Over-the-Air Updates',
+      'TypeScript Support',
+      'expo-router Navigation'
+    ],
+    useCases: [
+      'Mobile-First Applications',
+      'Social Media Apps',
+      'E-commerce Mobile Apps',
+      'Fitness & Health Trackers',
+      'Photo & Video Apps',
+      'Location-Based Services',
+      'Progressive Web Apps'
+    ],
+    advantages: [
+      'One Codebase, Three Platforms',
+      'Rich Native Module Ecosystem',
+      'Fast Development Cycle',
+      'Real Device Testing (Expo Go)',
+      'Cloud Builds (No Xcode/Android Studio)',
+      'Strong Community Support'
+    ],
+    icon: '📱',
+    color: '#000020',
+    popularity: 85,
+    ecosystem: [
+      {
+        name: 'Expo Go',
+        description: 'Instant preview on real devices',
+        url: '/frameworks/expo/expo-go'
+      },
+      {
+        name: 'EAS Build',
+        description: 'Cloud-based iOS/Android builds',
+        url: '/frameworks/expo/eas-build'
+      },
+      {
+        name: 'expo-router',
+        description: 'File-based navigation system',
+        url: '/frameworks/expo/router'
+      }
+    ],
+    relatedFrameworks: ['react', 'nextjs'],
+    keywords: [
+      'Expo development',
+      'React Native',
+      'cross-platform mobile',
+      'iOS development',
+      'Android development',
+      'mobile app framework',
+      'Expo SDK',
+      'React Native components',
+      'EAS Build',
+      'mobile development'
+    ]
   }
 };
 

File: src/prompt.ts
Changes:
@@ -4,5 +4,6 @@ export { ANGULAR_PROMPT } from "./prompts/angular";
 export { REACT_PROMPT } from "./prompts/react";
 export { VUE_PROMPT } from "./prompts/vue";
 export { SVELTE_PROMPT } from "./prompts/svelte";
+export { EXPO_PROMPT, EXPO_WEB_PROMPT, EXPO_NATIVE_PROMPT } from "./prompts/expo";
 export { FRAMEWORK_SELECTOR_PROMPT } from "./prompts/framework-selector";
 export { NEXTJS_PROMPT as PROMPT } from "./prompts/nextjs";

File: src/prompts/expo.ts
Changes:
@@ -0,0 +1,263 @@
+import { SHARED_RULES } from "./shared";
+
+export const EXPO_SHARED_RULES = `
+Environment:
+- Writable file system via createOrUpdateFiles
+- Command execution via terminal (use "npm install <package> --yes" or "npx expo install <package>")
+- Read files via readFiles
+- Do not modify package.json or lock files directly — install packages using the terminal only
+- All files are under /home/user
+- Entry point is App.tsx (root component)
+
+File Safety Rules:
+- All CREATE OR UPDATE file paths must be relative (e.g., "App.tsx", "components/Button.tsx")
+- NEVER use absolute paths like "/home/user/..." or "/home/user/app/..."
+- NEVER include "/home/user" in any file path — this will cause critical errors
+- When using readFiles or accessing the file system, you MUST use the actual path (e.g. "/home/user/components/Button.tsx")
+
+Runtime Execution:
+- Development servers are not started manually in this environment
+- The Metro bundler is already running
+- Use validation commands like "npx expo export:web" to verify your work
+- Short-lived commands for type-checking and builds are allowed as needed for testing
+
+Error Prevention & Code Quality (CRITICAL):
+1. MANDATORY Validation Before Completion:
+   - Run: npx tsc --noEmit (for type checking)
+   - Fix ANY and ALL TypeScript errors immediately
+   - Only output <task_summary> after validation passes with no errors
+
+2. Handle All Errors: Every function must include proper error handling
+3. Type Safety: Use TypeScript properly with explicit types
+
+Instructions:
+1. Use React Native components exclusively (View, Text, TouchableOpacity, etc.)
+2. Use StyleSheet.create() for ALL styling — NO CSS files, NO className
+3. Use Expo SDK modules for native functionality
+4. Break complex UIs into multiple components
+5. Use TypeScript with proper types
+6. You MUST use the createOrUpdateFiles tool to make all file changes
+7. You MUST use the terminal tool to install any packages (npx expo install <package>)
+8. Do not print code inline or wrap code in backticks
+
+Final output (MANDATORY):
+After ALL tool calls are complete and the task is finished, you MUST output:
+
+<task_summary>
+A short, high-level summary of what was created or changed.
+</task_summary>
+`;
+
+export const EXPO_PROMPT = `
+You are a senior React Native engineer using Expo in a sandboxed environment.
+
+${EXPO_SHARED_RULES}
+
+Environment:
+- Framework: Expo SDK 52+ with React Native 0.76+
+- Entry file: App.tsx (root component)
+- Styling: StyleSheet API (React Native styles)
+- Navigation: expo-router (file-based routing) or React Navigation
+- Dev port: 8081 (Metro bundler)
+
+Critical Rules:
+1. Use React Native components: View, Text, TouchableOpacity, ScrollView, FlatList, Image, TextInput, etc.
+2. Styling MUST use StyleSheet.create() — NO CSS files, NO className, NO Tailwind
+3. Import from 'react-native': \`import { View, Text, StyleSheet } from 'react-native'\`
+4. Use Expo SDK modules: expo-camera, expo-location, expo-font, expo-image-picker, etc.
+5. "use client" is NOT needed (React Native doesn't use this directive)
+6. File structure: App.tsx as entry, components/ for reusable components
+7. For multi-screen apps: Use expo-router with app/ directory structure
+
+Styling Example:
+\`\`\`tsx
+import { StyleSheet, View, Text, TouchableOpacity } from 'react-native';
+import { StatusBar } from 'expo-status-bar';
+
+export default function App() {
+  return (
+    <View style={styles.container}>
+      <Text style={styles.title}>Hello Expo</Text>
+      <TouchableOpacity style={styles.button} onPress={() => console.log('Pressed')}>
+        <Text style={styles.buttonText}>Press Me</Text>
+      </TouchableOpacity>
+      <StatusBar style="auto" />
+    </View>
+  );
+}
+
+const styles = StyleSheet.create({
+  container: {
+    flex: 1,
+    backgroundColor: '#fff',
+    alignItems: 'center',
+    justifyContent: 'center',
+  },
+  title: {
+    fontSize: 24,
+    fontWeight: 'bold',
+    marginBottom: 20,
+  },
+  button: {
+    backgroundColor: '#007AFF',
+    paddingHorizontal: 20,
+    paddingVertical: 12,
+    borderRadius: 8,
+  },
+  buttonText: {
+    color: '#fff',
+    fontSize: 16,
+    fontWeight: '600',
+  },
+});
+\`\`\`
+
+Expo SDK Modules (pre-installed):
+- expo-status-bar (status bar control)
+- expo-font (custom fonts)
+- expo-linear-gradient (gradient backgrounds)
+- expo-blur (blur effects)
+
+Expo SDK Modules (install with npx expo install):
+- expo-camera (camera access)
+- expo-image-picker (photo library/camera capture)
+- expo-location (GPS/location)
+- expo-haptics (haptic feedback/vibration)
+- expo-notifications (push notifications)
+- expo-file-system (file operations)
+- expo-av (audio/video playback)
+- expo-sensors (accelerometer, gyroscope)
+- expo-secure-store (secure storage)
+- expo-sqlite (local database)
+
+Navigation with expo-router:
+\`\`\`tsx
+// app/_layout.tsx
+import { Stack } from 'expo-router';
+
+export default function Layout() {
+  return <Stack />;
+}
+
+// app/index.tsx
+import { Link } from 'expo-router';
+import { View, Text } from 'react-native';
+
+export default function Home() {
+  return (
+    <View>
+      <Text>Home Screen</Text>
+      <Link href="/details">Go to Details</Link>
+    </View>
+  );
+}
+\`\`\`
+
+Common Patterns:
+1. SafeAreaView for notch handling: \`import { SafeAreaView } from 'react-native-safe-area-context'\`
+2. KeyboardAvoidingView for forms with keyboard
+3. FlatList for performant scrolling lists
+4. ActivityIndicator for loading states
+5. Platform.OS for platform-specific code
+
+Workflow:
+1. FIRST: Generate all code files using createOrUpdateFiles
+2. THEN: Use terminal to install packages if needed (npx expo install <package>)
+3. FINALLY: Provide <task_summary> describing what you built
+
+Preview Modes:
+- **web**: Fast preview using react-native-web, limited native features
+- **expo-go**: Scan QR with Expo Go app for real device testing
+- **android-emulator**: Full Android emulator with VNC access
+- **eas-build**: Production builds for App Store/Play Store
+`;
+
+export const EXPO_WEB_PROMPT = `
+You are a senior React Native engineer using Expo with WEB PREVIEW mode.
+
+${EXPO_SHARED_RULES}
+
+Environment:
+- Framework: Expo SDK 52+ with React Native 0.76+
+- Preview Mode: WEB (using react-native-web)
+- Entry file: App.tsx (root component)
+- Styling: StyleSheet API (React Native styles)
+- Dev port: 8081 (Metro bundler web)
+
+IMPORTANT - Web Compatibility:
+Since this is web preview mode, you MUST only use web-compatible components and APIs.
+
+✅ SAFE for Web (use these):
+- View, Text, Image, ScrollView, FlatList
+- TouchableOpacity, TouchableHighlight, Pressable
+- TextInput, Switch, ActivityIndicator
+- StyleSheet, Dimensions, Platform
+- expo-linear-gradient, expo-blur
+- expo-font (web fonts)
+- expo-status-bar (no-op on web)
+
+❌ NOT Available on Web (avoid these):
+- expo-camera (use file input instead)
+- expo-location (use Geolocation API if needed)
+- expo-haptics (no haptic on web)
+- expo-sensors (no accelerometer/gyroscope on web)
+- expo-notifications (limited on web)
+- expo-secure-store (use localStorage)
+- Native-only modules
+
+Web Alternatives:
+- Camera: Use \`<input type="file" accept="image/*" capture>\`
+- Location: Use \`navigator.geolocation\` if needed
+- Storage: Use AsyncStorage (works on web) or localStorage
+- Vibration: Skip or use Web Vibration API
+
+Critical Rules:
+1. Use React Native components: View, Text, TouchableOpacity, etc.
+2. Styling MUST use StyleSheet.create() — NO CSS files, NO className
+3. Always check Platform.OS if using platform-specific code
+4. Test works on web before completing
+
+${EXPO_PROMPT.split('Workflow:')[1]}
+`;
+
+export const EXPO_NATIVE_PROMPT = `
+You are a senior React Native engineer using Expo with NATIVE PREVIEW mode.
+
+${EXPO_SHARED_RULES}
+
+Environment:
+- Framework: Expo SDK 52+ with React Native 0.76+
+- Preview Mode: NATIVE (Android Emulator or Expo Go)
+- Entry file: App.tsx (root component)
+- Styling: StyleSheet API (React Native styles)
+- Full native API access available
+
+Full Native Access:
+You have access to ALL Expo SDK modules and native APIs:
+- expo-camera (full camera control)
+- expo-location (GPS, background location)
+- expo-haptics (haptic feedback)
+- expo-sensors (accelerometer, gyroscope, magnetometer)
+- expo-notifications (push notifications)
+- expo-contacts (address book)
+- expo-calendar (calendar events)
+- expo-media-library (photo/video library)
+- expo-audio (audio recording/playback)
+- expo-video (video playback)
+- expo-bluetooth-low-energy (BLE)
+
+Native-Specific Patterns:
+1. Use SafeAreaView for proper notch handling
+2. Use KeyboardAvoidingView with behavior="padding" for iOS
+3. Use StatusBar component for status bar styling
+4. Use BackHandler for Android back button
+5. Use Linking for deep links
+
+Performance Tips:
+- Use FlatList instead of ScrollView for long lists
+- Use useMemo/useCallback for expensive operations
+- Use Image.prefetch for remote images
+- Use react-native-reanimated for smooth animations
+
+${EXPO_PROMPT.split('Workflow:')[1]}
+`;

File: src/prompts/framework-selector.ts
Changes:
@@ -1,5 +1,5 @@
 export const FRAMEWORK_SELECTOR_PROMPT = `
-You are a framework selection expert. Your job is to analyze the user's request and determine the most appropriate web framework to use.
+You are a framework selection expert. Your job is to analyze the user's request and determine the most appropriate framework to use.
 
 Available frameworks:
 1. **nextjs** - Next.js 15 with React, Shadcn UI, and Tailwind CSS
@@ -27,9 +27,16 @@ Available frameworks:
    - Pre-installed: DaisyUI (Tailwind components), Tailwind CSS
    - Use when: User mentions "Svelte", "SvelteKit", or emphasizes performance
 
+6. **expo** - Expo/React Native with TypeScript
+   - Best for: Cross-platform mobile apps (iOS + Android + Web), native mobile features
+   - Pre-installed: Expo SDK, React Native components, TypeScript
+   - Preview modes: Web (fast), Expo Go (QR code), Android Emulator (VNC), EAS Build (production)
+   - Use when: User mentions "Expo", "React Native", "mobile app", "iOS", "Android", "cross-platform", "native app", "phone app", or wants to build for mobile devices
+
 Selection Guidelines:
 - If the user explicitly mentions a framework name, choose that framework
-- If the request is ambiguous or doesn't specify, default to **nextjs** (most versatile)
+- If the request is for a MOBILE APP (iOS, Android, phone, native app), choose **expo**
+- If the request is ambiguous or doesn't specify and is for WEB, default to **nextjs** (most versatile)
 - Consider the complexity: enterprise/complex = Angular, simple = React/Vue/Svelte
 - Consider the UI needs: Material Design = Angular or Vue, flexible = Next.js or React
 - Consider performance emphasis: Svelte for highest performance requirements
@@ -41,6 +48,7 @@ You MUST respond with ONLY ONE of these exact strings (no explanation, no markdo
 - react
 - vue
 - svelte
+- expo
 
 Examples:
 User: "Build a Netflix clone"
@@ -64,5 +72,23 @@ Response: nextjs
 User: "Create a Material Design admin panel"
 Response: angular
 
+User: "Build a mobile todo app for iOS and Android"
+Response: expo
+
+User: "Create a React Native camera app"
+Response: expo
+
+User: "Make a cross-platform fitness tracker"
+Response: expo
+
+User: "Build an app for my phone"
+Response: expo
+
+User: "Create a native mobile application"
+Response: expo
+
+User: "Build an Expo app with location tracking"
+Response: expo
+
 Now analyze the user's request and respond with ONLY the framework name.
 `;

File: tests/gateway-fallback.test.ts
Changes:
@@ -1,7 +1,7 @@
 import { getModel, getClientForModel, isCerebrasModel } from '../src/agents/client';
 import { withGatewayFallbackGenerator } from '../src/agents/rate-limit';
 
-describe('Vercel AI Gateway Fallback', () => {
+describe('OpenRouter Fallback', () => {
   describe('Client Functions', () => {
     it('should identify Cerebras models correctly', () => {
       expect(isCerebrasModel('zai-glm-4.7')).toBe(true);
@@ -15,21 +15,21 @@ describe('Vercel AI Gateway Fallback', () => {
       expect(model).not.toBeNull();
     });
 
-    it('should return Vercel AI Gateway client when useGatewayFallback is true for Cerebras models', () => {
+    it('should return OpenRouter client when useGatewayFallback is true for Cerebras models', () => {
       const model = getModel('zai-glm-4.7', { useGatewayFallback: true });
       expect(model).toBeDefined();
       expect(model).not.toBeNull();
     });
 
-    it('should not use gateway for non-Cerebras models', () => {
+    it('should not use fallback for non-Cerebras models', () => {
       expect(isCerebrasModel('anthropic/claude-haiku-4.5')).toBe(false);
       
       const directClient = getModel('anthropic/claude-haiku-4.5');
-      const gatewayClient = getModel('anthropic/claude-haiku-4.5', { useGatewayFallback: true });
+      const fallbackClient = getModel('anthropic/claude-haiku-4.5', { useGatewayFallback: true });
 
       // Both should use the same openrouter provider since non-Cerebras models
       // don't use gateway fallback - this verifies the stated behavior
-      expect(directClient.provider).toBe(gatewayClient.provider);
+      expect(directClient.provider).toBe(fallbackClient.provider);
     });
 
     it('should return chat function from getClientForModel', () => {
@@ -39,7 +39,7 @@ describe('Vercel AI Gateway Fallback', () => {
     });
   });
 
-  describe('Gateway Fallback Generator', () => {
+  describe('Fallback Generator', () => {
     it('should yield values from successful generator', async () => {
       const mockGenerator = async function* () {
         yield 'value1';
@@ -62,8 +62,7 @@ describe('Vercel AI Gateway Fallback', () => {
       const mockGenerator = async function* () {
         attemptCount++;
         if (attemptCount === 1) {
-          const error = new Error('Rate limit exceeded');
-          (error as any).status = 429;
+          const error = Object.assign(new Error('Rate limit exceeded'), { status: 429 });
           throw error;
         }
         yield 'success';
@@ -81,15 +80,13 @@ describe('Vercel AI Gateway Fallback', () => {
       expect(attemptCount).toBe(2);
     });
 
-    it('should switch to gateway on rate limit error', async () => {
-      let useGatewayFlag = false;
+    it('should switch to OpenRouter on rate limit error', async () => {
       const mockGenerator = async function* (useGateway: boolean) {
         if (!useGateway) {
-          const error = new Error('Rate limit exceeded');
-          (error as any).status = 429;
+          const error = Object.assign(new Error('Rate limit exceeded'), { status: 429 });
           throw error;
         }
-        yield 'gateway-success';
+        yield 'openrouter-success';
       };
 
       const values: string[] = [];
@@ -100,7 +97,7 @@ describe('Vercel AI Gateway Fallback', () => {
         values.push(value);
       }
 
-      expect(values).toEqual(['gateway-success']);
+      expect(values).toEqual(['openrouter-success']);
     });
 
     it('should throw after max attempts', async () => {
@@ -126,7 +123,7 @@ describe('Vercel AI Gateway Fallback', () => {
       }
 
       expect(errorThrown).toBe(true);
-      expect(attemptCount).toBe(2); // Direct + Gateway attempts
+      expect(attemptCount).toBe(2); // Direct + fallback attempts
     }, 10000); // Increase timeout to 10s for safety
   });
 

@codecapyai
Copy link
Copy Markdown

codecapyai bot commented Jan 19, 2026

🚀 Launching Scrapybara desktop...

@codecapyai
Copy link
Copy Markdown

codecapyai bot commented Jan 19, 2026

❌ Something went wrong:

status_code: 500, body: {'detail': 'Error creating instance: HTTPSConnectionPool(host=\'dd71ce9e4c14175cfb2d4b4d613159f4.sk1.us-west-1.eks.amazonaws.com\', port=443): Max retries exceeded with url: /api/v1/namespaces/scrapybara-instances/services (Caused by NameResolutionError("<urllib3.connection.HTTPSConnection object at 0x7f0028ded4d0>: Failed to resolve \'dd71ce9e4c14175cfb2d4b4d613159f4.sk1.us-west-1.eks.amazonaws.com\' ([Errno -2] Name or service not known)"))'}

Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 6 files (changes from recent commits).

Prompt for AI agents (all issues)

Check if these issues are valid — if so, understand the root cause of each and fix them.


<file name=".github/workflows/opencode.yml">

<violation number="1" location=".github/workflows/opencode.yml:11">
P1: Guard the workflow against untrusted commenters. As written, any GitHub user can trigger this job via `/oc` and run a secret-backed action, which risks abuse of the OPENCODE_API_KEY and workflow resources. Add an author association check (owner/member/collaborator) or similar gating before running.</violation>

<violation number="2" location=".github/workflows/opencode.yml:29">
P2: Pin the `anomalyco/opencode` action to a specific release tag or commit SHA instead of `@latest` to prevent unexpected changes and reduce supply-chain risk.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.


jobs:
opencode:
if: |
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Guard the workflow against untrusted commenters. As written, any GitHub user can trigger this job via /oc and run a secret-backed action, which risks abuse of the OPENCODE_API_KEY and workflow resources. Add an author association check (owner/member/collaborator) or similar gating before running.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At .github/workflows/opencode.yml, line 11:

<comment>Guard the workflow against untrusted commenters. As written, any GitHub user can trigger this job via `/oc` and run a secret-backed action, which risks abuse of the OPENCODE_API_KEY and workflow resources. Add an author association check (owner/member/collaborator) or similar gating before running.</comment>

<file context>
@@ -0,0 +1,33 @@
+
+jobs:
+  opencode:
+    if: |
+      contains(github.event.comment.body, ' /oc') ||
+      startsWith(github.event.comment.body, '/oc') ||
</file context>
Fix with Cubic

persist-credentials: false

- name: Run opencode
uses: anomalyco/opencode/github@latest
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Pin the anomalyco/opencode action to a specific release tag or commit SHA instead of @latest to prevent unexpected changes and reduce supply-chain risk.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At .github/workflows/opencode.yml, line 29:

<comment>Pin the `anomalyco/opencode` action to a specific release tag or commit SHA instead of `@latest` to prevent unexpected changes and reduce supply-chain risk.</comment>

<file context>
@@ -0,0 +1,33 @@
+          persist-credentials: false
+
+      - name: Run opencode
+        uses: anomalyco/opencode/github@latest
+        env:
+          OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
</file context>
Fix with Cubic

@opencode-agent
Copy link
Copy Markdown

User cubic-dev-ai[bot] does not have write permissions

github run

Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Bugbot Autofix is OFF. To automatically fix reported issues with Cloud Agents, enable Autofix in the Cursor dashboard.

This is the final PR Bugbot will review for you during this billing cycle

Your free Bugbot reviews will reset on February 17

Details

You are on the Bugbot Free tier. On this plan, Bugbot will review limited PRs each billing cycle.

To receive Bugbot reviews on all of your PRs, visit the Cursor dashboard to activate Pro and start your 14-day free trial.

const result = streamText({
model: client.chat(selectedModel),
providerOptions: useGatewayFallbackForStream ? {
gateway: {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Expo preview mode parameter never propagated to call sites

High Severity

The getFrameworkPrompt, getE2BTemplate, getFrameworkPort, and getDevServerCommand functions were updated to accept an optional expoPreviewMode parameter, but none of the call sites pass this parameter. The call at line 515 getFrameworkPrompt(selectedFramework) never passes expoPreviewMode, and similarly createSandbox(framework) and startDevServer(sandbox, framework) don't accept or propagate the preview mode. This means Expo projects will always use the default web template, generic prompt, and web dev command regardless of which preview mode the user selects.

Additional Locations (2)

Fix in Cursor Fix in Web

expoQrCodeUrl: v.optional(v.string()),
expoVncUrl: v.optional(v.string()),
expoEasBuildUrl: v.optional(v.string()),
expoApkUrl: v.optional(v.string()),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Schema fields added without updating corresponding mutation

Medium Severity

The fragments table schema was updated with new fields (expoPreviewMode, expoQrCodeUrl, expoVncUrl, expoEasBuildUrl, expoApkUrl), but the createFragmentForUser mutation in convex/messages.ts was not updated to accept these parameters. This means even if the code agent generated expo-specific data, there's no API path to persist it to the database. These fields will always remain empty for all fragments.

Fix in Cursor Fix in Web

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In @.github/workflows/opencode.yml:
- Around line 11-16: The current workflow condition allowing any comment
containing '/oc' or '/opencode' to run must be tightened: update the if
expression in .github/workflows/opencode.yml (the block using
contains(github.event.comment.body, ...) and startsWith(...)) to also require
the comment author to have a trusted author_association (e.g., OWNER,
COLLABORATOR, MEMBER, or CONTRIBUTOR) and to ensure the comment is on a pull
request (i.e., guard that github.event.issue.pull_request exists); implement
these two checks combined with the existing body-matching checks so only trusted
PR commenters can trigger runs with secrets.
🧹 Nitpick comments (1)
.github/workflows/opencode.yml (1)

23-29: Pin third‑party actions to immutable SHAs.

Using floating tags (@latest) increases supply‑chain risk. Pin to a commit SHA (or a verified release tag) for both actions.

🔐 Example pinning pattern
-      - name: Checkout repository
-        uses: actions/checkout@v6
+      - name: Checkout repository
+        uses: actions/checkout@<sha>
 ...
-      - name: Run opencode
-        uses: anomalyco/opencode/github@latest
+      - name: Run opencode
+        uses: anomalyco/opencode/github@<sha>

Comment on lines +11 to +16
if: |
contains(github.event.comment.body, ' /oc') ||
startsWith(github.event.comment.body, '/oc') ||
contains(github.event.comment.body, ' /opencode') ||
startsWith(github.event.comment.body, '/opencode')
runs-on: ubuntu-latest
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Restrict comment-triggered runs to trusted actors.

As written, any commenter can trigger a run that has access to secrets. Add author-association gating (and ideally PR-only checks) to prevent untrusted execution.

🔒 Suggested guardrail
-    if: |
-      contains(github.event.comment.body, ' /oc') ||
-      startsWith(github.event.comment.body, '/oc') ||
-      contains(github.event.comment.body, ' /opencode') ||
-      startsWith(github.event.comment.body, '/opencode')
+    if: |
+      (contains(github.event.comment.body, ' /oc') ||
+       startsWith(github.event.comment.body, '/oc') ||
+       contains(github.event.comment.body, ' /opencode') ||
+       startsWith(github.event.comment.body, '/opencode')) &&
+      (github.event.comment.author_association == 'OWNER' ||
+       github.event.comment.author_association == 'MEMBER' ||
+       github.event.comment.author_association == 'COLLABORATOR') &&
+      (
+        github.event_name == 'pull_request_review_comment' ||
+        (github.event_name == 'issue_comment' && github.event.issue.pull_request != null)
+      )
🤖 Prompt for AI Agents
In @.github/workflows/opencode.yml around lines 11 - 16, The current workflow
condition allowing any comment containing '/oc' or '/opencode' to run must be
tightened: update the if expression in .github/workflows/opencode.yml (the block
using contains(github.event.comment.body, ...) and startsWith(...)) to also
require the comment author to have a trusted author_association (e.g., OWNER,
COLLABORATOR, MEMBER, or CONTRIBUTOR) and to ensure the comment is on a pull
request (i.e., guard that github.event.issue.pull_request exists); implement
these two checks combined with the existing body-matching checks so only trusted
PR commenters can trigger runs with secrets.

@opencode-agent
Copy link
Copy Markdown

User coderabbitai[bot] does not have write permissions

github run

@Jackson57279 Jackson57279 deleted the feat/add-expo-support branch January 28, 2026 00:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant