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.

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.

Boot Order

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

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 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 (persistence) => EngineChoice[], built from the session. There is no way to obtain the engine list without being handed the store engines write through.
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.

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

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

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.

Shell & Adapters

The UI-shell seam in detail.

Protocol

The 11 events every engine speaks.