Skip to Content

Events and SSE

You can also follow the progress of a Turn or Run through events. Polling, SSE, and webhooks all read the same public records, so results do not differ by method.

Polling and SSE

MethodWhen to use it
PollingThe simplest. Repeat the status read every 2 to 5 seconds. Stop at a terminal status.
SSEWhen you want progress events in real time. If the connection drops, it resumes from the last event.
WebhookWhen you want the server to tell you about completion. See webhooks.

SSE is GET /v1/sessions/{session_id}/events and GET /v1/runs/{run_id}/events. It needs the events:read scope, and a Run also needs runs:read. If the connection drops or the server closes it, an execution that was already accepted is not canceled.

curl -N "$BASE/v1/sessions/$SESSION_ID/events" \ -H "Authorization: Bearer $UPSERVE_API_KEY" \ -H "Accept: text/event-stream" # Reconnect and continue after the last event you processed curl -N "$BASE/v1/sessions/$SESSION_ID/events" \ -H "Authorization: Bearer $UPSERVE_API_KEY" \ -H "Last-Event-ID: $LAST_EVENT_ID"

Frame format

Each event is one standard SSE frame.

  • id is the event’s event_id. It is an opaque UUID, so do not infer ordering from it.
  • event is the event type, for example message.created, tool.started, turn.progress, turn.completed, run.step.completed, and run.completed.
  • data is one line of JSON. It carries event_id, aggregate_kind, aggregate_id, sequence, type, created_at, and data. The inner data.kind tells the kind of content.
  • A line that starts with : keep-alive is a comment sent about every 15 seconds. Ignore it.
  • sequence can be compared only within the same Session or the same Run. Do not compare Session and Run numbers with each other.
id: 7c2e0d44-0000-4000-8000-000000000007 event: message.created data: {"event_id":"7c2e0d44-0000-4000-8000-000000000007","aggregate_kind":"SESSION","aggregate_id":"6f1c0a52-0000-4000-8000-000000000001","sequence":3,"type":"message.created","created_at":"2026-10-05T09:00:18+00:00","data":{"kind":"MESSAGE"}} : keep-alive id: 7c2e0d44-0000-4000-8000-000000000008 event: turn.completed data: {"event_id":"7c2e0d44-0000-4000-8000-000000000008","aggregate_kind":"SESSION","aggregate_id":"6f1c0a52-0000-4000-8000-000000000001","sequence":4,"type":"turn.completed","created_at":"2026-10-05T09:00:20+00:00","data":{"kind":"OUTCOME","status":"SUCCEEDED"}}

Public events carry only tool names, call IDs, progress phases, and sanitized final messages. Tool arguments, raw tool results, and internal reasoning are not included.

Here is a minimal example that reads SSE frames.

def events(session_id: str, last_event_id: str | None = None): headers = {**AUTH, "Accept": "text/event-stream"} if last_event_id: headers["Last-Event-ID"] = last_event_id with httpx.stream("GET", f"{BASE}/v1/sessions/{session_id}/events", headers=headers, timeout=None) as response: response.raise_for_status() frame = {} for line in response.iter_lines(): if line == "": if frame: yield frame frame = {} elif not line.startswith(":"): name, _, value = line.partition(":") frame[name] = value.removeprefix(" ")

Reconnecting with Last-Event-ID

  • If the connection drops, reconnect with the id of the last event you finished processing in the Last-Event-ID header (or the after query). The server resends from the event after it.
  • If you send both, they must be the same ID. Otherwise you get 400 INVALID_CURSOR.
  • An ID from another Session or Run returns 404.
  • The same event can arrive twice, so drop duplicates by event_id.
  • If reconnecting keeps failing, retry with growing intervals, and check the status by polling in the meantime.

Recovering from an expired cursor (410)

Event and Item content is kept for at most 30 days, and an old cursor becomes 410 EVENT_CURSOR_EXPIRED. The details of the response carry the recovery paths.

{ "error": { "code": "EVENT_CURSOR_EXPIRED", "message": "The event cursor has expired", "retryable": false, "request_id": "0b7b8f0e-0000-4000-8000-000000000009", "details": { "state_url": "/v1/sessions/6f1c0a52-0000-4000-8000-000000000001/turns/6f1c0a52-0000-4000-8000-000000000002", "items_url": "/v1/sessions/6f1c0a52-0000-4000-8000-000000000001/items" } } }
  1. Read the current state from state_url.
  2. Read the retained results from items_url.
  3. If you still need events, open SSE again without a cursor.

If the retention of the Session content itself has ended, the same code comes with details.content_expired set to true. In that case, having a recovery URL does not extend retention.

Errors after the headers were sent

SSE sends 200 and the headers first, so an error that happens afterward arrives not as an HTTP status but as an event: error frame. This frame has no id, and the server closes the connection after sending it.

event: error data: {"error":{"code":"EVENT_CURSOR_EXPIRED","message":"...","retryable":false,"request_id":"...","details":{}}}
  • If the error’s retryable is true, reconnect with the last id.
  • Even while connected, the key, Deployment, and Session permissions are rechecked periodically. If the key is revoked or permission is withdrawn, the connection is closed.

Slow consumers

If you read events so slowly that the server send limit (about 10 seconds) is exceeded, the server ends the connection. It does not buffer without limit. Reconnect with the last processed id and you pick up the missed events. If your processing is slow, put received frames on a queue and handle them in a separate job.