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
| Method | When to use it |
|---|---|
| Polling | The simplest. Repeat the status read every 2 to 5 seconds. Stop at a terminal status. |
| SSE | When you want progress events in real time. If the connection drops, it resumes from the last event. |
| Webhook | When 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.
idis the event’sevent_id. It is an opaque UUID, so do not infer ordering from it.eventis the event type, for examplemessage.created,tool.started,turn.progress,turn.completed,run.step.completed, andrun.completed.datais one line of JSON. It carriesevent_id,aggregate_kind,aggregate_id,sequence,type,created_at, anddata. The innerdata.kindtells the kind of content.- A line that starts with
: keep-aliveis a comment sent about every 15 seconds. Ignore it. sequencecan 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
idof the last event you finished processing in theLast-Event-IDheader (or theafterquery). 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"
}
}
}- Read the current state from
state_url. - Read the retained results from
items_url. - 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
retryableistrue, reconnect with the lastid. - 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.