Skip to main content
Every turn that fails ends on a real error row carrying the reason and one recovery affordance. The row’s kind — never its prose message — chooses whether the user is sent to Settings, offered Retry, or shown no action at all.
A transport failure caused by an unreachable engine on a phone is also recovered from Settings — edit baseUrl — even though the classifier routes transport to Retry. Retry against the same unreachable address just re-fails; Settings is where the address lives. See Settings Screen.

Before the error: guidance on the first screen

A missing key is now caught before any Send, so the provider SDK error is no longer the first thing a new user sees. The prose The OPENAI_API_KEY environment variable is missing or empty used to be the only “no key” signal, shown after the first Send. It has been replaced by first-screen guidance — the “Add an API key to start” panel — see Empty Chat. If a user still reaches this error prose from another path — clearing the key mid-chat and then sending a message — the resulting error prose now classifies as auth (PR #4873), so the row’s recovery button lands them on Settings just like the empty-state panel does — via the same strings.recoveryLabel("settings")“Open settings” label, so the route reads the same everywhere. See Common Pitfalls → A missing key is auth, not internal for the classification detail.

Quick Start

1

See a 401 (auth)

An expired or wrong API key returns kind: "auth". The error row offers Go to Settings, because a retry with the same rejected credential just fails again.
2

See a 500 (transport)

A plain HTTP 500 — the most common provider failure — classifies as kind: "transport", along with the rest of the 500–504 range. The error row offers Retry — the engine may be fine and the next attempt may reach it.

The Recovery Mapping

recoveryFor(kind) is exhaustive over ErrorKind. Every kind lands in exactly one of three affordances.
The message text is prose from a provider and may say anything. The recovery button is chosen from kind alone, so a reworded provider error never silently loses its affordance.
A local list-load failure on the chats route is a separate class from these pipeline errors. It is neither an ErrorKind nor a BootResult — it is a caught rejection inside the route handler that renders strings.crashed in place of the chat list. There is no button: recovery is to back out of the route and keep using the conversation you were in. Pinned by "a storage failure while the chat list loads stays LOCAL, not fatal". See History & Reopen → When the list load itself fails.
The in-process engine now ships as a lazy chunk. Two failure modes both surface as an ordinary recoverable error event, so the standard Retry affordance applies to each: (1) the engine chunk fails to fetch — flaky connection, hashed file the page no longer matches — caught by the loadPraisonAgent guard and pinned by "the in-process engine, when its chunk cannot load, fails RECOVERABLY" in app/src/main.test.ts; and (2) the chunk loads but the first turn reaches the model layer with no API-key setting and fails there. Neither is a crash. See Engines → The known API-key gap.
An unreachable engine is caught at boot, not on the first message. Since PR #4672, a remote engine that is unreachable at launch no longer manifests as a mid-turn transport error on the first send. The boot-time health probe reports a retryable unreadiness, the app still boots, and the transcript already carries a warning notice (booted.notReady) — so the user can open Settings and correct the address before typing. If a support ticket says “a warning row appeared right after opening”, that is the boot notice, not this mid-turn path. See Boot Failures → An unreachable engine boots with a warning.

Pre-first-token and mid-stream are unified

A failure before the first token now surfaces the same real reason as one mid-stream. Both reach the error row with their true kind. transcript.ts::apply() promotes an error that arrives while the turn is still idle: it flips the turn to streaming and reprocesses the event, so the reason survives. Before this, every pre-first-token failure was dropped and re-labelled kind: "empty", so a 401 and a dead socket rendered identically — and the auth → settings branch was unreachable.
The transcript-level promotion sits alongside a controller-level rule that fixes the same failure earlier. A pre-first-token failure now starts a fresh turn at the top of runTurn, so an error arriving without a start event is applied to the new turn rather than dropped as wrong_msg_id against the previous turn’s ended state.
See Dropped Events for how this exception sits against the general before-start drop rule.

An error row shows the kind in words

Since PR #4873, the row renders strings.errorTitle(errorKind) as a bold block above the provider’s message, in addition to the errorRowName aria-label that already existed for the screen-reader announcement. The title is chosen by kind, never by parsing the message. The full provider prose is kept in an .error-message span underneath — support engineers still find it by search. Before this landed, sighted users saw only the SDK sentence (e.g. The OPENAI_API_KEY environment variable is missing or empty; either provide it, or instantiate the OpenAI client with an apiKey option, like new OpenAI({ apiKey: 'My API Key' }).) while screen-reader users heard “Sign-in problem. …” — two audiences, two different accounts of one event. paint() for case "error" appends two spans inside the row:
The DOM shape:
The .error-title span is display: block — not cosmetic. Both title and message are <span>s inside one row, so without it the row reads “Sign-in problemThe OPENAI_API_KEY environment variable…” as one run. The CSS rule is .row-error .error-title { display: block; font-weight: 600; }; no colour is set on the selector on purpose — .row-error already sets one and both spans inherit it.

Body and transport rules for cancel and approve POSTs

Status is only half the contract. postOk / decide / cancel in engines/src/remote-http/engine.ts also judge the body and survive a thrown transport — a 200 with a bad body is “a lie the UI cannot detect”, so a Stop or an approval is confirmed only when the engine actually accepted it.
A dropped connection is not a successful cancel. A thrown transport on the way to /cancel or /approve reports false, never true. The worst failure the port contract names — a Stop button that confirms a cancellation that never happened — is closed by this. Pinned by "a 200 whose body does not say ok:true is reported as refused" and "a cancel whose transport THREW is reported as refused".
The pair keeps the rule honest: a 200 with {"ok": true} must still succeed, so the module cannot degenerate to “refuse everything”. Pinned by "a 200 with ok:true IS accepted -- the pair". The UX side of the same Stop button is described in Approvals & Cancellation.

Tool rows in the transcript

A tool call renders as a single-line preview (the first line of output) plus a full output field kept for the expanded view. This is what keeps every ls, every stack trace, and every file read from breaking the transcript’s row layout.
A multi-line tool output previews as one line. Dropping firstLine(output) — showing the raw output in the row — lets a stack trace or a long ls blow out the row height. The full text stays in output for the expanded view. Pinned by "a multi-line tool output previews as ONE line".
A tool_result must preserve the arguments its matching tool_call recorded. A completed tool row is the record of what the tool ran with — and the same args are what an approval row showed the user before they allowed it. Wiping them to {} on completion means the user (and any future audit of the transcript) can no longer see what a finished tool actually did. An orphan tool_result — one with no matching tool_call — records {} rather than inventing arguments. Pinned by "a tool_result keeps the arguments its tool_call recorded" and "a tool_result with no matching call records empty args rather than inventing them". reasoning events accumulate — the visible thinking on screen is the concatenation of every reasoning chunk, in order. Assigning each chunk instead of appending it truncates the model’s thinking to only its final fragment. Pinned by "reasoning accumulates across chunks".

A message that was never stored says so

When a turn ends without an end.userIndex — an offline send, a cancelled turn, or an engine error before persistence — the user row moves to unstored and shows a caveat under it:
Not saved — this message is not in the stored conversation
The caveat is only ever on an unstored row. A live turn (sent) and a saved one (stored) carry no note — a warning on every message is a warning nobody reads. The same isPersisted predicate that suppresses Fork and Delete for an unsaved turn is what puts the caveat here, so the two can never disagree. See Transcript User Row → Three storage states.

Common Pitfalls

The default baseUrl is 127.0.0.1:8765, which on a phone resolves to the phone — not your dev machine. With nothing listening there, the boot-time probe cannot reach it. Since PR #4672 the app still opens and shows a warning notice (booted.notReady) instead of failing on the first send — so the user can set the engine address in Settings before typing.The full recovery loop needs no relaunch:
  • The field is editable and persists the change (from PR #4685).
  • The correction takes effect immediately — the next /chat and /health resolve the new baseUrl, because enginesFor reads it per request rather than capturing it at boot (from PR #4694).
  • A refused address is spoken back, not silently reset — settingRejected(label) names the setting in an assertive alert, so a typo on the recovery screen no longer looks like the tap did not register.
See Settings Screen → How a refusal is spoken and Boot Failures → An unreachable engine boots with a warning.
An expired key is auth and routes to Settings; being offline is transport and routes to Retry. They are distinct kinds precisely so the app does not tell an offline user to fix credentials that are fine.
A 403 — what a scoped key or a proxy returns — is kind: "auth", so it sends the user to credentials rather than offering an endless Retry. The branch is status-first: {status: 403, message: "nope"} classifies as auth even when the message says nothing recognisable. Without the status branch, only prose containing the literal "403" or "forbidden" would be caught, and a provider whose message is silent would misclassify as internal and offer no route to Settings. {status: 401} behaves the same way. Pinned by "a 403 is an auth failure even when its message says nothing useful".
When a provider error arrives without a status — a thrown SDK error, an unwrapped provider response — classify in engines/src/praisonai-ts/classify.ts falls back to a message regex. There are now three independent groups of message alternatives, and any one alone routes to Settings:A bare authentication_error with no “invalid api key” phrase still classifies as auth, so the user is sent to Settings rather than to a Retry that fails again with the same rejected credential. Drop the authentication alternative and such an error misclassifies as internal with no route to Settings — the exact regression the source comment records as fixed. Pinned by "anthropic auth, no key phrase", "authentication, spelled out", and "authentication failed".The third group — the missing-key patterns from PR #4873 — completes the story: a key that was never set is a different sentence from a key that was rejected, and until #4873 it reached neither of the first two alternatives. See the next accordion for the exact branches.
A key that was never set is a different sentence from a key that was rejected, and until PR #4873 it reached none of the auth patterns. The single most likely failure a fresh install has — The OPENAI_API_KEY environment variable is missing or empty — classified as internal, whose recovery is none, so the one error whose entire answer is “open Settings and paste a key” offered no route to Settings.Three regex branches now route missing-key prose to authsettings:Now recognised as auth (all were internal before):The gap between “api key” and its qualifier is bounded by [^.;] (not .) so a later “missing” in an unrelated clause of the same paragraph cannot drag an unrelated failure into auth. Unrelated failures still classify correctly — "The server had an error while processing your request" and "something went sideways" stay internal, "fetch failed" stays transport, "429 Rate limit reached for gpt-4o" stays rate_limit, and "The model produced an empty response" stays internal (recovery retry, not settings). Pinned by "a key that was never SET is an auth failure, not an internal one" and its pair "the missing-key patterns do not swallow unrelated failures" in classify.test.ts.
A plain HTTP 500 — the single most common provider failure — is transport, so the user is offered Retry. The boundary is status >= 500, pinned by tests over [500, 501, 502, 503, 504]. 502/503/504 were already transport; before this fix, exactly 500 fell through to internal and the user was told “something went wrong” with no recovery affordance. Pinned by "an HTTP 500 is transport, so the UI still offers Retry".
A 400 (bad request) or 422 (unprocessable) is internal, not transport, so no Retry is offered. Offering Retry for a malformed request or a revoked key is a promise the user can never make good on. The pair [400, 422] is asserted to never classify as transport, pinned by "a 4xx is not transport, so Retry is not offered where it cannot help".
Turn 1 answered, turn 2 got a 401, and the screen still showed turn 1’s answer with turn 1’s end outcome. The composer cleared on Send, but the user got no error row, no auth prompt, and no hint a run was attempted. Silent, repeatable, permanent.If you integrate against pre-4572 mobile builds, expect the failure to appear only after the next successful turn — carrying a “1 event could not be read” row blaming itself for its predecessor. The default baseUrl is 127.0.0.1:8765 — the phone itself — so “engine unreachable” is the common case, not an exotic one.
createWebHttp hard-coded status: 200, so a 401 / 403 / 429 / 502 was reported as success. The engine reads the status to pick auth vs transport, so the whole recovery distinction — go to settings, or retry — was fed a constant, and both the auth → settings and transport → retry branches were unreachable through the web adapter. The tests that covered the distinction drove the fake http, not the adapter, so the one place the real status is read was unguarded. Fixed in #4578; the regression now reports the received status across [200, 204, 401, 403, 429, 500, 502].
Even with the web adapter fixed, postOk inside engines/src/remote-http/engine.ts did not check the status it received — so decide and cancel returned true for a rejected credential (401), a rate-limited stop (429), or a proxy error (502). The run stream classified status correctly, but the approval and cancel POSTs did not. The two fixes together are what make the auth → settings / transport → retry mapping reachable for approval and cancel posts, not only for the streaming run. The regression "a decision or cancel the engine did NOT accept is reported as refused" iterates [202, 400, 401, 403, 500, 502] and asserts both decide and cancel return false.
A second tap on an emptied composer is a no-op — not a re-send of the previous question. Before #4578, the sent text stayed in the box; a user could double-tap Send, re-submit the same question, and pay twice with no on-screen sign it happened.
On iOS, rubber-banding past the bottom of the transcript legitimately reports a negative scrollTop on the visual viewport. Distance-from-bottom is clamped with Math.max(0, scrollTop) before the comparison, so an overscroll counts as “at the bottom” and follow-the-stream stays engaged. Before #4589, a negative scrollTop made the distance come out positive and follow-the-stream switched itself off mid-answer on every rubber-band — the exact behaviour the module exists to prevent. The paired guarantee is unchanged: a real scroll away from the bottom (scrollHeight > clientHeight with a non-negative scrollTop) is still reported and still stops the transcript following. Pinned by "iOS rubber-banding past the bottom still counts as being at the bottom" and "a real scroll away from the bottom is still reported" in scroll.test.ts.

Approvals & Cancellation

Stopping a run, and what a refused stop does.

Dropped Events

The before-start drop rule and the error exception.

Boot Failures

Failures that never reach a turn at all.

Protocol

The error event and its kind field on the wire.

Chat Recovery

A corrupt chat file surfaced without hiding the rest.

Empty Chat

The pre-error guidance surface for a missing key.