Run Events (SSE)
Watch runs progress and finish without polling: two Server-Sent Events streams deliver a snapshot on connect, then lifecycle and step events as they happen. Plain HTTP — no WebSockets, no client library, the browser's native EventSource works as-is.
Endpoints
Auth is the same as every other endpoint: Authorization: Bearer with an API key, or the session cookie from a browser. Ownership rules mirror the REST reads — workspace-bound keys only see their workspace.
curl -N https://api.esy.com/v1/runs/run-807ee354/events \
-H "Authorization: Bearer $ESY_API_KEY"// Browsers: EventSource can't send headers — auth rides the session cookie.
const stream = new EventSource(
"https://api.esy.com/v1/runs/" + runId + "/events",
{ withCredentials: true },
);
stream.addEventListener("snapshot", (e) => render(JSON.parse(e.data)));
for (const name of ["run.started", "step.started", "step.phase",
"step.completed", "run.completed", "run.failed"]) {
stream.addEventListener(name, (e) => apply(JSON.parse(e.data)));
}
// IMPORTANT: ignore event names you don't recognize — the contract is additive.The contract
| Rule | What it means for your client |
|---|---|
| Snapshot first | The first event is `snapshot` — the authoritative current state. Replace your local state with it wholesale; every later event is an incremental hint. |
| Events are state hints | Each event carries enough to render (status, step info, timing). There is no Last-Event-ID resume: on reconnect you simply get a fresh snapshot. Refetch the run if you need detail an event doesn’t carry (e.g. the priced cost ledger). |
| Additive evolution | New event names and fields will appear over time. Ignore anything you don’t recognize — never treat an unknown event as an error. |
| Heartbeats | Comment lines (`: hb`) arrive every ~20s. No traffic for more than ~40s means the connection is dead — reconnect. |
| Terminal close | artifact.created always precedes run.completed / run.review / run.failed, and the terminal event carries artifactId — safe to close on the terminal event. |
Event names
| Event | Fires when | On the firehose? |
|---|---|---|
run.created | run accepted (pending) | yes |
run.started | execution begins | yes |
step.started / step.completed / step.failed | each workflow step boundary | no (single-run stream only) |
step.phase | sub-step checkpoints inside long provider calls (e.g. callingProvider, parsingOutput) | no |
artifact.created | the artifact is persisted | yes |
run.completed / run.review / run.failed | terminal state | yes |
event: step.completed
data: {
"type": "step.completed",
"runId": "run-807ee354",
"ts": "2026-07-04T19:08:12.412Z",
"status": "running",
"templateId": "generate-clip-art-asset-v2",
"currentStepIndex": 1,
"stepCount": 3,
"step": {
"index": 0, "name": "Render image", "kind": "image",
"status": "completed", "provider": "openai", "model": "gpt-image-2",
"durationMs": 14210
}
}Recommended client ladder
This is the pattern app.esy.com ships — copy it rather than reinventing it. The worst case at every rung is plain polling, never a broken UI:
- Paint fast, then stream. Fire a normal
GET /v1/runs/{id}immediately (renders in one round-trip) while the SSE handshake runs in parallel; let the snapshot replace state when it arrives. - On stream error, close the socket, make one authenticated GET through your regular HTTP client — it refreshes an expired session (EventSource can't) and resyncs state — then reconnect with 1s → 8s backoff.
- After ~4 consecutive failures, fall back to polling the GET every ~2s and quietly retry the stream every ~30s.
- On a terminal event, do one final GET (the priced cost ledger and server-computed timings land there), then close.
/v1/ai/completions also streams — but that stream is the tokens of one completion. Run Events streams the lifecycle of workflow runs. If you are watching a generation finish, you want Run Events.