Concepts

Projects

How Extentos models projects and identifies them across Android and iOS — the matching reverse-DNS convention, the auto-join behavior at mint time, the Merge action when identifiers diverge, and what's stored on each session row.

A project in Extentos is one app — your Android version and your iOS version live under the same project. The dashboard at extentos.com/projects groups them automatically, and the agent's createSimulatorSession calls flow into the right project without you doing anything special — if you follow one convention. This page explains exactly how project identity works, what makes two platforms join under one project, what to do when they don't, and what each piece of stored data means.

TL;DR

  • Each project has a Project ID (your reverse-DNS package name, e.g. com.example.myapp).
  • The Project ID is derived from your app's source code, not invented by Extentos:
    • Android: applicationId in app/build.gradle.kts
    • iOS: bundle identifier in your Xcode project
  • When the Android applicationId and iOS bundle identifier match (the standard reverse-DNS convention — com.example.myapp on both), minting a simulator from each platform automatically joins the same project. Two platform chips, one card. Zero manual setup.
  • When they don't match, two separate projects appear. Fix by aligning the identifiers in your source, or by using the dashboard's Merge action.
  • The original platform identifier is preserved per row even after a Merge, so the Settings page always shows the truth.

How project identity works

When the agent calls createSimulatorSession for one of your projects, the MCP server reads the project's extentos.manifest.json and extracts the appPackage field:

  • Android: appPackage = the applicationId Gradle compiles into your APK. Set in app/build.gradle.kts under defaultConfig.
  • iOS: appPackage = the bundle identifier compiled into your .app. Set in your Xcode project's General tab → Identity → Bundle Identifier (or in the project's pbxproj as PRODUCT_BUNDLE_IDENTIFIER).

This value is sent to the backend as projectInstallId. The backend's get-or-create logic looks for an existing saved sim under (your account, this projectInstallId, this platform); if one exists, it's returned; otherwise a new one is minted. The dashboard groups all of an account's sessions by projectInstallId — one card per unique value.

If the project doesn't have a manifest yet (you've called createSimulatorSession before generateConnectionModule), the MCP server falls back to:

  1. Android: rootProject.name from settings.gradle.kts, prefixed as android:<name> (e.g. android:my-app)
  2. iOS: no fallback — anonymous mint, no project grouping

Once generateConnectionModule writes the manifest, future mints use the manifest's appPackage and the project keys become stable.

The matching reverse-DNS convention (and why it matters)

Most professional projects use the same reverse-DNS identifier for both Android and iOS:

  • Android applicationId: com.example.myapp
  • iOS bundle identifier: com.example.myapp

This is the standard that App Store and Google Play both nudge developers toward (transferability across stores, brand consistency, deep-linking). Most React Native, Expo, Flutter, Capacitor, and Tauri toolchains enforce it by default.

When the identifiers match, createSimulatorSession from each platform produces the same projectInstallId, so:

  • First mint (e.g. Android): a new project row is created with projectInstallId = com.example.myapp.
  • Second mint (e.g. iOS): the get-or-create lookup finds the existing project key, and the iOS row joins under the same projectInstallId.
  • Dashboard: shows ONE project card with two platform chips (Android · iOS).

No Merge action needed. No manual linking. No URL surgery. This is the happy path and it's how the system is supposed to feel — invisible.

When the identifiers don't match

Sometimes Android applicationId and iOS bundle identifier diverge. Common causes:

  • Different reverse-DNS chosen per platform (rare in pro projects, more common in hobby projects or projects that grew organically per-platform).
  • One project had no manifest yet — Android fell back to android:<rootProject.name> while iOS used the real bundle ID.
  • The Android and iOS apps were authored in different teams or eras with no coordination on identifiers.

When the identifiers don't match, two separate projects appear on the dashboard. Two cards, each with one platform chip.

Fix option 1: align the identifiers (best long-term)

Edit one platform's reverse-DNS to match the other:

  • Android: change applicationId in app/build.gradle.kts:
    android {
        defaultConfig {
            applicationId = "com.example.myapp"  // match iOS bundle ID
        }
    }
  • iOS: change the bundle identifier in your Xcode project (Target → General → Identity → Bundle Identifier).

Then re-run generateConnectionModule to regenerate the manifest, and mint a fresh sim. The new mint joins the existing project (or creates a new one with the unified ID — whichever already exists). Same root cause permanently fixed.

Caveat: changing applicationId after a Play Store release is a breaking change for installed users (it's a new app from the store's perspective). Don't do this lightly if your app is already shipped.

Fix option 2: dashboard Merge action

If you can't or don't want to change the identifiers, use the Merge action on the dashboard:

  1. Open the source project's Overview at extentos.com/projects/<id>.
  2. Navigate to the Settings tab (sidebar) → scroll to Danger Zone → click Merge….
  3. Pick the target project from the dropdown. The modal previews which platforms will move and surfaces any platform conflicts (e.g. both source and target already have a live Android sim — must Reset or Delete one of the duplicates first).
  4. Confirm. The merge updates the source's projectInstallId to the target's value across every live row in a single atomic database update. Live WebSocket sessions are unaffected (the sessionId stays stable; only the project grouping changes).

After the merge, the source project disappears from the dashboard and its sessions appear under the target.

What's stored on each session row

There are three identifier fields on every session row, each with a distinct role:

  • projectInstallId — the dashboard grouping key. Equals the source-derived appPackage at mint time. Updated to the target's value during a Merge. This is what controls which card a session appears under.
  • platformInstallId — the original platform identifier, set on insert and never modified. Equals projectInstallId at mint time. After a Merge, this preserves the row's original Android applicationId / iOS bundle identifier even though projectInstallId has been rewritten. Surfaces in Settings → Project info → Per-platform identifiers when it diverges from the project ID.
  • appLabel — the friendly display name shown on the dashboard card and the project header. Cosmetic, editable from Settings → Project info → Display name. Defaults to the source-derived value at mint time (Android: rootProject.name; iOS: appPackage).

The first two are derived from your source code; the third is yours to edit.

After a Merge: per-platform truth in Settings

Because platformInstallId is preserved per row, even a heavily-merged project stays honest about its underlying platform identifiers. On the project's Settings page:

  • Project ID displays the post-merge projectInstallId (the canonical grouping value).
  • Per-platform identifiers appears as a separate section only when at least one platform's platformInstallId differs from the Project ID. It lists each diverging platform with its actual identifier, e.g.:
    • Android: com.example.android-original
    • iOS: com.example.ios-original

If your identifiers match (the happy path), this section is hidden — the project ID alone is the complete picture.

Editing the project name

The Project ID is read-only on the dashboard — your app's source is the source of truth. Editing the dashboard's stored projectInstallId would just create a mismatch with the next mint (which derives the value from the manifest again).

The display name (appLabel) is editable on the Settings tab. Saving it propagates atomically across every platform row of the project, so Android and iOS rows display the same label.

What the agent does

createSimulatorSession is get-or-create under the hood. The first call from a project mints; subsequent calls return the existing saved sim with status: "resumed". When you want to rotate the session ID (URL hijack recovery, clean-slate test), pass resetFresh: true — the existing session is archived and a new one is minted with the same project key.

When the agent mints from a second platform with a matching identifier, the second-platform row simply joins the existing project — no special tool call, no flag, no agent prompt required. The agent just calls createSimulatorSession for the iOS or Android project as normal.

Reference

  • Settings UI lives at extentos.com/projects/[id]/settings.
  • Backend endpoints:
    • PATCH /api/projects/[projectId] updates the display name across all rows.
    • POST /api/projects/[sourceId]/merge runs the merge.
    • POST /api/sessions/[id]/reset rotates a single session ID.
    • POST /api/sessions/[id]/delete soft-deletes a session (7-day undo grace).
  • Schema columns on simulator_sessions: project_install_id, platform_install_id, app_label (plus the rest of the row).