> ## Documentation Index
> Fetch the complete documentation index at: https://praison.ai/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# 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.

```ts theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
import { decodeEvent, isDecoded } from "praisonai-mobile/protocol/decode";

const outcome = decodeEvent(rawFrame);
if (isDecoded(outcome) && outcome.event.type === "delta") {
  render(outcome.event.text);
}
```

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph LR
    Wire[📡 Wire frame] --> Decode[🔍 decodeEvent]
    Decode --> Event[📋 Typed RunEvent]
    Decode --> Ignored[⚠️ Explained no-op]

    classDef wire fill:#8B0000,stroke:#7C90A0,color:#fff
    classDef proc fill:#189AB4,stroke:#7C90A0,color:#fff
    classDef out fill:#10B981,stroke:#7C90A0,color:#fff
    classDef warn fill:#F59E0B,stroke:#7C90A0,color:#fff

    class Wire wire
    class Decode proc
    class Event out
    class Ignored warn
```

## Quick Start

<Steps>
  <Step title="Decode a frame">
    ```ts theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    const outcome = decodeEvent(rawFrame);
    ```

    `decodeEvent` never throws. Every rejection is a value with a reason, so a client can count them.
  </Step>

  <Step title="Stop at the terminal event">
    ```ts theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    import { isTerminal } from "praisonai-mobile/protocol/events";

    if (isTerminal(event)) return;
    ```

    After a terminal event nothing else follows.
  </Step>
</Steps>

***

## What Happens To A Rejection

A rejection is not silent — it lands on the transcript as a dropped row.

<Warning>
  A refused frame is **not** discarded. The mobile app surfaces it as a dropped event, so a shorter-than-expected reply must never be treated as a clean answer. See [Dropped Events](/docs/features/mobile/dropped-events).
</Warning>

`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.                     |

<Warning>
  The **"always allow"** button exists in the UI and is bound to the string `"always"`. Any decoder that quietly maps an unknown choice to a default authorises something the user did not pick — and the falsy direction to guess is `"allow"`, which is the dangerous one.
</Warning>

### Approval choices

The decoder exports `decodeApprovalChoice`, and the set is exhaustive — every unknown string returns `null`, never a silent default.

```ts theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
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-sensitive
```

***

## Event 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`:

```ts theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
// 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](https://github.com/MervinPraison/PraisonAI/pull/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.

<Warning>
  This class of mis-decode never reached `onIgnored`: the frame was **mis-typed**, not refused, so the DropSink channel could not have caught it. See [Dropped Events](/docs/features/mobile/dropped-events). The regression `"the SSE event name decides the type, not a field inside the payload"` pins that a `delta` frame whose data object carries `type: "error"` is decoded as a `delta` — `assert.equal(delta.text, "kept")`.
</Warning>

***

## How A Turn Streams

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
sequenceDiagram
    participant User
    participant Engine
    User->>Engine: prompt
    Engine-->>User: start
    Engine-->>User: delta*
    Engine-->>User: tool_drafting
    Engine-->>User: tool_call
    Engine-->>User: approval_request
    User->>Engine: decide("allow")
    Engine-->>User: tool_result
    Engine-->>User: delta*
    Engine-->>User: end
```

***

## Fields That Trap Readers

<Warning>
  **`callId` vs `approvalId` are never interchangeable.** `callId` says which row to attach the prompt to; `approvalId` is what gets sent back. In the two-approval case the prompts arrive in the *opposite* order to their tool rows, so zipping by index crosses `rm` with `curl`.
</Warning>

<Warning>
  **`end.userIndex === null` means "not on disk".** The write failed, so Fork and Delete must be withheld. Index `0` is a valid, persisted message — a falsy check (`if (!userIndex)`) is a trap.
</Warning>

<Note>
  **Terminal events are mutually exclusive and final.** After `end`, `cancelled`, or `error`, nothing else may follow. A cancelled turn is never persisted, so no `end` and no `usage` follow it.
</Note>

<Note>
  **`chat_id` on the wire is per-conversation.** Each request carries the id of the conversation it belongs to, minted at New chat via `controller.setChat(mintChatId())`. In a shipped app it is never the literal `"unassigned"` — that placeholder only appears when `setChat` is never called, which keys every conversation to one server-side thread against an engine that stores history by `chat_id`. See [New chat semantics](/docs/features/mobile/overview#new-chat).
</Note>

<Info>
  **Unknown `ErrorKind` degrades to `"internal"`.** `decode.ts` maps any kind it does not recognise to `"internal"` rather than dropping the event, so a newer engine's category still reaches the user.
</Info>

<Note>
  **Large `tool_result` payloads no longer block the main thread.** A big file arriving as one frame over many small chunks is read in linear time — see [Frame buffering](#frame-buffering) below.
</Note>

***

## 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.

<Info>
  A CRLF, LF, or bare-CR stream is handled identically. `createSseReader` tracks a pending `\r` across chunk boundaries, so a `\r\n` split between two chunks is still one terminator, and a lone-CR stream still completes its last frame. **Mixed-terminator streams (a CRLF followed by a bare LF, legal per the SSE spec) are also handled correctly — the LF that follows a swallowed CRLF is still recognised as its own line break, so an event boundary written two ways in one stream is not lost.** Before this, a chunk ending in `\r` followed by a chunk beginning `\n` synthesised a spurious frame boundary and half the answer could vanish; before [#4589](https://github.com/MervinPraison/PraisonAI/pull/4589) the reverse could also happen — a CRLF-then-LF pair inside one stream joined two frames into one, so an event boundary was lost. Pinned by the differential-probe test in `sse.test.ts` — cutting `event: delta\r\n\ndata: x\n\n` at `[13,14]` yields two frames when correct and one when the flag is inverted.
</Info>

<Info id="nn-boundary-straddle">
  **The `\n\n` boundary that straddles two chunks is handled explicitly.** The reader searches for the frame terminator inside each arriving chunk alone rather than over the concatenated buffer. When the buffered text ends with `\n` and the next chunk begins with `\n`, that `\n\n` exists only across the join and is invisible to the per-chunk search — the reader detects that case up front and completes the frame. Pinned by the differential-probe test in `sse.test.ts` — 932 (stream, chunking) pairs, every split index for LF, CRLF, bare CR, mixed terminators, comments, folded data, `id`/`retry`, no-colon lines, blank runs, and unterminated tails, plus each with an empty chunk wedged in.
</Info>

<Info id="frame-buffering">
  **Frame buffering is linear in frame length, not quadratic.** `createSseReader` keeps arriving chunks as an unjoined array and searches for the `\n\n` boundary inside each chunk alone; the pieces are joined only when a frame completes, over that frame's own length. A single large frame arriving over many small chunks — a `tool_result` carrying a big file over a cellular link is exactly that shape — is O(n) in its length. Measured for one 1 MB frame over 512 B chunks: 2.4 / 10.7 / 40.7 / **163.1 ms** at 128 / 256 / 512 / 1024 kB before the fix (4.01× per doubling) → 0.3 / 0.5 / 0.8 / **1.4 ms** after (1.73× per doubling). At 1 / 2 / 4 MB over 1 kB chunks: 100 / 288 / **1185 ms** → 1.3 / 2.0 / **2.8 ms**. Many small frames — the common case — are unchanged.
</Info>

<Info>
  **The SSE reader strips exactly one leading space from a field value, per the SSE spec — never more.** A payload that legitimately begins with whitespace (indented JSON, or text whose first token is deliberately padded) is preserved verbatim after the single-space strip: `"  {"` on the wire becomes `" {"` in `frame.data`, not `"{"`. The rule is `if (value.startsWith(" ")) value = value.slice(1)` — never `value.trimStart()`, which would strip all leading whitespace and corrupt those payloads silently, with neither the reader nor the decoder reporting the corruption. Pinned by `"the SSE reader strips exactly one leading space, per the SSE spec"`.
</Info>

<Note>
  **The SSE reader recognises four fields: `event`, `data`, `id`, and `retry`.** `id` and `retry` are read and discarded today (the desktop engine sends neither), but they still count as "recognised": a `retry`-only frame is a real frame the reader deliberately ignored, not a `null` that vanishes from the stream. The paired rule: a block carrying **no** recognised field at all (blank lines, comments only, or only unknown fields) returns `null` — otherwise every SSE heartbeat would inject a junk `{ event: "", data: "" }` frame. This is a reader contract, upstream of the decoder. Pinned by `"a retry-only frame is read and discarded, not dropped"` and `"a block with no recognised field is not a frame"`.
</Note>

<Note>
  **An `id:`-only frame is read-and-discarded, the same way `retry:` is.** In `protocol/src/sse.ts`, `id` is the field that would become the resume cursor if the engine ever sent one; it is not consumed today, but the reader must not drop the frame around it. Same rule as `retry`, different field — the recognised-but-unused set is `{id, retry}`, not `{retry}` alone.
</Note>

<Note>
  **A line without a colon is a legal SSE field.** Per the SSE spec, a bare `data` (no colon, no value) is an empty data field, not garbage — the reader must not drop the frame that contains it. This is subtle because most implementations parse `field:value` only; the spec requires the field-only form to work, so `protocol/src/sse.ts` treats a colonless line as `field` with an empty value rather than discarding it. The paired positive case remains the ordinary `field: value` line, which still parses as before.
</Note>

***

## 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"`.

<Warning>
  **A refusal states `expected: PROTOCOL_VERSION` — what we require, not what we got.** Reporting `expected: engine` produces the self-contradictory `"too_old: engine=1 expected=1"` string a user then pastes into a bug report; a message where expected equals actual explains nothing. The refusal's whole job is to name the mismatch. Pinned by `"a refusal states the version WE expect, not the one we got"`.
</Warning>

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

<AccordionGroup>
  <Accordion title="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.
  </Accordion>

  <Accordion title="Route approvals by approvalId">
    Bind a decision to a row by `approvalId`, not by position — position holds only while exactly one approval is outstanding.
  </Accordion>

  <Accordion title="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"`.
  </Accordion>
</AccordionGroup>

***

## Related

<CardGroup cols={2}>
  <Card title="Approvals & Cancellation" icon="hand-back-fist" href="/docs/features/mobile/approvals-and-cancellation">
    The human-in-the-loop flow on mobile.
  </Card>

  <Card title="Capabilities & Gaps" icon="list-check" href="/docs/features/mobile/capabilities-and-gaps">
    Which events each engine emits today.
  </Card>

  <Card title="Dropped Events" icon="triangle-exclamation" href="/docs/features/mobile/dropped-events">
    Where a refused frame goes instead of the floor.
  </Card>

  <Card title="Errors & Recovery" icon="triangle-exclamation" href="/docs/features/mobile/errors-and-recovery">
    How each `ErrorKind` maps to a recovery affordance.
  </Card>
</CardGroup>
