# Events & streaming (/brain/docs/reference/events)

Every turn in a session produces a sequence of typed events. Brain appends retained events to the session's local log (`events.jsonl`) and broadcasts both retained and live-only events over server-sent events (SSE).

## Event envelope [#event-envelope]

Every event returned by `POST /sessions/{id}/events` or `GET /sessions/{id}/events` shares the same outer JSON envelope:

```json
{
  "id": "8f14e45fceea167a5a36dedd4bea2543",
  "type": "assistant.message",
  "time": "2026-10-15T12:00:00Z",
  "input_id": "c9f0f895fb98ab9159f51fd0297e236d",
  "data": {
    "text": "Append-only logs write new records at the end of a file without modifying existing bytes.",
    "usage": { "input": 42, "output": 19 },
    "stop_reason": "stop"
  },
  "cursor": "eyJvIjoxMjgsImkiOiI4ZjE0ZTQ1ZmNlZWExNjdhNWEzNmRlZGQ0YmVhMjU0MyJ9"
}
```

| Field | Type | Present on | Description |
| --- | --- | --- | --- |
| `id` | `string` | All events | Unique 32-character hex identifier for this event. |
| `type` | `string` | All events | Event type name in `{domain}.{action}` format (see [Event types](#event-types)). |
| `time` | `string` | All events | UTC RFC 3339 timestamp when Brain recorded or published the event. |
| `input_id` | `string` | Turn events | The `id` of the `user.message` event that triggered this event. Omitted on `user.message` itself and on session-level status changes (such as `close`). |
| `data` | `object` | All events | Type-specific payload (see [Event types](#event-types)). |
| `cursor` | `string` | Retained events | Opaque position token encoding the byte offset and event ID in `events.jsonl`. Omitted on live-only events (`assistant.delta` and `tool.progress`). |

<Callout type="warn" title="Do not construct cursors or pass id as after">
Always pass the exact `cursor` string from a retained event (or `next_cursor` from a history page) as `after`. Passing an event `id` or a fabricated string returns `409 cursor_unavailable`.
</Callout>

## Event types [#event-types]

Brain emits eight event types. Six are saved to disk (`events.jsonl`) and replayed on history/stream requests; two are live-only progress signals sent to connected SSE subscribers.

| Type | Saved to history? | `data` fields | Description |
| --- | --- | --- | --- |
| `user.message` | Yes | `type` (`"user.message"`), `text`, `delivery` (`"queue"`), optional `key` | User message accepted by `POST /sessions/{id}/events`. |
| `assistant.delta` | No (live only) | `text` | Incremental text chunk streamed while the model generates a response. |
| `assistant.message` | Yes | `text`, plus `usage` and `stop_reason` from Pi | Complete assistant message emitted at the end of a model generation step. |
| `tool.call` | Yes | `id`, `name`, `arguments` | Tool invocation requested by the model before Brain dispatches the HTTP, SSE, or MCP call. |
| `tool.progress` | No (live only) | `call_id`, `data` | Intermediate JSON progress frame emitted by an SSE tool (`event: progress`) or MCP progress notification. |
| `tool.result` | Yes | `call_id`, `result` (`content[]`, optional `is_error`, optional `unknown_outcome`) | Final outcome of a tool call returned to the harness. Each item in `content` has `type: "text"` (`text`) or `type: "image"` (`data`, `mime_type`). |
| `session.status` | Yes | `status` (`"running"`, `"idle"`, `"interrupted"`, `"failed"`, or `"closed"`) | Lifecycle transition for the session. When a turn finishes and the input queue is empty, Brain emits `status: "idle"` with `input_id` set to that turn's `user.message` `id`. |
| `session.error` | Yes | `code` (`"provider_error"`, `"brain_error"`, `"conflict"`, etc.), `message` | Fatal turn error or cancelled queued input. See [Errors](/brain/docs/reference/errors#session-errors-sessionerror). |

Each retained record in `events.jsonl` is capped at 4 MiB, and the total log file is capped at `limits.session_bytes` (default 1 GiB).

## SSE wire format [#sse-wire-format]

Send `GET /sessions/{id}/events` with `Accept: text/event-stream` to open an SSE stream:

```text
id: eyJvIjowLCJpIjoiYzlmMGY4OTVmYjk4YWI5MTU5ZjUxZmQwMjk3ZTIzNmQifQ
event: user.message
data: {"id":"c9f0f895fb98ab9159f51fd0297e236d","type":"user.message","time":"2026-10-15T12:00:00Z","data":{"type":"user.message","text":"Hello","delivery":"queue"},"cursor":"eyJvIjowLCJpIjoiYzlmMGY4OTVmYjk4YWI5MTU5ZjUxZmQwMjk3ZTIzNmQifQ"}

event: assistant.delta
data: {"id":"45c48cce2e2d7fbdea1afc51c7c6ad26","type":"assistant.delta","time":"2026-10-15T12:00:01Z","input_id":"c9f0f895fb98ab9159f51fd0297e236d","data":{"text":"Hello"}}

id: eyJvIjoxMjgsImkiOiI4ZjE0ZTQ1ZmNlZWExNjdhNWEzNmRlZGQ0YmVhMjU0MyJ9
event: assistant.message
data: {"id":"8f14e45fceea167a5a36dedd4bea2543","type":"assistant.message","time":"2026-10-15T12:00:01Z","input_id":"c9f0f895fb98ab9159f51fd0297e236d","data":{"text":"Hello"},"cursor":"eyJvIjoxMjgsImkiOiI4ZjE0ZTQ1ZmNlZWExNjdhNWEzNmRlZGQ0YmVhMjU0MyJ9"}

: keepalive

```

- **Framing:** Every SSE frame includes `event: <type>` and `data: <json-envelope>`, followed by a blank line (`\n\n`).
- **Event IDs:** Retained events include `id: <cursor>` before `event:`. Live-only events (`assistant.delta` and `tool.progress`) omit the `id:` line so standard `EventSource` clients never overwrite their last valid reconnection cursor.
- **Keepalives and deadlines:** Brain writes `: keepalive\n\n` every 15 seconds while idle and applies a 15-second write deadline per frame or keepalive. Keepalives never touch model state or trigger LLM calls.

## Reconnecting with cursors [#reconnecting-with-cursors]

When subscribing over SSE, Brain captures the current log boundary under lock, replays all retained events after your cursor in batches of 100, and then switches to live events without gaps or duplicates.

1. Record `event.cursor` whenever it is non-empty.
2. Reconnect with `GET /sessions/{id}/events?after=CURSOR` or the `Last-Event-ID: CURSOR` header (`?after=` takes precedence if both are sent; cursors longer than 512 bytes are rejected).
3. If the session was restored from an older external checkpoint and your cursor points into an unsynced tail that no longer exists, Brain responds with `409 cursor_unavailable`. Reset your local state and read from `after=""`.

## Stream lifecycle and backpressure [#stream-lifecycle-and-backpressure]

- **Multi-turn streams:** SSE streams stay open across `idle` transitions and future turns until the client disconnects, the server shuts down, or the session is closed.
- **Client disconnects:** Closing an SSE connection never interrupts a running turn on the server.
- **Slow-consumer eviction:** Each SSE subscriber has a bounded in-memory buffer (`limits.subscriber_events`, default `128`, and `limits.subscriber_bytes`, default `2 MiB`). If a slow client falls behind either limit during live streaming, Brain closes that subscriber's connection without affecting the turn or other subscribers; the client can immediately reconnect with its last `cursor`.
