Skip to main content
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.
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

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

The graph is data, not convention.
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.tscontroller.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.
Because the crash handler is global, any async work started after boot — e.g. a route handler rebuilding the chat list — must catch its own rejections. An uncaught reject escalates to the crash screen even though boot succeeded. Pinned by "a storage failure while the chat list loads stays LOCAL, not fatal" in app/src/main.test.ts. See History & Reopen → When the list load itself fails.

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.
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.
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 = [].
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.
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.
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.

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

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

Why a Factory, Not an Array

AppDeps.engines is a factory whose type refuses a pre-built list.
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.
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.
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.
Teardown runs in reverse and is idempotent.

Best Practices

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)).
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.
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.
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.
Always mount the next screen before hiding the current one. The reverse order shows a blank page for one frame on every navigation.
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”.
A new framework is a directory under engines/src plus a conformance run — never an edit above the seam.
ui/ returns descriptions of what to render, so a React Native port reimplements only the renderer and reuses everything else.
Run npm run boundaries locally; the same gate runs on every push and PR.

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.