# Stream and replay events

Consume Tanomis run events, persist a cursor, reconnect safely, and confirm durable run status.

Run events let your application render progress while an agent works. Treat the stream as a live
delivery channel—not as the authoritative run record or a permanent audit log. A reliable consumer
processes each event idempotently, stores a run-scoped cursor, and reads the run after every terminal
signal or end-of-stream.

This guide assumes you already have a session ID, run ID, and a supported access-token path. See
[Sessions and runs](/concepts/sessions-and-runs) first if those resource boundaries are unfamiliar.

- Open the stream with `Accept: text/event-stream`; every frame has an id, an event name, and the full event as JSON.
- Store the last id you processed **after** processing it, and deduplicate by run id plus event id.
- On disconnect, reconnect with `Last-Event-ID`; replay starts strictly after it.
- Terminal events are `run_completed`, `run_cancelled`, and `error`. A stream can end without one, so the run resource decides.

Event `41` is processed and stored → event `42` is processed → the connection breaks before cursor
`42` is stored → reconnect sends `Last-Event-ID: 41` → event `42` arrives again and is ignored by
deduplication → event `43` signals `run_completed` → `GET` run confirms authoritative status
`succeeded`.

If cursor `42` had been stored successfully, reconnecting with `Last-Event-ID: 42` would begin
strictly after it. The duplicate above exists because application work completed before its cursor
checkpoint did—not because the server replays the requested cursor.

## Open one run stream

Read events for one session and run:

`GET /v1/sessions/{session_id}/runs/{run_id}/events`

```bash
curl --no-buffer --fail-with-body \
  "$TANOMIS_API/v1/sessions/$SESSION_ID/runs/$RUN_ID/events" \
  --header "Authorization: Bearer $TANOMIS_TOKEN" \
  --header "X-Organization-ID: $TANOMIS_ORG" \
  --header "X-Workspace-Tenant-ID: $TANOMIS_WORKSPACE" \
  --header "Accept: text/event-stream"
```

The stream is scoped to this session and run. It does not provide ordering across runs or combine
events from other sessions.

## Read complete SSE frames

Wait for the blank line that ends a frame before processing it. Every public event frame contains:

| SSE field | Contract |
|---|---|
| `id` | An opaque event ID. It orders events only inside this run. |
| `event` | The event name. Only the three terminal names below have stable terminal meaning. |
| `data` | One complete JSON event object, including its own `id`, `type`, `session_id`, `run_id`, and harness-specific `data`. |

A synthetic stream can look like this:

```text
id: 41
event: message_delta
data: {"id":"41","type":"message_delta","session_id":"ses_example","run_id":"run_example","created_at":"2026-09-03T09:00:00.041Z","data":{"text":"Draft"}}

id: 42
event: message_delta
data: {"id":"42","type":"message_delta","session_id":"ses_example","run_id":"run_example","created_at":"2026-09-03T09:00:00.042Z","data":{"text":" ready"}}

id: 43
event: run_completed
data: {"id":"43","type":"run_completed","session_id":"ses_example","run_id":"run_example","created_at":"2026-09-03T09:00:00.043Z","data":{}}

```

`message_delta` is illustrative, not a universal schema promise. Event names and fields inside the
harness-specific `data` object can evolve.

## Reconnect without double-applying

Use this sequence for every connection:

1. Open the events endpoint with `Accept: text/event-stream`. If a cursor exists for this run, send
   it in `Last-Event-ID`.
2. Parse one complete frame. Verify its session and run IDs match the resource you requested.
3. Build a deduplication key from the run ID and event ID. If that key is already processed, skip
   the side effect and advance the stored cursor if needed.
4. Apply the event idempotently. Only after processing succeeds, record the deduplication key and
   persist the event ID as this run's cursor.
5. On `run_completed`, `run_cancelled`, or `error`, stop interpreting the stream and read the run
   resource for its authoritative lifecycle status and error details.
6. On a clean EOF without a terminal event, read the run. Reconnect with bounded backoff only while
   the run remains `queued` or `running`.

Store the cursor with both the session ID and run ID. Treat the event ID as an opaque string; never
compare IDs from separate runs. Where an event changes durable application state, make the state
change and cursor checkpoint atomic when possible. Otherwise, make the handler idempotent so a
replayed frame is safe.

Reconnect by sending the last **successfully processed** event ID:

```http
Accept: text/event-stream
Last-Event-ID: 41
```

When live or persisted history is available, replay starts strictly after that ID.

## Backend consumer pseudocode

Keep bearer credentials and cursor storage in your trusted backend. This pseudocode names the
required behavior without prescribing an SDK or storage engine:

```text
cursor = load_cursor(session_id, run_id)

loop:
  stream = open_events(session_id, run_id, last_event_id=cursor)

  for frame in stream:
    event = parse_complete_json_frame(frame)
    assert event.session_id == session_id and event.run_id == run_id

    key = (run_id, event.id)
    if already_processed(key):
      cursor = checkpoint(event.id)
      continue

    apply_idempotently(event)
    record_processed_and_checkpoint(key, event.id)
    cursor = event.id

    if event.type in {run_completed, run_cancelled, error}:
      break

  run = get_run(session_id, run_id)
  if run.status in {succeeded, failed, cancelled}:
    return run

  wait_with_bounded_backoff()
```

Do not start a new run when this stream disconnects. Reconnect to the same run with its cursor.

## Connect from a browser deliberately

The browser's native `EventSource` constructor cannot attach an arbitrary `Authorization` header.
Do not put bearer credentials in a query string to work around that boundary.

Choose an architecture that matches your application's security model:

- Stream through your authenticated same-origin backend, which keeps the Tanomis bearer token
  server-side and forwards only the events the signed-in user may see.
- Use a fetch-based SSE parser only when the API is same-origin or your environment administrator
  explicitly configures CORS for your frontend origin and the required request headers. Your
  application must also be designed to hold and send the token in that browser context.
  Do not assume the hosted API accepts cross-origin browser requests. The same frame, cursor,
  deduplication, and final-read rules still apply.

Browser tab closure, page navigation, and sleep are ordinary disconnects. Persist the run-scoped
cursor somewhere that survives the recovery behavior your product promises.

## Handle closure, replay, and change

| What you observe | What it means | What to do |
|---|---|---|
| A previously processed event arrives again | The stored cursor lagged successful processing, or the consumer is recovering around a failure boundary. | Deduplicate by run ID plus event ID; do not repeat the side effect. |
| `200 OK` closes with zero frames | History may be absent, exhausted, or temporarily unreadable. This is a normal, fail-soft EOF—not proof of success. | Read the run resource. Reconnect boundedly only if its status is non-terminal. |
| The stream closes after non-terminal events | Delivery ended before a terminal signal reached this connection. | Preserve the last processed cursor, read the run, and reconnect to the same run if needed. |
| `run_completed`, `run_cancelled`, or `error` arrives | The stream emitted a terminal signal. | Stop consuming and read the run for authoritative lifecycle status and error details. |
| An unknown event name or JSON field appears | A compatible harness or platform addition is present. Unknown events are not terminal. | Ignore it or render it generically; preserve fields you proxy and keep consuming. |
| A frame cannot be parsed or fails scope checks | The frame was not processed successfully. | Do not advance the cursor; close the connection, inspect the run, and reconnect boundedly. |

Persisted replay is best-effort and fail-soft. It does not guarantee that every prior event remains
available, so do not use the event stream as your audit record. The final run resource tells you the
durable lifecycle status even when the detailed transcript is unavailable.
It does not reconstruct agent output or event payloads that your consumer missed.

## Protect event content

Treat `data` as potentially sensitive customer and agent content. Depending on the harness and run,
it can carry user input, model output, or tool-related details. Apply your product's authorization,
redaction, retention, and display rules before storing or logging it. Avoid raw-frame logging by
default; identifiers and event names are usually enough for transport diagnostics.

## Where to go next

- [Stream run events](/api/runs/stream-run-events): exact request and response shapes.
- [Sessions and runs](/concepts/sessions-and-runs): lifecycle, retries, cancellation.
- [Approval-required actions](/guides/run/approval-required-actions): when the stream pauses
  for a person.
