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.

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 502 (transport)

A dead socket or a proxy returning an HTML error page classifies as kind: "transport". 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.

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.

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 very first turn fails with a refused connection. Set the engine address in Settings before the first send.
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.
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.

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.