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
import { enginesFor } from "praisonai-mobile/app/registry";

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

Inside enginesFor, baseUrl is a resolver, not a captured string — the next request goes wherever Settings says right now. See Engines → The address is resolved per request.

Diagram

Route to Screen

🧭 Route

🧠 screenFor / transition

📋 ScreenChange

🖼️ mount / hide / remove

Boot Order

🛡️ Install crash handler

⚙️ Load Settings

💾 Create Session

🔌 persistenceFor

📜 historyFor

🏭 engines factory

✅ Select Engine

🎛️ Controller

Diagram

Import directions

📱 app

🪟 ui

🔌 adapters

🧠 engines

⚙️ core

📡 protocol

Quick Start

  1. The composition root builds everything concrete

    createApp takes its adapters injected, so the whole boot runs under test against fakes.

    ts
    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(),
    });
  2. 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
    const session = createSession({ storage, engineId, now, newChatId });
    const selection = await selectEngine(engineId, deps.engines(persistenceFor(session)));
  3. 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.

  4. 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.

  5. Read the layer graph
    bash
    cat tools/boundaries.json

    The graph is data, not convention.

  6. Check the boundaries
    bash
    npm run boundaries

    tools/depgraph.mjs reports every import that crosses a line it should not. The file walk lives in depgraph.mjs as sourceFilesUnder(base, roots) and covers both .ts and .mjs, so tools/ — which is entirely .mjs — is no longer walked past. It is testable: "the walk collects .mjs files as well as .ts" calls it directly.


Boot Order

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

Diagram
ControllerselectEngineengines(persistence)persistenceForcreateSessioncreateAppmountControllerselectEngineengines(persistence)persistenceForcreateSessioncreateAppmountinstallCrashHandler (step 0)detectPlatform (may throw SecurityError)bootOrFail(createApp)create the store firstsessionpersistenceFor(session)RunPersistenceengines(persistence)EngineChoice[]selectEngine(id, choices)enginecreateRunController(engine)
StepWhat happensWhy here
0. Parser paints the boot indicatorindex.html paints the PraisonAI wordmark and Starting… from markup, styled by the render-blocking stylesheetThe first frame is no longer blank, before any script runs. See Boot Indicator.
0a. boot-guard.js installs its error listenerA classic script, loaded before the deferred app.js module, listens in the capture phase for <script src="./app.js"> failing or a throw whose target is windowA listener installed by the deferred module could miss the fetch’s error when a cache answers instantly. See Boot Indicator → The failure notice.
0b. Install crash handlerinstallCrashHandler({...}) in mount()Installed before anything else can throw — including detectPlatform, which reads window.localStorage and raises SecurityError in a WKWebView with site data blocked. Running it second left a blank white page on a device nobody can debug.
1. Load settingscreateSettingsStore(...).load()The engine choice and credentials come from settings; building first means rebuilding.
2. Create sessioncreateSession(...)The in-process engine writes through it, so it must exist first.
3. BridgepersistenceFor(session)Adapts Session.record(prompt, answer) to RunPersistence.record(request, answer).
4. Build enginesdeps.engines(persistenceFor(session)) → appEngines(...)The factory receives the thing engines write through; appEngines is the single seam where praisonai-ts’s lazy module load lives, so the praisonai graph never touches this path.
5. Select engineselectEngine(engineId, choices)A protocol mismatch stops the boot here, with a name, not mid-answer.
6. ControllercreateRunController({ engine, ... })Everything above the seam, none of it naming a concrete adapter.

newChatId is injected into createApp for the first conversation’s id. Every subsequent chat mints its own id at the new-chat intent handler in main.ts — controller.setChat(mintChatId()) — so no two conversations share chat_id: "unassigned". See New chat semantics.


The Route→Screen Seam

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

LayerFileResponsibility
Decision (pure)ui/src/screens.tsscreenFor, transition, RETAINED — returns a description of what should change
DOM (thin)app/src/mount.tsAppends, 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.

The chat screen is registered as already-live

main.ts builds the chat screen up front and registers it with screens.nodes.set("chat", screen), so transition treats it as live and never tries to build a second one.

ts
// main.ts — register the pre-built chat screen so transition retains it.
screens.nodes.set("chat", screen);

The router’s default root is chats, but the app opens on the chat screen. main.ts replaces the root with { name: "chat", chatId: "" } at boot — otherwise pushing chats later would be swallowed as a push of the route already on top, and the chat list would never open.

ts
// main.ts — align the router root with the screen the app opens on.
app.router.replace({ name: "chat", chatId: "" });

The Reopen Flow

Reopening a stored conversation resets the live render state, then seeds the reconciler with the history before any new turn arrives.

Diagram
DOMReconcilerControllerSessionUserDOMReconcilerControllerSessionUseropen-chat intent → session.open(chatId)openedcontroller.setChat(chatId)reset render / nodes / announcer / transcript / live regionshistory = historyRows(chat.messages)reconcile(emptyRender, history)opsapplyOps(transcript, nodes, ops)

history: readonly Row[] lives in the mount closure and is prepended to every reconcile in publish, so a follow-up turn’s rows land below it and a reconcile never emits remove for history it did not know about. New chat resets history = [].

ts
// publish() — history first, then the live turn.
const rows = history.length === 0 ? built.rows : [...history, ...built.rows];
const diff = reconcile(render, rows);

The reopen path preserves the role: historyRows maps a stored user message to a user row and an assistant message to a text row, so a restored conversation paints both speakers rather than flattening them into one voice. The shape of the flow above is unchanged; only the row kind each message becomes is.


The Persistence Seam

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

Diagram
UISessionpersistenceFor(session)In-Process EngineUISessionpersistenceFor(session)In-Process Enginerecord(request, answer)record(prompt, answer)indices (or null)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 built from the session, taking both the persistence and the history. There is no way to obtain the engine list without being handed the store engines write through — and the type refuses a pre-built list, and refuses a composition that supplies one and forgets the other.

historyFor(session) is the read-side peer of persistenceFor(session), exported by name from core/src/chat/session.ts and fed from the same session at the same seam. The write projects Session.record into RunPersistence; the read projects Session.current() into ConversationHistory — one store, two symmetric ports, and only the in-process engine plugs into both. See History & Reopen → The model actually gets the conversation.

The shipping composition root exports appEngines(deps) — the function mount delegates to — and this function is what wires the createInProcess factory into enginesFor. Extracting it (rather than inlining it in mount) is what lets main.test.ts assert the engine is offered, per the package’s rule of asserting the extracted expression, not that it appears in source. appEngines is also the single seam where praisonai-ts’s lazy module load lives, so the praisonai graph never touches the boot path.


The Reconciler Contract

The reconciler turns a row list into DOM operations without touching the DOM, and two guarantees hold.

The reconciler (ui/render/reconcile.ts) diffs the previous row list against the next one and emits insert / remove / move / update ops. app/dom.ts applies them.

  • Minimal ops. An unchanged list emits zero ops; appending one row is one insert. A “rebuilding” reconciler that happened to render the right order by re-writing everything would fail this.
  • Target order. After the ops apply, the DOM matches the target order exactly — including cases where an insert precedes a reorder.

A move op names an index into the current DOM (the list as it will look after this pass), not into the previous list minus removals. That coordinate-system distinction is what lets an insert-then-reorder sequence like [a,b,c] → [d,e,b,c,a] land correctly; applyOps reads children[index] before detaching the node, so the placement is relative to a real sibling.


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.

Diagram

🔡 Tokens

🧺 Coalescer

⏱ tick or maxBytes?

🖼 Paint

The coalescer paints on whichever bound is hit first. The tick that fires the delay bound is driven by the TimePort — a fake that never fires the tick makes short answers arrive in one lump.

BoundDefaultMeaning
maxBytes256Flush once this many buffered characters accumulate.
maxDelayMs16Flush this long after the first buffered byte, however few arrived.

The Publish Gate

The publish gate is the pipeline’s single backpressure authority: it skips a paint while the renderer is still catching up, then releases the stranded text once it can keep up.

core/src/pacing/publish-gate.ts is a port of the desktop’s stream-pacing, with two constants tightened for mobile.

ConstantValueMeaning
MAX_HELD_CHARS96Characters allowed to accumulate behind a closed gate before it forces a paint.
UNPAINTED_REOPEN_MS200How long the gate stays shut before it reopens on its own.

gate(streamed) returns true when the gate is OPEN. It opens on its first call and reopens on a frame callback or after UNPAINTED_REOPEN_MS with no paint, so a tick after a quiet period always paints immediately. The only tick it skips is one that fires just after a paint, while the renderer is still catching up — that skip is the backpressure the pipeline previously lacked.

Three paths reach the gate, and two escapes bypass it on purpose:

PathWiringGated?
Per-event (a delta the coalescer paints)if (frames.length > 0 && gate(streamed)) publish()Yes
Flush tick (the fix)if (gate(streamed)) publish()Yes
Structured event (start, end, tool-call, …)publishes the whole transcript, sets paintedChars = streamedNo — by design
Final finally-publishalways publishes at stream endNo — by design

Before the wiring, the flush tick published unconditionally and the coalescer drained the buffer every maxDelayMs, so push() returned [] and the per-event gate was consulted only above ~3200 tokens/sec — unreachable at real streaming speed (20–150 tokens/sec). After the wiring, the gate is consulted on every tick after the first paint, so both constants are load-bearing.

How backpressure now works

Under load the gate skips a tick’s paint; when it reopens — via requestFrame or after UNPAINTED_REOPEN_MS — a catch-up tick paints whatever the rejected tick had drained onto the transcript but left unpainted. MAX_HELD_CHARS bounds how far the paint can fall behind streamed, and the ungated finally-publish guarantees the tail always lands, so a closed gate can never swallow the end of an answer.

Stranded progress and catch-up

A tick drains the coalescer before the gate is consulted, so text from a rejected tick is already gone from the coalescer — it lives on the transcript but is not yet painted. If the stream then pauses, no later tick sees pending text and the gate reopening does not itself publish. The controller tracks paintedChars; on a tick that finds nothing new to drain, if streamed > paintedChars and the gate has reopened, it publishes that stranded progress. Streamed text that arrives just before a pause therefore paints mid-pause, not only when the turn ends.

Diagram
PaintTranscriptPublish gateFlush tickPaintTranscriptPublish gateFlush tickreopens after UNPAINTED_REOPEN_MSgate(streamed) — openpublish (paintedChars = streamed)gate(streamed) — closed (backpressure)text drained, left unpaintedcatch-up tick — streamed > paintedCharspublish stranded text

Two tests pin this behaviour: the flush tick paints through the publish gate, so backpressure bounds it drives 40 ticks with no frame release and sees a handful of paints, not one per tick (the ungated tick gave 45 paints for 40 ticks and failed the bound); text drained by a rejected tick is still painted when the stream pauses confirms a rejected tick’s text paints mid-pause once UNPAINTED_REOPEN_MS fires, not only at end.


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.

LayerMay importPurpose
protocol—The wire contract and the 11 events.
coreprotocolPorts, run controller, transcript, chat, settings.
enginesprotocol, coreAgent-framework implementations.
adaptersprotocol, coreUI-shell implementations.
uiprotocol, coreFramework-free render logic.
appall of the aboveThe composition root.

Why a Factory, Not an Array

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

ts
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.


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
"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.

Diagram

New agent framework

New UI shell

What do you
want to add?

Add under
engines/src

Add under
adapters/src + ui

Pass the
conformance suite

Pass the
shell contract


Common Patterns

Boot fails loud when an engine cannot hold the contract.

ts
if (!booted.ok) {
  renderFatal(root, strings.bootFailed(booted.detail));
  return null;
}

Teardown runs in reverse and is idempotent.

ts
async dispose() {
  if (disposed) return;
  disposed = true;
  for (const off of subscriptions.splice(0)) off();
  await controller.stop();
  await engine.dispose();
}

Best Practices

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)).

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.

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.

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.

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.

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”.

Add, never reach across

A new framework is a directory under engines/src plus a conformance run — never an edit above the seam.

Keep ui/ framework-free

ui/ returns descriptions of what to render, so a React Native port reimplements only the renderer and reuses everything else.

Let CI hold the line

Run npm run boundaries locally; the same gate runs on every push and PR.


Related