AgentEnginePort and nothing else.
Quick Start
RegistryDeps requires a persistence
persistence is required. It is passed only into the in-process engine’s factory.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.selectEngine returns notReady on a retryable probe
notReady, set only for a retryable unreadiness. A permanent unreadiness (version_mismatch) takes the failure branch and disposes the engine.enginesFor wires a probe and passes persistence to in-process only
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.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.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.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."the real composition root offers the in-process engine" in app/src/main.test.ts.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.Answer an approval
Surface refused frames with onIgnored
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 capturedbaseUrl 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.
${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 meansbase() 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:
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.
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.
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 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.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 pincreateApp on a blank screen.
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.
"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.
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".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.
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.
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).
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.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 reportsend.userIndex from the store it wrote to — and reads the next turn’s history back from the same store.
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:
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.
null return means the save failed — the turn is on screen but not on disk.
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 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.- 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.
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’sAgent through a single literal dynamic import, in engines/src/praisonai-ts/load-agent.ts:
- 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, notpraisonai. The default entry re-exports the CLI, the MCP server and the knowledge store, whose Node builtins are import-time fatal in a webview./mobileis the package’s webview-safe allowlist entry.- One file may name it.
tools/boundaries.jsonallows the stringpraisonaionly fromengines/src/praisonai-ts— the agent-framework seam. Making the import literal did not cost that rule;load-agent.tsis the one place it lives.
The shell and lazy split
The build emits two populations of chunk, paid for at different moments, sotools/bundle.mjs carries two budgets:
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.
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.
Development consumes praisonai through a file link
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 anopenaiApiKey secret setting, and createInProcessEngine reads it through apiKeyFor(secrets, settings.defs()) on every turn.
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
Passingengines/src/conformance.ts is the definition of implementing the seam.
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:
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.
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
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.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
Never bypass the factory
Never bypass the factory
Treat persistence as required, not optional
Treat persistence as required, not optional
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.Do not persist remote-http into the local session
Do not persist remote-http into the local session
Do not switch chats from the request
Do not switch chats from the request
Read null as 'not on disk'
Read null as 'not on disk'
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.Read capabilities before rendering
Read capabilities before rendering
capabilities is a property, not a method, so the UI decides what to render before the first token arrives.Never fake an unsupported scenario
Never fake an unsupported scenario
unsupported map is honest; faking it hides a defect the conformance suite exists to catch.Return false, never a lie
Return false, never a lie
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].Break out of the iterator to release the socket
Break out of the iterator to release the socket
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.A failed engine-chunk fetch fails RECOVERABLY, not opaquely
A failed engine-chunk fetch fails RECOVERABLY, not opaquely
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 retryably unready engine boots the app, not the crash screen
A retryably unready engine boots the app, not the crash screen
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.A run given an already-aborted signal produces no answer
A run given an already-aborted signal produces no answer
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.
