---
title: Projects
description: How Extentos identifies a project across Android and iOS — the matching reverse-DNS convention, auto-join at mint time, and the Merge action when ids diverge.
type: concept
platform: all
related:
  - /docs/concepts/sessions
  - /docs/getting-started/with-agent
  - /docs/concepts/architecture
---

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](https://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`:
  ```kotlin
  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](https://extentos.com/projects).
- 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).
