> ## 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 Architecture

> Six layers, two build-enforced seams, and the route→screen and persistence seams in detail.

A route becomes a screen through a pure decision layer and a thin DOM layer, and a completed turn is joined to persistence at one named seam. Two enforced seams — one for the agent framework, one for the UI shell — keep engines and shells swappable.

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

const engines = enginesFor({ settings, http });
```

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph LR
    subgraph "Boot Order"
        S[⚙️ Load Settings] --> Se[💾 Create Session]
        Se --> P[🔌 persistenceFor]
        P --> E[🏭 engines factory]
        E --> Sel[✅ Select Engine]
        Sel --> C[🎛️ Controller]
    end

    subgraph "Route to Screen"
        Route[🧭 Route] --> Decide[🧠 screenFor / transition]
        Decide --> Change[📋 ScreenChange]
        Change --> Mount[🖼️ mount / hide / remove]
    end

    classDef settings fill:#6366F1,stroke:#7C90A0,color:#fff
    classDef store fill:#8B0000,stroke:#7C90A0,color:#fff
    classDef bridge fill:#189AB4,stroke:#7C90A0,color:#fff
    classDef factory fill:#F59E0B,stroke:#7C90A0,color:#fff
    classDef done fill:#10B981,stroke:#7C90A0,color:#fff
    classDef input fill:#8B0000,stroke:#7C90A0,color:#fff
    classDef process fill:#189AB4,stroke:#7C90A0,color:#fff
    classDef output fill:#10B981,stroke:#7C90A0,color:#fff

    class S settings
    class Se store
    class P bridge
    class E factory
    class Sel,C done
    class Route input
    class Decide,Change process
    class Mount output
```

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph TB
    subgraph "Import directions"
        App[📱 app] --> UI[🪟 ui]
        App --> Adapters[🔌 adapters]
        App --> Engines[🧠 engines]
        UI --> Core[⚙️ core]
        Adapters --> Core
        Engines --> Core
        Core --> Protocol[📡 protocol]
    end

    classDef top fill:#8B0000,stroke:#7C90A0,color:#fff
    classDef mid fill:#189AB4,stroke:#7C90A0,color:#fff
    classDef base fill:#6366F1,stroke:#7C90A0,color:#fff

    class App,UI top
    class Adapters,Engines,Core mid
    class Protocol base
```

## Quick Start

<Steps>
  <Step title="The composition root builds everything concrete">
    `createApp` takes its adapters injected, so the whole boot runs under test against fakes.

    ```ts theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    const booted = await createApp({
      storage: platform.storage,
      secrets: platform.secrets,
      time: platform.time,
      shell: platform.shell,
      // A FACTORY, not an array: the engine list is built FROM the session.
      engines: (persistence) => enginesFor({ settings: facadeStub(), http: platform.http, persistence }),
      settingDefs: SETTING_DEFS,
      engineId: "remote-http",
      onPublish: publish,
      now: () => Date.now(),
      newChatId: () => globalThis.crypto.randomUUID(),
    });
    ```
  </Step>

  <Step title="The session is bridged to the engine with persistenceFor">
    `boot.ts` builds the session first, then hands it to the engine factory through the named adapter `persistenceFor`.

    ```ts theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    const session = createSession({ storage, engineId, now, newChatId });
    const selection = await selectEngine(engineId, deps.engines(persistenceFor(session)));
    ```
  </Step>

  <Step title="Decide what changes (pure)">
    `screenFor(route)` maps a route to a `ScreenId`, and `transition(from, to, live)` returns a `ScreenChange` describing what to mount, hide, and remove. No DOM is touched here, so the decision is tested without a browser.
  </Step>

  <Step title="Apply it to the page (thin)">
    `createScreens(host).apply(change)` mounts, hides, and removes nodes according to the `ScreenChange`. It mounts the next screen before hiding the current one, so there is never a blank frame.
  </Step>

  <Step title="Read the layer graph">
    ```bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    cat tools/boundaries.json
    ```

    The graph is data, not convention.
  </Step>

  <Step title="Check the boundaries">
    ```bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    npm run boundaries
    ```

    `tools/depgraph.mjs` reports every import that crosses a line it should not.
  </Step>
</Steps>

***

## Boot Order

The boot sequence runs in one order, and that order exists because the in-process engine writes through the session.

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
sequenceDiagram
    participant Boot as createApp
    participant Session as createSession
    participant Bridge as persistenceFor
    participant Factory as engines(persistence)
    participant Engine as selectEngine
    participant Controller

    Boot->>Session: create the store first
    Session-->>Boot: session
    Boot->>Bridge: persistenceFor(session)
    Bridge-->>Boot: RunPersistence
    Boot->>Factory: engines(persistence)
    Factory-->>Boot: EngineChoice[]
    Boot->>Engine: selectEngine(id, choices)
    Engine-->>Boot: engine
    Boot->>Controller: createRunController(engine)
```

| Step              | What happens                            | Why here                                                                               |
| ----------------- | --------------------------------------- | -------------------------------------------------------------------------------------- |
| 1. Load settings  | `createSettingsStore(...).load()`       | The engine choice and credentials come from settings; building first means rebuilding. |
| 2. Create session | `createSession(...)`                    | The in-process engine writes through it, so it must exist first.                       |
| 3. Bridge         | `persistenceFor(session)`               | Adapts `Session.record(prompt, answer)` to `RunPersistence.record(request, answer)`.   |
| 4. Build engines  | `deps.engines(persistenceFor(session))` | The factory receives the thing engines write through.                                  |
| 5. Select engine  | `selectEngine(engineId, choices)`       | A protocol mismatch stops the boot here, with a name, not mid-answer.                  |
| 6. Controller     | `createRunController({ engine, ... })`  | Everything above the seam, none of it naming a concrete adapter.                       |

***

## The Route→Screen Seam

Navigation splits into a pure half and a thin half so the part worth testing has no DOM in it.

| Layer           | File                | Responsibility                                                                      |
| --------------- | ------------------- | ----------------------------------------------------------------------------------- |
| Decision (pure) | `ui/src/screens.ts` | `screenFor`, `transition`, `RETAINED` — returns a description of what should change |
| DOM (thin)      | `app/src/mount.ts`  | Appends, hides, and removes nodes per the description                               |

A `ScreenChange` carries the plan: `show`, `mount`, `unmount`, `hide`, and `noop`. A retained screen appears in `hide`, never `unmount` — its nodes stay so scroll position and streaming survive.

<Note>
  A chat-to-chat move is a **content** change, not a screen change — `transition` returns `noop: true` so the transcript is not rebuilt on navigation within the same screen.
</Note>

***

## The Persistence Seam

The engine writes a completed turn through the same session the UI reads, joined at one named adapter.

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
sequenceDiagram
    participant Engine as In-Process Engine
    participant Adapter as persistenceFor(session)
    participant Session
    participant UI

    Engine->>Adapter: record(request, answer)
    Adapter->>Session: record(prompt, answer)
    Session-->>Engine: indices (or null)
    UI->>Session: read chat list
```

`persistenceFor(session)` in `core/src/chat/session.ts` is the one place the engine's `RunPersistence` vocabulary and the `Session` vocabulary meet. The engine calls `record(request, answer)`; the adapter forwards `request.prompt` to `session.record(prompt, answer)`. Naming the adapter — rather than inlining a lambda at the call site — keeps this seam findable.

The wiring is enforced by the type: `AppDeps.engines` is a factory `(persistence) => EngineChoice[]`, built **from** the session. There is no way to obtain the engine list without being handed the store engines write through.

<Warning>
  `end.userIndex === null` means the turn was **not** written to disk — the write failed, so the UI withholds Fork and Delete. Index `0` is valid, so a falsy check is a trap.
</Warning>

***

## Streaming Pacing

Tokens flow through a coalescer that flushes either when it has enough bytes or after a short delay, so short answers still paint incrementally.

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph LR
    Tokens[🔡 Tokens] --> Coalescer[🧺 Coalescer]
    Coalescer --> Decide{⏱ tick or maxBytes?}
    Decide --> Paint[🖼 Paint]

    classDef input fill:#6366F1,stroke:#7C90A0,color:#fff
    classDef process fill:#F59E0B,stroke:#7C90A0,color:#fff
    classDef output fill:#10B981,stroke:#7C90A0,color:#fff

    class Tokens input
    class Coalescer,Decide process
    class Paint output
```

The coalescer paints on whichever bound is hit first.

| Bound        | Default | Meaning                                                             |
| ------------ | ------- | ------------------------------------------------------------------- |
| `maxBytes`   | `256`   | Flush once this many buffered characters accumulate.                |
| `maxDelayMs` | `16`    | Flush this long after the first buffered byte, however few arrived. |

<Warning>
  If the periodic tick is not wired, only the byte cap can flush — so a 130-character answer produces zero intermediate paints and arrives in one lump when the run ends. The delay bound is what makes a short answer stream at all; that is why the pacing design matters, not merely how fast it is.
</Warning>

***

## How It Works

Each layer declares what it may import. `app` sits at the top and wires everything; `protocol` sits at the bottom and imports nothing.

| Layer      | May import         | Purpose                                            |
| ---------- | ------------------ | -------------------------------------------------- |
| `protocol` | —                  | The wire contract and the 11 events.               |
| `core`     | `protocol`         | Ports, run controller, transcript, chat, settings. |
| `engines`  | `protocol`, `core` | Agent-framework implementations.                   |
| `adapters` | `protocol`, `core` | UI-shell implementations.                          |
| `ui`       | `protocol`, `core` | Framework-free render logic.                       |
| `app`      | all of the above   | The composition root.                              |

***

## Why a Factory, Not an Array

`AppDeps.engines` is a factory whose type refuses a pre-built list.

```ts theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
readonly engines: (persistence: RunPersistence) => readonly EngineChoice[];
```

A pre-built array was the original bug: the session existed, the engine's `persistence` port existed, and nothing connected them — so `record()` never ran in a real turn and no conversation was ever saved. Taking a factory makes that impossible to express: there is no way to obtain the engine list without being handed the thing engines write through. The wiring is enforced by the type, not by a comment.

<Note>
  `persistenceFor(session)` is a **named** adapter in `core/src/chat/session.ts`, not an inline lambda. It is the one place the two vocabularies meet — `Session.record(prompt, answer)` versus `RunPersistence.record(request, answer)` — and a lambda buried in composition is a seam nobody can find later.
</Note>

***

## Why Build-Enforced

A rule enforced only by review stops being enforced. `tools/depgraph.mjs` runs in CI (`.github/workflows/mobile.yml`) and fails the build on any crossing.

```jsonc theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
"externals": {
  "praisonai": ["engines/src/praisonai-ts"],
  "@tauri-apps/api": ["adapters/src/tauri/bridge.ts"],
  "@tauri-apps/plugin-*": ["adapters/src/tauri"]
}
```

Only `engines/src/praisonai-ts` may import `praisonai`. Only `adapters/src/tauri` may import `@tauri-apps/*`. Everything above the seams is written against ports and cannot tell one implementation from another.

***

## Choose Your Extension Point

Which directory you touch depends on what you are adding.

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph TB
    Start{What do you<br/>want to add?} -->|New agent framework| Engines[Add under<br/>engines/src]
    Start -->|New UI shell| Adapters[Add under<br/>adapters/src + ui]
    Engines --> Conform[Pass the<br/>conformance suite]
    Adapters --> Contract[Pass the<br/>shell contract]

    classDef q fill:#F59E0B,stroke:#7C90A0,color:#fff
    classDef path fill:#189AB4,stroke:#7C90A0,color:#fff
    classDef done fill:#10B981,stroke:#7C90A0,color:#fff

    class Start q
    class Engines,Adapters path
    class Conform,Contract done
```

***

## Common Patterns

Boot fails loud when an engine cannot hold the contract.

```ts theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
if (!booted.ok) {
  renderFatal(root, strings.bootFailed(booted.detail));
  return null;
}
```

Teardown runs in reverse and is idempotent.

```ts theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
async dispose() {
  if (disposed) return;
  disposed = true;
  for (const off of subscriptions.splice(0)) off();
  await controller.stop();
  await engine.dispose();
}
```

***

## Best Practices

<AccordionGroup>
  <Accordion title="Build the session before the engine list">
    The in-process engine writes through the session, so a pre-built engine list cannot carry a live persistence. Always call `createSession` first, then `deps.engines(persistenceFor(session))`.
  </Accordion>

  <Accordion title="Name the bridge, do not inline it">
    `persistenceFor` is exported by name. Inlining the adapter as a lambda at the call site hides the one seam where the session and the engine vocabularies meet.
  </Accordion>

  <Accordion title="Keep the composition root injectable">
    `createApp` takes every adapter as a parameter. A composition root that constructs its own dependencies is the one part of an app that can never be tested — and it is where ordering bugs live.
  </Accordion>

  <Accordion title="Keep decisions out of the DOM layer">
    Anything that decides what to keep or destroy belongs in `screens.ts` as a pure function. `mount.ts` only carries out the plan, so a test can inspect the plan without a browser.
  </Accordion>

  <Accordion title="Mount before hide">
    Always mount the next screen before hiding the current one. The reverse order shows a blank page for one frame on every navigation.
  </Accordion>

  <Accordion title="Trust the persisted index, not the screen">
    A cancelled or errored turn stays on screen but is never written, so screen position and disk position diverge. Read the index the writer reports in `end`, and treat `null` as "not on disk".
  </Accordion>

  <Accordion title="Add, never reach across">
    A new framework is a directory under `engines/src` plus a conformance run — never an edit above the seam.
  </Accordion>

  <Accordion title="Keep ui/ framework-free">
    `ui/` returns descriptions of what to render, so a React Native port reimplements only the renderer and reuses everything else.
  </Accordion>

  <Accordion title="Let CI hold the line">
    Run `npm run boundaries` locally; the same gate runs on every push and PR.
  </Accordion>

  <Accordion title="Test pacing against a fake clock that ticks">
    A `TimePort.every` fake that returns `() => () => {}` is worse than no test — it never fires the tick, so every test using it passes for the wrong reason. Pacing must be driven by a fake clock that actually advances, or a coalescer whose delay bound was never wired looks correct in green.
  </Accordion>
</AccordionGroup>

***

## Related

<CardGroup cols={2}>
  <Card title="Overview" icon="mobile" href="/docs/features/mobile/overview">
    Retained chat and native navigation.
  </Card>

  <Card title="Engines" icon="microchip" href="/docs/features/mobile/engines">
    Which engine owns the write, and why only one does.
  </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="Capabilities & Gaps" icon="list-check" href="/docs/features/mobile/capabilities-and-gaps">
    What each engine can and cannot report.
  </Card>

  <Card title="Native Shell" icon="mobile-button" href="/docs/features/mobile/native-shell">
    The Tauri shell — safe-area, keyboard, lifecycle, and back-gesture arbitration.
  </Card>

  <Card title="Shell & Adapters" icon="mobile-button" href="/docs/features/mobile/shell-and-adapters">
    The UI-shell seam in detail.
  </Card>

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