The 11 Events
One discriminated union of 11 events — every token, tool call, and result is typed.
Every turn is a stream of typed events, decoded once so nothing above has to parse prose.
import { decodeEvent, isDecoded } from "praisonai-mobile/protocol/decode";
const outcome = decodeEvent(rawFrame);
if (isDecoded(outcome) && outcome.event.type === "delta") {
render(outcome.event.text);
}Quick Start
- Decode a frame
ts const outcome = decodeEvent(rawFrame);decodeEventnever throws. Every rejection is a value with a reason, so a client can count them. - Stop at the terminal event
ts import { isTerminal } from "praisonai-mobile/protocol/events"; if (isTerminal(event)) return;After a terminal event nothing else follows.
What Happens To A Rejection
A rejection is not silent — it lands on the transcript as a dropped row.
parseFrame adds two reasons the decoder alone could not report — unparseable_json (an HTML error page or truncated body) and not_an_object (a JSON array, string, or null) — so five distinct wire failures are no longer collapsed to one missing_msg_id.
The 11 Events
Every event carries msgId. Fields listed are the ones beyond that.
| Event | Purpose | Key fields | Terminal |
|---|---|---|---|
start | A turn begins | runId | No |
delta | Assistant text | text (never empty) | No |
reasoning | Intermediate thinking | text | No |
tool_drafting | A tool is being drafted (a status, not a row) | name | No |
tool_call | A tool call is issued | callId, name, args | No |
tool_result | A tool call returns | callId, name, ok, output, seconds | No |
approval_request | The agent asks permission | approvalId, callId, name, args | No |
usage | Cost/timing report | chars, seconds, ttftSeconds | No |
cancelled | The turn was stopped | runId | Yes |
error | The turn failed | kind, message | Yes |
end | The turn completed | userIndex, assistantIndex, versions, active | Yes |
What The Decoder Refuses
decodeEvent takes unknown, so the validation below is the wire contract — not a suggestion a buggy engine adapter can skip.
| Rule | Behaviour | Why it matters |
|---|---|---|
Empty-string msg_id, type, call_id, approval_id | Frame is refused (surfaces as a dropped row) | An empty msg_id is accepted-then-orphaned: every later event mismatches it and is dropped as wrong_msg_id — the UI reports “engine said nothing”. |
NaN / +Infinity / -Infinity in versions, active, chars, seconds | Not a finite number, so the field falls back to its default (1, 0, undefined, null respectively) | JSON cannot carry these, but decodeEvent takes unknown and a buggy engine adapter can. |
versions | Clamped to Math.max(1, versions) — never 0 or negative | versions: 0 reaching the UI = a message that exists in zero versions. |
active | Clamped into [0, versions - 1] | An out-of-range active renders the message bubble blank. |
assistant_index absent | Derived as user_index + 1 (never equal to user_index) | Pointing at the user’s own index would render the prompt back as the answer. |
assistant_index: undefined (present but undefined) | Malformed → resolves to null (Fork/Delete withheld), NOT derived | Present-but-undefined is a serializer bug; deriving would fabricate an index the disk never wrote. |
end.active absent when versions is set | Defaults to 0 (first version), NEVER 1 | Silently selecting the second version of every message is invisible with a single version but wrong the moment there are two. |
tool_call.args = JSON array | Refused; consumers see {} | Args render to the user as the command being approved; [1, 2] becoming {"0":1,"1":2} misrepresents what they are authorising. |
Approval choices
The decoder exports decodeApprovalChoice, and the set is exhaustive — every unknown string returns null, never a silent default.
import { decodeApprovalChoice } from "praisonai-mobile/protocol/decode";
decodeApprovalChoice("allow"); // "allow"
decodeApprovalChoice("always"); // "always" — do NOT omit; the button is real
decodeApprovalChoice("deny"); // "deny"
decodeApprovalChoice("maybe"); // null — never a silent default
decodeApprovalChoice("ALLOW"); // null — case-sensitiveEvent type comes from the SSE event: line, never the data payload
The event’s type is read from the SSE event: line, not from any type field inside the frame’s data object.
The engine builds the decoded event as decodeEvent({ ...parsed.value, type: frame.event }) — the event: name overwrites any type the payload carried. A delta frame whose data object happens to include type: "error" is still a delta:
// A delta frame that carries its own conflicting `type` in the payload.
// event: delta
// data: { "msg_id": "m1", "text": "kept", "type": "error" }
// -> decoded as a delta with text "kept". The `event:` line wins.This is the shape assumption the whole 11-event contract stands on. Before #4579 the two were transposed, so a delta with type: "error" in its payload decoded as an ill-formed error, was dropped silently, and text vanished from the answer.
How A Turn Streams
Fields That Trap Readers
Error Kinds
ErrorKind selects the recovery the UI offers.
| Kind | Meaning |
|---|---|
auth | The provider rejected the credential (HTTP 401 or 403) — send the user to settings. |
rate_limit | Retrying is meaningful. |
empty | The engine produced no output at all. |
transport | The stream broke; the engine may be fine. |
protocol | Client and engine disagree about the contract. |
internal | Anything else, including unrecognised kinds. |
Before this, a 403 — what an engine behind a proxy or a scoped key actually returns — was classified transport, so the UI offered Retry forever instead of sending the user to credentials.
Protocol version reporting
checkProtocol in protocol/src/version.ts compares the engine’s stated version against what this client requires, and shapes a diagnostic a user can paste into a bug report. Two rules keep that diagnostic honest — they are the same absent-vs-present discipline the decoder uses for tool_result.seconds.
| The engine stated | checkProtocol reports engine: |
|---|---|
| A valid non-negative integer | that number |
undefined, null, non-integer, negative, NaN, or an object | null — never 0 |
engine: 0 would read as “the engine answered 0” instead of “the engine did not answer”, so a version that was never stated is reported as null, not zero — a present number and an absent one are different facts. Pinned by "an engine that cannot state its version reports ABSENT, not zero".
A refusal carries one of four reasons:
reason | When |
|---|---|
too_old | The engine’s version is below PROTOCOL_VERSION. |
too_new | The engine’s version is above PROTOCOL_VERSION. |
unreadable | The engine did not state a readable version (engine: null). |
version_mismatch | The versions differ in a way not captured by the more specific reasons above. |
The paired positive case: an engine stating exactly PROTOCOL_VERSION passes, so the check does not degenerate to “refuse everything”.
Best Practices
Trust tool_result.ok, never the output
ok is the only signal of tool success. Inferring success from a non-empty output is the exact defect the field prevents.
Route approvals by approvalId
Bind a decision to a row by approvalId, not by position — position holds only while exactly one approval is outstanding.
Treat null as a real value
For end.userIndex and tool_result.seconds, null means “unknown / not persisted”, which is different from 0 and from absent.
For tool_result.seconds the decoder distinguishes three states: the field is absent on the wire (seconds key missing) → decoded as null (unknown); the field is present and null → decoded as null; the field is present and 0 → decoded as 0 (a measured zero — a call the engine timed that returned instantly). The rule is seconds === undefined ? null : seconds — decoding absent as 0 would present a measurement as fact. formatElapsedLocalised (see format-intl.ts) keeps them apart: null → strings.unknownValue, 0 → "0.0s". The same absent/null/zero distinction holds for end.userIndex and usage.ttftSeconds. Pinned by "a tool_result with no seconds decodes as unknown, not as zero" and its pair "a tool_result WITH seconds keeps the number it was given".