Skip to main content
The mobile app picks an engine from a factory that is handed the store engines write through, so the in-process engine records a turn to the same session the chat list reads. Everything above the seam is written against AgentEnginePort and nothing else.

Quick Start

1

RegistryDeps requires a persistence

persistence is required. It is passed only into the in-process engine’s factory.
2

An EngineChoice can carry a probe

create builds the engine; the optional probe answers whether the thing it talks to is ready. The in-process engine has no remote to probe, so it supplies none.
3

selectEngine returns notReady on a retryable probe

The success branch carries an optional notReady, set only for a retryable unreadiness. A permanent unreadiness (version_mismatch) takes the failure branch and disposes the engine.
4

enginesFor wires a probe and passes persistence to in-process only

The remote-http choice supplies a probe; the remote engine deliberately does not receive persistence. baseUrl is a resolver, not a captured string — read at the moment it is used, so a Settings edit is live on the next /chat and /health.
The resolver is passed to createRemoteHttpEngine and the probe is probeHealth(deps.http, baseUrl()) — invoked, not captured. Pinned by the resolver tests in registry.test.ts and engine.test.ts.
5

Build engines from the session

AppDeps.engines is a factory (persistence) => EngineChoice[]. The composition root builds it from the session, so an engine cannot exist without the store it writes through.
6

The shipping composition root wires it

appEngines supplies createInProcess to enginesFor, so the in-process engine is on offer. The remote engine stays first, so the default keeps working with nothing configured.
Pinned by "the real composition root offers the in-process engine" in app/src/main.test.ts.
7

Run a turn

run returns an AsyncIterable, so for await gives free backpressure and one cancellation path. Breaking out of the for await cancels the underlying response stream in a finally (await reader.cancel()), so the engine stops generating and the socket is released. Before #4579 this was silently untrue for remote-http — the finally lacked the cancel, so leaving the loop left the model billing.
8

Answer an approval

Approval is a reverse channel while the stream is live, so it is a method, not an event.
9

Surface refused frames with onIgnored

Without onIgnored, decoder refusals are invisible — a truncated answer reported as a clean success. The composition root wires it via the DropSink; anyone hand-constructing the engine must pass it explicitly.

Options On remote-http

RemoteHttpOptions carries the request target and the refusal callback. ControllerDeps.dropSink is the other end of the same seam: what the engine refuses becomes a dropped row on the turn it belonged to. See Dropped Events.

The address is resolved per request

The engine is built once at boot and held for the session, so a captured baseUrl string strands a phone that cannot reach its default engine — the only recovery is force-quitting the app. A resolver reads the address at the moment it is used, so the next /chat and /health go wherever Settings says right now.
Trailing slashes are stripped per resolution, not at the caller: the resolver’s answer is not seen until the string is used, and ${base}/chat against http://host/ produces //chat — which some servers 404 and others redirect, losing the POST body. Rebuilding the whole engine on a Settings change was the alternative and a much larger one: createRunController takes the engine at construction with no seam to swap it, so following the setting that way means rebuilding the controller mid-session and dropping the turn in flight. A resolver costs one call per request and no signature above it.

Cancel and Approve are pinned to the origin the run was born at

A resolver means base() can move between calls — that is the point — but a run’s /cancel and an approval’s /approve are addressed by an id the originating engine minted. Two maps pin the control requests to where the run started: The origin is resolved once, at the top of run(...), and remembered for that run’s control requests. The live resolver is only a fallback for an id this instance never saw — a resumed app that reconnected. Without pinning, a Settings edit mid-turn would send /cancel to an engine that never issued the runId, postOk would 404, cancel would return false, and controller.ts would leave the original engine generating (and billing) while the user believes they hit Stop.
Readiness is strictly HTTP 200. The remote-HTTP engine’s readiness probe treats any non-200 status as an http_status failure — including sub-200 responses (100, 101, 199) that would otherwise fall through to body classification. A websocket upgrade or a 100 Continue from a misconfigured proxy can no longer be reported as ready: true and then offered as a usable engine whose every request fails. Pinned by "any status that is not exactly 200 is an http_status failure".

Readiness body rules

After the 200 passes, classify in engines/src/remote-http/readiness.ts judges the body by strict identity, not by truthiness. The healthy predicate is body.ok === true — nothing else counts as healthy. A missing ok must never be mistaken for a present healthy one: {ok: "true"} (a string) and a body with no ok at all are both refused. The paired positive case — body.ok === true — is what stops the predicate degenerating to “refuse everything”.
Unhealthy is retryable — and retryable now boots the app. An unhealthy verdict carries retryable: true. Since PR #4672 that no longer means “poll from a crash screen”: selectEngine returns ok: true with the engine attached and a notReady field, so the app boots and shows a warning instead of refusing. Only version_mismatch still refuses boot. Pinned by "an unhealthy engine is worth retrying", with "a healthy engine IS ready - the pair" as its companion. See Boot Failures → An unreachable engine boots with a warning.
The probe now actually runs at boot. These readiness rules are no longer hypothetical: enginesFor wires probe: () => probeHealth(deps.http, baseUrl()) on the remote-http choice — baseUrl invoked, not captured — and selectEngine calls it before the engine is offered. A 200 {"ok": false}, a non-200, or an unreachable engine is handled at boot with a named reason — a permanent one refuses boot, a retryable one boots the app with a warning. See Boot Failures → Boot-time health probe.

Probe timeout

The probe runs before the app mounts, so a request or body read that never completes must abort on a deadline rather than pin createApp on a blank screen.
A single AbortController covers both the request and the body read, so an endpoint that accepts the socket but never sends the response — a still-binding engine, a black-hole proxy — aborts on the deadline. A timeout classifies as retryable transport, exactly the case worth polling.

Order of checks in selectEngine

A probe opens a socket, so it must not run for an engine this build cannot even speak to. The checks run in order, cheapest first. That step 3 does not run when step 2 fails is pinned by "selectEngine runs the probe only after the protocol check passes". The split at step 3 mirrors readiness.retryable: a permanent unreadiness disposes and refuses, a retryable one keeps the engine and returns ok: true.
The verdict reason is load-bearing, not just ready. Readers of classify() route the UI on reason: "unhealthy" versus reason: "version_mismatch" — the two lead to different recovery arms (retry vs. a version-mismatch boot failure). Asserting only ready is what let a test hold for the wrong reason in #4606; always assert the reason too. Pinned by "a missing ok field is not read as healthy". The unhealthy → retry recovery arm is described in Errors & Recovery.
run_id and chat_id are separate wire fields. The stream POST body carries run_id and chat_id as separate fields. POST /cancel/{runId} matches on run_id alone, so swapping the two — they are the same shape — makes Stop permanently unmatched against the remote engine (the model keeps generating and keeps billing) while nothing about the payload looks wrong. Pinned by "the request carries the RUN id and the CHAT id in their own fields".
The stream request advertises SSE. The POST /chat stream request sends Accept: text/event-stream. Dropping it makes a conforming proxy or engine free to answer with something else entirely, and the failure surfaces as an unparseable stream rather than as a bad request. Pinned by "the stream request advertises that it wants SSE".

First-Launch Default

defaultEngineIdFor(kind: Platform["kind"]) returns the engineId used only on the very first launch, when settings name no engine — and a persisted engineId always wins over it. The first-launch default is not the only engine the app ever uses: a persisted engineId still wins, and the Settings Screen is what persists a change to it. The function splits by platform: "tauri" defaults to the in-process engine, "web" to remote. Pinned by "a device's first-launch default is the in-process engine; the web's is remote" in app/src/main.test.ts.
A device defaults in-process because a phone cannot reach http://127.0.0.1:8765, and the cleartext loopback address is refused by iOS ATS and Android besides — so a remote default was a first prompt that failed with a status code. The in-process engine now ships as a lazily-fetched chunk beside app.js (see How the engine reaches the app), so it is the one choice that works with nothing configured — up to the model itself, which needs an OpenAI key pasted into Settings (see API Keys and How the in-process engine authenticates). The web keeps the remote engine: a browser tab has a server to talk to, and no reason to fetch ~900 kB of engine before first paint. See Web App (PWA) for how the web target boots, installs, and works offline.
Desktop Tauri counts as a device. Platform["kind"] is only "tauri" | "web", so cargo tauri dev on a laptop reports "tauri" exactly as a phone does — and both start in-process. The shipped design accepts this trade: a developer with a server running switches to remote-http in Settings, and the persisted engineId wins over this default (chosenStringOr in boot.ts), so it only ever decides the very first launch.
The seam is a function a test drives directly rather than an expression asserted to appear in mount (mirrors stopNotice and appEngines, the house rule). Defaulting a device to in-process removes the intermediate hop over http://127.0.0.1:8765 and the iOS ATS / Android usesCleartextTraffic exception on the default path (see Errors & Recovery).
The persisted engineId wins. chosenStringOr in boot.ts prefers the persisted engineId over this default, so defaultEngineIdFor only decides the very first launch after install (or after a settings clear). The Settings Screen is where a user overrides both engineId (from SETTING_DEFS.engineId.choices) and baseUrl on-device. See Storage & Secrets.

Who Persists

Two engines, two owners of the write. The remote engine deliberately does not take persistence: the server it connects to owns the write and is the only thing that can report authoritative indices for its own store.
A picker omits an engine whose prerequisites are absent rather than offering it and then failing. createInProcess is optional — when it is not supplied, only the remote engine is offered. The shipping composition root does supply it, via appEngines, so enginesFor returns both engines in real builds, and both remote-http and praisonai-ts sit in SETTING_DEFS.engineId.choices (the picker). The engine ships as a chunk beside app.js, so the picker offers a choice the shipping build can construct — the reason registry.test.ts compares choices against the composition main.ts actually mounts. Pinned by "the real composition root offers the in-process engine" in app/src/main.test.ts.

Where a Completed Turn Is Written

Each engine owns its own write, and reports end.userIndex from the store it wrote to — and reads the next turn’s history back from the same store. The read side is symmetric with the write: the in-process engine is handed both RunPersistence and ConversationHistory, both projected from the same session; the remote engine takes neither, because the server it talks to owns both. See History & Reopen → The model actually gets the conversation. The in-process engine records the turn and reports the indices it actually wrote, which is what makes end.userIndex real:
A turn answered by the default remote-http engine does not currently mirror into the local mobile session. The remote server owns that transcript and is the only thing that can report authoritative indices for its own store. Local mirroring is tracked separately.

What a Recorded Turn Returns

session.record(prompt, answer) writes the user message and the answer together, then returns the indices into the persisted array. A null return means the save failed — the turn is on screen but not on disk.
The user message is written when the turn succeeds, not when the user pressed send. A turn that never completes must not leave a dangling user message with no reply under it.
The transcript reads end.userIndex through the same isPersisted predicate that decides whether Fork and Delete are offered — not a second copy of the rule. An index means the user row is stored; no index means it is unstored and carries the “Not saved” caveat. See Transcript User Row → Three storage states.

Persisted Answer Text

The in-process engine (praisonai-ts) writes exactly one string as the assistant answer per turn. Which string depends on whether the turn produced a finish event.
finish.text replaces the streamed accumulation — it does not append to it. A provider that both streams deltas and then emits finish with the full text writes the answer once, not twice. Appending instead leaves the on-screen answer looking correct (the screen is built from deltas) while the persisted transcript contains the answer twice — visible only when the user reopens the chat.
finish also carries the final text for a non-streaming provider that emits no deltas at all. Trusting only the streamed accumulation would persist an empty string in that case.
Why replace rather than append?
  • Upstream may normalise the final text — a typo fix or whitespace tidy-up. Persisting the concatenation would store text the model did not finally say.
  • A non-streaming provider emits no deltas, only finish. Persisting only the accumulation would drop the answer entirely.
This rule applies to the in-process engine’s own write. The remote-http engine does not persist locally at all — the server it talks to owns that write and reports its own indices. See Who Persists and the Protocol for the finish and end event shapes.

The Port

AgentEnginePort is the entire agent-framework coupling.

The Three Engines

Three implementations pass the same conformance suite, which is what makes “swappable” a fact.

How the engine reaches the app

The in-process engine loads praisonai’s Agent through a single literal dynamic import, in engines/src/praisonai-ts/load-agent.ts:
Three facts make this the shipped seam:
  • The specifier is a literal. esbuild can only put a module in its own lazily-fetched chunk if it can see which module it is; the runtime-computed path this used to be was opaque, so the engine either came along eagerly or, as an external, not at all. The literal is what turns it into a chunk.
  • praisonai/mobile, not praisonai. The default entry re-exports the CLI, the MCP server and the knowledge store, whose Node builtins are import-time fatal in a webview. /mobile is the package’s webview-safe allowlist entry.
  • One file may name it. tools/boundaries.json allows the string praisonai only from engines/src/praisonai-ts — the agent-framework seam. Making the import literal did not cost that rule; load-agent.ts is the one place it lives.

The shell and lazy split

The build emits two populations of chunk, paid for at different moments, so tools/bundle.mjs carries two budgets: They are two budgets, not one in disguise: bundle.test.mjs proves each can fail on its own, and depgraph.test.mjs pins both values so neither can be widened without a failing test. The 1000 kB ceiling is not a round number — it is re-derived on each raise from the current measured bundle. The rule is a floor (measured 912.6 kB + a 30 kB drift band) and a lid (measured + 100 kB, the smallest provider on the measured list); at or above the lid a new provider can no longer trip the gate, which is the one thing the constant exists to force. 1000 kB sits 57 kB above the floor and 12 kB under the lid — two independent derivations land there. See tools/bundle.mjs for the full derivation, re-derived from the measured bundle after the #4874 and #4882 reclaims in PR #4903. Re-measure the per-chunk breakdown with node src/praisonai-mobile/tools/bundle.mjs on main before quoting per-package byte counts — the earlier @ai-sdk/* breakdown is stale after #4874.
supported: { "dynamic-import": true } is load-bearing. Left to its own table at a chrome58 target, esbuild lowers import() to a static import wrapped in a promise — the split silently collapses and the whole engine lands in the shell (measured: 1486.8 kB shell, 0 lazy, which fails the shell budget). bundle() overrides the target to keep import() intact. SPLIT_MIN_CHROME = chrome63 records the first Chrome where esbuild leaves import() alone — the shipped page’s true floor — measured one below and at in bundle-target.test.mjs. ANDROID_WEBVIEW_FLOOR records nothing between API 26 (chrome58) and 30 (chrome87); whether minSdkVersion should move is recorded in bundle.mjs, not decided there.

Consumer-first bare-import resolution

bundle.mjs resolves every bare import from this package first, from the importer second. Peers are the consumer’s to provide: praisonai lists ai and @ai-sdk/* as optional peers, this package declares them, and a registry install hoists them beside praisonai. Through the file: link (below) Node’s real-path resolution looks in ../praisonai-ts/node_modules instead and cannot see them — @ai-sdk/cohere, which praisonai imports and never declares, came back unresolved, measured. Consumer-first is the layout a registry install produces without depending on hoisting, and keeps zod and @ai-sdk/provider-utils to one copy. praisonai’s own dependencies (openai, and the rest) are not here and fall through to its node_modules, as they should. package.json links praisonai as file:../praisonai-ts, not a pinned version, so the engine the app ships is the one from the same monorepo commit. The fix that makes the engine buildable below chrome89 — praisonai-ts #4720, which removed a top-level await from praisonai-ts’s esm shim on the mobile graph — is in the sibling package and reaches the app through this link. .github/workflows/mobile.yml watches src/praisonai-ts/{src,scripts}/** and package.json, and every job builds praisonai-ts (npm install --legacy-peer-deps && npm run build) before npm ci in the mobile package. Editing praisonai-ts and mobile in the same PR is therefore one build, not a publish-and-bump.

How the in-process engine authenticates

The gap is closed as of PR #4792: the registry now declares an openaiApiKey secret setting, and createInProcessEngine reads it through apiKeyFor(secrets, settings.defs()) on every turn.
Reading per turn — not once at construction — is the same lesson enginesFor learned with baseUrl: the engine is built once and held for the session, so a key pasted into Settings works on the very next message rather than after a force-quit. The key is omitted from the agent config when unset rather than passed as "" or null, because upstream treats a falsy value as “fall back to the environment” and a phone has no environment. appEngines now takes secrets: SecretsPort as a required dependency, so a composition that forgets it fails typecheck rather than building an engine with no credential. See the API Keys page for the user-facing paste-and-send flow.

Conformance

Passing engines/src/conformance.ts is the definition of implementing the seam.
The suite also asserts the negative direction: an engine declaring approvals: false must never emit an approval_request. Every unsupported scenario is printed on each run, so a contract that quietly shrinks is visible rather than silently green. The suite drives each guarantee against a deliberately broken run. The shipped break modes are: Both no_start and start_only are caught by the same case “a run emits start first and exactly one terminal event last” — one missing the opening event, the other missing the terminal one. start_only proved the case’s events.length >= 2 assertion is redundant rather than unguarded: weakening it to >= 1 still catches a run that opens and never ends.
The abort case originally asserted seen.length < 50, but every scenario emits five events or fewer, so an engine that ignored the signal ran to completion and still satisfied it — a cap larger than any real run cannot tell stopping from finishing, and that is what an arbitrary ceiling always is. It now compares against whole.events.length from a real driven happy-path run, which abort_ignored cannot satisfy. This is start_only one level further in: the >= 2 cap was redundant, the < 50 cap was vacuous.
praisonai-ts declares two_approvals unsupported because upstream Agent.streamEvents() cannot emit approval_request. Tool scenarios and the single-approval scenario are produced and passing. See src/praisonai-mobile/docs/gaps.md.

When To Pick Which Engine

On-device conversation memory that survives a relaunch is the in-process engine’s job: praisonai-ts restores the prior conversation from the local session store on every turn. remote-http is not a memory-loss workaround — its conversation is server-owned by chat_id, and a chat it answered leaves the local session empty with no local history to restore.
Both engines now appear in the picker (SETTING_DEFS.engineId.choices is [remote-http, praisonai-ts]), because the in-process engine ships as a lazy chunk the shipping build can construct. On a Tauri build the in-process engine is also the first-launch default (defaultEngineIdFor("tauri") === "praisonai-ts"); remote-http stays available and is the sensible pick for the desktop dev flow, where a server is running and tool/approval rows are useful to watch. See First-Launch Default.

Common Patterns

An engine whose prerequisites are absent is omitted, not offered and then failed.
selectEngine names the available engines when an id is unknown, so a missing engine is an honest message rather than a crash.

Best Practices

Obtaining the engine list requires the persistence argument. Do not construct engines directly — the type makes the wiring impossible to forget.
RegistryDeps.persistence has no default. The in-process engine’s end.userIndex is only real because it records through this store — without it, the turn is on screen and not on disk.
The remote server owns its own store and reports its own indices. Writing a second copy locally reintroduces divergence between screen position and disk position.
The session already knows which conversation is open. Taking direction from the run request would let an in-flight turn write into whichever chat the user has since navigated to.
When record returns null the write failed. Null travels to the UI as “do not offer Fork or Delete”, because those affordances would address a message that does not exist. Index 0 is valid, so a falsy check is a trap.
capabilities is a property, not a method, so the UI decides what to render before the first token arrives.
Declaring a gap in the unsupported map is honest; faking it hides a defect the conformance suite exists to catch.
decide and cancel return false for an unknown id, a decided id, and any non-200 response from the engine — reporting success for a request the engine did not accept is a lie the UI cannot detect. postOk checks if (response.status !== 200) return false;, pinned by "a decision or cancel the engine did NOT accept is reported as refused" over [202, 400, 401, 403, 500, 502].
The consumer’s break, return, or exception out of the for await loop is the cancellation channel — there is no separate close(). The engine’s finally calls await reader.cancel(), so leaving the stream releases the reader and the model stops billing. The regression "leaving the stream early releases the socket" asserts the underlying ReadableStream’s cancel() fires when the consumer breaks after the first delta. See Stop is idempotent and race-safe.
The engine reaches praisonai through a lazily-fetched chunk, and a fetch can fail — a flaky connection, a build that left the engine out, a hashed file the page no longer matches. When it does, the failure arrives as a single error event through the run loop — a named row with the standard Retry affordance — never as an unhandled rejection. create() itself succeeds because it wires the factory, not the chunk; the loadPraisonAgent guard turns the rejected import("praisonai/mobile") into that recoverable event. Pinned by "the in-process engine, when its chunk cannot load, fails RECOVERABLY" in app/src/main.test.ts, which injects a failing loader through the appEngines seam. The separate first-turn gap for want of a key is The known API-key gap.
A retryable probe failure (unhealthy, http_status, malformed, transport) returns ok: true with a notReady field — the engine is kept and the app starts. Only version_mismatch refuses boot. On a phone with no desktop engine to reach, the old hard gate meant the app never opened and the user could not fix the address, because Settings lives inside the app that would not start. Waiting cannot fix a version mismatch, but it does fix a socket still binding. Pinned by the boot and selection tests in app/src/boot.test.ts and app/src/engines.ts. See Boot Failures.
When the caller passes an AbortSignal whose .aborted is already true — the dispose-then-new-run path, and Stop-immediately-followed-by-Send — the engine aborts its own controller before subscribing, so no delta events are emitted and nothing is persisted. Before this fix, the code path attached addEventListener("abort") after the fact, which never fires for a signal that went down before the listener was attached; the run streamed to completion and persisted an answer the UI had already discarded. Pinned by "a run started with an ALREADY-aborted signal produces no answer", with "a run with a live signal still streams, so the abort test is not vacuous" as the companion. See Approvals & Cancellation for the Stop-then-Send route into the same signal.

Architecture

Boot order and the engines factory shape.

Overview

Native navigation and the retained chat screen.

Capabilities & Gaps

What each engine can and cannot report.

Shell & Adapters

The keyboard snapshot and the pinch-zoom guard.

Protocol

The 11 events every engine speaks.

Dropped Events

Where a refused frame lands via onIgnored.

Boot Failures

Where a retryable probe boots the app with a warning, and a permanent one refuses.

Settings Screen

Where a user overrides the persisted engineId and baseUrl on-device.

Web App (PWA)

Why the web target defaults to remote-http, and how it installs and works offline.