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.
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.
Quick Start
- The composition root builds everything concrete
createApptakes 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(), }); - The session is bridged to the engine with persistenceFor
boot.tsbuilds the session first, then hands it to the engine factory through the named adapterpersistenceFor.ts const session = createSession({ storage, engineId, now, newChatId }); const selection = await selectEngine(engineId, deps.engines(persistenceFor(session))); - Decide what changes (pure)
screenFor(route)maps a route to aScreenId, andtransition(from, to, live)returns aScreenChangedescribing what to mount, hide, and remove. No DOM is touched here, so the decision is tested without a browser. - Apply it to the page (thin)
createScreens(host).apply(change)mounts, hides, and removes nodes according to theScreenChange. It mounts the next screen before hiding the current one, so there is never a blank frame. - Read the layer graph
bash cat tools/boundaries.jsonThe graph is data, not convention.
- Check the boundaries
bash npm run boundariestools/depgraph.mjsreports every import that crosses a line it should not. The file walk lives indepgraph.mjsassourceFilesUnder(base, roots)and covers both.tsand.mjs, sotools/— 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.
| Step | What happens | Why here |
|---|---|---|
| 0. Parser paints the boot indicator | index.html paints the PraisonAI wordmark and Starting… from markup, styled by the render-blocking stylesheet | The first frame is no longer blank, before any script runs. See Boot Indicator. |
0a. boot-guard.js installs its error listener | A 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 window | A 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 handler | installCrashHandler({...}) 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 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)) → 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 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. |
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.
| 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.
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.
// 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.
// 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.
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 = [].
// 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.
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.
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.
| 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. |
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.
| Constant | Value | Meaning |
|---|---|---|
MAX_HELD_CHARS | 96 | Characters allowed to accumulate behind a closed gate before it forces a paint. |
UNPAINTED_REOPEN_MS | 200 | How 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:
| Path | Wiring | Gated? |
|---|---|---|
| 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 = streamed | No — by design |
Final finally-publish | always publishes at stream end | No — 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.
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.
| 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.
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.
"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.
Common Patterns
Boot fails loud when an engine cannot hold the contract.
if (!booted.ok) {
renderFatal(root, strings.bootFailed(booted.detail));
return null;
}Teardown runs in reverse and is idempotent.
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
Retained chat and native navigation.
EnginesWhich engine owns the write, and why only one does.
Shell & AdaptersThe keyboard snapshot and the pinch-zoom guard.
Capabilities & GapsWhat each engine can and cannot report.
Native ShellThe Tauri shell — safe-area, keyboard, lifecycle, and back-gesture arbitration. On Android the safe-area and keyboard values come from WindowInsetsCompat through a native → JS global, not from env() / visualViewport.
The tauri-plugin-secrets plugin behind platform.secrets — iOS / macOS via SecItem*, Android via EncryptedSharedPreferences.
The UI-shell seam in detail.
ProtocolThe 11 events every engine speaks.
Boot FailuresWhat each BootResult.reason renders on the crash screen.
The pre-boot frame and the guard that fires before any app code runs.
CardThe two clocks and the schedulers pacing is built on.
History & ReopenReopening a stored conversation through the reconciler.
Route FocusFocus and announcements on every route change.