> ## Documentation Index
> Fetch the complete documentation index at: https://praison.ai/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Mobile Engines

> One interface, three engines, a conformance suite, and how the in-process engine persists a turn.

The mobile app picks an engine from a factory that is handed the store engines write through, so the in-process engine records a turn to the same session the chat list reads. Everything above the seam is written against `AgentEnginePort` and nothing else.

```ts theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
import { createRemoteHttpEngine } from "praisonai-mobile/engines/remote-http";

const engine = createRemoteHttpEngine({ baseUrl: "http://127.0.0.1:8765", http });
for await (const event of engine.run(request, signal)) {
  if (event.type === "delta") process.stdout.write(event.text);
}
```

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph LR
    subgraph "Engine Registry"
        R[🧾 RegistryDeps] --> H[🌐 remote-http]
        R --> I[📱 praisonai-ts]
        I --> P[💾 RunPersistence]
        I --> Hist[📜 ConversationHistory]
        H -.server-owned.-> Server[🖥️ remote server]
    end

    subgraph "Engine Selection"
        Session[💾 Session] --> Factory[🏭 engines#40;persistence#41;]
        Factory --> InProc[🤖 In-Process]
        Factory --> Remote[🌐 Remote HTTP]
    end

    classDef reg fill:#6366F1,stroke:#7C90A0,color:#fff
    classDef remote fill:#189AB4,stroke:#7C90A0,color:#fff
    classDef inproc fill:#8B0000,stroke:#7C90A0,color:#fff
    classDef store fill:#10B981,stroke:#7C90A0,color:#fff
    classDef process fill:#189AB4,stroke:#7C90A0,color:#fff

    class R reg
    class H remote
    class I inproc
    class P,Hist store
    class Server remote
    class Session store
    class Factory process
    class InProc inproc
    class Remote remote
```

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph LR
    Controller[⚙️ Run Controller] --> Port[🔌 AgentEnginePort]
    Port --> TS[🧠 praisonai-ts]
    Port --> HTTP[🌐 remote-http]
    Port --> Fake[🧪 scripted fake]

    classDef ctrl fill:#8B0000,stroke:#7C90A0,color:#fff
    classDef port fill:#6366F1,stroke:#7C90A0,color:#fff
    classDef impl fill:#189AB4,stroke:#7C90A0,color:#fff

    class Controller ctrl
    class Port port
    class TS,HTTP,Fake impl
```

## Quick Start

<Steps>
  <Step title="RegistryDeps requires a persistence">
    `persistence` is required. It is passed only into the in-process engine's factory.

    ```ts theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    export interface RegistryDeps {
      readonly settings: SettingsFacade;
      readonly http: HttpPort;
      readonly createInProcess?: (persistence: RunPersistence) => AgentEnginePort | Promise<AgentEnginePort>;
      readonly persistence: RunPersistence; // required
    }
    ```
  </Step>

  <Step title="An EngineChoice can carry a probe">
    `create` builds the engine; the optional `probe` answers whether the thing it talks to is ready. The in-process engine has no remote to probe, so it supplies none.

    ```ts theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    export interface EngineChoice {
      readonly id: string;
      readonly create: () => AgentEnginePort | Promise<AgentEnginePort>;
      /** Is the thing this engine talks to actually ready to answer?
       *  Optional — the in-process engine has no remote to probe. When
       *  present it runs at selection, so a remote that answers 200 with
       *  `{"ok": false}` is refused at boot with a name rather than
       *  surfacing as a transport error mid-turn. */
      readonly probe?: () => Promise<Readiness>;
    }
    ```
  </Step>

  <Step title="selectEngine returns notReady on a retryable probe">
    The success branch carries an optional `notReady`, set only for a **retryable** unreadiness. A permanent unreadiness (`version_mismatch`) takes the failure branch and disposes the engine.

    ```ts theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    export type EngineSelection =
      | {
          readonly ok: true;
          readonly engine: AgentEnginePort;
          /** The engine was selected but is not answering yet. Set only for a
           *  RETRYABLE unreadiness -- a permanent one refuses instead. */
          readonly notReady?: { readonly reason: string; readonly detail: string };
        }
      | {
          readonly ok: false;
          readonly reason: "unknown_engine" | "protocol_mismatch" | Extract<Readiness, { ready: false }>["reason"];
          readonly detail: string;
          /** Only a probe failure sets this: a transport error may resolve on a
           *  retry, a version mismatch never will. */
          readonly retryable?: boolean;
        };
    ```
  </Step>

  <Step title="enginesFor wires a probe and passes persistence to in-process only">
    The remote-http choice supplies a `probe`; the remote engine deliberately does not receive `persistence`. `baseUrl` is a **resolver**, not a captured string — read at the moment it is used, so a Settings edit is live on the next `/chat` and `/health`.

    ```ts theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    export function enginesFor(deps: RegistryDeps): readonly EngineChoice[] {
      // A RESOLVER, not a captured string: read at the moment it's used, so a
      // Settings edit is live on the next /chat and /health.
      const baseUrl = (): string =>
        stringSetting(deps.settings, "baseUrl", "http://127.0.0.1:8765").replace(/\/+$/, "");
      const choices: EngineChoice[] = [
        {
          id: ENGINE_REMOTE_HTTP,
          create: () => createRemoteHttpEngine({ baseUrl, http: deps.http, id: ENGINE_REMOTE_HTTP }),
          // Called, not captured — probing the address the user just replaced
          // reports a failure about a machine nobody is talking to.
          probe: () => probeHealth(deps.http, baseUrl()),
        },
      ];
      // in-process engine (no probe — it IS the device)
      if (deps.createInProcess !== undefined) {
        const build = deps.createInProcess;
        choices.push({ id: ENGINE_PRAISONAI_TS, create: () => build(deps.persistence) });
      }
      return choices;
    }
    ```

    The resolver is passed to `createRemoteHttpEngine` and the probe is `probeHealth(deps.http, baseUrl())` — invoked, not captured. Pinned by the resolver tests in `registry.test.ts` and `engine.test.ts`.
  </Step>

  <Step title="Build engines from the session">
    `AppDeps.engines` is a factory `(persistence) => EngineChoice[]`. The composition root builds it from the session, so an engine cannot exist without the store it writes through.
  </Step>

  <Step title="The shipping composition root wires it">
    `appEngines` supplies `createInProcess` to `enginesFor`, so the in-process engine is on offer. The remote engine stays first, so the default keeps working with nothing configured.

    ```ts theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    import { appEngines } from "praisonai-mobile/app/main";

    const choices = appEngines({
      settings,
      http: platform.http,
      persistence,
      onIgnored: (reason, detail) => sink.note(reason, detail),
    });
    // choices.map(c => c.id) → ["remote-http", "praisonai-ts"]
    ```

    Pinned by `"the real composition root offers the in-process engine"` in `app/src/main.test.ts`.
  </Step>

  <Step title="Run a turn">
    ```ts theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    for await (const event of engine.run(request, signal)) {
      console.log(event.type);
    }
    ```

    `run` returns an `AsyncIterable`, so `for await` gives free backpressure and one cancellation path. Breaking out of the `for await` cancels the underlying response stream in a `finally` (`await reader.cancel()`), so the engine stops generating and the socket is released. Before [#4579](https://github.com/MervinPraison/PraisonAI/pull/4579) this was silently untrue for `remote-http` — the `finally` lacked the cancel, so leaving the loop left the model billing.
  </Step>

  <Step title="Answer an approval">
    ```ts theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    const recorded = await engine.decide(approvalId, "allow");
    ```

    Approval is a reverse channel while the stream is live, so it is a method, not an event.
  </Step>

  <Step title="Surface refused frames with onIgnored">
    ```ts theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    const engine = createRemoteHttpEngine({
      baseUrl,
      http,
      onIgnored: (reason, detail) => sink.note(reason, detail),
    });
    ```

    Without `onIgnored`, decoder refusals are invisible — a truncated answer reported as a clean success. The composition root wires it via the `DropSink`; anyone hand-constructing the engine must pass it explicitly.
  </Step>
</Steps>

***

## Options On remote-http

`RemoteHttpOptions` carries the request target and the refusal callback.

| Option      | Type                       | Purpose                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |
| ----------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `baseUrl`   | `string \| (() => string)` | Engine base URL, or a resolver read per request. A plain string still works and still means "this one, forever"; a resolver is re-read on each `/chat`, `/cancel`, `/approve`, and `/health`, so a Settings edit is live on the next message. Any number of trailing slashes are stripped **per resolution** before the path is appended, so `http://host`, `http://host/`, `http://host//`, and `http://host///` all POST to `http://host/chat`. Pinned by `"a base URL with several trailing slashes still builds one clean path"`. |
| `http`      | `HttpPort`                 | All I/O goes through this port — no `fetch` in the engine.                                                                                                                                                                                                                                                                                                                                                                                                                                                                            |
| `token`     | `string`                   | Optional bearer token; loopback is unauthenticated, off-device must not be.                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| `onIgnored` | `(reason, detail) => void` | Called for every frame the decoder **refused**. Wired to `dropSink.note` by the composition root.                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| `id`        | `string`                   | Optional engine id; defaults to `"remote-http"`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      |

`ControllerDeps.dropSink` is the other end of the same seam: what the engine refuses becomes a dropped row on the turn it belonged to. See [Dropped Events](/docs/features/mobile/dropped-events).

### The address is resolved per request

The engine is built once at boot and held for the session, so a captured `baseUrl` string strands a phone that cannot reach its default engine — the only recovery is force-quitting the app. A resolver reads the address at the moment it is used, so the next `/chat` and `/health` go wherever Settings says right now.

```ts theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
// engine.ts — resolved per call, never captured.
const base = (): string =>
  (typeof options.baseUrl === "string" ? options.baseUrl : options.baseUrl()).replace(/\/+$/, "");
```

Trailing slashes are stripped **per resolution**, not at the caller: the resolver's answer is not seen until the string is used, and `${base}/chat` against `http://host/` produces `//chat` — which some servers 404 and others redirect, losing the POST body.

Rebuilding the whole engine on a Settings change was the alternative and a much larger one: `createRunController` takes the engine at construction with no seam to swap it, so following the setting that way means rebuilding the controller mid-session and dropping the turn in flight. A resolver costs one call per request and no signature above it.

### Cancel and Approve are pinned to the origin the run was born at

A resolver means `base()` can move between calls — that is the point — but a run's `/cancel` and an approval's `/approve` are addressed by an id the **originating** engine minted. Two maps pin the control requests to where the run started:

| Map                                     | Records                                        | Read by                                                                 |
| --------------------------------------- | ---------------------------------------------- | ----------------------------------------------------------------------- |
| `runBase: Map<runId, string>`           | the resolved origin at the start of `run(...)` | `cancel(runId)` — `runBase.get(runId) ?? base()`                        |
| `approvalBase: Map<approvalId, string>` | the origin when an `approval_request` arrives  | `decide(approvalId, choice)` — `approvalBase.get(approvalId) ?? base()` |

The origin is resolved once, at the top of `run(...)`, and remembered for that run's control requests. The live resolver is only a fallback for an id this instance never saw — a resumed app that reconnected.

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
sequenceDiagram
    participant User
    participant Engine
    participant Old as Engine A (run origin)
    participant New as Engine B (edited address)

    User->>Engine: run(runId) — origin pinned to A
    User->>Engine: Settings edit → base() now B
    User->>Engine: cancel(runId)
    Engine->>Old: /cancel to A (runBase.get)
    Old-->>Engine: ok:true — run stopped
    Note over New: B never issued runId; a /cancel there 404s
```

Without pinning, a Settings edit mid-turn would send `/cancel` to an engine that never issued the `runId`, `postOk` would 404, `cancel` would return `false`, and `controller.ts` would leave the original engine generating (and billing) while the user believes they hit Stop.

<Note>
  **Readiness is strictly HTTP 200.** The remote-HTTP engine's readiness probe treats any non-200 status as an `http_status` failure — including sub-200 responses (100, 101, 199) that would otherwise fall through to body classification. A websocket upgrade or a `100 Continue` from a misconfigured proxy can no longer be reported as `ready: true` and then offered as a usable engine whose every request fails. Pinned by `"any status that is not exactly 200 is an http_status failure"`.
</Note>

### Readiness body rules

After the 200 passes, `classify` in `engines/src/remote-http/readiness.ts` judges the body by **strict identity**, not by truthiness. The healthy predicate is `body.ok === true` — nothing else counts as healthy.

| Body after HTTP 200                                                               | Verdict                                              |
| --------------------------------------------------------------------------------- | ---------------------------------------------------- |
| `{ok: true}` (optionally with a valid `version`)                                  | **healthy** → `ready: true`                          |
| `{ok: "yes"}`, `{ok: 1}`, `{ok: "true"}`, `{ok: "false"}`, `{ok: {}}`, `{ok: []}` | `unhealthy` — even when a valid `version` is present |
| `ok` missing entirely, or `ok: false / null / 0`                                  | `unhealthy`                                          |

A missing `ok` must never be mistaken for a present healthy one: `{ok: "true"}` (a string) and a body with no `ok` at all are both refused. The paired positive case — `body.ok === true` — is what stops the predicate degenerating to "refuse everything".

<Note>
  **Unhealthy is retryable — and retryable now boots the app.** An `unhealthy` verdict carries `retryable: true`. Since PR #4672 that no longer means "poll from a crash screen": `selectEngine` returns `ok: true` with the engine attached and a `notReady` field, so the app **boots** and shows a warning instead of refusing. Only `version_mismatch` still refuses boot. Pinned by `"an unhealthy engine is worth retrying"`, with `"a healthy engine IS ready - the pair"` as its companion. See [Boot Failures → An unreachable engine boots with a warning](/docs/features/mobile/boot-failures#an-unreachable-engine-boots-with-a-warning).
</Note>

<Note>
  **The probe now actually runs at boot.** These readiness rules are no longer hypothetical: `enginesFor` wires `probe: () => probeHealth(deps.http, baseUrl())` on the remote-http choice — `baseUrl` invoked, not captured — and `selectEngine` calls it before the engine is offered. A `200 {"ok": false}`, a non-200, or an unreachable engine is handled at boot with a named reason — a permanent one refuses boot, a retryable one boots the app with a warning. See [Boot Failures → Boot-time health probe](/docs/features/mobile/boot-failures#boot-time-health-probe).
</Note>

### Probe timeout

The probe runs *before* the app mounts, so a request or body read that never completes must abort on a deadline rather than pin `createApp` on a blank screen.

```ts theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
export const PROBE_TIMEOUT_MS = 5000;

// Bounded by timeoutMs; a request or body read that never completes
// aborts on the deadline and classifies as retryable transport.
await probeHealth(http, baseUrl, token, PROBE_TIMEOUT_MS);
```

A single `AbortController` covers both the request **and** the body read, so an endpoint that accepts the socket but never sends the response — a still-binding engine, a black-hole proxy — aborts on the deadline. A timeout classifies as retryable `transport`, exactly the case worth polling.

### Order of checks in `selectEngine`

A probe opens a socket, so it must not run for an engine this build cannot even speak to. The checks run in order, cheapest first.

| Step | Check                                          | Outcome                                                                             |
| ---- | ---------------------------------------------- | ----------------------------------------------------------------------------------- |
| 1    | Is `id` known?                                 | Fails: `unknown_engine`                                                             |
| 2    | Does `protocolVersion` match?                  | Fails: `protocol_mismatch`                                                          |
| 3a   | Probe returns `ready: false, retryable: false` | Fails: `version_mismatch`. **Engine disposed.**                                     |
| 3b   | Probe returns `ready: false, retryable: true`  | Succeeds with `notReady` attached. **Engine kept** — it's the one the app will use. |

That step 3 does not run when step 2 fails is pinned by `"selectEngine runs the probe only after the protocol check passes"`. The split at step 3 mirrors `readiness.retryable`: a permanent unreadiness disposes and refuses, a retryable one keeps the engine and returns `ok: true`.

<Warning>
  **The verdict `reason` is load-bearing, not just `ready`.** Readers of `classify()` route the UI on `reason: "unhealthy"` versus `reason: "version_mismatch"` — the two lead to different recovery arms (retry vs. a version-mismatch boot failure). Asserting only `ready` is what let a test hold for the wrong reason in [#4606](https://github.com/MervinPraison/PraisonAI/pull/4606); always assert the reason too. Pinned by `"a missing ok field is not read as healthy"`. The `unhealthy → retry` recovery arm is described in [Errors & Recovery](/docs/features/mobile/errors-and-recovery).
</Warning>

<Note>
  **`run_id` and `chat_id` are separate wire fields.** The stream POST body carries `run_id` and `chat_id` as **separate** fields. `POST /cancel/{runId}` matches on `run_id` alone, so swapping the two — they are the same shape — makes Stop permanently unmatched against the remote engine (the model keeps generating and keeps billing) while nothing about the payload looks wrong. Pinned by `"the request carries the RUN id and the CHAT id in their own fields"`.
</Note>

<Note>
  **The stream request advertises SSE.** The `POST /chat` stream request sends `Accept: text/event-stream`. Dropping it makes a conforming proxy or engine free to answer with something else entirely, and the failure surfaces as an unparseable stream rather than as a bad request. Pinned by `"the stream request advertises that it wants SSE"`.
</Note>

***

## First-Launch Default

`defaultEngineIdFor(kind: Platform["kind"])` returns the `engineId` used only on the very first launch, when settings name no engine — and a persisted `engineId` always wins over it.

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph TB
    Start[🚀 First launch] --> Persisted{💾 engineId persisted?}
    Persisted -->|Yes| Use[✅ Use persisted engineId]
    Persisted -->|No| Default[🏭 defaultEngineIdFor#40;platform.kind#41;]
    Default --> Kind{📱 platform.kind}
    Kind -->|tauri| InProc[praisonai-ts #40;in-process#41;]
    Kind -->|web| Remote[remote-http]
    InProc --> Boot[🖼️ mount uses this as engineId]
    Remote --> Boot

    classDef start fill:#8B0000,stroke:#7C90A0,color:#fff
    classDef branch fill:#F59E0B,stroke:#7C90A0,color:#fff
    classDef ok fill:#10B981,stroke:#7C90A0,color:#fff
    classDef fn fill:#6366F1,stroke:#7C90A0,color:#fff
    classDef pick fill:#189AB4,stroke:#7C90A0,color:#fff

    class Start start
    class Persisted branch
    class Use ok
    class Default fn
    class Kind branch
    class InProc pick
    class Remote pick
    class Boot ok
```

The first-launch default is not the only engine the app ever uses: a persisted `engineId` still wins, and the [Settings Screen](/docs/features/mobile/settings-screen) is what persists a change to it.

The function splits by platform: `"tauri"` defaults to the in-process engine, `"web"` to remote. Pinned by `"a device's first-launch default is the in-process engine; the web's is remote"` in `app/src/main.test.ts`.

```ts theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
import { defaultEngineIdFor } from "praisonai-mobile/app/main";

defaultEngineIdFor("tauri"); // "praisonai-ts"  — a device runs in-process
defaultEngineIdFor("web");   // "remote-http"   — a browser tab has a server

// mount() calls it once, at boot, when no engineId is persisted.
// A persisted engineId always wins over this default.
```

A device defaults in-process because a phone cannot reach `http://127.0.0.1:8765`, and the cleartext loopback address is refused by iOS ATS and Android besides — so a remote default was a first prompt that failed with a status code. The in-process engine now ships as a lazily-fetched chunk beside `app.js` (see [How the engine reaches the app](#how-the-engine-reaches-the-app)), so it is the one choice that works with nothing configured — up to the model itself, which needs an OpenAI key pasted into Settings (see [API Keys](/docs/features/mobile/api-keys) and [How the in-process engine authenticates](#how-the-in-process-engine-authenticates)).

The web keeps the remote engine: a browser tab has a server to talk to, and no reason to fetch \~900 kB of engine before first paint. See [Web App (PWA)](/docs/features/mobile/web-app#which-engine-the-web-picks) for how the web target boots, installs, and works offline.

<Warning>
  **Desktop Tauri counts as a device.** `Platform["kind"]` is only `"tauri" | "web"`, so `cargo tauri dev` on a laptop reports `"tauri"` exactly as a phone does — and both start in-process. The shipped design accepts this trade: a developer with a server running switches to `remote-http` in [Settings](/docs/features/mobile/settings-screen), and the persisted `engineId` wins over this default (`chosenStringOr` in `boot.ts`), so it only ever decides the very first launch.
</Warning>

The seam is a function a test drives directly rather than an expression asserted to appear in `mount` (mirrors `stopNotice` and `appEngines`, the house rule). Defaulting a device to in-process removes the intermediate hop over `http://127.0.0.1:8765` and the iOS ATS / Android `usesCleartextTraffic` exception on the default path (see [Errors & Recovery](/docs/features/mobile/errors-and-recovery)).

<Note>
  **The persisted engineId wins.** `chosenStringOr` in `boot.ts` prefers the persisted `engineId` over this default, so `defaultEngineIdFor` only decides the very first launch after install (or after a settings clear). The [Settings Screen](/docs/features/mobile/settings-screen) is where a user overrides both `engineId` (from `SETTING_DEFS.engineId.choices`) and `baseUrl` on-device. See [Storage & Secrets](/docs/features/mobile/storage-and-secrets).
</Note>

***

## Who Persists

Two engines, two owners of the write.

| Engine                      | Receives persistence | Who owns the write                |
| --------------------------- | -------------------- | --------------------------------- |
| In-process (`praisonai-ts`) | **Yes**              | The mobile session on this device |
| Remote HTTP (`remote-http`) | No                   | The server it talks to            |

The remote engine deliberately does not take persistence: the server it connects to owns the write and is the only thing that can report authoritative indices for its own store.

<Note>
  A picker omits an engine whose prerequisites are absent rather than offering it and then failing. `createInProcess` is optional — when it is not supplied, only the remote engine is offered. The shipping composition root **does** supply it, via `appEngines`, so `enginesFor` returns both engines in real builds, and **both** `remote-http` and `praisonai-ts` sit in `SETTING_DEFS.engineId.choices` (the picker). The engine ships as a chunk beside `app.js`, so the picker offers a choice the shipping build can construct — the reason `registry.test.ts` compares `choices` against the composition `main.ts` actually mounts. Pinned by `"the real composition root offers the in-process engine"` in `app/src/main.test.ts`.
</Note>

***

## Where a Completed Turn Is Written

Each engine owns its own write, and reports `end.userIndex` from the store it wrote to — and reads the next turn's history back from the same store.

| Engine                      | Writes the turn to                                                                 | Reports `end.userIndex` from                        | Reads history for the next turn from                                                                                   |
| --------------------------- | ---------------------------------------------------------------------------------- | --------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| `praisonai-ts` (in-process) | The mobile app's session store, via the `RunPersistence` handed in at construction | The store it just wrote to                          | The mobile app's session store, via `ConversationHistory` (`historyFor(session)`), bounded by `HISTORY_CHAR_BUDGET`    |
| `remote-http`               | The desktop/remote server it POSTs the run to                                      | The server's own store (reported over the protocol) | The remote server (server keys by `chat_id`) — client-side history deliberately **not** passed, to avoid doubled turns |

The read side is symmetric with the write: the in-process engine is handed both `RunPersistence` and `ConversationHistory`, both projected from the same session; the remote engine takes neither, because the server it talks to owns both. See [History & Reopen → The model actually gets the conversation](/docs/features/mobile/history-and-reopen#the-model-actually-gets-the-conversation).

The in-process engine records the turn and reports the indices it actually wrote, which is what makes `end.userIndex` real:

```ts theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
const indices = await options.persistence.record(request, answer);
yield {
  type: "end",
  msgId,
  userIndex: indices?.userIndex ?? null,
  assistantIndex: indices?.assistantIndex ?? null,
  versions: indices?.versions ?? 1,
  active: indices?.active ?? 0,
};
```

<Warning>
  A turn answered by the default `remote-http` engine does **not** currently mirror into the local mobile session. The remote server owns that transcript and is the only thing that can report authoritative indices for its own store. Local mirroring is tracked separately.
</Warning>

***

## What a Recorded Turn Returns

`session.record(prompt, answer)` writes the user message and the answer together, then returns the indices into the persisted array.

| Field            | Meaning                              |
| ---------------- | ------------------------------------ |
| `userIndex`      | Position of the user message on disk |
| `assistantIndex` | Position of the answer on disk       |
| `versions`       | Number of stored versions            |
| `active`         | Which version is shown               |

A `null` return means the save failed — the turn is on screen but not on disk.

<Warning>
  The user message is written **when the turn succeeds**, not when the user pressed send. A turn that never completes must not leave a dangling user message with no reply under it.
</Warning>

The transcript reads `end.userIndex` through the same `isPersisted` predicate that decides whether Fork and Delete are offered — not a second copy of the rule. An index means the user row is `stored`; no index means it is `unstored` and carries the "Not saved" caveat. See [Transcript User Row → Three storage states](/docs/features/mobile/transcript-user-row#three-storage-states).

***

## Persisted Answer Text

The in-process engine (`praisonai-ts`) writes exactly one string as the assistant answer per turn. Which string depends on whether the turn produced a `finish` event.

| Turn saw a `finish` event? | What gets persisted                                             |
| -------------------------- | --------------------------------------------------------------- |
| Yes                        | `finish.text` — the streamed deltas are discarded for the write |
| No                         | Concatenation of every `delta.text` in order                    |

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph TB
    Start[🧾 Turn ends] --> Q{🔍 Saw a finish event?}
    Q -->|Yes| Finish[✅ Persist finish.text]
    Q -->|No| Deltas[✅ Persist concat of deltas]

    classDef start fill:#8B0000,stroke:#7C90A0,color:#fff
    classDef q fill:#F59E0B,stroke:#7C90A0,color:#fff
    classDef out fill:#10B981,stroke:#7C90A0,color:#fff

    class Start start
    class Q q
    class Finish,Deltas out
```

<Warning>
  `finish.text` **replaces** the streamed accumulation — it does not append to it. A provider that both streams deltas and then emits `finish` with the full text writes the answer **once**, not twice. Appending instead leaves the on-screen answer looking correct (the screen is built from deltas) while the persisted transcript contains the answer twice — visible only when the user reopens the chat.
</Warning>

<Note>
  `finish` also carries the final text for a **non-streaming** provider that emits no deltas at all. Trusting only the streamed accumulation would persist an empty string in that case.
</Note>

Why replace rather than append?

* Upstream may **normalise** the final text — a typo fix or whitespace tidy-up. Persisting the concatenation would store text the model did not finally say.
* A non-streaming provider emits **no** deltas, only `finish`. Persisting only the accumulation would drop the answer entirely.

```ts theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
if (event.type === "text") {
  if (event.delta === "") continue;
  answer += event.delta;                 // accumulate while streaming
  yield { type: "delta", msgId, text: event.delta };
} else if (event.type === "finish") {
  answer = event.text;                   // REPLACES the accumulation
}
```

<Info>
  This rule applies to the in-process engine's own write. The `remote-http` engine does not persist locally at all — the server it talks to owns that write and reports its own indices. See [Who Persists](#who-persists) and the [Protocol](/docs/features/mobile/protocol) for the `finish` and `end` event shapes.
</Info>

***

## The Port

`AgentEnginePort` is the entire agent-framework coupling.

| Member                       | Type                      | Purpose                                                                                                 |
| ---------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------- |
| `id`                         | `string`                  | Stable id, written into persisted chats.                                                                |
| `protocolVersion`            | `number`                  | Checked once at boot; a mismatch fails boot with a name.                                                |
| `capabilities`               | `EngineCapabilities`      | What the engine can **report**, checked before the first token.                                         |
| `run(request, signal)`       | `AsyncIterable<RunEvent>` | One turn: `start` first, exactly one terminal event last.                                               |
| `decide(approvalId, choice)` | `Promise<boolean>`        | Answer an approval; `false` for an unknown id, a decided id, **or a non-200 response from the engine**. |
| `cancel(runId)`              | `Promise<boolean>`        | Stop a run; `false` when it was not live **or when the engine returns a non-200**.                      |
| `dispose()`                  | `Promise<void>`           | Release clients and sockets. Idempotent.                                                                |

***

## The Three Engines

Three implementations pass the same conformance suite, which is what makes "swappable" a fact.

| Engine         | Where                      | Talks to                                   |
| -------------- | -------------------------- | ------------------------------------------ |
| `praisonai-ts` | `engines/src/praisonai-ts` | The agent loop, in-process on the device.  |
| `remote-http`  | `engines/src/remote-http`  | A PraisonAI engine over HTTP + SSE.        |
| scripted fake  | `testing/`                 | Nothing — replays canned events for tests. |

### How the engine reaches the app

The in-process engine loads praisonai's `Agent` through a single **literal** dynamic import, in `engines/src/praisonai-ts/load-agent.ts`:

```ts theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
export async function loadPraisonAgent(): Promise<PraisonAgentModule> {
  try {
    const mod = (await import("praisonai/mobile")) as unknown as { Agent: PraisonAgentModule };
    return mod.Agent;
  } catch (cause) {
    throw new Error(
      "the in-process engine is unavailable in this build: praisonai could not be loaded",
      { cause },
    );
  }
}
```

Three facts make this the shipped seam:

* **The specifier is a literal.** esbuild can only put a module in its own lazily-fetched chunk if it can see which module it is; the runtime-computed path this used to be was opaque, so the engine either came along eagerly or, as an external, not at all. The literal is what turns it into a chunk.
* **`praisonai/mobile`, not `praisonai`.** The default entry re-exports the CLI, the MCP server and the knowledge store, whose Node builtins are import-time fatal in a webview. `/mobile` is the package's webview-safe allowlist entry.
* **One file may name it.** `tools/boundaries.json` allows the string `praisonai` only from `engines/src/praisonai-ts` — the agent-framework seam. Making the import literal did not cost that rule; `load-agent.ts` is the one place it lives.

### The shell and lazy split

The build emits two populations of chunk, paid for at different moments, so `tools/bundle.mjs` carries two budgets:

| Budget               | Covers                                                                                                         | Ceiling     | Measured at the `chrome58` floor                                                                                          |
| -------------------- | -------------------------------------------------------------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------- |
| `SHELL_BUDGET_BYTES` | the entry chunk + its **static** import graph — fetched and parsed before the first frame, on every cold start | **400 kB**  | **87.7 kB** (`app.js` + esbuild's interop helper)                                                                         |
| `LAZY_BUDGET_BYTES`  | everything reachable only through `import()` — the engine and its remaining provider stack                     | **1000 kB** | **912.6 kB** across 17 chunks (the engine plus `zod`, `ai`, `openai`, and `praisonai`) plus \~100 kB of chrome58 lowering |

They are two budgets, not one in disguise: `bundle.test.mjs` proves each can fail on its own, and `depgraph.test.mjs` pins both values so neither can be widened without a failing test.

The 1000 kB ceiling is not a round number — it is re-derived on each raise from the current measured bundle. The rule is a **floor** (measured 912.6 kB + a 30 kB drift band) and a **lid** (measured + 100 kB, the smallest provider on the measured list); at or above the lid a new provider can no longer trip the gate, which is the one thing the constant exists to force. 1000 kB sits 57 kB above the floor and 12 kB under the lid — two independent derivations land there. See `tools/bundle.mjs` for the full derivation, re-derived from the measured bundle after the [#4874](https://github.com/MervinPraison/PraisonAI/pull/4874) and [#4882](https://github.com/MervinPraison/PraisonAI/pull/4882) reclaims in [PR #4903](https://github.com/MervinPraison/PraisonAI/pull/4903). Re-measure the per-chunk breakdown with `node src/praisonai-mobile/tools/bundle.mjs` on `main` before quoting per-package byte counts — the earlier `@ai-sdk/*` breakdown is stale after #4874.

<Warning>
  **`supported: { "dynamic-import": true }` is load-bearing.** Left to its own table at a `chrome58` target, esbuild **lowers** `import()` to a static import wrapped in a promise — the split silently collapses and the whole engine lands in the shell (measured: **1486.8 kB** shell, **0** lazy, which fails the shell budget). `bundle()` overrides the target to keep `import()` intact. `SPLIT_MIN_CHROME = chrome63` records the first Chrome where esbuild leaves `import()` alone — the shipped page's true floor — measured one below and at in `bundle-target.test.mjs`. `ANDROID_WEBVIEW_FLOOR` records nothing between API 26 (chrome58) and 30 (chrome87); whether `minSdkVersion` should move is recorded in `bundle.mjs`, not decided there.
</Warning>

### Consumer-first bare-import resolution

`bundle.mjs` resolves every bare import **from this package first, from the importer second**. Peers are the consumer's to provide: praisonai lists `ai` and `@ai-sdk/*` as optional peers, this package declares them, and a registry install hoists them beside praisonai. Through the `file:` link (below) Node's real-path resolution looks in `../praisonai-ts/node_modules` instead and cannot see them — `@ai-sdk/cohere`, which praisonai imports and never declares, came back **unresolved**, measured. Consumer-first is the layout a registry install produces without depending on hoisting, and keeps `zod` and `@ai-sdk/provider-utils` to one copy. praisonai's own dependencies (`openai`, and the rest) are not here and fall through to its `node_modules`, as they should.

### Development consumes praisonai through a file link

`package.json` links `praisonai` as `file:../praisonai-ts`, not a pinned version, so the engine the app ships is the one from the same monorepo commit. The fix that makes the engine buildable below chrome89 — praisonai-ts [#4720](https://github.com/MervinPraison/PraisonAI/pull/4720), which removed a top-level `await` from praisonai-ts's esm shim on the mobile graph — is in the sibling package and reaches the app through this link. `.github/workflows/mobile.yml` watches `src/praisonai-ts/{src,scripts}/**` and `package.json`, and every job builds praisonai-ts (`npm install --legacy-peer-deps && npm run build`) before `npm ci` in the mobile package. Editing praisonai-ts and mobile in the same PR is therefore one build, not a publish-and-bump.

### How the in-process engine authenticates

The gap is closed as of [PR #4792](https://github.com/MervinPraison/PraisonAI/pull/4792): the registry now declares an `openaiApiKey` secret setting, and `createInProcessEngine` reads it through `apiKeyFor(secrets, settings.defs())` on **every** turn.

```ts theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
const apiKey = await apiKeyFor(secrets, settings.defs());
return new Agent({
  instructions: "You are a helpful assistant.",
  llm: settingString("model", "gpt-4o-mini"),
  // Omitted, not passed as "" or null, when unset: upstream treats a falsy
  // apiKey as "fall back to the environment", and a phone has none.
  ...(apiKey === null ? {} : { apiKey }),
});
```

Reading per turn — not once at construction — is the same lesson `enginesFor` learned with `baseUrl`: the engine is built once and held for the session, so a key pasted into Settings works on the very next message rather than after a force-quit. The key is **omitted** from the agent config when unset rather than passed as `""` or `null`, because upstream treats a falsy value as "fall back to the environment" and a phone has no environment.

`appEngines` now takes `secrets: SecretsPort` as a **required** dependency, so a composition that forgets it fails typecheck rather than building an engine with no credential. See the [API Keys](/docs/features/mobile/api-keys) page for the user-facing paste-and-send flow.

***

## Conformance

Passing `engines/src/conformance.ts` **is** the definition of implementing the seam.

```ts theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
export interface EngineHarness {
  readonly name: string;
  create(scenario: ScenarioName): Promise<AgentEnginePort>;
  readonly unsupported?: Partial<Record<ScenarioName, string>>;
}
```

The suite also asserts the negative direction: an engine declaring `approvals: false` must never emit an `approval_request`. Every unsupported scenario is printed on each run, so a contract that quietly shrinks is visible rather than silently green.

The suite drives each guarantee against a deliberately broken run. The shipped break modes are:

| break mode           | breaks the run by                                                            |
| -------------------- | ---------------------------------------------------------------------------- |
| `no_start`           | Skipping its opening event.                                                  |
| `start_only`         | Opening and then simply stopping, with **no terminal event at all**.         |
| `tool_failure_as_ok` | Reporting a failed tool as ok.                                               |
| `empty_is_fine`      | Reporting an empty stream as an empty answer instead of an error.            |
| `decide_always_true` | Reporting an unknown approval as accepted.                                   |
| `abort_ignored`      | Never consulting the abort signal, so a cancelled run streams to completion. |

Both `no_start` and `start_only` are caught by the same case *"a run emits start first and exactly one terminal event last"* — one missing the opening event, the other missing the terminal one. `start_only` proved the case's `events.length >= 2` assertion is redundant rather than unguarded: weakening it to `>= 1` still catches a run that opens and never ends.

<Note>
  The abort case originally asserted `seen.length < 50`, but every scenario emits five events or fewer, so an engine that ignored the signal ran to completion and still satisfied it — a cap larger than any real run cannot tell stopping from finishing, and that is what an arbitrary ceiling always is. It now compares against `whole.events.length` from a real driven happy-path run, which `abort_ignored` cannot satisfy. This is `start_only` one level further in: the `>= 2` cap was redundant, the `< 50` cap was vacuous.
</Note>

<Info>
  `praisonai-ts` declares `two_approvals` unsupported because upstream `Agent.streamEvents()` cannot emit `approval_request`. Tool scenarios and the single-approval scenario are produced and passing. See `src/praisonai-mobile/docs/gaps.md`.
</Info>

***

## When To Pick Which Engine

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph TB
    Start{What do you need?} -->|Tool rows, approvals,<br/>reasoning in the UI| HTTP[remote-http]
    Start -->|On-device, no server| TS[praisonai-ts]
    Start -->|Conversation memory that<br/>survives an app relaunch on-device| TS
    Start -->|Deterministic tests| Fake[scripted fake]

    classDef q fill:#F59E0B,stroke:#7C90A0,color:#fff
    classDef pick fill:#10B981,stroke:#7C90A0,color:#fff

    class Start q
    class HTTP,TS,Fake pick
```

<Note>
  On-device conversation memory that survives a relaunch is the in-process engine's job: `praisonai-ts` restores the prior conversation from the local session store on every turn. `remote-http` is **not** a memory-loss workaround — its conversation is server-owned by `chat_id`, and a chat it answered leaves the local session empty with no local history to restore.
</Note>

<Note>
  Both engines now appear in the picker (`SETTING_DEFS.engineId.choices` is `[remote-http, praisonai-ts]`), because the in-process engine ships as a lazy chunk the shipping build can construct. On a Tauri build the in-process engine is also the first-launch default (`defaultEngineIdFor("tauri") === "praisonai-ts"`); `remote-http` stays available and is the sensible pick for the desktop dev flow, where a server is running and tool/approval rows are useful to watch. See [First-Launch Default](#first-launch-default).
</Note>

***

## Common Patterns

An engine whose prerequisites are absent is omitted, not offered and then failed.

```ts theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
// createInProcess is optional: without it, only remote-http is listed.
if (deps.createInProcess !== undefined) {
  choices.push({ id: ENGINE_PRAISONAI_TS, create: () => build(deps.persistence) });
}
```

`selectEngine` names the available engines when an id is unknown, so a missing engine is an honest message rather than a crash.

```ts theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
const outcome = await selectEngine("nope", choices);
// outcome.detail names what IS available, e.g. "remote-http, praisonai-ts"
```

***

## Best Practices

<AccordionGroup>
  <Accordion title="Never bypass the factory">
    Obtaining the engine list requires the persistence argument. Do not construct engines directly — the type makes the wiring impossible to forget.
  </Accordion>

  <Accordion title="Treat persistence as required, not optional">
    `RegistryDeps.persistence` has no default. The in-process engine's `end.userIndex` is only real because it records through this store — without it, the turn is on screen and not on disk.
  </Accordion>

  <Accordion title="Do not persist remote-http into the local session">
    The remote server owns its own store and reports its own indices. Writing a second copy locally reintroduces divergence between screen position and disk position.
  </Accordion>

  <Accordion title="Do not switch chats from the request">
    The session already knows which conversation is open. Taking direction from the run request would let an in-flight turn write into whichever chat the user has since navigated to.
  </Accordion>

  <Accordion title="Read null as 'not on disk'">
    When `record` returns null the write failed. Null travels to the UI as "do not offer Fork or Delete", because those affordances would address a message that does not exist. Index `0` is valid, so a falsy check is a trap.
  </Accordion>

  <Accordion title="Read capabilities before rendering">
    `capabilities` is a property, not a method, so the UI decides what to render before the first token arrives.
  </Accordion>

  <Accordion title="Never fake an unsupported scenario">
    Declaring a gap in the `unsupported` map is honest; faking it hides a defect the conformance suite exists to catch.
  </Accordion>

  <Accordion title="Return false, never a lie">
    `decide` and `cancel` return `false` for an unknown id, a decided id, **and any non-200 response from the engine** — reporting success for a request the engine did not accept is a lie the UI cannot detect. `postOk` checks `if (response.status !== 200) return false;`, pinned by `"a decision or cancel the engine did NOT accept is reported as refused"` over `[202, 400, 401, 403, 500, 502]`.
  </Accordion>

  <Accordion title="Break out of the iterator to release the socket">
    The consumer's `break`, `return`, or exception out of the `for await` loop is the cancellation channel — there is no separate `close()`. The engine's `finally` calls `await reader.cancel()`, so leaving the stream releases the reader and the model stops billing. The regression `"leaving the stream early releases the socket"` asserts the underlying `ReadableStream`'s `cancel()` fires when the consumer `break`s after the first delta. See [Stop is idempotent and race-safe](/docs/features/mobile/approvals-and-cancellation#stop-is-idempotent-and-race-safe).
  </Accordion>

  <Accordion title="A failed engine-chunk fetch fails RECOVERABLY, not opaquely">
    The engine reaches praisonai through a lazily-fetched chunk, and a fetch can fail — a flaky connection, a build that left the engine out, a hashed file the page no longer matches. When it does, the failure arrives as a single `error` event through the run loop — a named row with the standard Retry affordance — never as an unhandled rejection. `create()` itself succeeds because it wires the factory, not the chunk; the `loadPraisonAgent` guard turns the rejected `import("praisonai/mobile")` into that recoverable event. Pinned by `"the in-process engine, when its chunk cannot load, fails RECOVERABLY"` in `app/src/main.test.ts`, which injects a failing loader through the `appEngines` seam. The separate first-turn gap for want of a key is [The known API-key gap](#the-known-api-key-gap).
  </Accordion>

  <Accordion title="A retryably unready engine boots the app, not the crash screen">
    A retryable probe failure (`unhealthy`, `http_status`, `malformed`, `transport`) returns `ok: true` with a `notReady` field — the engine is kept and the app starts. Only `version_mismatch` refuses boot. On a phone with no desktop engine to reach, the old hard gate meant the app never opened and the user could not fix the address, because Settings lives inside the app that would not start. Waiting cannot fix a version mismatch, but it does fix a socket still binding. Pinned by the boot and selection tests in `app/src/boot.test.ts` and `app/src/engines.ts`. See [Boot Failures](/docs/features/mobile/boot-failures#an-unreachable-engine-boots-with-a-warning).
  </Accordion>

  <Accordion title="A run given an already-aborted signal produces no answer">
    When the caller passes an `AbortSignal` whose `.aborted` is already `true` — the dispose-then-new-run path, and Stop-immediately-followed-by-Send — the engine aborts its own controller **before** subscribing, so no `delta` events are emitted and nothing is persisted. Before this fix, the code path attached `addEventListener("abort")` after the fact, which never fires for a signal that went down before the listener was attached; the run streamed to completion and persisted an answer the UI had already discarded. Pinned by `"a run started with an ALREADY-aborted signal produces no answer"`, with `"a run with a live signal still streams, so the abort test is not vacuous"` as the companion. See [Approvals & Cancellation](/docs/features/mobile/approvals-and-cancellation) for the Stop-then-Send route into the same signal.
  </Accordion>
</AccordionGroup>

***

## Related

<CardGroup cols={2}>
  <Card title="Architecture" icon="sitemap" href="/docs/features/mobile/architecture">
    Boot order and the engines factory shape.
  </Card>

  <Card title="Overview" icon="mobile" href="/docs/features/mobile/overview">
    Native navigation and the retained chat screen.
  </Card>

  <Card title="Capabilities & Gaps" icon="list-check" href="/docs/features/mobile/capabilities-and-gaps">
    What each engine can and cannot report.
  </Card>

  <Card title="Shell & Adapters" icon="mobile-screen" href="/docs/features/mobile/shell-and-adapters">
    The keyboard snapshot and the pinch-zoom guard.
  </Card>

  <Card title="Protocol" icon="network-wired" href="/docs/features/mobile/protocol">
    The 11 events every engine speaks.
  </Card>

  <Card title="Dropped Events" icon="triangle-exclamation" href="/docs/features/mobile/dropped-events">
    Where a refused frame lands via onIgnored.
  </Card>

  <Card title="Boot Failures" icon="bug" href="/docs/features/mobile/boot-failures">
    Where a retryable probe boots the app with a warning, and a permanent one refuses.
  </Card>

  <Card title="Settings Screen" icon="sliders" href="/docs/features/mobile/settings-screen">
    Where a user overrides the persisted engineId and baseUrl on-device.
  </Card>

  <Card title="Web App (PWA)" icon="globe" href="/docs/features/mobile/web-app">
    Why the web target defaults to remote-http, and how it installs and works offline.
  </Card>
</CardGroup>
