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
1
The composition root builds everything concrete
createApp takes its adapters injected, so the whole boot runs under test against fakes.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.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
6
Check the 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.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 wiring landed in PR #4572; before that,
controller was constructed without a chatId and defaulted to the literal "unassigned", so the first conversation on every launch went to the engine under one shared id.createApp returns a typed BootResult for the failures it anticipates. It can also throw — an unguarded settingsStore.load() propagates a StoragePort failure — so mount() wraps it in bootOrFail, turning a throw into { ok: false, reason: "storage_unavailable" }. See Boot Failures.The Route→Screen Seam
Navigation splits into a pure half and a thin half so the part worth testing has no DOM in it.
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.
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.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.
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.
The
replace at boot also seeds the depth counter that tells a push from a pop. previousDepth starts at 1, so the first real navigation classifies correctly. See Route Focus.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 = [].
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.
Full detail — including why history row ids carry a
history:{index}:{role} prefix and the bug the reconciler-seeded restore replaces — is on History & Reopen. The role-preserving user row itself is on Transcript User Row.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.
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.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.
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:
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 — viarequestFrame 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 trackspaintedChars; 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.
Why a Factory, Not an Array
AppDeps.engines is a factory whose type refuses a pre-built list.
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.
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.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.
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.Best Practices
Build the session before the engine list
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
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
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
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
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
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
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
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
Let CI hold the line
Run
npm run boundaries locally; the same gate runs on every push and PR.Related
Overview
Retained chat and native navigation.
Engines
Which engine owns the write, and why only one does.
Shell & Adapters
The keyboard snapshot and the pinch-zoom guard.
Capabilities & Gaps
What each engine can and cannot report.
Native Shell
The 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.Native Secrets
The
tauri-plugin-secrets plugin behind platform.secrets — iOS / macOS via SecItem*, Android via EncryptedSharedPreferences.Shell & Adapters
The UI-shell seam in detail.
Protocol
The 11 events every engine speaks.
Boot Failures
What each
BootResult.reason renders on the crash screen.Boot Indicator
The pre-boot frame and the guard that fires before any app code runs.
The two clocks and the schedulers pacing is built on.
History & Reopen
Reopening a stored conversation through the reconciler.
Route Focus
Focus and announcements on every route change.

